From 6a3addd1cae571bf98849b96be166749baff26c1 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 29 Apr 2026 13:32:22 +0330 Subject: [PATCH] feature: a very minimal patients feature implemented. it needs lots of improvments though --- .../migration.sql | 47 ++++ backend/prisma/schema.prisma | 40 ++++ backend/src/app.module.ts | 2 + .../modules/auth/strategies/jwt.strategy.ts | 5 +- .../patients/dto/create-patient.dto.ts | 29 +++ .../dto/create-treatment-history.dto.ts | 28 +++ .../modules/patients/dto/list-patients.dto.ts | 21 ++ .../patients/dto/update-patient.dto.ts | 4 + .../modules/patients/patients.controller.ts | 77 ++++++ .../src/modules/patients/patients.module.ts | 10 + .../src/modules/patients/patients.service.ts | 140 +++++++++++ .../src/app/(dashboard)/patients/page.tsx | 219 ++++++++++++++++++ .../patients/CreatePatientModal.tsx | 69 ++++++ .../patients/PatientSearchSelect.tsx | 63 +++++ .../patients/PatientSummaryCard.tsx | 28 +++ .../patients/TreatmentHistoryPreview.tsx | 37 +++ frontend/src/lib/api/patients.ts | 43 ++++ frontend/src/lib/hooks/useAuth.tsx | 13 +- frontend/src/types/patient.ts | 57 +++++ 19 files changed, 929 insertions(+), 3 deletions(-) create mode 100644 backend/prisma/migrations/20260429090959_add_patients_feature/migration.sql create mode 100644 backend/src/modules/patients/dto/create-patient.dto.ts create mode 100644 backend/src/modules/patients/dto/create-treatment-history.dto.ts create mode 100644 backend/src/modules/patients/dto/list-patients.dto.ts create mode 100644 backend/src/modules/patients/dto/update-patient.dto.ts create mode 100644 backend/src/modules/patients/patients.controller.ts create mode 100644 backend/src/modules/patients/patients.module.ts create mode 100644 backend/src/modules/patients/patients.service.ts create mode 100644 frontend/src/app/(dashboard)/patients/page.tsx create mode 100644 frontend/src/components/patients/CreatePatientModal.tsx create mode 100644 frontend/src/components/patients/PatientSearchSelect.tsx create mode 100644 frontend/src/components/patients/PatientSummaryCard.tsx create mode 100644 frontend/src/components/patients/TreatmentHistoryPreview.tsx create mode 100644 frontend/src/lib/api/patients.ts create mode 100644 frontend/src/types/patient.ts diff --git a/backend/prisma/migrations/20260429090959_add_patients_feature/migration.sql b/backend/prisma/migrations/20260429090959_add_patients_feature/migration.sql new file mode 100644 index 0000000..5179f14 --- /dev/null +++ b/backend/prisma/migrations/20260429090959_add_patients_feature/migration.sql @@ -0,0 +1,47 @@ +-- CreateTable +CREATE TABLE "patients" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "phone" TEXT, + "email" TEXT, + "dateOfBirth" TIMESTAMP(3), + "notes" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "patients_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "patient_treatment_histories" ( + "id" TEXT NOT NULL, + "patientId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "status" TEXT NOT NULL, + "treatmentAt" TIMESTAMP(3) NOT NULL, + "tooth" TEXT, + "notes" TEXT, + "totalCost" DOUBLE PRECISION, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "patient_treatment_histories_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "patients_organizationId_createdAt_idx" ON "patients"("organizationId", "createdAt"); + +-- CreateIndex +CREATE INDEX "patients_organizationId_lastName_firstName_idx" ON "patients"("organizationId", "lastName", "firstName"); + +-- CreateIndex +CREATE INDEX "patient_treatment_histories_patientId_treatmentAt_idx" ON "patient_treatment_histories"("patientId", "treatmentAt"); + +-- AddForeignKey +ALTER TABLE "patients" ADD CONSTRAINT "patients_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "patient_treatment_histories" ADD CONSTRAINT "patient_treatment_histories_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 b08fc49..f6517c6 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -55,6 +55,7 @@ model Organization { sharedWithMe OrganizationLink[] @relation("OrganizationB") sharedWithOthers OrganizationLink[] @relation("OrganizationA") + patients Patient[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -62,6 +63,45 @@ model Organization { @@map("organizations") } +model Patient { + id String @id @default(uuid()) + organizationId String + firstName String + lastName String + phone String? + email String? + dateOfBirth DateTime? + notes String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id]) + treatments PatientTreatmentHistory[] + + @@index([organizationId, createdAt]) + @@index([organizationId, lastName, firstName]) + @@map("patients") +} + +model PatientTreatmentHistory { + id String @id @default(uuid()) + patientId String + title String + status String + treatmentAt DateTime + tooth String? + notes String? + totalCost Float? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) + + @@index([patientId, treatmentAt]) + @@map("patient_treatment_histories") +} + 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 adaca56..468fd14 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -6,6 +6,7 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AdminModule } from './admin/admin.module'; import { PrismaModule } from '../prisma/prisma.module'; // ✅ +import { PatientsModule } from './modules/patients/patients.module'; @Module({ imports: [ @@ -15,6 +16,7 @@ import { PrismaModule } from '../prisma/prisma.module'; // ✅ }), PrismaModule, // ✅ ADD THIS AuthModule, + PatientsModule, AdminModule.forRoot(), ], controllers: [AppController], diff --git a/backend/src/modules/auth/strategies/jwt.strategy.ts b/backend/src/modules/auth/strategies/jwt.strategy.ts index d8d725d..faaaff3 100644 --- a/backend/src/modules/auth/strategies/jwt.strategy.ts +++ b/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -31,6 +31,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } const { passwordHash, ...result } = user; - return result; + return { + ...result, + organizationId: payload.organizationId, + }; } } \ No newline at end of file diff --git a/backend/src/modules/patients/dto/create-patient.dto.ts b/backend/src/modules/patients/dto/create-patient.dto.ts new file mode 100644 index 0000000..fa92769 --- /dev/null +++ b/backend/src/modules/patients/dto/create-patient.dto.ts @@ -0,0 +1,29 @@ +import { IsDateString, IsEmail, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreatePatientDto { + @IsString() + @MaxLength(80) + firstName: string; + + @IsString() + @MaxLength(80) + lastName: string; + + @IsOptional() + @IsString() + @MaxLength(30) + phone?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsDateString() + dateOfBirth?: string; + + @IsOptional() + @IsString() + @MaxLength(1000) + notes?: string; +} diff --git a/backend/src/modules/patients/dto/create-treatment-history.dto.ts b/backend/src/modules/patients/dto/create-treatment-history.dto.ts new file mode 100644 index 0000000..e0620d4 --- /dev/null +++ b/backend/src/modules/patients/dto/create-treatment-history.dto.ts @@ -0,0 +1,28 @@ +import { IsDateString, IsNumber, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateTreatmentHistoryDto { + @IsString() + @MaxLength(120) + title: string; + + @IsString() + @MaxLength(40) + status: string; + + @IsDateString() + treatmentAt: string; + + @IsOptional() + @IsString() + @MaxLength(20) + tooth?: string; + + @IsOptional() + @IsString() + @MaxLength(1000) + notes?: string; + + @IsOptional() + @IsNumber() + totalCost?: number; +} diff --git a/backend/src/modules/patients/dto/list-patients.dto.ts b/backend/src/modules/patients/dto/list-patients.dto.ts new file mode 100644 index 0000000..65ca689 --- /dev/null +++ b/backend/src/modules/patients/dto/list-patients.dto.ts @@ -0,0 +1,21 @@ +import { Transform } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class ListPatientsDto { + @IsOptional() + @IsString() + q?: string; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + @Max(100) + limit = 10; +} diff --git a/backend/src/modules/patients/dto/update-patient.dto.ts b/backend/src/modules/patients/dto/update-patient.dto.ts new file mode 100644 index 0000000..5d5f88c --- /dev/null +++ b/backend/src/modules/patients/dto/update-patient.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreatePatientDto } from './create-patient.dto'; + +export class UpdatePatientDto extends PartialType(CreatePatientDto) {} diff --git a/backend/src/modules/patients/patients.controller.ts b/backend/src/modules/patients/patients.controller.ts new file mode 100644 index 0000000..df77e81 --- /dev/null +++ b/backend/src/modules/patients/patients.controller.ts @@ -0,0 +1,77 @@ +import { + Body, + Controller, + Get, + Param, + ParseIntPipe, + Patch, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CreatePatientDto } from './dto/create-patient.dto'; +import { ListPatientsDto } from './dto/list-patients.dto'; +import { UpdatePatientDto } from './dto/update-patient.dto'; +import { PatientsService } from './patients.service'; +import { CreateTreatmentHistoryDto } from './dto/create-treatment-history.dto'; + +@ApiTags('patients') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard) +@Controller('patients') +export class PatientsController { + constructor(private readonly patientsService: PatientsService) {} + + @Post() + @ApiOperation({ summary: 'Create a patient for current organization' }) + create(@Body() createPatientDto: CreatePatientDto, @Req() req) { + const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); + return this.patientsService.create(createPatientDto, organizationId); + } + + @Get() + @ApiOperation({ summary: 'List patients with search and pagination' }) + findAll(@Query() query: ListPatientsDto, @Req() req) { + const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); + return this.patientsService.findAll(query, organizationId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get one patient by id' }) + findOne(@Param('id') id: string, @Req() req) { + const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); + return this.patientsService.findOne(id, organizationId); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update patient' }) + update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto, @Req() req) { + const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); + return this.patientsService.update(id, updatePatientDto, organizationId); + } + + @Get(':id/treatments') + @ApiOperation({ summary: 'Get patient treatment history' }) + findTreatments( + @Param('id') id: string, + @Query('limit', new ParseIntPipe({ optional: true })) limit = 20, + @Req() req, + ) { + const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); + return this.patientsService.findTreatments(id, organizationId, limit); + } + + @Post(':id/treatments') + @ApiOperation({ summary: 'Add treatment history item for a patient' }) + addTreatment( + @Param('id') id: string, + @Body() dto: CreateTreatmentHistoryDto, + @Req() req, + ) { + const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); + return this.patientsService.addTreatment(id, dto, organizationId); + } +} diff --git a/backend/src/modules/patients/patients.module.ts b/backend/src/modules/patients/patients.module.ts new file mode 100644 index 0000000..514afd3 --- /dev/null +++ b/backend/src/modules/patients/patients.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { PatientsController } from './patients.controller'; +import { PatientsService } from './patients.service'; + +@Module({ + controllers: [PatientsController], + providers: [PatientsService, PrismaService], +}) +export class PatientsModule {} diff --git a/backend/src/modules/patients/patients.service.ts b/backend/src/modules/patients/patients.service.ts new file mode 100644 index 0000000..60457f9 --- /dev/null +++ b/backend/src/modules/patients/patients.service.ts @@ -0,0 +1,140 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { CreatePatientDto } from './dto/create-patient.dto'; +import { ListPatientsDto } from './dto/list-patients.dto'; +import { UpdatePatientDto } from './dto/update-patient.dto'; +import { CreateTreatmentHistoryDto } from './dto/create-treatment-history.dto'; + +@Injectable() +export class PatientsService { + constructor(private readonly prisma: PrismaService) {} + + async create(createPatientDto: CreatePatientDto, organizationId: string) { + const patient = await this.prisma.patient.create({ + data: { + ...createPatientDto, + dateOfBirth: createPatientDto.dateOfBirth ? new Date(createPatientDto.dateOfBirth) : null, + organizationId, + }, + }); + + return { success: true, data: patient }; + } + + async findAll(query: ListPatientsDto, organizationId: string) { + const { page = 1, limit = 10, q } = query; + const skip = (page - 1) * limit; + + const where: Prisma.PatientWhereInput = { + organizationId, + ...(q + ? { + OR: [ + { firstName: { contains: q, mode: 'insensitive' } }, + { lastName: { contains: q, mode: 'insensitive' } }, + { email: { contains: q, mode: 'insensitive' } }, + { phone: { contains: q, mode: 'insensitive' } }, + ], + } + : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.patient.findMany({ + where, + skip, + take: limit, + orderBy: [{ updatedAt: 'desc' }], + }), + this.prisma.patient.count({ where }), + ]); + + return { + success: true, + data: { + items, + pagination: { + page, + limit, + total, + totalPages: Math.max(1, Math.ceil(total / limit)), + }, + }, + }; + } + + async findOne(id: string, organizationId: string) { + const patient = await this.prisma.patient.findFirst({ + where: { id, organizationId }, + }); + + if (!patient) { + throw new NotFoundException('Patient not found'); + } + + return { success: true, data: patient }; + } + + async update(id: string, updatePatientDto: UpdatePatientDto, organizationId: string) { + await this.ensurePatient(id, organizationId); + + const patient = await this.prisma.patient.update({ + where: { id }, + data: { + ...updatePatientDto, + dateOfBirth: updatePatientDto.dateOfBirth ? new Date(updatePatientDto.dateOfBirth) : undefined, + }, + }); + + return { success: true, data: patient }; + } + + async findTreatments(patientId: string, organizationId: string, limit = 20) { + await this.ensurePatient(patientId, organizationId); + + const items = await this.prisma.patientTreatmentHistory.findMany({ + where: { patientId }, + orderBy: [{ treatmentAt: 'desc' }], + take: limit, + }); + + return { success: true, data: items }; + } + + async addTreatment( + patientId: string, + dto: CreateTreatmentHistoryDto, + organizationId: string, + ) { + await this.ensurePatient(patientId, organizationId); + + const treatment = await this.prisma.patientTreatmentHistory.create({ + data: { + ...dto, + treatmentAt: new Date(dto.treatmentAt), + patientId, + }, + }); + + return { success: true, data: treatment }; + } + + private async ensurePatient(id: string, organizationId: string) { + const patient = await this.prisma.patient.findFirst({ + where: { id, organizationId }, + select: { id: true }, + }); + + if (!patient) { + throw new NotFoundException('Patient not found'); + } + } + + getOrganizationIdFromUser(user: { organizationId?: string }) { + if (!user?.organizationId) { + throw new BadRequestException('Organization is not selected'); + } + return user.organizationId; + } +} diff --git a/frontend/src/app/(dashboard)/patients/page.tsx b/frontend/src/app/(dashboard)/patients/page.tsx new file mode 100644 index 0000000..87bebfe --- /dev/null +++ b/frontend/src/app/(dashboard)/patients/page.tsx @@ -0,0 +1,219 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Plus } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { patientsApi } from '@/lib/api/patients'; +import { + CreatePatientInput, + CreateTreatmentHistoryInput, + Patient, + TreatmentHistoryItem, +} from '@/types/patient'; +import { PatientSearchSelect } from '@/components/patients/PatientSearchSelect'; +import { CreatePatientModal } from '@/components/patients/CreatePatientModal'; +import { PatientSummaryCard } from '@/components/patients/PatientSummaryCard'; +import { TreatmentHistoryPreview } from '@/components/patients/TreatmentHistoryPreview'; + +const EMPTY_PATIENT_FORM: CreatePatientInput = { + firstName: '', + lastName: '', + phone: '', + email: '', +}; + +export default function PatientsPage() { + const [search, setSearch] = useState(''); + const [patients, setPatients] = useState([]); + const [selectedPatient, setSelectedPatient] = useState(); + const [treatments, setTreatments] = useState([]); + const [loadingPatients, setLoadingPatients] = useState(false); + const [loadingTreatments, setLoadingTreatments] = useState(false); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [savingPatient, setSavingPatient] = useState(false); + const [savingTreatment, setSavingTreatment] = useState(false); + const [patientForm, setPatientForm] = useState(EMPTY_PATIENT_FORM); + const [errorMessage, setErrorMessage] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + + const sortedPatients = useMemo( + () => + [...patients].sort((a, b) => + `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), + ), + [patients], + ); + + useEffect(() => { + const timeout = setTimeout(() => { + void loadPatients(search); + }, 300); + return () => clearTimeout(timeout); + }, [search]); + + useEffect(() => { + void loadPatients(''); + }, []); + + useEffect(() => { + if (!successMessage) { + return; + } + + const timeout = setTimeout(() => { + setSuccessMessage(''); + }, 3000); + + return () => clearTimeout(timeout); + }, [successMessage]); + + async function loadPatients(q: string) { + setLoadingPatients(true); + setErrorMessage(''); + try { + const response = await patientsApi.list({ q, page: 1, limit: 25 }); + const items = response.data.items; + setPatients(items); + + if (selectedPatient) { + const freshSelected = items.find((item) => item.id === selectedPatient.id); + setSelectedPatient(freshSelected); + } + } catch (error: any) { + const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message; + setErrorMessage(message || 'Failed to load patients.'); + } finally { + setLoadingPatients(false); + } + } + + async function loadTreatments(patientId: string) { + setLoadingTreatments(true); + setErrorMessage(''); + try { + const response = await patientsApi.listTreatments(patientId); + setTreatments(response.data); + } catch (error: any) { + const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message; + setErrorMessage(message || 'Failed to load treatment history.'); + } finally { + setLoadingTreatments(false); + } + } + + async function handleCreatePatient() { + setSavingPatient(true); + setErrorMessage(''); + setSuccessMessage(''); + try { + const response = await patientsApi.create(patientForm); + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + await loadPatients(search); + setSelectedPatient(response.data); + await loadTreatments(response.data.id); + setSuccessMessage( + `Patient ${response.data.firstName} ${response.data.lastName} was saved successfully.`, + ); + } catch (error: any) { + const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message; + setErrorMessage(message || 'Failed to save patient.'); + } finally { + setSavingPatient(false); + } + } + + async function handleQuickAddTreatment() { + if (!selectedPatient) { + return; + } + + const payload: CreateTreatmentHistoryInput = { + title: 'Initial consultation', + status: 'scheduled', + treatmentAt: new Date().toISOString(), + notes: 'Created from quick action on patients page.', + }; + + setSavingTreatment(true); + setErrorMessage(''); + setSuccessMessage(''); + try { + await patientsApi.addTreatment(selectedPatient.id, payload); + await loadTreatments(selectedPatient.id); + setSuccessMessage('Treatment entry added successfully.'); + } catch (error: any) { + const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message; + setErrorMessage(message || 'Failed to add treatment entry.'); + } finally { + setSavingTreatment(false); + } + } + + return ( +
+
+

