From 8123a94a3d6070f81103edf9118e7cfd82bf9438 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 6 May 2026 23:05:37 +0330 Subject: [PATCH 1/2] feature: a minimal implementation of the appointment feature done. --- .../migration.sql | 26 ++ backend/prisma/schema.prisma | 22 ++ backend/src/app.module.ts | 2 + .../appointments/appointments.controller.ts | 51 +++ .../appointments/appointments.module.ts | 10 + .../appointments/appointments.service.ts | 235 ++++++++++++ .../dto/create-appointment.dto.ts | 28 ++ .../appointments/dto/list-appointments.dto.ts | 12 + .../src/app/(dashboard)/appointments/page.tsx | 349 +++++++++++++++++- frontend/src/app/(dashboard)/layout.tsx | 11 +- .../appointments/AppointmentBookingModal.tsx | 199 ++++++++++ .../appointments/AppointmentScheduleGrid.tsx | 157 ++++++++ .../AppointmentScheduleLegend.tsx | 23 ++ .../AppointmentsPatientSearch.tsx | 87 +++++ .../appointments/appointmentPurposeStyles.ts | 45 +++ .../src/components/ui/common/Dropdown.tsx | 68 ++++ .../ui/common/ScheduleDayPicker.tsx | 51 +++ frontend/src/components/ui/common/Sidebar.tsx | 9 +- frontend/src/lib/api/appointments.ts | 32 ++ frontend/src/lib/appointmentTime.ts | 63 ++++ frontend/src/lib/formatApiError.ts | 12 + frontend/src/shared/permissions.ts | 32 ++ frontend/src/types/appointment.ts | 27 ++ 23 files changed, 1542 insertions(+), 9 deletions(-) create mode 100644 backend/prisma/migrations/20260506163313_add_appointments/migration.sql create mode 100644 backend/src/modules/appointments/appointments.controller.ts create mode 100644 backend/src/modules/appointments/appointments.module.ts create mode 100644 backend/src/modules/appointments/appointments.service.ts create mode 100644 backend/src/modules/appointments/dto/create-appointment.dto.ts create mode 100644 backend/src/modules/appointments/dto/list-appointments.dto.ts create mode 100644 frontend/src/components/ui/appointments/AppointmentBookingModal.tsx create mode 100644 frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx create mode 100644 frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx create mode 100644 frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx create mode 100644 frontend/src/components/ui/appointments/appointmentPurposeStyles.ts create mode 100644 frontend/src/components/ui/common/Dropdown.tsx create mode 100644 frontend/src/components/ui/common/ScheduleDayPicker.tsx create mode 100644 frontend/src/lib/api/appointments.ts create mode 100644 frontend/src/lib/appointmentTime.ts create mode 100644 frontend/src/lib/formatApiError.ts create mode 100644 frontend/src/types/appointment.ts diff --git a/backend/prisma/migrations/20260506163313_add_appointments/migration.sql b/backend/prisma/migrations/20260506163313_add_appointments/migration.sql new file mode 100644 index 0000000..a0b1671 --- /dev/null +++ b/backend/prisma/migrations/20260506163313_add_appointments/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 394229c..3d74145 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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" diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 0f6d322..270c45c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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(), diff --git a/backend/src/modules/appointments/appointments.controller.ts b/backend/src/modules/appointments/appointments.controller.ts new file mode 100644 index 0000000..6732028 --- /dev/null +++ b/backend/src/modules/appointments/appointments.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/appointments/appointments.module.ts b/backend/src/modules/appointments/appointments.module.ts new file mode 100644 index 0000000..4ec86d1 --- /dev/null +++ b/backend/src/modules/appointments/appointments.module.ts @@ -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 {} diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts new file mode 100644 index 0000000..c36d5e6 --- /dev/null +++ b/backend/src/modules/appointments/appointments.service.ts @@ -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 } } }, + }); + } +} diff --git a/backend/src/modules/appointments/dto/create-appointment.dto.ts b/backend/src/modules/appointments/dto/create-appointment.dto.ts new file mode 100644 index 0000000..19b293b --- /dev/null +++ b/backend/src/modules/appointments/dto/create-appointment.dto.ts @@ -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; +} diff --git a/backend/src/modules/appointments/dto/list-appointments.dto.ts b/backend/src/modules/appointments/dto/list-appointments.dto.ts new file mode 100644 index 0000000..a61c17d --- /dev/null +++ b/backend/src/modules/appointments/dto/list-appointments.dto.ts @@ -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; +} diff --git a/frontend/src/app/(dashboard)/appointments/page.tsx b/frontend/src/app/(dashboard)/appointments/page.tsx index d638c9f..ead192c 100644 --- a/frontend/src/app/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/(dashboard)/appointments/page.tsx @@ -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([]); + const [appointments, setAppointments] = useState([]); + const [loadingSchedule, setLoadingSchedule] = useState(false); + const [scheduleError, setScheduleError] = useState(''); + + const [search, setSearch] = useState(''); + const [patients, setPatients] = useState([]); + const [selectedPatient, setSelectedPatient] = useState(); + const [loadingPatients, setLoadingPatients] = useState(false); + + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [savingPatient, setSavingPatient] = useState(false); + const [patientForm, setPatientForm] = useState(EMPTY_PATIENT_FORM); + + const [bookingOpen, setBookingOpen] = useState(false); + const [bookingHour, setBookingHour] = useState(9); + const [bookingProviderId, setBookingProviderId] = useState(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 ( -
-

