Compare commits

...

14 Commits

Author SHA1 Message Date
633c939560 feature: treatment frontend wired with the newly implemented backend. no mocked data no more. 2026-05-19 23:43:43 +03:30
f743046b38 feature: a minimal v1 backend implemented for treatments feature. 2026-05-19 22:29:29 +03:30
935bb8c214 Merge pull request 'bugfix/demo-bugs-fixed' (#19) from bugfix/demo-bugs-fixed 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/19
2026-05-18 19:11:28 +03:30
3a0f5cbc6e bugfix: a new flow added to re-enable disabled staffs. 2026-05-18 19:03:52 +03:30
10d8fac3f8 bugfix: a new flow added to disable staffs and free the used seats. 2026-05-18 14:58:47 +03:30
4487d3260b bugfix: a new flow added to disable staffs and free the used seats. 2026-05-18 14:56:07 +03:30
81fe14823f bugfix: staffs feature toasts unified with the other features. 2026-05-18 14:20:50 +03:30
7dba7cc144 bugfix: organization sidebar icon updated like organization switch feature. 2026-05-18 14:08:07 +03:30
95ed1bd4ab bugfix: create organization button is now hidden for none owner users. 2026-05-18 13:37:20 +03:30
535b49310f bugfix: click on new patient button resets the patient data entry form now. appointment's add patient flow updated so that it matches tha patients feature. 2026-05-18 13:09:46 +03:30
3b12c52fd3 bugfix: selecting past dates is now possible in appointment and treatment features. bur, add, edit and delete actions are disabled for past dates. 2026-05-18 12:50:25 +03:30
eb636db653 bugfix: a small refactor done in folder structure and naming conventions. 2026-05-18 12:31:21 +03:30
4590255b31 bugfix: patients feature toasts unified with the other features. 2026-05-18 12:22:19 +03:30
0029a85e17 Merge pull request 'bugfix/demo-bugs-fixed' (#18) from bugfix/demo-bugs-fixed 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/18
2026-05-17 18:07:51 +03:30
81 changed files with 3388 additions and 1311 deletions

1
backend/.gitignore vendored
View File

@@ -45,6 +45,7 @@ lerna-debug.log*
# temp directory
.temp
.tmp
/uploads
# Runtime data
pids

View File

@@ -35,6 +35,7 @@
"express-formidable": "^1.2.0",
"express-session": "^1.19.0",
"helmet": "^8.1.0",
"multer": "^2.1.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
@@ -55,6 +56,7 @@
"@types/express": "^5.0.0",
"@types/express-session": "^1.18.2",
"@types/jest": "^30.0.0",
"@types/multer": "^2.1.0",
"@types/node": "^22.10.7",
"@types/pg": "^8.16.0",
"@types/react": "^19.2.14",
@@ -6251,6 +6253,16 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/@types/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "22.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
@@ -12374,7 +12386,7 @@
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"resolved": "https://registry.npmmirror.com/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {

View File

@@ -41,7 +41,6 @@
"@nestjs/swagger": "^11.2.6",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^6.19.2",
"prisma": "^6.19.2",
"adminjs": "^7.8.17",
"axios": "^1.13.5",
"bcrypt": "^6.0.0",
@@ -54,10 +53,12 @@
"express-formidable": "^1.2.0",
"express-session": "^1.19.0",
"helmet": "^8.1.0",
"multer": "^2.1.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.18.0",
"prisma": "^6.19.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"styled-components": "^6.3.11",
@@ -73,6 +74,7 @@
"@types/express": "^5.0.0",
"@types/express-session": "^1.18.2",
"@types/jest": "^30.0.0",
"@types/multer": "^2.1.0",
"@types/node": "^22.10.7",
"@types/pg": "^8.16.0",
"@types/react": "^19.2.14",

View File

@@ -0,0 +1,154 @@
-- CreateEnum
CREATE TYPE "TreatmentStatus" AS ENUM ('DRAFT', 'COMPLETED');
-- CreateTable
CREATE TABLE "treatments" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"patientId" TEXT NOT NULL,
"appointmentId" TEXT,
"providerUserId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"status" "TreatmentStatus" NOT NULL DEFAULT 'DRAFT',
"treatmentAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "treatments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "treatment_cases" (
"id" TEXT NOT NULL,
"treatmentId" TEXT NOT NULL,
"clientKey" TEXT,
"sortOrder" INTEGER NOT NULL,
"treatmentType" TEXT NOT NULL,
"teeth" JSONB NOT NULL,
"comment" TEXT,
"sentAt" TIMESTAMP(3),
CONSTRAINT "treatment_cases_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "treatment_case_attachments" (
"id" TEXT NOT NULL,
"caseId" TEXT,
"appointmentId" TEXT,
"caseClientKey" TEXT,
"fileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"sizeBytes" INTEGER NOT NULL,
"storagePath" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "treatment_case_attachments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "treatment_case_sends" (
"id" TEXT NOT NULL,
"caseId" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "treatment_case_sends_pkey" PRIMARY KEY ("id")
);
-- Migrate legacy patient_treatment_histories into treatments + single case per row
INSERT INTO "treatments" (
"id",
"organizationId",
"patientId",
"appointmentId",
"providerUserId",
"title",
"status",
"treatmentAt",
"createdAt",
"updatedAt"
)
SELECT
h."id",
p."organizationId",
h."patientId",
NULL,
o."ownerId",
h."title",
'COMPLETED'::"TreatmentStatus",
h."treatmentAt",
h."createdAt",
h."updatedAt"
FROM "patient_treatment_histories" h
JOIN "patients" p ON p."id" = h."patientId"
JOIN "organizations" o ON o."id" = p."organizationId";
INSERT INTO "treatment_cases" (
"id",
"treatmentId",
"clientKey",
"sortOrder",
"treatmentType",
"teeth",
"comment",
"sentAt"
)
SELECT
h."id" || '-case',
h."id",
NULL,
0,
'visit',
CASE
WHEN h."tooth" IS NOT NULL AND btrim(h."tooth") <> '' THEN jsonb_build_array(h."tooth")
ELSE '[]'::jsonb
END,
h."notes",
NULL
FROM "patient_treatment_histories" h;
-- Drop legacy table
DROP TABLE "patient_treatment_histories";
-- CreateIndex
CREATE UNIQUE INDEX "treatments_appointmentId_key" ON "treatments"("appointmentId");
-- CreateIndex
CREATE INDEX "treatments_patientId_treatmentAt_idx" ON "treatments"("patientId", "treatmentAt");
-- CreateIndex
CREATE INDEX "treatments_organizationId_status_idx" ON "treatments"("organizationId", "status");
-- CreateIndex
CREATE INDEX "treatment_cases_treatmentId_sortOrder_idx" ON "treatment_cases"("treatmentId", "sortOrder");
-- CreateIndex
CREATE INDEX "treatment_case_attachments_appointmentId_caseClientKey_idx" ON "treatment_case_attachments"("appointmentId", "caseClientKey");
-- CreateIndex
CREATE INDEX "treatment_case_attachments_caseId_idx" ON "treatment_case_attachments"("caseId");
-- CreateIndex
CREATE UNIQUE INDEX "treatment_case_sends_caseId_organizationId_key" ON "treatment_case_sends"("caseId", "organizationId");
-- AddForeignKey
ALTER TABLE "treatments" ADD CONSTRAINT "treatments_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "treatments" ADD CONSTRAINT "treatments_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "patients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "treatments" ADD CONSTRAINT "treatments_appointmentId_fkey" FOREIGN KEY ("appointmentId") REFERENCES "appointments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "treatment_cases" ADD CONSTRAINT "treatment_cases_treatmentId_fkey" FOREIGN KEY ("treatmentId") REFERENCES "treatments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "treatment_case_attachments" ADD CONSTRAINT "treatment_case_attachments_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "treatment_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "treatment_case_sends" ADD CONSTRAINT "treatment_case_sends_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "treatment_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "treatment_case_sends" ADD CONSTRAINT "treatment_case_sends_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -61,6 +61,8 @@ model Organization {
sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter")
patients Patient[]
appointments Appointment[]
treatments Treatment[]
caseSends TreatmentCaseSend[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -81,8 +83,8 @@ model Patient {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id])
treatments PatientTreatmentHistory[]
organization Organization @relation(fields: [organizationId], references: [id])
treatments Treatment[]
appointments Appointment[]
@@index([organizationId, createdAt])
@@ -90,24 +92,6 @@ model Patient {
@@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 Appointment {
id String @id @default(uuid())
organizationId String
@@ -119,6 +103,7 @@ model Appointment {
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
treatment Treatment?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -128,6 +113,84 @@ model Appointment {
@@map("appointments")
}
enum TreatmentStatus {
DRAFT
COMPLETED
}
model Treatment {
id String @id @default(uuid())
organizationId String
patientId String
appointmentId String? @unique
providerUserId String
title String
status TreatmentStatus @default(DRAFT)
treatmentAt DateTime
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull)
cases TreatmentCase[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([patientId, treatmentAt])
@@index([organizationId, status])
@@map("treatments")
}
model TreatmentCase {
id String @id @default(uuid())
treatmentId String
clientKey String?
sortOrder Int
treatmentType String
teeth Json
comment String?
sentAt DateTime?
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
attachments TreatmentCaseAttachment[]
sends TreatmentCaseSend[]
@@index([treatmentId, sortOrder])
@@map("treatment_cases")
}
model TreatmentCaseAttachment {
id String @id @default(uuid())
caseId String?
appointmentId String?
caseClientKey String?
fileName String
mimeType String
sizeBytes Int
storagePath String
case TreatmentCase? @relation(fields: [caseId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([appointmentId, caseClientKey])
@@index([caseId])
@@map("treatment_case_attachments")
}
model TreatmentCaseSend {
id String @id @default(uuid())
caseId String
organizationId String
sentAt DateTime @default(now())
case TreatmentCase @relation(fields: [caseId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@unique([caseId, organizationId])
@@map("treatment_case_sends")
}
model Plan {
id String @id @default(uuid())
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"

View File

@@ -10,6 +10,7 @@ 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';
import { TreatmentsModule } from './modules/treatments/treatments.module';
@Module({
imports: [
@@ -21,6 +22,7 @@ import { AppointmentsModule } from './modules/appointments/appointments.module';
AuthModule,
PatientsModule,
AppointmentsModule,
TreatmentsModule,
StaffModule,
OrganizationModule,
AdminModule.forRoot(),

View File

@@ -220,6 +220,9 @@ export class AppointmentsService {
if (names.includes('TAB_TREATMENT_EDIT')) {
return;
}
if (names.includes('TAB_TREATMENT_READ')) {
return;
}
throw new ForbiddenException('You do not have access to appointments');
}

View File

@@ -126,7 +126,11 @@ export class AuthController {
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create organization for current user' })
async createOrganization(@Req() req, @Body() dto: CreateOrganizationDto) {
return this.authService.createOrganization(req.user.id, dto);
return this.authService.createOrganization(
req.user.id,
req.user.organizationId,
dto,
);
}
// =========================

View File

@@ -4,6 +4,7 @@ import {
UnauthorizedException,
BadRequestException,
ConflictException,
ForbiddenException,
InternalServerErrorException
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@@ -270,7 +271,11 @@ export class AuthService {
return this.login({ email, password } as any, validatedUser);
}
async createOrganization(userId: string, dto: CreateOrganizationDto) {
async createOrganization(
userId: string,
currentOrganizationId: string | undefined,
dto: CreateOrganizationDto,
) {
const owner = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true },
@@ -280,6 +285,28 @@ export class AuthService {
throw new UnauthorizedException('User not found');
}
if (!currentOrganizationId) {
throw new ForbiddenException(
'Select an organization before creating a new one.',
);
}
const currentMembership = await this.prisma.membership.findUnique({
where: {
userId_organizationId: {
userId,
organizationId: currentOrganizationId,
},
},
select: { isOwner: true },
});
if (!currentMembership?.isOwner) {
throw new ForbiddenException(
'Only owners of the current organization can create new organizations.',
);
}
const organization = await this.prisma.$transaction(async (tx) => {
const createdOrganization = await tx.organization.create({
data: {

View File

@@ -1,28 +0,0 @@
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;
}

View File

@@ -16,7 +16,6 @@ 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')
@@ -52,26 +51,4 @@ export class PatientsController {
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);
}
}

View File

@@ -3,7 +3,6 @@ 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 {
@@ -89,36 +88,6 @@ export class PatientsService {
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 },

View File

@@ -80,6 +80,32 @@ export class StaffController {
return this.staffService.updateMember(req.user.id, organizationId, membershipId, dto);
}
@Patch('members/:membershipId/enable')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Re-enable a disabled staff member (uses one plan seat; no new invitation)',
})
enableMember(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('membershipId') membershipId: string,
) {
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
return this.staffService.enableMember(req.user.id, organizationId, membershipId);
}
@Patch('members/:membershipId/disable')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Disable staff member (frees a seat; member cannot access this organization)',
})
disableMember(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('membershipId') membershipId: string,
) {
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
return this.staffService.disableMember(req.user.id, organizationId, membershipId);
}
@Delete('members/:membershipId')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Remove staff member from organization' })

View File

@@ -7,6 +7,7 @@ import {
} from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions';
@@ -399,6 +400,88 @@ export class StaffService {
return { success: true, message: 'Member updated' };
}
async enableMember(actorUserId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot manage staff');
}
const target = await this.prisma.membership.findFirst({
where: { id: membershipId, organizationId },
include: {
invitations: { orderBy: { createdAt: 'desc' }, take: 1 },
},
});
if (!target) {
throw new NotFoundException('Member not found');
}
if (target.isOwner) {
throw new ForbiddenException('Cannot enable the organization owner');
}
if (target.isActive) {
throw new BadRequestException('This member is already active');
}
const invitation = target.invitations[0];
if (invitation && !invitation.acceptedAt) {
throw new BadRequestException(
'This member has not completed their invitation yet. Share the invite link instead.',
);
}
await this.prisma.$transaction(async (tx) => {
await this.assertOrganizationHasAvailableSeat(organizationId, tx);
await tx.membership.update({
where: { id: membershipId },
data: { isActive: true },
});
});
return {
success: true,
message: 'Member enabled. They can sign in to this organization again.',
};
}
async disableMember(actorUserId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot manage staff');
}
const target = await this.prisma.membership.findFirst({
where: { id: membershipId, organizationId },
});
if (!target) {
throw new NotFoundException('Member not found');
}
if (target.isOwner) {
throw new ForbiddenException('Cannot disable the organization owner');
}
if (actorUserId === target.userId) {
throw new BadRequestException('You cannot disable your own access');
}
if (!target.isActive) {
throw new BadRequestException('This member is already disabled or pending activation');
}
await this.prisma.membership.update({
where: { id: membershipId },
data: { isActive: false },
});
await this.prisma.session.deleteMany({
where: { userId: target.userId },
});
return {
success: true,
message: 'Member disabled. Their seat is now available for another invite.',
};
}
async removeMember(actorUserId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
@@ -421,6 +504,37 @@ export class StaffService {
return { success: true, message: 'Member removed' };
}
private async assertOrganizationHasAvailableSeat(
organizationId: string,
db: Prisma.TransactionClient | PrismaService = this.prisma,
) {
const org = await db.organization.findUnique({
where: { id: organizationId },
include: { plan: true },
});
if (!org) {
throw new NotFoundException('Organization not found');
}
if (!org.plan) {
throw new BadRequestException(
'This organization has no active subscription. Please choose a plan before adding staff.',
);
}
const maxUsers = org.plan.maxUsers;
const seatsUsed = await db.membership.count({
where: {
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
});
if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) {
throw new BadRequestException(
`Your plan allows ${maxUsers} team members. Free a seat by disabling another member or upgrade your plan.`,
);
}
}
private async getActorMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId },
@@ -435,11 +549,12 @@ export class StaffService {
isOwner: boolean;
isActive: boolean;
invitations: { acceptedAt: Date | null; revokedAt: Date | null; expiresAt: Date }[];
}): 'ACTIVE' | 'PENDING' | 'EXPIRED' {
if (m.isOwner || m.isActive) return 'ACTIVE';
}): 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED' {
if (m.isOwner) return 'ACTIVE';
if (m.isActive) return 'ACTIVE';
const invitation = m.invitations[0];
if (!invitation) return 'EXPIRED';
if (invitation.acceptedAt) return 'ACTIVE';
if (invitation?.acceptedAt) return 'DISABLED';
if (!invitation) return 'DISABLED';
if (invitation.revokedAt) return 'EXPIRED';
return invitation.expiresAt.getTime() > Date.now() ? 'PENDING' : 'EXPIRED';
}

View File

@@ -0,0 +1,60 @@
import {
ArrayMinSize,
IsArray,
IsIn,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const;
export class SaveTreatmentCaseDto {
@IsString()
@MaxLength(64)
clientId: string;
@IsOptional()
@IsUUID()
id?: string;
@IsIn(TREATMENT_TYPES)
treatmentType: string;
@IsArray()
@IsString({ each: true })
teeth: string[];
@IsOptional()
@IsString()
@MaxLength(5000)
comment?: string;
@IsOptional()
@IsArray()
@IsUUID(undefined, { each: true })
attachmentIds?: string[];
}
export class SaveTreatmentDraftDto {
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => SaveTreatmentCaseDto)
cases: SaveTreatmentCaseDto[];
}
export class SendTreatmentCaseDto {
@IsArray()
@ArrayMinSize(1)
@IsUUID(undefined, { each: true })
organizationIds: string[];
}
export class ListPatientTreatmentHistoryDto {
@IsOptional()
limit?: number;
}

View File

@@ -0,0 +1,16 @@
import { generateTreatmentTitle, normalizeTeeth } from './treatment.utils';
describe('treatment.utils', () => {
it('normalizes valid FDI teeth', () => {
expect(normalizeTeeth(['45', '14', '14', '99'])).toEqual(['14', '45']);
});
it('generates a title from cases', () => {
expect(
generateTreatmentTitle([
{ treatmentType: 'filling', teeth: ['14', '15'] },
{ treatmentType: 'endo', teeth: ['45'] },
]),
).toBe('Filling 14, 15 · Endo 45');
});
});

View File

@@ -0,0 +1,53 @@
import { TreatmentStatus } from '@prisma/client';
const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const;
export type TreatmentTypeValue = (typeof TREATMENT_TYPES)[number];
export function isTreatmentType(value: string): value is TreatmentTypeValue {
return (TREATMENT_TYPES as readonly string[]).includes(value);
}
const FDI_TOOTH_IDS = new Set([
'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 function normalizeTeeth(teeth: unknown): string[] {
if (!Array.isArray(teeth)) {
return [];
}
const unique = new Set<string>();
for (const tooth of teeth) {
if (typeof tooth !== 'string') continue;
const trimmed = tooth.trim();
if (FDI_TOOTH_IDS.has(trimmed)) {
unique.add(trimmed);
}
}
return [...unique].sort();
}
export function generateTreatmentTitle(
cases: { treatmentType: string; teeth: string[] }[],
): string {
if (cases.length === 0) {
return 'Treatment';
}
const parts = cases.map((c) => {
const label = c.treatmentType.charAt(0).toUpperCase() + c.treatmentType.slice(1);
if (c.teeth.length > 0) {
return `${label} ${c.teeth.join(', ')}`;
}
return label;
});
return parts.join(' · ');
}
export function mapTreatmentStatusForApi(status: TreatmentStatus): string {
return status === TreatmentStatus.DRAFT ? 'draft' : 'completed';
}

View File

@@ -0,0 +1,148 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
Put,
Query,
Req,
Res,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto';
import { TreatmentsService } from './treatments.service';
@ApiTags('treatments')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard)
@Controller('treatments')
export class TreatmentsController {
constructor(private readonly treatmentsService: TreatmentsService) {}
@Get('linked-organizations')
@ApiOperation({ summary: 'List active linked counterpart organizations (TAB_TREATMENT_READ)' })
listLinkedOrganizations(@Req() req: { user: { id: string; organizationId?: string } }) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.listLinkedOrganizations(req.user.id, organizationId);
}
@Get('patients/:patientId/history')
@ApiOperation({ summary: 'List completed treatments for a patient (TAB_TREATMENT_READ)' })
listPatientHistory(
@Param('patientId') patientId: string,
@Query('limit', new ParseIntPipe({ optional: true })) limit = 20,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.listPatientHistory(
patientId,
organizationId,
req.user.id,
limit,
);
}
@Get('appointments/:appointmentId/draft')
@ApiOperation({ summary: 'Get draft treatment for an appointment (TAB_TREATMENT_READ)' })
getDraft(
@Param('appointmentId') appointmentId: string,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.getDraftForAppointment(
appointmentId,
organizationId,
req.user.id,
);
}
@Put('appointments/:appointmentId/draft')
@ApiOperation({ summary: 'Save draft treatment for an appointment (TAB_TREATMENT_EDIT)' })
saveDraft(
@Param('appointmentId') appointmentId: string,
@Body() dto: SaveTreatmentDraftDto,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.saveDraftForAppointment(
appointmentId,
dto,
organizationId,
req.user.id,
);
}
@Post('appointments/:appointmentId/cases/:caseClientKey/attachments')
@ApiOperation({ summary: 'Upload attachments for a draft case (TAB_TREATMENT_EDIT)' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
files: {
type: 'array',
items: { type: 'string', format: 'binary' },
},
},
},
})
@UseInterceptors(
FilesInterceptor('files', 20, {
storage: memoryStorage(),
}),
)
uploadAttachments(
@Param('appointmentId') appointmentId: string,
@Param('caseClientKey') caseClientKey: string,
@UploadedFiles() files: Express.Multer.File[],
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.uploadCaseAttachments(
appointmentId,
caseClientKey,
files,
organizationId,
req.user.id,
);
}
@Get('attachments/:attachmentId/file')
@ApiOperation({ summary: 'Download a treatment attachment (TAB_TREATMENT_READ)' })
async downloadAttachment(
@Param('attachmentId') attachmentId: string,
@Req() req: { user: { id: string; organizationId?: string } },
@Res() res: Response,
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
const file = await this.treatmentsService.streamAttachmentFile(
attachmentId,
organizationId,
req.user.id,
);
res.setHeader('Content-Type', file.mimeType);
res.setHeader('Content-Disposition', `inline; filename="${file.fileName}"`);
file.stream.pipe(res);
}
@Post('cases/:caseId/send')
@ApiOperation({ summary: 'Send a treatment case to linked organizations (TAB_TREATMENT_EDIT)' })
sendCase(
@Param('caseId') caseId: string,
@Body() dto: SendTreatmentCaseDto,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.sendCase(caseId, dto, organizationId, req.user.id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { TreatmentsController } from './treatments.controller';
import { TreatmentsService } from './treatments.service';
@Module({
controllers: [TreatmentsController],
providers: [TreatmentsService, PrismaService],
})
export class TreatmentsModule {}

View File

@@ -0,0 +1,625 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LinkStatus, TreatmentStatus } from '@prisma/client';
import { createReadStream, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service';
import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto';
import {
generateTreatmentTitle,
isTreatmentType,
mapTreatmentStatusForApi,
normalizeTeeth,
} from './treatment.utils';
const treatmentInclude = {
cases: {
orderBy: [{ sortOrder: 'asc' as const }],
include: {
attachments: { orderBy: [{ createdAt: 'asc' as const }] },
sends: {
orderBy: [{ sentAt: 'asc' as const }],
include: { organization: { select: { id: true, name: true } } },
},
},
},
};
@Injectable()
export class TreatmentsService {
private readonly uploadRoot = join(process.cwd(), 'uploads', 'treatments');
constructor(private readonly prisma: PrismaService) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected');
}
return user.organizationId;
}
async listLinkedOrganizations(userId: string, organizationId: string) {
await this.assertCanReadTreatment(userId, organizationId);
const [linksA, linksB] = await Promise.all([
this.prisma.organizationLink.findMany({
where: { organizationAId: organizationId, status: LinkStatus.ACTIVE },
include: { organizationB: { select: { id: true, name: true } } },
}),
this.prisma.organizationLink.findMany({
where: { organizationBId: organizationId, status: LinkStatus.ACTIVE },
include: { organizationA: { select: { id: true, name: true } } },
}),
]);
const data = [
...linksA.map((l) => ({
id: l.organizationB.id,
name: l.organizationB.name,
active: true,
})),
...linksB.map((l) => ({
id: l.organizationA.id,
name: l.organizationA.name,
active: true,
})),
].sort((a, b) => a.name.localeCompare(b.name));
return { success: true, data };
}
async listPatientHistory(
patientId: string,
organizationId: string,
actorUserId: string,
limit = 20,
) {
await this.assertCanReadTreatment(actorUserId, organizationId);
await this.ensurePatientInOrg(patientId, organizationId);
const items = await this.prisma.treatment.findMany({
where: {
patientId,
organizationId,
status: TreatmentStatus.COMPLETED,
},
include: treatmentInclude,
orderBy: [{ treatmentAt: 'desc' }],
take: Math.min(Math.max(limit, 1), 100),
});
return { success: true, data: items.map((t) => this.mapTreatment(t)) };
}
async getDraftForAppointment(
appointmentId: string,
organizationId: string,
actorUserId: string,
) {
await this.assertCanReadTreatment(actorUserId, organizationId);
const appointment = await this.ensureAppointmentProvider(
appointmentId,
organizationId,
actorUserId,
false,
);
const treatment = await this.prisma.treatment.findFirst({
where: {
appointmentId: appointment.id,
organizationId,
status: TreatmentStatus.DRAFT,
},
include: treatmentInclude,
});
return { success: true, data: treatment ? this.mapTreatment(treatment) : null };
}
async saveDraftForAppointment(
appointmentId: string,
dto: SaveTreatmentDraftDto,
organizationId: string,
actorUserId: string,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
const appointment = await this.ensureAppointmentProvider(
appointmentId,
organizationId,
actorUserId,
true,
);
for (const c of dto.cases) {
if (!isTreatmentType(c.treatmentType)) {
throw new BadRequestException(`Invalid treatment type: ${c.treatmentType}`);
}
}
const normalizedCases = dto.cases.map((c, index) => ({
...c,
sortOrder: index,
teeth: normalizeTeeth(c.teeth),
comment: c.comment?.trim() || null,
attachmentIds: c.attachmentIds ?? [],
}));
const title = generateTreatmentTitle(
normalizedCases.map((c) => ({ treatmentType: c.treatmentType, teeth: c.teeth })),
);
const treatment = await this.prisma.$transaction(async (tx) => {
const existing = await tx.treatment.findFirst({
where: { appointmentId: appointment.id, organizationId },
select: { id: true },
});
const saved = existing
? await tx.treatment.update({
where: { id: existing.id },
data: {
title,
treatmentAt: appointment.startAt,
patientId: appointment.patientId,
providerUserId: appointment.providerUserId,
status: TreatmentStatus.DRAFT,
},
})
: await tx.treatment.create({
data: {
organizationId,
patientId: appointment.patientId,
appointmentId: appointment.id,
providerUserId: appointment.providerUserId,
title,
status: TreatmentStatus.DRAFT,
treatmentAt: appointment.startAt,
},
});
const keepCaseIds = normalizedCases.map((c) => c.id).filter(Boolean) as string[];
const existingCases = existing
? await tx.treatmentCase.findMany({
where: { treatmentId: saved.id },
select: { id: true, sentAt: true },
})
: [];
const sentCaseIds = new Set(
existingCases.filter((c) => c.sentAt).map((c) => c.id),
);
const removableCaseIds = existingCases
.filter((c) => !keepCaseIds.includes(c.id) && !c.sentAt)
.map((c) => c.id);
if (removableCaseIds.length > 0) {
await tx.treatmentCase.deleteMany({
where: { id: { in: removableCaseIds }, treatmentId: saved.id },
});
}
for (const c of normalizedCases) {
if (c.id && sentCaseIds.has(c.id)) {
continue;
}
const row = c.id
? await tx.treatmentCase.update({
where: { id: c.id },
data: {
clientKey: c.clientId,
sortOrder: c.sortOrder,
treatmentType: c.treatmentType,
teeth: c.teeth,
comment: c.comment,
},
})
: await tx.treatmentCase.create({
data: {
treatmentId: saved.id,
clientKey: c.clientId,
sortOrder: c.sortOrder,
treatmentType: c.treatmentType,
teeth: c.teeth,
comment: c.comment,
},
});
const allowedAttachmentIds = new Set(c.attachmentIds);
const pendingAttachments = await tx.treatmentCaseAttachment.findMany({
where: {
appointmentId: appointment.id,
caseClientKey: c.clientId,
},
});
for (const attachment of pendingAttachments) {
if (!allowedAttachmentIds.has(attachment.id)) {
await tx.treatmentCaseAttachment.delete({ where: { id: attachment.id } });
} else {
await tx.treatmentCaseAttachment.update({
where: { id: attachment.id },
data: { caseId: row.id, appointmentId: null, caseClientKey: null },
});
}
}
await tx.treatmentCaseAttachment.deleteMany({
where: {
caseId: row.id,
id: { notIn: [...allowedAttachmentIds] },
},
});
}
return tx.treatment.findUniqueOrThrow({
where: { id: saved.id },
include: treatmentInclude,
});
});
return { success: true, data: this.mapTreatment(treatment) };
}
async sendCase(
caseId: string,
dto: SendTreatmentCaseDto,
organizationId: string,
actorUserId: string,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
const treatmentCase = await this.prisma.treatmentCase.findFirst({
where: {
id: caseId,
treatment: { organizationId },
},
include: {
treatment: { select: { providerUserId: true, appointmentId: true } },
sends: { select: { organizationId: true } },
},
});
if (!treatmentCase) {
throw new NotFoundException('Treatment case not found');
}
if (treatmentCase.treatment.providerUserId !== actorUserId) {
const membership = await this.getMembership(actorUserId, organizationId);
if (!membership?.isOwner) {
throw new ForbiddenException('Only the appointment provider can send this case');
}
}
const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
const uniqueTargets = [...new Set(dto.organizationIds)];
for (const orgId of uniqueTargets) {
if (!linkedOrgIds.has(orgId)) {
throw new BadRequestException('One or more organizations are not active linked counterparts');
}
}
const alreadySent = new Set(treatmentCase.sends.map((s) => s.organizationId));
const newTargets = uniqueTargets.filter((id) => !alreadySent.has(id));
if (newTargets.length === 0) {
throw new BadRequestException('Case was already sent to all selected organizations');
}
const now = new Date();
await this.prisma.$transaction(async (tx) => {
await tx.treatmentCaseSend.createMany({
data: newTargets.map((organizationId) => ({
caseId,
organizationId,
})),
});
if (!treatmentCase.sentAt) {
await tx.treatmentCase.update({
where: { id: caseId },
data: { sentAt: now },
});
}
});
const refreshed = await this.prisma.treatmentCase.findUniqueOrThrow({
where: { id: caseId },
include: {
attachments: { orderBy: [{ createdAt: 'asc' }] },
sends: {
orderBy: [{ sentAt: 'asc' }],
include: { organization: { select: { id: true, name: true } } },
},
},
});
return { success: true, data: this.mapCase(refreshed) };
}
async uploadCaseAttachments(
appointmentId: string,
caseClientKey: string,
files: Express.Multer.File[],
organizationId: string,
actorUserId: string,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId, true);
if (!caseClientKey?.trim()) {
throw new BadRequestException('caseClientKey is required');
}
if (!files?.length) {
throw new BadRequestException('At least one file is required');
}
const orgDir = join(this.uploadRoot, organizationId);
mkdirSync(orgDir, { recursive: true });
const created: {
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
}[] = [];
for (const file of files) {
const storageName = `${randomUUID()}-${file.originalname.replace(/[^\w.\-()+]/g, '_')}`;
const storagePath = join(orgDir, storageName);
const { writeFileSync } = await import('fs');
writeFileSync(storagePath, file.buffer);
const attachment = await this.prisma.treatmentCaseAttachment.create({
data: {
appointmentId,
caseClientKey,
fileName: file.originalname,
mimeType: file.mimetype || 'application/octet-stream',
sizeBytes: file.size,
storagePath,
},
});
created.push(this.mapAttachment(attachment));
}
return { success: true, data: created };
}
async streamAttachmentFile(
attachmentId: string,
organizationId: string,
actorUserId: string,
) {
await this.assertCanReadTreatment(actorUserId, organizationId);
const attachment = await this.prisma.treatmentCaseAttachment.findFirst({
where: {
id: attachmentId,
OR: [
{ case: { treatment: { organizationId } } },
{ appointmentId: { not: null } },
],
},
include: {
case: { select: { treatment: { select: { organizationId: true } } } },
},
});
if (!attachment) {
throw new NotFoundException('Attachment not found');
}
if (attachment.case && attachment.case.treatment.organizationId !== organizationId) {
throw new NotFoundException('Attachment not found');
}
if (!attachment.case && attachment.appointmentId) {
const appointment = await this.prisma.appointment.findFirst({
where: { id: attachment.appointmentId, organizationId },
select: { id: true },
});
if (!appointment) {
throw new NotFoundException('Attachment not found');
}
}
if (!existsSync(attachment.storagePath)) {
throw new NotFoundException('File is no longer available');
}
return {
stream: createReadStream(attachment.storagePath),
fileName: attachment.fileName,
mimeType: attachment.mimeType,
};
}
private mapTreatment(treatment: {
id: string;
patientId: string;
appointmentId: string | null;
title: string;
status: TreatmentStatus;
treatmentAt: Date;
cases: Array<{
id: string;
clientKey: string | null;
treatmentType: string;
teeth: unknown;
comment: string | null;
sentAt: Date | null;
attachments: Array<{
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
}>;
sends: Array<{ organizationId: string; sentAt: Date; organization: { id: string; name: string } }>;
}>;
}) {
const documents = treatment.cases.flatMap((c) =>
c.attachments.map((a) => this.mapAttachment(a)),
);
return {
id: treatment.id,
patientId: treatment.patientId,
appointmentId: treatment.appointmentId,
title: treatment.title,
treatmentAt: treatment.treatmentAt.toISOString(),
status: mapTreatmentStatusForApi(treatment.status),
cases: treatment.cases.map((c) => this.mapCase(c)),
documents,
};
}
private mapCase(c: {
id: string;
clientKey?: string | null;
treatmentType: string;
teeth: unknown;
comment?: string | null;
sentAt?: Date | null;
attachments?: Array<{
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
}>;
sends?: Array<{ organizationId: string; sentAt: Date; organization?: { id: string; name: string } }>;
}) {
return {
id: c.id,
clientId: c.clientKey ?? c.id,
treatmentType: c.treatmentType,
teeth: normalizeTeeth(c.teeth),
notes: c.comment ?? null,
sentAt: c.sentAt?.toISOString() ?? null,
sendToOrganizationIds: c.sends?.map((s) => s.organizationId) ?? [],
sends:
c.sends?.map((s) => ({
organizationId: s.organizationId,
organizationName: s.organization?.name ?? 'Unknown organization',
sentAt: s.sentAt.toISOString(),
})) ?? [],
attachmentMetas: (c.attachments ?? []).map((a) => this.mapAttachment(a)),
};
}
private mapAttachment(a: {
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
}) {
return {
id: a.id,
fileName: a.fileName,
mimeType: a.mimeType,
sizeBytes: a.sizeBytes,
};
}
private async getActiveLinkedOrganizationIds(organizationId: string) {
const [linksA, linksB] = await Promise.all([
this.prisma.organizationLink.findMany({
where: { organizationAId: organizationId, status: LinkStatus.ACTIVE },
select: { organizationBId: true },
}),
this.prisma.organizationLink.findMany({
where: { organizationBId: organizationId, status: LinkStatus.ACTIVE },
select: { organizationAId: true },
}),
]);
return new Set([
...linksA.map((l) => l.organizationBId),
...linksB.map((l) => l.organizationAId),
]);
}
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 ensureAppointmentProvider(
appointmentId: string,
organizationId: string,
actorUserId: string,
requireProviderMatch: boolean,
) {
const appointment = await this.prisma.appointment.findFirst({
where: { id: appointmentId, organizationId },
select: {
id: true,
patientId: true,
providerUserId: true,
startAt: true,
},
});
if (!appointment) {
throw new NotFoundException('Appointment not found');
}
if (requireProviderMatch) {
const membership = await this.getMembership(actorUserId, organizationId);
const isOwner = membership?.isOwner ?? false;
if (!isOwner && appointment.providerUserId !== actorUserId) {
throw new ForbiddenException('You are not the provider for this appointment');
}
}
return appointment;
}
private async assertCanReadTreatment(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_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT')) {
return;
}
throw new ForbiddenException('You do not have access to treatments');
}
private async assertCanEditTreatment(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_TREATMENT_EDIT')) {
return;
}
throw new ForbiddenException('You cannot edit treatments');
}
private async getMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
include: { permissions: { include: { permission: true } } },
});
}
}

View File

@@ -4,7 +4,7 @@ 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 { canEditAppointments, hasPermission } from '@/components/shared/permissions';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import type { CreatePatientInput, Patient } from '@/types/patient';
import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal';
@@ -13,12 +13,12 @@ import { AppointmentBookingModal } from '@/components/ui/appointments/Appointmen
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 { ToastStack } from '@/components/ui/common/Toast';
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
import { ToastStack } from '@/components/ui/shared/Toast';
import { useToast } from '@/lib/hooks/useToast';
import type { AppointmentPurpose } from '@/types/appointment';
import { formatApiErrorMessage } from '@/lib/formatApiError';
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime';
const EMPTY_PATIENT_FORM: CreatePatientInput = {
firstName: '',
@@ -273,6 +273,7 @@ export default function AppointmentsPage() {
if (!canEditPatients) {
return;
}
setPatientForm(EMPTY_PATIENT_FORM);
setIsCreateOpen(true);
}}
/>
@@ -285,7 +286,6 @@ export default function AppointmentsPage() {
<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 && (
@@ -323,18 +323,18 @@ export default function AppointmentsPage() {
deleting={deletingAppointment}
/>
{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>
)}
<CreatePatientModal
variant="dialog"
isOpen={isCreateOpen}
formData={patientForm}
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
onSubmit={() => void handleCreatePatient()}
onClose={() => {
setIsCreateOpen(false);
setPatientForm(EMPTY_PATIENT_FORM);
}}
loading={savingPatient}
/>
</div>
);

View File

@@ -2,13 +2,13 @@
'use client';
import { useState } from 'react';
import { Pencil } from 'lucide-react';
import { Button } from '@/components/ui/common/Button';
import { Badge } from '@/components/ui/common/Badge';
import { Card } from '@/components/ui/common/Card';
import { Table } from '@/components/ui/common/Table';
import { SearchBar } from '@/components/ui/common/SearchBar';
import { Button } from '@/components/ui/shared/Button';
import { Badge } from '@/components/ui/shared/Badge';
import { Card } from '@/components/ui/shared/Card';
import { Table } from '@/components/ui/shared/Table';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { useAuth } from '@/lib/hooks/useAuth';
import { hasPermission } from '@/shared/permissions';
import { hasPermission } from '@/components/shared/permissions';
// Mock data matching your design
const invoices = [
{ id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' },

View File

@@ -3,15 +3,15 @@
import { memo, useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import Sidebar from '@/components/ui/common/Sidebar';
import { ThemeToggle } from '@/components/ui/common/ThemeToggle';
import Sidebar from '@/components/ui/shared/Sidebar';
import { ThemeToggle } from '@/components/ui/shared/ThemeToggle';
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
import {
canAccessAppointmentsSection,
firstAccessibleDashboardPath,
getRequiredReadPermissionForPath,
hasPermission,
} from '@/shared/permissions';
} from '@/components/shared/permissions';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, currentOrganization, isAuthReady } = useAuth();

View File

@@ -14,12 +14,12 @@ import {
import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks';
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog';
import { Button } from '@/components/ui/common/Button';
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/common/Badge';
import { Input } from '@/components/ui/common/Input';
import { SearchBar } from '@/components/ui/common/SearchBar';
import { Table } from '@/components/ui/common/Table';
import { ToastStack } from '@/components/ui/common/Toast';
import { Button } from '@/components/ui/shared/Button';
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge';
import { Input } from '@/components/ui/shared/Input';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { Table } from '@/components/ui/shared/Table';
import { ToastStack } from '@/components/ui/shared/Toast';
import type { ApiError } from '@/types/api';
function formatOrganizationStatusLabel(status: string): string {

View File

@@ -1,20 +1,17 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/common/Button';
import { Button } from '@/components/ui/shared/Button';
import { ToastStack } from '@/components/ui/shared/Toast';
import { patientsApi } from '@/lib/api/patients';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { hasPermission } from '@/shared/permissions';
import {
CreatePatientInput,
CreateTreatmentHistoryInput,
Patient,
TreatmentHistoryItem,
} from '@/types/patient';
import { useToast } from '@/lib/hooks/useToast';
import { hasPermission } from '@/components/shared/permissions';
import { CreatePatientInput, Patient } from '@/types/patient';
import { PatientSearchSelect } from '../../../components/ui/patient/PatientSearchSelect';
import { CreatePatientModal } from '../../../components/ui/patient/CreatePatientModal';
import { PatientSummaryCard } from '../../../components/ui/patient/PatientSummaryCard';
import { TreatmentHistoryPreview } from '../../../components/ui/patient/TreatmentHistoryPreview';
const EMPTY_PATIENT_FORM: CreatePatientInput = {
firstName: '',
@@ -25,18 +22,14 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = {
export default function PatientsPage() {
const { currentOrganization } = useAuth();
const toast = useToast();
const [search, setSearch] = useState('');
const [patients, setPatients] = useState<Patient[]>([]);
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
const [treatments, setTreatments] = useState<TreatmentHistoryItem[]>([]);
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<CreatePatientInput>(EMPTY_PATIENT_FORM);
const [errorMessage, setErrorMessage] = useState<string>('');
const [successMessage, setSuccessMessage] = useState<string>('');
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
const sortedPatients = useMemo(
@@ -58,21 +51,9 @@ export default function PatientsPage() {
void loadPatients('');
}, []);
useEffect(() => {
if (!successMessage) {
return;
}
const timeout = setTimeout(() => {
setSuccessMessage('');
}, 3000);
return () => clearTimeout(timeout);
}, [successMessage]);
async function loadPatients(q: string) {
setLoadingPatients(true);
setErrorMessage('');
toast.setError('');
try {
const response = await patientsApi.list({ q, page: 1, limit: 25 });
const items = response.data.items;
@@ -82,86 +63,43 @@ export default function PatientsPage() {
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.');
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, '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('');
toast.setError('');
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(
toast.showSuccess(
`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.');
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, '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 (
<div className="relative space-y-6 pb-20">
<div className="flex items-center justify-between">
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-text-primary">Patients</h1>
<Button
variant="primary"
disabled={!canEditPatients}
onClick={() => {
if (!canEditPatients) return;
toast.clear();
setPatientForm(EMPTY_PATIENT_FORM);
setIsCreateOpen(true);
}}
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
@@ -170,14 +108,21 @@ export default function PatientsPage() {
</Button>
</div>
<CreatePatientModal
isOpen={isCreateOpen}
formData={patientForm}
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
onSubmit={handleCreatePatient}
onClose={() => setIsCreateOpen(false)}
loading={savingPatient}
/>
<ToastStack {...toast.messages} />
{isCreateOpen && (
<CreatePatientModal
isOpen={isCreateOpen}
formData={patientForm}
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
onSubmit={() => void handleCreatePatient()}
onClose={() => {
setIsCreateOpen(false);
setPatientForm(EMPTY_PATIENT_FORM);
}}
loading={savingPatient}
/>
)}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-1">
@@ -186,48 +131,15 @@ export default function PatientsPage() {
onSearchChange={setSearch}
patients={sortedPatients}
selectedPatientId={selectedPatient?.id}
onSelectPatient={(patient) => {
setSelectedPatient(patient);
void loadTreatments(patient.id);
}}
onSelectPatient={setSelectedPatient}
loading={loadingPatients}
/>
</div>
<div className="xl:col-span-2 space-y-4">
<PatientSummaryCard patient={selectedPatient} />
<div className="flex">
<Button
variant="secondary"
disabled={!selectedPatient || !canEditPatients}
isLoading={savingTreatment}
onClick={() => {
if (!canEditPatients) return;
void handleQuickAddTreatment();
}}
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
>
Add Quick Treatment Entry
</Button>
</div>
<TreatmentHistoryPreview items={treatments} loading={loadingTreatments} />
</div>
</div>
{(errorMessage || successMessage) && (
<div className="absolute bottom-0 left-0 right-0 z-10 w-full">
{errorMessage && (
<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">
{errorMessage}
</div>
)}
{successMessage && (
<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">
{successMessage}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -5,8 +5,8 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth';
import { Button } from '@/components/ui/common/Button';
import { Toast } from '@/components/ui/common/Toast';
import { Button } from '@/components/ui/shared/Button';
import { Toast } from '@/components/ui/shared/Toast';
import type { SubscriptionAlertData } from '@/types/subscription';
const PLAN_OPTIONS = [

View File

@@ -6,7 +6,7 @@ import {
firstAccessibleDashboardPath,
canEditStaff,
canViewStaff,
} from '@/shared/permissions';
} from '@/components/shared/permissions';
import {
STAFF_FEATURE_GROUPS,
permissionNamesFromFeatureState,
@@ -16,16 +16,18 @@ import {
formatAccessSummary,
type FeaturePermState,
} from '../../../components/staff/staff-permission-form';
import { Pencil, Trash2, Copy, Check, X } from 'lucide-react';
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton';
import { Pencil, Trash2, Copy, Check, X, UserX, UserCheck } from 'lucide-react';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { useAuth } from '@/lib/hooks/useAuth';
import { staffApi, type StaffMemberDto } from '@/lib/api/staff';
import { Button } from '@/components/ui/common/Button';
import { Badge } from '@/components/ui/common/Badge';
import { Input } from '@/components/ui/common/Input';
import { Checkbox } from '@/components/ui/common/Checkbox';
import { Table } from '@/components/ui/common/Table';
import type { ApiError } from '@/types/api';
import { Button } from '@/components/ui/shared/Button';
import { Badge } from '@/components/ui/shared/Badge';
import { Input } from '@/components/ui/shared/Input';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { Table } from '@/components/ui/shared/Table';
import { ToastStack } from '@/components/ui/shared/Toast';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast';
type StoredInviteLink = {
membershipId: string;
@@ -54,16 +56,19 @@ function writeStoredInviteLinks(orgId: string, links: Record<string, StoredInvit
window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links));
}
function formatApiMessage(err: unknown): string {
if (!err || typeof err !== 'object') return 'Something went wrong';
const m = (err as ApiError).message;
if (Array.isArray(m)) return m.join(', ');
if (typeof m === 'string') return m;
return 'Something went wrong';
function canShareStaffInviteLink(member: StaffMemberDto): boolean {
return (
!member.isOwner &&
(member.invitationStatus === 'PENDING' || member.invitationStatus === 'EXPIRED')
);
}
function canShareStaffInviteLink(member: StaffMemberDto): boolean {
return !member.isOwner && member.invitationStatus !== 'ACTIVE';
function canDisableStaff(member: StaffMemberDto): boolean {
return !member.isOwner && member.isActive;
}
function canEnableStaff(member: StaffMemberDto): boolean {
return !member.isOwner && member.invitationStatus === 'DISABLED';
}
function PermissionGrid({
@@ -136,8 +141,7 @@ export default function StaffPage() {
unlimited: boolean;
} | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const toast = useToast();
const [inviteOpen, setInviteOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState('');
@@ -159,6 +163,10 @@ export default function StaffPage() {
const [editName, setEditName] = useState('');
const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState());
const [editLoading, setEditLoading] = useState(false);
const [disableTarget, setDisableTarget] = useState<StaffMemberDto | null>(null);
const [disablingMembershipId, setDisablingMembershipId] = useState<string | null>(null);
const [enableTarget, setEnableTarget] = useState<StaffMemberDto | null>(null);
const [enablingMembershipId, setEnablingMembershipId] = useState<string | null>(null);
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
const hasActivePlan = Boolean(currentOrganization?.plan);
@@ -168,15 +176,21 @@ export default function StaffPage() {
return seats.used >= seats.limit;
}, [seats]);
const hasAvailableSeat = useMemo(() => {
if (!seats || seats.unlimited) return true;
if (seats.limit == null) return true;
return seats.used < seats.limit;
}, [seats]);
const load = useCallback(async () => {
setError('');
toast.setError('');
setLoading(true);
try {
const res = await staffApi.list();
setMembers(res.data.members);
setSeats(res.data.seats);
} catch (e) {
setError(formatApiMessage(e));
toast.showError(formatApiErrorMessage(e, 'Failed to load staff.'));
} finally {
setLoading(false);
}
@@ -221,17 +235,11 @@ export default function StaffPage() {
}
}, [currentOrganization, router]);
useEffect(() => {
if (!success) return;
const t = setTimeout(() => setSuccess(''), 4000);
return () => clearTimeout(t);
}, [success]);
async function copyStaffInviteLink(member: StaffMemberDto) {
if (!canShareStaffInviteLink(member)) return;
setCopyingInviteMembershipId(member.id);
setError('');
toast.setError('');
try {
let invitationUrl = pendingInviteLinks[member.id]?.invitationUrl;
if (!invitationUrl || member.invitationStatus === 'EXPIRED') {
@@ -257,7 +265,7 @@ export default function StaffPage() {
await load();
}
} catch (e) {
setError(formatApiMessage(e));
toast.showError(formatApiErrorMessage(e, 'Could not copy invitation link.'));
} finally {
setCopyingInviteMembershipId(null);
}
@@ -265,7 +273,7 @@ export default function StaffPage() {
async function submitInvite() {
setInviteLoading(true);
setError('');
toast.setError('');
setLastInviteInfo(null);
const displayName = inviteName.trim();
const displayEmail = inviteEmail.trim();
@@ -295,14 +303,13 @@ export default function StaffPage() {
setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}
setSuccess('');
setInviteOpen(false);
setInviteEmail('');
setInviteName('');
setInvitePerms(emptyFeaturePermissionState());
await load();
} catch (e) {
setError(formatApiMessage(e));
toast.showError(formatApiErrorMessage(e, 'Failed to send invitation.'));
} finally {
setInviteLoading(false);
}
@@ -320,36 +327,57 @@ export default function StaffPage() {
async function submitEdit() {
if (!editing) return;
setEditLoading(true);
setError('');
toast.setError('');
try {
await staffApi.updateMember(editing.id, {
name: editName.trim(),
permissionNames: permissionNamesFromFeatureState(editPerms),
});
setSuccess('Member updated');
toast.showSuccess('Member updated.');
setEditing(null);
await load();
} catch (e) {
setError(formatApiMessage(e));
toast.showError(formatApiErrorMessage(e, 'Failed to update member.'));
} finally {
setEditLoading(false);
}
}
async function removeMember(m: StaffMemberDto) {
if (m.isOwner) return;
if (m.userId === user?.id) {
if (!confirm('Remove yourself from this organization? You will lose access.')) return;
} else {
if (!confirm(`Remove ${m.name} from this organization?`)) return;
}
setError('');
function handleDeleteMember() {
toast.showError('Delete is not implemented yet.');
}
async function confirmDisableMember() {
if (!disableTarget || !canDisableStaff(disableTarget)) return;
setDisablingMembershipId(disableTarget.id);
toast.setError('');
try {
await staffApi.removeMember(m.id);
setSuccess('Member removed');
await staffApi.disableMember(disableTarget.id);
toast.showSuccess(`${disableTarget.name} was disabled. A seat is now available.`);
setDisableTarget(null);
await load();
} catch (e) {
setError(formatApiMessage(e));
toast.showError(formatApiErrorMessage(e, 'Failed to disable member.'));
} finally {
setDisablingMembershipId(null);
}
}
async function confirmEnableMember() {
if (!enableTarget || !canEnableStaff(enableTarget) || !hasAvailableSeat) return;
setEnablingMembershipId(enableTarget.id);
toast.setError('');
try {
await staffApi.enableMember(enableTarget.id);
toast.showSuccess(`${enableTarget.name} was enabled and can sign in again.`);
setEnableTarget(null);
await load();
} catch (e) {
toast.showError(formatApiErrorMessage(e, 'Failed to enable member.'));
} finally {
setEnablingMembershipId(null);
}
}
@@ -383,6 +411,8 @@ export default function StaffPage() {
</Button>
</div>
<ToastStack {...toast.messages} />
{seats && (
<p className="text-sm text-text-secondary">
Seats:{' '}
@@ -400,18 +430,6 @@ export default function StaffPage() {
</p>
)}
{error && (
<div className="rounded-[var(--radius-md)] border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{success && (
<div className="rounded-[var(--radius-md)] border border-primary/30 bg-primary-soft/40 px-4 py-3 text-sm text-text-primary">
{success}
</div>
)}
{lastInviteInfo && (
<div className="relative rounded-[var(--radius-md)] border border-border-strong bg-background-secondary/90 px-4 py-3 pr-12 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] space-y-3">
<button
@@ -453,7 +471,7 @@ export default function StaffPage() {
}
void (async () => {
setCopyingInviteMembershipId(lastInviteInfo.membershipId);
setError('');
toast.setError('');
try {
const res = await staffApi.getInvitationLink(lastInviteInfo.membershipId);
if (currentOrganization?.id) {
@@ -473,7 +491,7 @@ export default function StaffPage() {
setCopiedInviteMembershipId(lastInviteInfo.membershipId);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch (e) {
setError(formatApiMessage(e));
toast.showError(formatApiErrorMessage(e, 'Could not copy invitation link.'));
} finally {
setCopyingInviteMembershipId(null);
}
@@ -501,7 +519,9 @@ export default function StaffPage() {
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">Role</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">Access</th>
<th className="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider w-28">Actions</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider w-36">
Action
</th>
</tr>
}
body={
@@ -522,6 +542,8 @@ export default function StaffPage() {
<Badge variant="success">Active</Badge>
) : m.invitationStatus === 'PENDING' ? (
<Badge variant="warning">Pending</Badge>
) : m.invitationStatus === 'DISABLED' ? (
<Badge variant="default">Disabled</Badge>
) : (
<Badge variant="danger">Expired</Badge>
)}
@@ -535,9 +557,9 @@ export default function StaffPage() {
</span>
)}
</td>
<td className="px-6 py-1.5 align-middle">
<td className="px-6 py-1.5 align-middle text-center">
{!m.isOwner && (
<div className="flex min-h-[36px] items-center justify-end gap-1">
<div className="flex min-h-[36px] items-center justify-center gap-1 mx-auto w-fit">
{canShareStaffInviteLink(m) && (
<button
type="button"
@@ -554,6 +576,44 @@ export default function StaffPage() {
)}
</button>
)}
{canEnableStaff(m) && (
<button
type="button"
className={`p-2 rounded-md ${
canEdit
? 'text-text-secondary hover:bg-background-card/80 hover:text-primary'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Enable member"
disabled={!canEdit || enablingMembershipId === m.id}
title="Enable member (uses a seat)"
onClick={() => {
if (!canEdit) return;
setEnableTarget(m);
}}
>
<UserCheck className="w-4 h-4" />
</button>
)}
{canDisableStaff(m) && (
<button
type="button"
className={`p-2 rounded-md ${
canEdit
? 'text-text-secondary hover:bg-background-card/80 hover:text-amber-600'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Disable member"
disabled={!canEdit || disablingMembershipId === m.id}
title="Disable member (frees a seat)"
onClick={() => {
if (!canEdit) return;
setDisableTarget(m);
}}
>
<UserX className="w-4 h-4" />
</button>
)}
<button
type="button"
className={`p-2 rounded-md ${
@@ -577,11 +637,12 @@ export default function StaffPage() {
? 'text-text-secondary hover:bg-red-500/15 hover:text-red-600'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Remove member"
aria-label="Delete member"
disabled={!canEdit}
title="Delete member (not implemented)"
onClick={() => {
if (!canEdit) return;
void removeMember(m);
handleDeleteMember();
}}
>
<Trash2 className="w-4 h-4" />
@@ -647,6 +708,120 @@ export default function StaffPage() {
</div>
)}
{enableTarget && (
<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="enable-staff-title"
>
<div className="flex items-start justify-between gap-2">
<h2 id="enable-staff-title" className="text-lg font-semibold text-text-primary pr-2">
Enable team member
</h2>
<DialogCloseButton
onClick={() => {
if (enablingMembershipId) return;
setEnableTarget(null);
}}
/>
</div>
<p className="text-sm text-text-secondary">
Enable <span className="font-medium text-text-primary">{enableTarget.name}</span> (
{enableTarget.email})?
</p>
<ul className="text-sm text-text-secondary space-y-2 list-disc pl-5">
<li>They can sign in to this organization again with their existing account.</li>
<li>No new invitation is sent and no data was removed while they were disabled.</li>
<li>
Enabling uses <span className="text-text-primary font-medium">one seat</span> on your
plan.
</li>
</ul>
{!hasAvailableSeat && (
<p className="text-sm text-amber-600 dark:text-amber-400">
No seats are available. Disable another member or upgrade your plan before enabling
this person.
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<Button
type="button"
variant="outline"
disabled={Boolean(enablingMembershipId)}
onClick={() => setEnableTarget(null)}
>
Cancel
</Button>
<Button
type="button"
variant="primary"
isLoading={enablingMembershipId === enableTarget.id}
disabled={Boolean(enablingMembershipId) || !hasAvailableSeat}
onClick={() => void confirmEnableMember()}
>
Enable member
</Button>
</div>
</div>
</div>
)}
{disableTarget && (
<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="disable-staff-title"
>
<div className="flex items-start justify-between gap-2">
<h2 id="disable-staff-title" className="text-lg font-semibold text-text-primary pr-2">
Disable team member
</h2>
<DialogCloseButton
onClick={() => {
if (disablingMembershipId) return;
setDisableTarget(null);
}}
/>
</div>
<p className="text-sm text-text-secondary">
Disable <span className="font-medium text-text-primary">{disableTarget.name}</span> (
{disableTarget.email})?
</p>
<ul className="text-sm text-text-secondary space-y-2 list-disc pl-5">
<li>They will not be able to sign in to this organization.</li>
<li>No data will be removed.</li>
<li>
Disabling frees <span className="text-text-primary font-medium">one seat</span> on your
plan so you can invite someone else.
</li>
</ul>
<div className="flex justify-end gap-2 pt-1">
<Button
type="button"
variant="outline"
disabled={Boolean(disablingMembershipId)}
onClick={() => setDisableTarget(null)}
>
Cancel
</Button>
<Button
type="button"
variant="danger"
isLoading={disablingMembershipId === disableTarget.id}
disabled={Boolean(disablingMembershipId)}
onClick={() => void confirmDisableMember()}
>
Disable member
</Button>
</div>
</div>
</div>
)}
{editing && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div

View File

@@ -2,7 +2,7 @@
import Link from 'next/link';
import { useAuth } from '@/lib/hooks/useAuth';
import { Card } from '@/components/ui/common/Card';
import { Card } from '@/components/ui/shared/Card';
export default function TodayPage() {
const { currentOrganization } = useAuth();

View File

@@ -4,8 +4,8 @@ import { useEffect, useMemo, useState } from 'react';
import { Suspense } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
import { staffApi } from '@/lib/api/staff';
function AcceptInviteContent() {

View File

@@ -8,8 +8,8 @@ import type { OrganizationDetailsFormValues } from '@/components/ui/auth/Organiz
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Lock, Mail, User } from 'lucide-react';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
import { organizationApi } from '@/lib/api/organization';

View File

@@ -116,8 +116,8 @@ import Link from 'next/link';
import { Mail, Lock } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
const loginSchema = z.object({
email: z.string().email('Please enter a valid email address'),

View File

@@ -2,8 +2,8 @@
import Link from 'next/link';
import { useAuth } from '@/lib/hooks/useAuth';
import { Button } from '@/components/ui/common/Button';
import { ThemeToggle } from '@/components/ui/common/ThemeToggle';
import { Button } from '@/components/ui/shared/Button';
import { ThemeToggle } from '@/components/ui/shared/ThemeToggle';
import { Building2, Beaker, Calendar, Shield, Clock, Users } from 'lucide-react';
export default function HomePage() {

View File

@@ -9,8 +9,8 @@ import { Mail, Lock, User } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
const registerSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Please enter a valid email address'),

View File

@@ -0,0 +1,16 @@
import { Building2, Beaker, type LucideIcon } from 'lucide-react';
import type { Organization } from '@/types/organization';
/** Clinic → Building2, Lab → Beaker (switch-organization cards). */
export function organizationTypeIcon(type: Organization['type']): LucideIcon {
return type === 'CLINIC' ? Building2 : Beaker;
}
/** Organizations tab lists counterpart orgs (labs for clinics, clinics for labs). */
export function counterpartOrganizationType(
currentType: Organization['type'] | undefined,
): Organization['type'] {
if (currentType === 'CLINIC') return 'LAB';
if (currentType === 'LAB') return 'CLINIC';
return 'CLINIC';
}

View File

@@ -16,6 +16,11 @@ export function hasPermission(org: Organization | null, permission: string): boo
return Boolean(org.permissions?.includes(permission));
}
/** True when the user is owner of the currently selected organization. */
export function canCreateOrganizationFromCurrentOrg(org: Organization | null): boolean {
return Boolean(org?.isOwner);
}
/** Sidebar / route guard: READ access to a tab */
export function canViewTab(org: Organization | null, readPermission: string): boolean {
return hasPermission(org, readPermission);
@@ -77,7 +82,8 @@ export function canAccessAppointmentsSection(org: Organization | null): boolean
return (
hasPermission(org, 'TAB_APPOINTMENTS_READ') ||
hasPermission(org, 'TAB_APPOINTMENTS_EDIT') ||
hasPermission(org, 'TAB_TREATMENT_EDIT')
hasPermission(org, 'TAB_TREATMENT_EDIT') ||
hasPermission(org, 'TAB_TREATMENT_READ')
);
}
@@ -87,3 +93,13 @@ export function canEditTreatment(org: Organization | null): boolean {
if (org.isOwner) return true;
return hasPermission(org, 'TAB_TREATMENT_EDIT');
}
/** View treatment workspace (read-only or edit) */
export function canViewTreatment(org: Organization | null): boolean {
if (!org) return false;
if (org.isOwner) return true;
return (
hasPermission(org, 'TAB_TREATMENT_READ') ||
hasPermission(org, 'TAB_TREATMENT_EDIT')
);
}

View File

@@ -1,4 +1,4 @@
import { isSameLocalCalendarDay } from '@/lib/appointmentTime';
import { isSameLocalCalendarDay } from '@/components/appointments/appointmentTime';
import type { TreatmentAppointment } from '@/types/treatment';
/**

View File

@@ -0,0 +1,40 @@
import type { LinkedOrganizationOption, TreatmentCaseSendInfo } from '@/types/treatment';
export function formatCaseSentLines(
sends: TreatmentCaseSendInfo[] | undefined,
fallback?: {
organizationIds: string[];
sentAt: string | null;
orgs?: LinkedOrganizationOption[];
},
): string[] {
if (sends?.length) {
return sends.map((s) => {
const at = new Date(s.sentAt).toLocaleString();
return `Sent to ${s.organizationName} at ${at}`;
});
}
if (fallback?.sentAt && fallback.organizationIds.length > 0) {
const at = new Date(fallback.sentAt).toLocaleString();
const nameById = new Map(fallback.orgs?.map((o) => [o.id, o.name]) ?? []);
return fallback.organizationIds.map((id) => {
const name = nameById.get(id) ?? 'organization';
return `Sent to ${name} at ${at}`;
});
}
return [];
}
export function formatCaseSentSummary(
sends: TreatmentCaseSendInfo[] | undefined,
fallback?: {
organizationIds: string[];
sentAt: string | null;
orgs?: LinkedOrganizationOption[];
},
): string | null {
const lines = formatCaseSentLines(sends, fallback);
return lines.length > 0 ? lines.join(' · ') : null;
}

View File

@@ -1,9 +1,9 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/common/Button';
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton';
import { Dropdown } from '@/components/ui/common/Dropdown';
import { Button } from '@/components/ui/shared/Button';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { Dropdown } from '@/components/ui/shared/Dropdown';
import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles';
import type { Patient } from '@/types/patient';
@@ -12,7 +12,7 @@ import {
compareLocalDayStart,
formatTimeForInput,
isSameLocalCalendarDay,
} from '@/lib/appointmentTime';
} from '@/components/appointments/appointmentTime';
interface AppointmentBookingModalProps {
open: boolean;

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useRef } from 'react';
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import {
APPOINTMENT_PURPOSE_LABEL,
purposeStyle,

View File

@@ -2,12 +2,12 @@
import { useMemo, useState } from 'react';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import { formatHourLabel } from '@/lib/appointmentTime';
import { formatHourLabel } from '@/components/appointments/appointmentTime';
import {
computeAppointmentLaneLayouts,
findOverlapCluster,
lanePositionStyles,
} from '@/lib/appointmentOverlapLayout';
} from '@/components/appointments/appointmentOverlapLayout';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';

View File

@@ -1,8 +1,8 @@
'use client';
import { Search } from 'lucide-react';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
import type { Patient } from '@/types/patient';
interface AppointmentsPatientSearchProps {
@@ -49,7 +49,7 @@ export function AppointmentsPatientSearch({
onClick={onAddPatient}
title={!canAddPatient ? 'You do not have permission to add patients.' : undefined}
>
+ Add New Patient
New Patient
</Button>
)}
</div>

View File

@@ -2,7 +2,7 @@
import { Building2, Mail } from 'lucide-react';
import type { FieldErrors, UseFormRegister, UseFormSetValue } from 'react-hook-form';
import { Input } from '@/components/ui/common/Input';
import { Input } from '@/components/ui/shared/Input';
export type OrganizationDetailsFormValues = {
organizationName: string;

View File

@@ -1,10 +1,10 @@
'use client';
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton';
import { ToastStack, type ToastMessages } from '@/components/ui/common/Toast';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { ToastStack, type ToastMessages } from '@/components/ui/shared/Toast';
import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization';
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/common/Badge';
import { Table } from '@/components/ui/common/Table';
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge';
import { Table } from '@/components/ui/shared/Table';
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string {

View File

@@ -1,13 +1,26 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useAuth } from '@/lib/hooks/useAuth';
import { canCreateOrganizationFromCurrentOrg } from '@/components/shared/permissions';
import { Building2, Beaker, Mail } from 'lucide-react';
import { Input } from '@/components/ui/common/Input';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/shared/Input';
import { Button } from '@/components/ui/shared/Button';
export function OrganizationSelectorContent() {
const { organizations, selectOrganization, createOrganization, isLoading, error, clearError } = useAuth();
const {
organizations,
currentOrganization,
selectOrganization,
createOrganization,
isLoading,
error,
clearError,
} = useAuth();
const canCreateOrganization = useMemo(
() => canCreateOrganizationFromCurrentOrg(currentOrganization),
[currentOrganization],
);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [organizationName, setOrganizationName] = useState('');
const [organizationEmail, setOrganizationEmail] = useState('');
@@ -44,22 +57,26 @@ export function OrganizationSelectorContent() {
<div>
<h1 className="text-3xl font-semibold text-text-primary">Organizations</h1>
<p className="text-text-secondary mt-2">
Select an organization to continue, or create a new one.
{canCreateOrganization
? 'Select an organization to continue, or create a new one.'
: 'Select an organization to continue.'}
</p>
</div>
<Button
type="button"
variant={isCreateOpen ? 'outline' : 'primary'}
onClick={() => {
clearError();
setIsCreateOpen((prev) => !prev);
}}
>
{isCreateOpen ? 'Cancel' : 'Create Organization'}
</Button>
{canCreateOrganization && (
<Button
type="button"
variant={isCreateOpen ? 'outline' : 'primary'}
onClick={() => {
clearError();
setIsCreateOpen((prev) => !prev);
}}
>
{isCreateOpen ? 'Cancel' : 'Create Organization'}
</Button>
)}
</div>
{isCreateOpen && (
{canCreateOrganization && isCreateOpen && (
<div className="surface-card p-6 space-y-4">
<Input
label="Organization name"
@@ -126,7 +143,11 @@ export function OrganizationSelectorContent() {
{!organizations.length ? (
<div className="surface-card p-8 text-center">
<p className="text-text-secondary">No organizations found. Create your first one to continue.</p>
<p className="text-text-secondary">
{canCreateOrganization
? 'No organizations found. Create your first one to continue.'
: 'No organizations found. Ask an organization owner to invite you.'}
</p>
</div>
) : (
<div className="grid gap-4">

View File

@@ -1,7 +1,8 @@
'use client';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { Button } from '@/components/ui/shared/Button';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { Input } from '@/components/ui/shared/Input';
import { CreatePatientInput } from '@/types/patient';
interface CreatePatientModalProps {
@@ -11,22 +12,27 @@ interface CreatePatientModalProps {
onSubmit: () => void;
onClose: () => void;
loading?: boolean;
/** Inline panel on Patients page; centered dialog on Appointments. */
variant?: 'inline' | 'dialog';
}
export function CreatePatientModal({
isOpen,
function CreatePatientFormFields({
formData,
onChange,
onSubmit,
onClose,
loading = false,
}: CreatePatientModalProps) {
if (!isOpen) {
return null;
}
loading,
showCancel,
}: {
formData: CreatePatientInput;
onChange: (patch: Partial<CreatePatientInput>) => void;
onSubmit: () => void;
onClose: () => void;
loading: boolean;
showCancel: boolean;
}) {
return (
<div className="surface-card p-4 space-y-3">
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input
label="First name"
@@ -60,10 +66,80 @@ export function CreatePatientModal({
>
Save Patient
</Button>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
{showCancel && (
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
)}
</div>
</>
);
}
export function CreatePatientModal({
isOpen,
formData,
onChange,
onSubmit,
onClose,
loading = false,
variant = 'inline',
}: CreatePatientModalProps) {
if (!isOpen) {
return null;
}
if (variant === 'dialog') {
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/55"
role="presentation"
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div
className="surface-card w-full max-w-[min(56rem,calc(100vw-17rem))] p-5 space-y-4 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="create-patient-dialog-title"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-2">
<h2
id="create-patient-dialog-title"
className="text-lg font-semibold text-text-primary pr-2"
>
New patient
</h2>
<DialogCloseButton onClick={onClose} />
</div>
<CreatePatientFormFields
formData={formData}
onChange={onChange}
onSubmit={onSubmit}
onClose={onClose}
loading={loading}
showCancel={false}
/>
</div>
</div>
);
}
return (
<div className="surface-card p-4 space-y-3">
<CreatePatientFormFields
formData={formData}
onChange={onChange}
onSubmit={onSubmit}
onClose={onClose}
loading={loading}
showCancel
/>
</div>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/common/Input';
import { Input } from '@/components/ui/shared/Input';
import { Patient } from '@/types/patient';
interface PatientSearchSelectProps {

View File

@@ -1,37 +0,0 @@
import { TreatmentHistoryItem } from '@/types/patient';
interface TreatmentHistoryPreviewProps {
items: TreatmentHistoryItem[];
loading?: boolean;
}
export function TreatmentHistoryPreview({ items, loading = false }: TreatmentHistoryPreviewProps) {
return (
<div className="surface-card p-4 space-y-3">
<h3 className="text-base font-semibold text-text-primary">Treatment History</h3>
{loading && <p className="text-sm text-text-muted">Loading treatment history...</p>}
{!loading && items.length === 0 && (
<p className="text-sm text-text-muted">No treatment history yet.</p>
)}
<div className="space-y-2">
{items.map((item) => (
<div key={item.id} className="border border-border/60 rounded-[var(--radius-sm)] p-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-text-primary">{item.title}</p>
<p className="text-xs text-text-muted">
{new Date(item.treatmentAt).toLocaleDateString()}
</p>
</div>
<p className="text-xs text-text-secondary mt-1">
Status: {item.status}
{item.tooth ? ` | Tooth: ${item.tooth}` : ''}
</p>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,7 +1,8 @@
// src/components/ui/OrganizationCard.tsx
import React from 'react';
import { Building2, Beaker, ChevronRight } from 'lucide-react';
import { ChevronRight } from 'lucide-react';
import type { Organization } from '@/types/organization';
import { organizationTypeIcon } from '@/components/shared/organizationTypeIcon';
interface OrganizationCardProps {
organization: Organization;
@@ -12,7 +13,7 @@ export const OrganizationCard: React.FC<OrganizationCardProps> = ({
organization,
onSelect,
}) => {
const Icon = organization.type === 'CLINIC' ? Building2 : Beaker;
const Icon = organizationTypeIcon(organization.type);
const typeText = organization.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab';
return (

View File

@@ -2,17 +2,11 @@
import { useEffect, useId, useRef, useState } from 'react';
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
import {
addCalendarDays,
compareLocalDayStart,
startOfLocalDay,
} from '@/lib/appointmentTime';
import { addCalendarDays, startOfLocalDay } from '@/components/appointments/appointmentTime';
interface ScheduleDayPickerProps {
value: Date;
onChange: (day: Date) => void;
/** Optional lower bound for day selection and previous-day navigation. */
minDate?: Date;
label?: string;
}
@@ -39,27 +33,10 @@ function buildLocalDay(year: number, month: number, day: number): Date {
return new Date(year, month, day, 0, 0, 0, 0);
}
function clampToValidDay(
year: number,
month: number,
day: number,
min?: Date,
): Date {
const maxDay = daysInMonth(year, month);
let next = buildLocalDay(year, month, Math.min(Math.max(1, day), maxDay));
if (min) {
const floor = startOfLocalDay(min);
if (compareLocalDayStart(next, floor) < 0) {
next = floor;
}
}
return next;
}
function yearRange(min?: Date, anchor?: Date): number[] {
const now = new Date();
const startYear = min ? min.getFullYear() : now.getFullYear() - 5;
const endYear = Math.max(now.getFullYear() + 2, anchor?.getFullYear() ?? now.getFullYear());
function yearRange(anchor: Date): number[] {
const anchorYear = anchor.getFullYear();
const startYear = anchorYear - 10;
const endYear = anchorYear + 2;
const years: number[] = [];
for (let y = startYear; y <= endYear; y += 1) {
years.push(y);
@@ -72,21 +49,18 @@ const selectClassName = `
bg-background-card/90 text-text-primary text-sm
pl-2 pr-7 py-1.5
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
disabled:opacity-50 disabled:cursor-not-allowed
`;
export function ScheduleDayPicker({
value,
onChange,
minDate,
label = 'Schedule date',
}: ScheduleDayPickerProps) {
/**
* Calendar day navigator (arrows + year/month/day panel).
* Does not restrict past dates parent pages enforce read-only vs editable for schedule grids/forms.
*/
export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) {
const panelId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const [panelOpen, setPanelOpen] = useState(false);
const normalizedValue = startOfLocalDay(value);
const normalizedMin = minDate ? startOfLocalDay(minDate) : undefined;
const labelText = normalizedValue.toLocaleDateString(undefined, {
weekday: 'short',
@@ -95,28 +69,20 @@ export function ScheduleDayPicker({
year: 'numeric',
});
const previousDay = addCalendarDays(normalizedValue, -1);
const canGoPrevious =
!normalizedMin || compareLocalDayStart(previousDay, normalizedMin) >= 0;
const years = yearRange(normalizedMin, normalizedValue);
const years = yearRange(normalizedValue);
const selectedYear = normalizedValue.getFullYear();
const selectedMonth = normalizedValue.getMonth();
const selectedDay = normalizedValue.getDate();
const dayCount = daysInMonth(selectedYear, selectedMonth);
function applyParts(year: number, month: number, day: number, closePanel = false) {
onChange(clampToValidDay(year, month, day, normalizedMin));
const maxDay = daysInMonth(year, month);
onChange(buildLocalDay(year, month, Math.min(Math.max(1, day), maxDay)));
if (closePanel) {
setPanelOpen(false);
}
}
function handlePreviousDay() {
if (!canGoPrevious) return;
onChange(previousDay);
}
useEffect(() => {
if (!panelOpen) return;
@@ -146,9 +112,8 @@ export function ScheduleDayPicker({
<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"
onClick={handlePreviousDay}
disabled={!canGoPrevious}
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 disabled:opacity-40 disabled:pointer-events-none"
onClick={() => onChange(addCalendarDays(normalizedValue, -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="Previous day"
>
<ChevronLeft className="h-4 w-4 icon-flat" />
@@ -232,17 +197,11 @@ export function ScheduleDayPicker({
}
className={selectClassName}
>
{MONTH_LABELS.map((name, index) => {
const disabled =
normalizedMin &&
selectedYear === normalizedMin.getFullYear() &&
index < normalizedMin.getMonth();
return (
<option key={name} value={index} disabled={disabled}>
{name}
</option>
);
})}
{MONTH_LABELS.map((name, index) => (
<option key={name} value={index}>
{name}
</option>
))}
</select>
<ChevronDown
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
@@ -272,18 +231,11 @@ export function ScheduleDayPicker({
}
className={selectClassName}
>
{Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => {
const disabled =
normalizedMin &&
selectedYear === normalizedMin.getFullYear() &&
selectedMonth === normalizedMin.getMonth() &&
day < normalizedMin.getDate();
return (
<option key={day} value={day} disabled={disabled}>
{day}
</option>
);
})}
{Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => (
<option key={day} value={day}>
{day}
</option>
))}
</select>
<ChevronDown
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"

View File

@@ -13,10 +13,14 @@ import {
CreditCard,
} from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { canAccessAppointmentsSection, canViewTab } from '@/shared/permissions';
import { canAccessAppointmentsSection, canViewTab } from '@/components/shared/permissions';
import {
counterpartOrganizationType,
organizationTypeIcon,
} from '@/components/shared/organizationTypeIcon';
const menu = [
{ name: 'Today', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const },
{ name: 'Dashboard', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const },
{ name: 'Staff', path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const },
{ name: 'Patients', path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const },
{ name: 'Appointment', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const },
@@ -29,6 +33,9 @@ function Sidebar() {
const pathname = usePathname();
const { currentOrganization } = useAuth();
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
const organizationsTabIcon = organizationTypeIcon(
counterpartOrganizationType(currentOrganization?.type),
);
const visibleMenu = useMemo(
() => {
@@ -38,7 +45,7 @@ function Sidebar() {
{
name: counterpartLabel,
path: '/organizations',
icon: FlaskConical,
icon: organizationsTabIcon,
read: 'TAB_ORGANIZATIONS_READ' as const,
},
menu[2],
@@ -54,7 +61,7 @@ function Sidebar() {
return canViewTab(currentOrganization, item.read);
});
},
[counterpartLabel, currentOrganization],
[counterpartLabel, organizationsTabIcon, currentOrganization],
);
return (

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import type { BadgeVariant } from '@/components/ui/common/Badge';
import type { BadgeVariant } from '@/components/ui/shared/Badge';
interface ToastProps {
children: ReactNode;

View File

@@ -2,9 +2,9 @@
import { CalendarDays } from 'lucide-react';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { Card } from '@/components/ui/common/Card';
import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker';
import { startOfLocalDay } from '@/lib/appointmentTime';
import { Card } from '@/components/ui/shared/Card';
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
import type { TreatmentAppointment } from '@/types/treatment';
interface AppointmentsStripProps {
@@ -12,8 +12,6 @@ interface AppointmentsStripProps {
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;
@@ -25,7 +23,6 @@ export function AppointmentsStrip({
onToggleStripHidden,
selectedDay,
onSelectDay,
minScheduleDate,
appointments,
selectedAppointmentId,
onSelectAppointment,
@@ -65,7 +62,6 @@ export function AppointmentsStrip({
<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 && (

View File

@@ -0,0 +1,72 @@
'use client';
import { useEffect, useState } from 'react';
import { FileText } from 'lucide-react';
import { treatmentsApi } from '@/lib/api/treatments';
import type { TreatmentAttachmentMeta } from '@/types/treatment';
interface CaseLatestAttachmentPreviewProps {
attachment: TreatmentAttachmentMeta;
className?: string;
}
export function CaseLatestAttachmentPreview({
attachment,
className = 'aspect-square w-full max-w-[11rem]',
}: CaseLatestAttachmentPreviewProps) {
const [url, setUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
let objectUrl: string | null = null;
void (async () => {
try {
const blob = await treatmentsApi.getAttachmentFileBlob(attachment.id);
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
setFailed(false);
} catch {
if (!cancelled) setFailed(true);
}
})();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attachment.id]);
const isImage = attachment.mimeType.startsWith('image/');
const isPdf = attachment.mimeType === 'application/pdf';
return (
<div
className={`${className} rounded-[var(--radius-md)] border border-border/60 bg-background-secondary overflow-hidden`}
title={attachment.fileName}
>
{url && isImage ? (
<img
src={url}
alt={attachment.fileName}
className="h-full w-full object-fill"
/>
) : url && isPdf ? (
<iframe
src={url}
title={attachment.fileName}
className="h-full w-full border-0"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-1.5 p-2 text-text-muted">
<FileText className="h-8 w-8 shrink-0 icon-flat" aria-hidden />
<span className="line-clamp-2 text-center text-[10px] leading-tight">
{failed ? 'Preview unavailable' : attachment.fileName}
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { formatCaseSentLines } from '@/components/treatment/caseSendLabel';
import type { LinkedOrganizationOption, PastTreatmentCase, TreatmentCaseDraft } from '@/types/treatment';
interface CaseSentLabelProps {
treatmentCase: Pick<
PastTreatmentCase | TreatmentCaseDraft,
'sends' | 'sendToOrganizationIds' | 'sentAt'
>;
orgs?: LinkedOrganizationOption[];
className?: string;
}
export function CaseSentLabel({ treatmentCase, orgs, className = 'text-xs text-text-muted' }: CaseSentLabelProps) {
const lines = formatCaseSentLines(treatmentCase.sends, {
organizationIds: treatmentCase.sendToOrganizationIds ?? [],
sentAt: treatmentCase.sentAt ?? null,
orgs,
});
if (lines.length === 0) return null;
return (
<div className={className}>
{lines.map((line, i) => (
<span key={`${line}-${i}`} className="block">
{line}
</span>
))}
</div>
);
}

View File

@@ -103,7 +103,7 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
};
const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => (
<div className="flex flex-wrap justify-center gap-x-1 gap-y-1">
<div className="flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit">
{teeth.map((fdi, i) => {
const kind = getToothShapeKind(fdi);
const gid = `${uid}-g-${fdi}-${i}`;
@@ -148,61 +148,36 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
);
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 className="surface-card p-3 space-y-3">
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center 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 className="text-[11px] text-text-muted mt-0.5">
Tap teeth to multi-select. Applies to the active case.
</p>
</div>
<p className="text-[11px] text-text-secondary tabular-nums sm:text-right">
Selected: {selected.size === 0 ? '—' : [...selected].sort().join(', ')}
</p>
</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="overflow-x-auto py-1 -mx-1 px-1">
<div className="relative isolate min-w-max mx-auto w-fit">
<div
className="my-4 h-px w-full max-w-[min(100%,42rem)] mx-auto bg-border/70"
role="separator"
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="my-4 pt-1">
<div className="flex justify-center gap-x-1">
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => {
<div className="relative z-10 space-y-0">
<Row teeth={FDI_UPPER_LEFT_TO_RIGHT} upper />
<div className={`flex flex-nowrap 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={`l-${fdi}`}
key={`u-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${
isSel ? 'text-primary' : 'text-text-muted'
}`}
@@ -212,8 +187,29 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
);
})}
</div>
<div className={TOOTH_NUMBER_GAP}>
<Row teeth={FDI_LOWER_LEFT_TO_RIGHT} />
<div className="my-2 h-px w-full bg-border/70" role="separator" aria-hidden />
<div className="pt-0.5">
<div className="flex flex-nowrap 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>

View File

@@ -2,98 +2,117 @@
import { FileText } from 'lucide-react';
import type { PastTreatment } from '@/types/treatment';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
interface PastTreatmentsPanelProps {
items: PastTreatment[];
loading?: boolean;
selectedTreatmentId?: string | null;
onSelectTreatment?: (treatment: PastTreatment) => void;
onReviewTreatment?: (treatment: PastTreatment) => void;
}
export function PastTreatmentsPanel({
items,
loading,
selectedTreatmentId,
onSelectTreatment,
onReviewTreatment,
}: 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 className="text-[11px] text-text-muted mt-0.5">
Completed treatments for this patient. Each case is listed separately.
</p>
</div>
{loading && <p className="text-sm text-text-muted">Loading history...</p>}
{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">
<div className="space-y-3 max-h-[min(420px,50vh)] 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'
}`}
className="border border-border/70 rounded-[var(--radius-md)] p-2.5 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">{t.title}</p>
<time className="text-xs text-text-muted tabular-nums shrink-0" dateTime={t.treatmentAt}>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{t.title}</p>
<p className="text-[11px] text-text-secondary capitalize mt-0.5">Status: {t.status}</p>
</div>
<time
className="text-[11px] 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>
)}
<div className="space-y-1.5">
{t.cases.map((c, idx) => {
const attachments = c.attachmentMetas ?? [];
return (
<div
key={c.id}
className="border border-border/60 rounded-[var(--radius-sm)] px-2.5 py-2 bg-background-secondary/30 space-y-1"
>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium text-text-primary capitalize">
Case {idx + 1} · {c.treatmentType}
</p>
{c.sentAt && (
<CaseSentLabel
treatmentCase={c}
className="text-[10px] text-text-muted shrink-0 text-right"
/>
)}
</div>
<p className="text-[11px] text-text-secondary">
Teeth: {c.teeth.length ? [...c.teeth].sort().join(', ') : 'None selected'}
</p>
{c.notes?.trim() && (
<p className="text-[11px] text-text-muted line-clamp-2">{c.notes}</p>
)}
<div>
<p className="text-[10px] uppercase tracking-wide text-text-muted mb-1">
Attachments
</p>
{attachments.length === 0 ? (
<p className="text-[11px] text-text-muted">None</p>
) : (
<ul className="space-y-0.5">
{attachments.map((doc) => (
<li
key={doc.id}
className="flex items-center gap-1.5 text-[11px] text-text-secondary"
>
<FileText className="w-3 h-3 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>
</div>
);
})}
</div>
{onSelectTreatment && (
<div className="mt-3 pt-2 border-t border-border/50 flex justify-end">
{onReviewTreatment && (
<div className="pt-1 flex justify-end">
<button
type="button"
onClick={() => onSelectTreatment(t)}
onClick={() => onReviewTreatment(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>

View File

@@ -0,0 +1,296 @@
'use client';
import { useRef } from 'react';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { Dropdown } from '@/components/ui/shared/Dropdown';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import type { LinkedOrganizationOption, TreatmentCaseDraft } from '@/types/treatment';
import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
interface TreatmentCasesEditorProps {
cases: TreatmentCaseDraft[];
activeCaseId: string;
onActiveCaseChange: (id: string) => void;
onCasesChange: (cases: TreatmentCaseDraft[]) => void;
disabled: boolean;
canEdit: boolean;
isDirty: boolean;
saveBusy: boolean;
sendBusyId: string | null;
uploadBusy: boolean;
orgs: LinkedOrganizationOption[];
organizationSearch: string;
onOrganizationSearchChange: (value: string) => void;
recentOrganizationIds: string[];
onRecentOrganizationPick: (orgId: string) => void;
onAddCase: () => void;
onPreview: () => void;
onSave: () => void;
onSendCase: (c: TreatmentCaseDraft) => void;
onUploadFiles: (files: FileList | null) => void;
}
export function TreatmentCasesEditor({
cases,
activeCaseId,
onActiveCaseChange,
onCasesChange,
disabled,
canEdit,
isDirty,
saveBusy,
sendBusyId,
uploadBusy,
orgs,
organizationSearch,
onOrganizationSearchChange,
recentOrganizationIds,
onRecentOrganizationPick,
onAddCase,
onPreview,
onSave,
onSendCase,
onUploadFiles,
}: TreatmentCasesEditorProps) {
const attachmentInputRef = useRef<HTMLInputElement>(null);
const activeCase = cases.find((c) => c.clientId === activeCaseId) ?? cases[0];
const activeLinkedOrganizations = orgs.filter((o) => o.active);
const filteredOrganizations = (() => {
const q = organizationSearch.trim().toLowerCase();
if (!q) return activeLinkedOrganizations;
return activeLinkedOrganizations.filter((o) => o.name.toLowerCase().includes(q));
})();
const recentOrganizations = recentOrganizationIds
.map((id) => activeLinkedOrganizations.find((o) => o.id === id))
.filter(Boolean) as LinkedOrganizationOption[];
const treatmentTypeTextColor = activeCase
? (
{
consultation: '#ddd6fe',
filling: '#fed7aa',
endo: '#fecaca',
visit: '#bae6fd',
hygiene: '#d9f99d',
} as Record<TreatmentCaseDraft['treatmentType'], string>
)[activeCase.treatmentType]
: undefined;
if (!activeCase) return null;
return (
<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 cases</h3>
<p className="text-xs text-text-muted mt-0.5">
Each case has its own teeth, notes, attachments, and destinations for send.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="secondary" disabled={!canEdit || disabled} onClick={onPreview}>
Preview
</Button>
<Button type="button" variant="primary" disabled={!canEdit || disabled} onClick={onAddCase}>
Add case
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{cases.map((c, idx) => {
const sentSummary = formatCaseSentSummary(c.sends, {
organizationIds: c.sendToOrganizationIds ?? [],
sentAt: c.sentAt ?? null,
orgs,
});
return (
<button
key={c.clientId}
type="button"
onClick={() => onActiveCaseChange(c.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
${
c.clientId === activeCaseId
? 'border-primary bg-primary-soft font-medium text-text-primary'
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
}
`}
>
Case {idx + 1}
{sentSummary ? ` · ${sentSummary}` : ''}
</button>
);
})}
</div>
<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={activeCase.comment}
onChange={(e) => {
const v = e.target.value;
onCasesChange(
cases.map((c) => (c.clientId === activeCaseId ? { ...c, comment: v } : c)),
);
}}
placeholder="Write clinical notes for this case…"
rows={5}
disabled={disabled || Boolean(activeCase.sentAt)}
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={activeCase.treatmentType}
onChange={(e) => {
const nextType = e.target.value as TreatmentCaseDraft['treatmentType'];
onCasesChange(
cases.map((c) =>
c.clientId === activeCaseId ? { ...c, treatmentType: nextType } : c,
),
);
}}
disabled={disabled || Boolean(activeCase.sentAt)}
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</p>
<input
ref={attachmentInputRef}
id="treatment-case-attachments"
type="file"
multiple
disabled={disabled || uploadBusy || Boolean(activeCase.sentAt)}
onChange={(e) => {
onUploadFiles(e.target.files);
e.target.value = '';
}}
className="sr-only"
aria-label="Attach files for this treatment case"
/>
<Button
type="button"
variant="primary"
disabled={disabled || uploadBusy || Boolean(activeCase.sentAt)}
isLoading={uploadBusy}
onClick={() => attachmentInputRef.current?.click()}
aria-controls="treatment-case-attachments"
>
Choose files
</Button>
{activeCase.attachmentMetas.length > 0 && (
<ul className="mt-2 space-y-1 text-xs text-text-muted">
{activeCase.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 case to linked organizations
</p>
<div className="space-y-2 mb-2">
<SearchBar
value={organizationSearch}
onChange={onOrganizationSearchChange}
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"
disabled={disabled || Boolean(activeCase.sentAt)}
onClick={() => onRecentOrganizationPick(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 disabled:opacity-50"
>
{o.name}
</button>
))}
</div>
)}
</div>
<div className="flex flex-col gap-2">
{filteredOrganizations.map((o) => (
<Checkbox
key={o.id}
checked={activeCase.sendToOrganizationIds.includes(o.id)}
disabled={disabled || Boolean(activeCase.sentAt)}
onChange={(checked) => {
onCasesChange(
cases.map((c) => {
if (c.clientId !== activeCaseId) return c;
const next = new Set(c.sendToOrganizationIds);
if (checked) next.add(o.id);
else next.delete(o.id);
return { ...c, sendToOrganizationIds: [...next] };
}),
);
}}
label={o.name}
/>
))}
{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={disabled || Boolean(activeCase.sentAt) || sendBusyId === activeCase.clientId}
isLoading={sendBusyId === activeCase.clientId}
onClick={() => onSendCase(activeCase)}
>
Send this case
</Button>
{activeCase.sentAt && (
<CaseSentLabel treatmentCase={activeCase} orgs={orgs} />
)}
</div>
</div>
{canEdit && (
<div className="flex flex-wrap gap-3 pt-2 border-t border-border/60">
<Button
type="button"
variant="primary"
disabled={disabled || saveBusy}
isLoading={saveBusy}
onClick={onSave}
>
Save treatment draft
</Button>
<p className="text-xs text-text-muted self-center">
{isDirty ? 'Unsaved changes' : 'Draft saved'}. Sending is per case and saves first automatically.
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,104 @@
'use client';
import { useEffect, useState } from 'react';
import { FileText } from 'lucide-react';
import { treatmentsApi } from '@/lib/api/treatments';
import type { TreatmentAttachmentMeta } from '@/types/treatment';
interface TreatmentLatestAttachmentPreviewProps {
attachment?: TreatmentAttachmentMeta | null;
className?: string;
}
function isImageMime(mimeType: string): boolean {
return mimeType.startsWith('image/');
}
function isPdfMime(mimeType: string): boolean {
return mimeType === 'application/pdf';
}
export function TreatmentLatestAttachmentPreview({
attachment,
className = '',
}: TreatmentLatestAttachmentPreviewProps) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
const [loading, setLoading] = useState(false);
const canRenderPreview = attachment
? isImageMime(attachment.mimeType) || isPdfMime(attachment.mimeType)
: false;
useEffect(() => {
if (!attachment || !canRenderPreview) {
setPreviewUrl(null);
setLoadFailed(false);
setLoading(false);
return;
}
let cancelled = false;
let objectUrl: string | null = null;
setLoading(true);
setLoadFailed(false);
setPreviewUrl(null);
void treatmentsApi
.getAttachmentFileBlob(attachment.id)
.then((blob) => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setPreviewUrl(objectUrl);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attachment, canRenderPreview]);
return (
<div
className={`aspect-square w-[6rem] shrink-0 overflow-hidden rounded-[var(--radius-md)] border border-border/70 bg-background-secondary/50 ${className}`}
title={attachment?.fileName}
>
{!attachment ? (
<div className="flex h-full w-full items-center justify-center text-[10px] text-text-muted">
None
</div>
) : loading ? (
<div className="flex h-full w-full items-center justify-center text-[10px] text-text-muted">
</div>
) : loadFailed || !canRenderPreview || !previewUrl ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-1.5 text-center">
<FileText className="h-4 w-4 shrink-0 icon-flat text-text-muted" aria-hidden />
<span className="line-clamp-2 text-[9px] leading-tight text-text-secondary">
{attachment.fileName}
</span>
</div>
) : isImageMime(attachment.mimeType) ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewUrl}
alt={attachment.fileName}
className="h-full w-full object-fill"
/>
) : (
<iframe
src={previewUrl}
title={attachment.fileName}
className="h-full w-full border-0"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { Button } from '@/components/ui/shared/Button';
import type { PastTreatment } from '@/types/treatment';
interface TreatmentPreviewCardProps {
draft: PastTreatment | null;
disabled?: boolean;
onPreview: () => void;
}
export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPreviewCardProps) {
return (
<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 preview</h3>
<Button type="button" variant="primary" disabled={disabled || !draft} onClick={onPreview}>
Preview current draft
</Button>
</div>
{!draft ? (
<p className="text-sm text-text-muted">Select an appointment to preview its draft.</p>
) : (
<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">{draft.title}</p>
<span className="text-xs text-text-muted tabular-nums shrink-0 capitalize">{draft.status}</span>
</div>
<p className="text-xs text-text-secondary">
{draft.cases.length} case{draft.cases.length === 1 ? '' : 's'} ·{' '}
{draft.cases.reduce((n, c) => n + (c.attachmentMetas?.length ?? 0), 0)} attachment
{draft.cases.reduce((n, c) => n + (c.attachmentMetas?.length ?? 0), 0) === 1 ? '' : 's'}
</p>
<div className="space-y-2">
{draft.cases.slice(0, 2).map((c, idx) => (
<div
key={c.id}
className="rounded-[var(--radius-sm)] border border-border/60 px-2.5 py-2 text-xs text-text-secondary"
>
<span className="text-text-primary font-medium capitalize">
Case {idx + 1}: {c.treatmentType}
</span>
{c.teeth.length > 0 && (
<span className="ml-1 tabular-nums">· Teeth {[...c.teeth].sort().join(', ')}</span>
)}
</div>
))}
{draft.cases.length > 2 && (
<p className="text-xs text-text-muted">+ {draft.cases.length - 2} more case(s)</p>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,235 @@
'use client';
import { useRef, useState } from 'react';
import { Loader2, Paperclip, Send } from 'lucide-react';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import type { LinkedOrganizationOption, PastTreatment, PastTreatmentCase } from '@/types/treatment';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
import { TreatmentLatestAttachmentPreview } from '@/components/ui/treatment/TreatmentLatestAttachmentPreview';
export type TreatmentPreviewMode = 'readonly' | 'editable';
interface TreatmentPreviewDialogProps {
open: boolean;
onClose: () => void;
treatment: PastTreatment | null;
mode: TreatmentPreviewMode;
orgs?: LinkedOrganizationOption[];
sendBusyCaseId?: string | null;
uploadBusyCaseId?: string | null;
onAttach?: (caseKey: string, files: FileList) => void | Promise<void>;
onSend?: (caseKey: string, organizationIds: string[]) => void | Promise<void>;
getCaseOrgIds?: (caseKey: string) => string[];
onToggleCaseOrg?: (caseKey: string, organizationId: string, checked: boolean) => void;
}
function caseKey(c: PastTreatmentCase): string {
return c.clientId ?? c.id;
}
const caseActionIconClass =
'inline-flex items-center justify-center rounded-[var(--radius-sm)] p-1.5 text-text-secondary transition-colors hover:bg-background-card/80 hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-40';
export function TreatmentPreviewDialog({
open,
onClose,
treatment,
mode,
orgs = [],
sendBusyCaseId,
uploadBusyCaseId,
onAttach,
onSend,
getCaseOrgIds,
onToggleCaseOrg,
}: TreatmentPreviewDialogProps) {
const [expandedSendCaseId, setExpandedSendCaseId] = useState<string | null>(null);
const fileInputsRef = useRef<Record<string, HTMLInputElement | null>>({});
if (!open || !treatment) return null;
const editable = mode === 'editable';
const activeOrgs = orgs.filter((o) => o.active);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div
className="w-full max-w-[min(56rem,calc(100vw-17rem))] max-h-[90vh] overflow-y-auto rounded-[var(--radius-md)] border border-border bg-background-secondary p-6 shadow-xl space-y-4"
role="dialog"
aria-modal="true"
aria-labelledby="treatment-preview-title"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 id="treatment-preview-title" className="text-lg font-semibold text-text-primary pr-2">
Treatment preview
</h2>
<p className="text-xs text-text-muted mt-0.5">
Review cases, attachments, and send destinations.
</p>
</div>
<DialogCloseButton onClick={onClose} />
</div>
<div className="border border-border/70 rounded-[var(--radius-md)] p-4 bg-background-secondary/40 space-y-3">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-text-primary">{treatment.title}</p>
<span className="text-xs text-text-muted tabular-nums shrink-0">
{new Date(treatment.treatmentAt).toLocaleDateString()}
</span>
</div>
<p className="text-xs text-text-secondary capitalize">Status: {treatment.status}</p>
{treatment.cases.length === 0 ? (
<p className="text-sm text-text-muted">No cases in this treatment.</p>
) : (
<div className="space-y-2">
{treatment.cases.map((c, idx) => {
const key = caseKey(c);
const attachments = c.attachmentMetas ?? [];
const latestAttachment =
attachments.length > 0 ? attachments[attachments.length - 1] : null;
const sent = Boolean(c.sentAt);
const actionsEnabled = editable && !sent;
const selectedOrgIds = getCaseOrgIds?.(key) ?? c.sendToOrganizationIds ?? [];
const sendExpanded = expandedSendCaseId === key;
const comment = c.notes?.trim() ?? '';
const attachBusy = uploadBusyCaseId === key;
const sendBusy = sendBusyCaseId === key;
return (
<div
key={key}
className="rounded-[var(--radius-md)] border border-border/60 px-3 py-2 bg-background-secondary/30"
>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1">
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<p className="text-xs font-medium text-text-primary">Case {idx + 1}</p>
{actionsEnabled && (
<div className="flex items-center gap-0.5">
<input
ref={(el) => {
fileInputsRef.current[key] = el;
}}
type="file"
multiple
className="sr-only"
aria-hidden
onChange={(e) => {
if (e.target.files?.length) {
void onAttach?.(key, e.target.files);
}
e.target.value = '';
}}
/>
<button
type="button"
className={caseActionIconClass}
disabled={attachBusy}
aria-label="Attach files"
title="Attach files"
onClick={() => fileInputsRef.current[key]?.click()}
>
{attachBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<Paperclip className="h-3.5 w-3.5" aria-hidden />
)}
</button>
<button
type="button"
className={`${caseActionIconClass} ${
sendExpanded ? 'bg-primary-soft text-primary' : ''
}`}
disabled={sendBusy}
aria-label="Send this case"
title="Send this case"
aria-expanded={sendExpanded}
onClick={() =>
setExpandedSendCaseId((prev) => (prev === key ? null : key))
}
>
{sendBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<Send className="h-3.5 w-3.5" aria-hidden />
)}
</button>
</div>
)}
</div>
<p className="text-[11px] text-text-secondary capitalize">
Type: {c.treatmentType}
</p>
<p className="text-[11px] text-text-secondary">
Teeth:{' '}
{c.teeth.length ? [...c.teeth].sort().join(', ') : 'None selected'}
</p>
{comment ? (
<p className="text-[11px] text-text-muted line-clamp-2" title={comment}>
Comments: {comment}
</p>
) : (
<p className="text-[11px] text-text-muted">Comments: </p>
)}
</div>
<div className="flex min-w-[6rem] flex-col items-end gap-1">
{sent && (
<CaseSentLabel
treatmentCase={c}
orgs={orgs}
className="text-[10px] text-text-muted text-right"
/>
)}
<p className="text-[10px] uppercase tracking-wide text-text-muted">
Attachments
</p>
<TreatmentLatestAttachmentPreview attachment={latestAttachment} />
</div>
</div>
{sendExpanded && editable && !sent && (
<div className="mt-2 space-y-2 border-t border-border/40 pt-2">
<p className="text-xs font-medium text-text-secondary">
Send to linked organizations
</p>
{activeOrgs.length === 0 ? (
<p className="text-xs text-text-muted">No active linked organizations.</p>
) : (
<div className="flex flex-col gap-1.5">
{activeOrgs.map((o) => (
<Checkbox
key={o.id}
checked={selectedOrgIds.includes(o.id)}
onChange={(checked) => onToggleCaseOrg?.(key, o.id, checked)}
label={o.name}
/>
))}
</div>
)}
<Button
type="button"
variant="primary"
size="sm"
disabled={!selectedOrgIds.length || sendBusy}
isLoading={sendBusy}
onClick={() => void onSend?.(key, selectedOrgIds)}
>
Confirm send
</Button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,8 @@
import { apiClient } from './client';
import {
CreatePatientInput,
CreateTreatmentHistoryInput,
Patient,
PatientsListResponse,
TreatmentHistoryItem,
} from '@/types/patient';
export const patientsApi = {
@@ -22,22 +20,4 @@ export const patientsApi = {
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;
},
};

View File

@@ -7,7 +7,7 @@ export interface StaffMemberDto {
name: string;
isOwner: boolean;
isActive: boolean;
invitationStatus: 'ACTIVE' | 'PENDING' | 'EXPIRED';
invitationStatus: 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED';
invitedAt: string | null;
acceptedAt: string | null;
permissions: string[] | null;
@@ -100,6 +100,20 @@ export const staffApi = {
return response.data;
},
disableMember: async (
membershipId: string,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.patch(`/staff/members/${membershipId}/disable`);
return response.data;
},
enableMember: async (
membershipId: string,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.patch(`/staff/members/${membershipId}/enable`);
return response.data;
},
removeMember: async (
membershipId: string,
): Promise<{ success: boolean; message: string }> => {

View File

@@ -0,0 +1,86 @@
import { apiClient } from './client';
import type {
LinkedOrganizationOption,
PastTreatment,
SaveTreatmentPayload,
SendTreatmentCasePayload,
TreatmentAttachmentMeta,
TreatmentCaseSendInfo,
} from '@/types/treatment';
export const treatmentsApi = {
listLinkedOrganizations: async (): Promise<{ success: boolean; data: LinkedOrganizationOption[] }> => {
const response = await apiClient.get('/treatments/linked-organizations');
return response.data;
},
listPatientHistory: async (
patientId: string,
limit = 20,
): Promise<{ success: boolean; data: PastTreatment[] }> => {
const response = await apiClient.get(`/treatments/patients/${patientId}/history`, {
params: { limit },
});
return response.data;
},
getDraft: async (
appointmentId: string,
): Promise<{ success: boolean; data: PastTreatment | null }> => {
const response = await apiClient.get(`/treatments/appointments/${appointmentId}/draft`);
return response.data;
},
saveDraft: async (
appointmentId: string,
payload: Pick<SaveTreatmentPayload, 'cases'>,
): Promise<{ success: boolean; data: PastTreatment }> => {
const response = await apiClient.put(`/treatments/appointments/${appointmentId}/draft`, payload);
return response.data;
},
uploadCaseAttachments: async (
appointmentId: string,
caseClientId: string,
files: File[],
): Promise<{ success: boolean; data: TreatmentAttachmentMeta[] }> => {
const form = new FormData();
for (const file of files) {
form.append('files', file);
}
const response = await apiClient.post(
`/treatments/appointments/${appointmentId}/cases/${encodeURIComponent(caseClientId)}/attachments`,
form,
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120_000 },
);
return response.data;
},
sendCase: async (
caseId: string,
payload: SendTreatmentCasePayload,
): Promise<{ success: boolean; data: PastTreatmentCaseResponse }> => {
const response = await apiClient.post(`/treatments/cases/${caseId}/send`, payload);
return response.data;
},
getAttachmentFileBlob: async (attachmentId: string): Promise<Blob> => {
const response = await apiClient.get(`/treatments/attachments/${attachmentId}/file`, {
responseType: 'blob',
timeout: 120_000,
});
return response.data;
},
};
export interface PastTreatmentCaseResponse {
id: string;
clientId: string;
treatmentType: string;
teeth: string[];
notes: string | null;
sentAt: string | null;
sendToOrganizationIds: string[];
sends: TreatmentCaseSendInfo[];
attachmentMetas: TreatmentAttachmentMeta[];
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { ToastMessages } from '@/components/ui/common/Toast';
import type { ToastMessages } from '@/components/ui/shared/Toast';
const DEFAULT_DURATION_MS = 4000;

View File

@@ -1,200 +0,0 @@
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

@@ -12,19 +12,6 @@ export interface Patient {
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;
@@ -34,15 +21,6 @@ export interface CreatePatientInput {
notes?: string;
}
export interface CreateTreatmentHistoryInput {
title: string;
status: string;
treatmentAt: string;
tooth?: string;
notes?: string;
totalCost?: number;
}
export interface PatientsListResponse {
success: boolean;
data: {

View File

@@ -61,20 +61,32 @@ export const TREATMENT_TYPES = [
export type TreatmentType = (typeof TREATMENT_TYPES)[number];
export interface PastTreatmentRecord {
export interface TreatmentCaseSendInfo {
organizationId: string;
organizationName: string;
sentAt: string;
}
export interface PastTreatmentCase {
id: string;
clientId: string;
treatmentType: TreatmentType;
teeth: FdiToothId[];
notes?: string | null;
attachmentMetas?: TreatmentAttachmentMeta[];
sendToOrganizationIds?: string[];
sends?: TreatmentCaseSendInfo[];
sentAt?: string | null;
}
export interface PastTreatment {
id: string;
patientId: string;
appointmentId?: string | null;
title: string;
treatmentAt: string;
status: string;
records: PastTreatmentRecord[];
cases: PastTreatmentCase[];
documents: TreatmentAttachmentMeta[];
}
@@ -84,29 +96,33 @@ export interface LinkedOrganizationOption {
active: boolean;
}
export interface TreatmentRecordDraft {
export interface TreatmentCaseDraft {
clientId: string;
id?: string;
treatmentType: TreatmentType;
teeth: FdiToothId[];
comment: string;
attachmentMetas: TreatmentAttachmentMeta[];
/** Organizations selected for sending this record (mock only until API exists) */
sendToOrganizationIds: string[];
sends?: TreatmentCaseSendInfo[];
sentAt?: string | null;
}
/** Payload for persisting a draft (mock API); omits ephemeral client-only fields */
export type SavedTreatmentRecordPayload = Omit<TreatmentRecordDraft, 'clientId' | 'sentAt'>;
export type SavedTreatmentCasePayload = {
clientId: string;
id?: string;
treatmentType: TreatmentType;
teeth: FdiToothId[];
comment: string;
attachmentIds: string[];
};
export interface SaveTreatmentPayload {
appointmentId: string;
patientId: string;
records: SavedTreatmentRecordPayload[];
cases: SavedTreatmentCasePayload[];
}
export interface SendTreatmentRecordPayload {
appointmentId: string;
patientId: string;
recordClientId: string;
export interface SendTreatmentCasePayload {
organizationIds: string[];
}