Patients

+ +
+ + setPatientForm((prev) => ({ ...prev, ...patch }))} + onSubmit={handleCreatePatient} + onClose={() => setIsCreateOpen(false)} + loading={savingPatient} + /> + +
+
+ { + setSelectedPatient(patient); + void loadTreatments(patient.id); + }} + loading={loadingPatients} + /> +
+ +
+ +
+ +
+ +
+
+ + {(errorMessage || successMessage) && ( +
+ {errorMessage && ( +
+ {errorMessage} +
+ )} + {successMessage && ( +
+ {successMessage} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/patients/CreatePatientModal.tsx b/frontend/src/components/patients/CreatePatientModal.tsx new file mode 100644 index 0000000..46c3f28 --- /dev/null +++ b/frontend/src/components/patients/CreatePatientModal.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { CreatePatientInput } from '@/types/patient'; + +interface CreatePatientModalProps { + isOpen: boolean; + formData: CreatePatientInput; + onChange: (patch: Partial) => void; + onSubmit: () => void; + onClose: () => void; + loading?: boolean; +} + +export function CreatePatientModal({ + isOpen, + formData, + onChange, + onSubmit, + onClose, + loading = false, +}: CreatePatientModalProps) { + if (!isOpen) { + return null; + } + + return ( +
+
+ onChange({ firstName: e.target.value })} + /> + onChange({ lastName: e.target.value })} + /> + onChange({ phone: e.target.value })} + /> + onChange({ email: e.target.value })} + /> +
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/components/patients/PatientSearchSelect.tsx b/frontend/src/components/patients/PatientSearchSelect.tsx new file mode 100644 index 0000000..2ac0bce --- /dev/null +++ b/frontend/src/components/patients/PatientSearchSelect.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { Search } from 'lucide-react'; +import { Input } from '@/components/ui/Input'; +import { Patient } from '@/types/patient'; + +interface PatientSearchSelectProps { + search: string; + onSearchChange: (value: string) => void; + patients: Patient[]; + selectedPatientId?: string; + onSelectPatient: (patient: Patient) => void; + loading?: boolean; +} + +export function PatientSearchSelect({ + search, + onSearchChange, + patients, + selectedPatientId, + onSelectPatient, + loading = false, +}: PatientSearchSelectProps) { + return ( +
+ onSearchChange(e.target.value)} + icon={} + /> + +
+ {loading &&

Loading patients...

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

No patients found for this search.

+ )} + + {patients.map((patient) => { + const isSelected = selectedPatientId === patient.id; + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/patients/PatientSummaryCard.tsx b/frontend/src/components/patients/PatientSummaryCard.tsx new file mode 100644 index 0000000..1e0d844 --- /dev/null +++ b/frontend/src/components/patients/PatientSummaryCard.tsx @@ -0,0 +1,28 @@ +import { Patient } from '@/types/patient'; + +interface PatientSummaryCardProps { + patient?: Patient; +} + +export function PatientSummaryCard({ patient }: PatientSummaryCardProps) { + if (!patient) { + return ( +
+

Select a patient to view details.

+
+ ); + } + + return ( +
+

+ {patient.firstName} {patient.lastName} +

+

Phone: {patient.phone || '-'}

+

Email: {patient.email || '-'}

+

+ Status: {patient.isActive ? 'Active' : 'Inactive'} +

+
+ ); +} diff --git a/frontend/src/components/patients/TreatmentHistoryPreview.tsx b/frontend/src/components/patients/TreatmentHistoryPreview.tsx new file mode 100644 index 0000000..9eb80a2 --- /dev/null +++ b/frontend/src/components/patients/TreatmentHistoryPreview.tsx @@ -0,0 +1,37 @@ +import { TreatmentHistoryItem } from '@/types/patient'; + +interface TreatmentHistoryPreviewProps { + items: TreatmentHistoryItem[]; + loading?: boolean; +} + +export function TreatmentHistoryPreview({ items, loading = false }: TreatmentHistoryPreviewProps) { + return ( +
+

Treatment History

+ + {loading &&

Loading treatment history...

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

No treatment history yet.

+ )} + +
+ {items.map((item) => ( +
+
+

{item.title}

+

+ {new Date(item.treatmentAt).toLocaleDateString()} +

+
+

+ Status: {item.status} + {item.tooth ? ` | Tooth: ${item.tooth}` : ''} +

+
+ ))} +
+
+ ); +} diff --git a/frontend/src/lib/api/patients.ts b/frontend/src/lib/api/patients.ts new file mode 100644 index 0000000..6ac16df --- /dev/null +++ b/frontend/src/lib/api/patients.ts @@ -0,0 +1,43 @@ +import { apiClient } from './client'; +import { + CreatePatientInput, + CreateTreatmentHistoryInput, + Patient, + PatientsListResponse, + TreatmentHistoryItem, +} from '@/types/patient'; + +export const patientsApi = { + list: async (params?: { q?: string; page?: number; limit?: number }): Promise => { + const response = await apiClient.get('/patients', { params }); + return response.data; + }, + + create: async (data: CreatePatientInput): Promise<{ success: boolean; data: Patient }> => { + const response = await apiClient.post('/patients', data); + return response.data; + }, + + getOne: async (id: string): Promise<{ success: boolean; data: Patient }> => { + const response = await apiClient.get(`/patients/${id}`); + return response.data; + }, + + listTreatments: async ( + patientId: string, + limit = 20, + ): Promise<{ success: boolean; data: TreatmentHistoryItem[] }> => { + const response = await apiClient.get(`/patients/${patientId}/treatments`, { + params: { limit }, + }); + return response.data; + }, + + addTreatment: async ( + patientId: string, + data: CreateTreatmentHistoryInput, + ): Promise<{ success: boolean; data: TreatmentHistoryItem }> => { + const response = await apiClient.post(`/patients/${patientId}/treatments`, data); + return response.data; + }, +}; diff --git a/frontend/src/lib/hooks/useAuth.tsx b/frontend/src/lib/hooks/useAuth.tsx index f0df035..1f80eda 100644 --- a/frontend/src/lib/hooks/useAuth.tsx +++ b/frontend/src/lib/hooks/useAuth.tsx @@ -77,11 +77,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const storedOrgId = localStorage.getItem('currentOrganizationId'); if (storedOrgId && orgs.length > 0) { const org = orgs.find(o => o.id === storedOrgId); - if (org) setCurrentOrganization(org); - else setCurrentOrganization(null); + if (org) { + setCurrentOrganization(org); + // Ensure cookie token carries organizationId for org-scoped APIs. + await authApi.selectOrganization(org.id); + } else { + setCurrentOrganization(null); + } } else if (orgs.length === 1 && userData) { setCurrentOrganization(orgs[0]); localStorage.setItem('currentOrganizationId', orgs[0].id); + // Keep JWT in sync with selected org even for single-org users. + await authApi.selectOrganization(orgs[0].id); } else { setCurrentOrganization(null); } @@ -125,6 +132,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (orgs.length === 1) { const org = orgs[0]; + await authApi.selectOrganization(org.id); setCurrentOrganization(org); localStorage.setItem('currentOrganizationId', org.id); router.push('/today'); @@ -156,6 +164,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (orgs.length === 1) { const org = orgs[0]; + await authApi.selectOrganization(org.id); setCurrentOrganization(org); localStorage.setItem('currentOrganizationId', org.id); router.push('/today'); diff --git a/frontend/src/types/patient.ts b/frontend/src/types/patient.ts new file mode 100644 index 0000000..04a2dd0 --- /dev/null +++ b/frontend/src/types/patient.ts @@ -0,0 +1,57 @@ +export interface Patient { + id: string; + organizationId: string; + firstName: string; + lastName: string; + phone?: string | null; + email?: string | null; + dateOfBirth?: string | null; + notes?: string | null; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface TreatmentHistoryItem { + id: string; + patientId: string; + title: string; + status: string; + treatmentAt: string; + tooth?: string | null; + notes?: string | null; + totalCost?: number | null; + createdAt: string; + updatedAt: string; +} + +export interface CreatePatientInput { + firstName: string; + lastName: string; + phone?: string; + email?: string; + dateOfBirth?: string; + notes?: string; +} + +export interface CreateTreatmentHistoryInput { + title: string; + status: string; + treatmentAt: string; + tooth?: string; + notes?: string; + totalCost?: number; +} + +export interface PatientsListResponse { + success: boolean; + data: { + items: Patient[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; + }; +}