Appointments

-

- Appointments module is coming soon. -

+
+
+
+
+

Appointments

+

+ Search a patient, pick a date, then click a time slot under a provider to book. +

+
+ + { + if (!canEditPatients) { + return; + } + setIsCreateOpen(true); + }} + /> + +
+ +
+ + +
+ setScheduleDate(startOfLocalDay(d))} + /> + {loadingSchedule && ( +

Loading schedule…

+ )} +
+ + {scheduleError && ( +
+ {scheduleError} +
+ )} + + void handleDeleteAppointment(id)} + onSlotClick={(hour, uid, name) => handleSlotClick(hour, uid, name)} + /> +
+
+ + setBookingOpen(false)} + onSubmit={handleSaveAppointment} + loading={savingAppointment} + /> + + {isCreateOpen && ( +
+ setPatientForm((prev) => ({ ...prev, ...patch }))} + onSubmit={() => void handleCreatePatient()} + onClose={() => setIsCreateOpen(false)} + loading={savingPatient} + /> +
+ )} + + {(toastError || toastSuccess || toastInfo) && ( +
+
+ {toastError && ( +
+ {toastError} +
+ )} + {toastInfo && ( +
+ {toastInfo} +
+ )} + {toastSuccess && ( +
+ {toastSuccess} +
+ )} +
+
+ )}
); } diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/(dashboard)/layout.tsx index f371642..60af0ed 100644 --- a/frontend/src/app/(dashboard)/layout.tsx +++ b/frontend/src/app/(dashboard)/layout.tsx @@ -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]); diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx new file mode 100644 index 0000000..0c9a8ac --- /dev/null +++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx @@ -0,0 +1,199 @@ +'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 { APPOINTMENT_PURPOSES, 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; + 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('consultation'); + const [error, setError] = useState(''); + + 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 ( +
+
+
+

+ New appointment +

+ +
+ +

+ Provider: {providerName} +

+ +
+ +

+ {patient ? `${patient.firstName} ${patient.lastName}` : '—'} +

+
+ +
+
+ + setStartTime(e.target.value)} + /> +
+
+ + setEndTime(e.target.value)} + /> +
+
+ + setPurpose(e.target.value as AppointmentPurpose)} + > + {APPOINTMENT_PURPOSES.map((p) => ( + + ))} + + + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx new file mode 100644 index 0000000..426517e --- /dev/null +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -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 ( +
+ No providers available. Add staff with treatment edit access to see columns here. +
+ ); + } + + return ( +
+
+
+
+ {providers.map((p) => ( +
+ {p.name} +
+ ))} +
+ +
+
+ {HOURS.map((h) => ( +
+ {formatHourLabel(h)} +
+ ))} +
+ +
+ {providers.map((p) => ( +
+ {HOURS.map((h) => { + const slotDisabled = !canBook; + return ( + + )} +
+ ); + })} +
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx new file mode 100644 index 0000000..6e36de3 --- /dev/null +++ b/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx @@ -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 ( +
+

Legend

+
+ {APPOINTMENT_PURPOSES.map((p) => ( +
+ + {APPOINTMENT_PURPOSE_LABEL[p]} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx new file mode 100644 index 0000000..717528d --- /dev/null +++ b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx @@ -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 ( +
+
+
+ onSearchChange(e.target.value)} + icon={} + /> +
+ {showAddForEmptyResults && ( + + )} +
+ +
+ {loading &&

Searching…

} + + {!loading && trimmed.length === 0 && ( +

Type to search patients by name, phone, or email.

+ )} + + {patients.map((patient) => { + const isSelected = selectedPatientId === patient.id; + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts b/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts new file mode 100644 index 0000000..ac4bd28 --- /dev/null +++ b/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts @@ -0,0 +1,45 @@ +import type { AppointmentPurpose } from '@/types/appointment'; + +export const APPOINTMENT_PURPOSE_LABEL: Record = { + consultation: 'Consultation', + filling: 'Filling', + endo: 'Endo', + visit: 'Visit', + hygiene: 'Hygiene', +}; + +/** Background + border for blocks / legend (matches reference palette). */ +export const APPOINTMENT_PURPOSE_STYLES: Record = { + 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 only — same hues as legend swatches (icon stroke via currentColor). */ +export function purposeDeleteIconClass(purpose: string): string { + const p = purpose as AppointmentPurpose; + const map: Record = { + consultation: 'text-violet-400 hover:text-violet-300', + filling: 'text-orange-400 hover:text-orange-300', + endo: 'text-red-400 hover:text-red-300', + visit: 'text-sky-400 hover:text-sky-300', + hygiene: 'text-lime-700 hover:text-lime-600', + }; + return map[p] ?? 'text-text-muted hover:text-text-secondary'; +} + +/** Small swatch for legend (background + border only). */ +export const APPOINTMENT_PURPOSE_LEGEND_SWATCH: Record = { + 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', +}; diff --git a/frontend/src/components/ui/common/Dropdown.tsx b/frontend/src/components/ui/common/Dropdown.tsx new file mode 100644 index 0000000..1dc9621 --- /dev/null +++ b/frontend/src/components/ui/common/Dropdown.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { ChevronDown } from 'lucide-react'; +import React, { forwardRef } from 'react'; + +interface DropdownProps extends React.SelectHTMLAttributes { + label?: string; + error?: string; +} + +export const Dropdown = forwardRef( + ({ label, error, className = '', id, children, ...props }, ref) => { + const selectId = id || `dropdown-${Math.random().toString(36).slice(2, 9)}`; + + return ( +
+ {label && ( + + )} + +
+ + +
+ +
+
+ + {error && ( +

+ {error} +

+ )} +
+ ); + }, +); + +Dropdown.displayName = 'Dropdown'; diff --git a/frontend/src/components/ui/common/ScheduleDayPicker.tsx b/frontend/src/components/ui/common/ScheduleDayPicker.tsx new file mode 100644 index 0000000..744f308 --- /dev/null +++ b/frontend/src/components/ui/common/ScheduleDayPicker.tsx @@ -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 ( +
+

{label}

+
+ +
+ {labelText} +
+ +
+
+ ); +} diff --git a/frontend/src/components/ui/common/Sidebar.tsx b/frontend/src/components/ui/common/Sidebar.tsx index e8d9b59..5d959bd 100644 --- a/frontend/src/components/ui/common/Sidebar.tsx +++ b/frontend/src/components/ui/common/Sidebar.tsx @@ -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], ); diff --git a/frontend/src/lib/api/appointments.ts b/frontend/src/lib/api/appointments.ts new file mode 100644 index 0000000..76237b0 --- /dev/null +++ b/frontend/src/lib/api/appointments.ts @@ -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; + }, +}; diff --git a/frontend/src/lib/appointmentTime.ts b/frontend/src/lib/appointmentTime.ts new file mode 100644 index 0000000..19abbe7 --- /dev/null +++ b/frontend/src/lib/appointmentTime.ts @@ -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; +} diff --git a/frontend/src/lib/formatApiError.ts b/frontend/src/lib/formatApiError.ts new file mode 100644 index 0000000..d60c0e1 --- /dev/null +++ b/frontend/src/lib/formatApiError.ts @@ -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; +} diff --git a/frontend/src/shared/permissions.ts b/frontend/src/shared/permissions.ts index 123cbe6..627a2f5 100644 --- a/frontend/src/shared/permissions.ts +++ b/frontend/src/shared/permissions.ts @@ -48,3 +48,35 @@ 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') + ); +} diff --git a/frontend/src/types/appointment.ts b/frontend/src/types/appointment.ts new file mode 100644 index 0000000..421f276 --- /dev/null +++ b/frontend/src/types/appointment.ts @@ -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; +} From f1c97021f8d6f1ea436d8e8db7cf7e6ead99b5e0 Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 7 May 2026 01:14:55 +0330 Subject: [PATCH 2/2] feature: delete appointment button's UI improved --- .../ui/appointments/AppointmentScheduleGrid.tsx | 4 ++-- .../ui/appointments/appointmentPurposeStyles.ts | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index 426517e..fd0a98f 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -133,7 +133,7 @@ export function AppointmentScheduleGrid({ {canDelete && onDeleteAppointment && ( )}
diff --git a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts b/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts index ac4bd28..6b1e5e5 100644 --- a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts +++ b/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts @@ -22,17 +22,17 @@ export function purposeStyle(purpose: string): string { return APPOINTMENT_PURPOSE_STYLES[p] ?? 'bg-surface-elevated border-border text-text-secondary'; } -/** Trash icon only — same hues as legend swatches (icon stroke via currentColor). */ +/** 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 = { - consultation: 'text-violet-400 hover:text-violet-300', - filling: 'text-orange-400 hover:text-orange-300', - endo: 'text-red-400 hover:text-red-300', - visit: 'text-sky-400 hover:text-sky-300', - hygiene: 'text-lime-700 hover:text-lime-600', + 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 hover:text-text-secondary'; + return map[p] ?? '!text-text-muted group-hover:!text-text-secondary'; } /** Small swatch for legend (background + border only). */