Compare commits

...

11 Commits

Author SHA1 Message Date
2161926fc9 Merge pull request 'feature/appointment' (#15) from feature/appointment into master
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 0s
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/15
2026-05-07 01:17:28 +03:30
f1c97021f8 feature: delete appointment button's UI improved 2026-05-07 01:14:55 +03:30
8123a94a3d feature: a minimal implementation of the appointment feature done. 2026-05-06 23:05:37 +03:30
bc06be3ca6 Merge pull request 'feature/clinic-lab-invitation' (#14) from feature/clinic-lab-invitation into master
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 0s
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/14
2026-05-06 19:28:12 +03:30
2dca609ba1 feature: Organizations link and invitation flow implemented minimally. 2026-05-06 19:25:01 +03:30
bb44270795 feature: Organizations form UX flow now matches the needs. 2026-05-06 18:55:42 +03:30
6258477860 feature: button and badge components updated. 2026-05-06 02:20:19 +03:30
adfb5b436e feature: clinic & lab invitation flow added. minimal ui implemented for organizations tab. 2026-05-05 19:50:07 +03:30
43e39883a4 Merge pull request 'improvement/infrastructure' (#13) from improvement/infrastructure into master
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 1s
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/13
2026-05-05 16:34:54 +03:30
1392cb1d3d Merge pull request 'improvement/infrastructure' (#12) from improvement/infrastructure into master
Some checks failed
Registry — build, push, deploy / build-and-push (push) Failing after 4s
Registry — build, push, deploy / deploy (push) Has been skipped
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/12
2026-05-05 00:38:17 +03:30
eddcc3aded Merge pull request 'improvement: fixed some package.json issues. readme files updated.' (#11) from improvement/infrastructure into master
Some checks failed
Registry — build, push, deploy / build-and-push (push) Failing after 0s
Registry — build, push, deploy / deploy (push) Has been skipped
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/11
2026-05-05 00:07:52 +03:30
52 changed files with 3526 additions and 161 deletions

View File

@@ -0,0 +1,33 @@
-- CreateTable
CREATE TABLE "organization_invitations" (
"id" TEXT NOT NULL,
"inviterOrganizationId" TEXT NOT NULL,
"inviterUserId" TEXT NOT NULL,
"invitedOrganizationId" TEXT,
"invitedOrganizationName" TEXT NOT NULL,
"invitedOwnerEmail" TEXT NOT NULL,
"invitedOrganizationType" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"acceptedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "organization_invitations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "organization_invitations_tokenHash_key" ON "organization_invitations"("tokenHash");
-- CreateIndex
CREATE INDEX "organization_invitations_inviterOrganizationId_createdAt_idx" ON "organization_invitations"("inviterOrganizationId", "createdAt");
-- CreateIndex
CREATE INDEX "organization_invitations_invitedOwnerEmail_createdAt_idx" ON "organization_invitations"("invitedOwnerEmail", "createdAt");
-- AddForeignKey
ALTER TABLE "organization_invitations" ADD CONSTRAINT "organization_invitations_inviterOrganizationId_fkey" FOREIGN KEY ("inviterOrganizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "organization_invitations" ADD CONSTRAINT "organization_invitations_inviterUserId_fkey" FOREIGN KEY ("inviterUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,7 @@
UPDATE "permissions"
SET "name" = 'TAB_ORGANIZATIONS_READ'
WHERE "name" = 'TAB_LAB_READ';
UPDATE "permissions"
SET "name" = 'TAB_ORGANIZATIONS_EDIT'
WHERE "name" = 'TAB_LAB_EDIT';

View File

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

View File

@@ -21,6 +21,7 @@ model User {
ownedOrganizations Organization[] @relation("OrganizationOwner")
sessions Session[] // 👈 ADD THIS - opposite relation for Session
sentStaffInvites StaffInvitation[]
sentOrganizationInvitations OrganizationInvitation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -57,7 +58,9 @@ model Organization {
sharedWithMe OrganizationLink[] @relation("OrganizationB")
sharedWithOthers OrganizationLink[] @relation("OrganizationA")
sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter")
patients Patient[]
appointments Appointment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -80,6 +83,7 @@ model Patient {
organization Organization @relation(fields: [organizationId], references: [id])
treatments PatientTreatmentHistory[]
appointments Appointment[]
@@index([organizationId, createdAt])
@@index([organizationId, lastName, firstName])
@@ -104,6 +108,26 @@ model PatientTreatmentHistory {
@@map("patient_treatment_histories")
}
model Appointment {
id String @id @default(uuid())
organizationId String
patientId String
providerUserId String
startAt DateTime
endAt DateTime
purpose String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, providerUserId, startAt])
@@index([organizationId, startAt])
@@map("appointments")
}
model Plan {
id String @id @default(uuid())
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"
@@ -211,6 +235,32 @@ model OrganizationLink {
@@map("organization_links")
}
model OrganizationInvitation {
id String @id @default(uuid())
inviterOrganizationId String
inviterUserId String
invitedOrganizationId String?
invitedOrganizationName String
invitedOwnerEmail String
invitedOrganizationType String
tokenHash String @unique
expiresAt DateTime
acceptedAt DateTime?
revokedAt DateTime?
inviterOrganization Organization @relation("OrganizationInvitationInviter", fields: [inviterOrganizationId], references: [id], onDelete: Cascade)
inviterUser User @relation(fields: [inviterUserId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([inviterOrganizationId, createdAt])
@@index([invitedOwnerEmail, createdAt])
@@map("organization_invitations")
}
model Session {
id String @id @default(uuid())
userId String

View File

@@ -74,8 +74,8 @@ async function main() {
permissions: ['TAB_STAFF_READ', 'TAB_STAFF_EDIT'],
},
{
name: 'Labs / Clinics',
permissions: ['TAB_LAB_READ', 'TAB_LAB_EDIT'],
name: 'Organizations',
permissions: ['TAB_ORGANIZATIONS_READ', 'TAB_ORGANIZATIONS_EDIT'],
},
{
name: 'Patients',

View File

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

View File

@@ -4,8 +4,8 @@ export const ALL_TAB_PERMISSIONS = [
'TAB_TODAY_EDIT',
'TAB_STAFF_READ',
'TAB_STAFF_EDIT',
'TAB_LAB_READ',
'TAB_LAB_EDIT',
'TAB_ORGANIZATIONS_READ',
'TAB_ORGANIZATIONS_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
@@ -38,7 +38,7 @@ const EDIT_TO_READ: Record<string, string> = {
TAB_PATIENTS_EDIT: 'TAB_PATIENTS_READ',
TAB_APPOINTMENTS_EDIT: 'TAB_APPOINTMENTS_READ',
TAB_STAFF_EDIT: 'TAB_STAFF_READ',
TAB_LAB_EDIT: 'TAB_LAB_READ',
TAB_ORGANIZATIONS_EDIT: 'TAB_ORGANIZATIONS_READ',
TAB_TREATMENT_EDIT: 'TAB_TREATMENT_READ',
TAB_BILLING_EDIT: 'TAB_BILLING_READ',
TAB_REPORTS_EDIT: 'TAB_REPORTS_READ',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,8 +20,8 @@ const ALL_PERMISSIONS = [
'TAB_TODAY_EDIT',
'TAB_STAFF_READ',
'TAB_STAFF_EDIT',
'TAB_LAB_READ',
'TAB_LAB_EDIT',
'TAB_ORGANIZATIONS_READ',
'TAB_ORGANIZATIONS_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
@@ -37,7 +37,7 @@ const ALL_PERMISSIONS = [
const READ_ONLY_PERMISSIONS = [
'TAB_TODAY_READ',
'TAB_STAFF_READ',
'TAB_LAB_READ',
'TAB_ORGANIZATIONS_READ',
'TAB_PATIENTS_READ',
'TAB_APPOINTMENTS_READ',
'TAB_TREATMENT_READ',

View File

@@ -0,0 +1,19 @@
import { IsString, MinLength } from 'class-validator';
export class AcceptOrganizationInviteDto {
@IsString()
@MinLength(1)
token: string;
@IsString()
@MinLength(1)
organizationName: string;
@IsString()
@MinLength(1)
ownerName: string;
@IsString()
@MinLength(8)
password: string;
}

View File

@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class CreateLinkRequestDto {
@IsUUID()
targetOrganizationId: string;
}

View File

@@ -0,0 +1,14 @@
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
export class InviteOrganizationDto {
@IsString()
@MinLength(1)
organizationName: string;
@IsEmail()
ownerEmail: string;
@IsOptional()
@IsString()
phone?: string;
}

View File

@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class PreviewOrganizationInviteDto {
@IsString()
@MinLength(1)
token: string;
}

View File

@@ -0,0 +1,6 @@
import { IsIn } from 'class-validator';
export class RespondLinkRequestDto {
@IsIn(['ACCEPT', 'REJECT'])
action: 'ACCEPT' | 'REJECT';
}

View File

@@ -0,0 +1,124 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
import { CreateLinkRequestDto } from './dto/create-link-request.dto';
import { InviteOrganizationDto } from './dto/invite-organization.dto';
import { PreviewOrganizationInviteDto } from './dto/preview-organization-invite.dto';
import { RespondLinkRequestDto } from './dto/respond-link-request.dto';
import { OrganizationService } from './organization.service';
@ApiTags('organizations')
@ApiBearerAuth('JWT-auth')
@Controller('organizations')
export class OrganizationController {
constructor(private readonly organizationService: OrganizationService) {}
@Get('invitations/preview')
@ApiOperation({ summary: 'Preview organization invite by token (public)' })
previewInvite(@Query() query: PreviewOrganizationInviteDto) {
return this.organizationService.previewInvite(query.token);
}
@Get('invitations')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'List invitation history for current organization' })
listInvitations(@Req() req: { user: { id: string; organizationId?: string } }) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.listInvitationHistory(req.user.id, organizationId);
}
@Post('invitations/accept')
@ApiOperation({ summary: 'Accept organization invite and create/link counterpart org (public)' })
acceptInvite(@Body() dto: AcceptOrganizationInviteDto) {
return this.organizationService.acceptInvite(dto);
}
@Get('search')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Search counterpart organizations (active subscription only)',
})
search(
@Req() req: { user: { id: string; organizationId?: string } },
@Query('q') q = '',
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.searchCounterpartOrganizations(req.user.id, organizationId, q);
}
@Get('links')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'List counterpart links and invitations for current org' })
list(@Req() req: { user: { id: string; organizationId?: string } }) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.list(req.user.id, organizationId);
}
@Post('links')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Create pending link request to an existing subscribed counterpart org' })
createLinkRequest(
@Req() req: { user: { id: string; organizationId?: string } },
@Body() dto: CreateLinkRequestDto,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.createLinkRequest(req.user.id, organizationId, dto);
}
@Patch('links/:linkId/respond')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Accept or reject a pending link request for current organization' })
respondToLinkRequest(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('linkId') linkId: string,
@Body() dto: RespondLinkRequestDto,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.respondToLinkRequest(req.user.id, organizationId, linkId, dto);
}
@Delete('links/:linkId')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Delete linked organization record' })
deleteLink(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('linkId') linkId: string,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.deleteLink(req.user.id, organizationId, linkId);
}
@Post('invitations/:invitationId/link')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Get a shareable invite link for a pending invitation' })
getInvitationLink(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('invitationId') invitationId: string,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.getInvitationLink(req.user.id, organizationId, invitationId);
}
@Post('invite')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Create invite link for owner of not-yet-subscribed counterpart org' })
inviteOrganization(
@Req() req: { user: { id: string; organizationId?: string } },
@Body() dto: InviteOrganizationDto,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.inviteOrganization(req.user.id, organizationId, dto);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { OrganizationController } from './organization.controller';
import { OrganizationService } from './organization.service';
@Module({
controllers: [OrganizationController],
providers: [OrganizationService, PrismaService],
})
export class OrganizationModule {}

View File

@@ -0,0 +1,630 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LinkStatus } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service';
import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
import { CreateLinkRequestDto } from './dto/create-link-request.dto';
import { InviteOrganizationDto } from './dto/invite-organization.dto';
import { RespondLinkRequestDto } from './dto/respond-link-request.dto';
@Injectable()
export class OrganizationService {
constructor(private readonly prisma: PrismaService) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected');
}
return user.organizationId;
}
async searchCounterpartOrganizations(userId: string, organizationId: string, query: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const targetType = this.getCounterpartType(actor.organization.type.name);
const q = query.trim();
const organizations = await this.prisma.organization.findMany({
where: {
type: { name: targetType },
planId: { not: null },
...(q
? {
OR: [
{ name: { contains: q, mode: 'insensitive' } },
{ email: { contains: q, mode: 'insensitive' } },
{ phone: { contains: q, mode: 'insensitive' } },
],
}
: {}),
},
select: {
id: true,
name: true,
email: true,
phone: true,
owner: { select: { email: true, name: true } },
},
orderBy: { name: 'asc' },
take: 25,
});
return { success: true, data: organizations };
}
async list(userId: string, organizationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const [linksA, linksB] = await Promise.all([
this.prisma.organizationLink.findMany({
where: { organizationAId: organizationId },
include: {
organizationB: { select: { id: true, name: true, email: true, phone: true, type: true } },
},
orderBy: { createdAt: 'desc' },
}),
this.prisma.organizationLink.findMany({
where: { organizationBId: organizationId },
include: {
organizationA: { select: { id: true, name: true, email: true, phone: true, type: true } },
},
orderBy: { createdAt: 'desc' },
}),
]);
const linkItems = [
...linksA.map((l) => ({
requestedByOrganizationId: this.getRequesterOrganizationId(l.sharedDataTypes),
id: l.id,
counterpartOrganizationId: l.organizationB.id,
organizationName: l.organizationB.name,
ownerEmail: l.organizationB.email,
phone: l.organizationB.phone,
status: l.status,
createdAt: l.createdAt.toISOString(),
acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null,
})),
...linksB.map((l) => ({
requestedByOrganizationId: this.getRequesterOrganizationId(l.sharedDataTypes),
id: l.id,
counterpartOrganizationId: l.organizationA.id,
organizationName: l.organizationA.name,
ownerEmail: l.organizationA.email,
phone: l.organizationA.phone,
status: l.status,
createdAt: l.createdAt.toISOString(),
acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null,
})),
];
return {
success: true,
data: {
items: linkItems.sort((a, b) =>
a.createdAt < b.createdAt ? 1 : -1,
),
},
};
}
async listInvitationHistory(userId: string, organizationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const invitations = await this.prisma.organizationInvitation.findMany({
where: { inviterOrganizationId: organizationId },
orderBy: { createdAt: 'desc' },
});
return {
success: true,
data: {
items: invitations.map((i) => ({
id: i.id,
organizationName: i.invitedOrganizationName,
ownerEmail: i.invitedOwnerEmail,
status: this.mapInvitationStatus(i.acceptedAt, i.revokedAt, i.expiresAt),
createdAt: i.createdAt.toISOString(),
acceptedAt: i.acceptedAt?.toISOString() ?? null,
})),
},
};
}
async createLinkRequest(userId: string, organizationId: string, dto: CreateLinkRequestDto) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
if (dto.targetOrganizationId === organizationId) {
throw new BadRequestException('You cannot link organization to itself');
}
const sourceType = actor.organization.type.name;
const targetType = this.getCounterpartType(sourceType);
const target = await this.prisma.organization.findUnique({
where: { id: dto.targetOrganizationId },
select: { id: true, type: true, planId: true },
});
if (!target) {
throw new NotFoundException('Organization not found');
}
if (target.type.name !== targetType) {
throw new BadRequestException(`You can only link to ${targetType} organizations`);
}
if (!target.planId) {
throw new BadRequestException('Target organization does not have an active subscription');
}
const [aId, bId] =
organizationId < dto.targetOrganizationId
? [organizationId, dto.targetOrganizationId]
: [dto.targetOrganizationId, organizationId];
const existing = await this.prisma.organizationLink.findUnique({
where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } },
});
if (existing) {
throw new ConflictException('Link already exists for these organizations');
}
const created = await this.prisma.organizationLink.create({
data: {
organizationAId: aId,
organizationBId: bId,
status: LinkStatus.PENDING,
sharedDataTypes: [`requested_by:${organizationId}`],
},
});
return {
success: true,
data: { id: created.id, status: created.status },
message: 'Link request created',
};
}
async respondToLinkRequest(
userId: string,
organizationId: string,
linkId: string,
dto: RespondLinkRequestDto,
) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const link = await this.prisma.organizationLink.findFirst({
where: {
id: linkId,
OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }],
},
});
if (!link) {
throw new NotFoundException('Link request not found');
}
if (link.status !== LinkStatus.PENDING) {
throw new BadRequestException('Only pending link requests can be responded to');
}
const requesterOrgId = this.getRequesterOrganizationId(link.sharedDataTypes);
if (requesterOrgId && requesterOrgId === organizationId) {
throw new ForbiddenException('You cannot respond to your own link request');
}
const nextStatus = dto.action === 'ACCEPT' ? LinkStatus.ACTIVE : LinkStatus.REJECTED;
const updated = await this.prisma.organizationLink.update({
where: { id: link.id },
data: { status: nextStatus },
});
return {
success: true,
data: { id: updated.id, status: updated.status },
message: nextStatus === LinkStatus.ACTIVE ? 'Link request accepted' : 'Link request rejected',
};
}
async deleteLink(userId: string, organizationId: string, linkId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const link = await this.prisma.organizationLink.findFirst({
where: {
id: linkId,
status: LinkStatus.ACTIVE,
OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }],
},
select: { id: true },
});
if (!link) {
throw new NotFoundException('Linked organization not found');
}
await this.prisma.organizationLink.delete({ where: { id: link.id } });
return {
success: true,
data: { id: link.id },
message: 'Linked organization removed',
};
}
async getInvitationLink(userId: string, organizationId: string, invitationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const invitation = await this.prisma.organizationInvitation.findFirst({
where: {
id: invitationId,
inviterOrganizationId: organizationId,
},
select: {
id: true,
acceptedAt: true,
revokedAt: true,
},
});
if (!invitation) {
throw new NotFoundException('Invitation not found');
}
if (invitation.acceptedAt || invitation.revokedAt) {
throw new BadRequestException('Only pending invitations can provide a link');
}
const plainToken = this.generateInviteToken();
const tokenHash = this.hashInviteToken(plainToken);
await this.prisma.organizationInvitation.update({
where: { id: invitation.id },
data: {
tokenHash,
expiresAt: this.getInviteExpiryDate(),
},
});
return {
success: true,
data: {
invitationId: invitation.id,
invitationUrl: this.buildInviteUrl(plainToken),
},
};
}
async inviteOrganization(userId: string, organizationId: string, dto: InviteOrganizationDto) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const ownerEmail = dto.ownerEmail.trim().toLowerCase();
const inviterType = actor.organization.type.name;
const invitedType = this.getCounterpartType(inviterType);
const plainToken = this.generateInviteToken();
const tokenHash = this.hashInviteToken(plainToken);
const existingOwnerWithPlan = await this.prisma.organization.findFirst({
where: {
owner: { email: ownerEmail },
planId: { not: null },
},
select: { id: true },
});
if (existingOwnerWithPlan) {
throw new BadRequestException(
'This owner already has an organization with active subscription. Select that organization from search instead of sending invitation.',
);
}
const invitation = await this.prisma.$transaction(async (tx) => {
let owner = await tx.user.findUnique({ where: { email: ownerEmail } });
if (!owner) {
owner = await tx.user.create({
data: {
email: ownerEmail,
name: dto.organizationName.trim(),
passwordHash: null,
},
});
}
let invitedOrg = await tx.organization.findFirst({
where: {
ownerId: owner.id,
type: { name: invitedType },
},
select: { id: true },
orderBy: { createdAt: 'desc' },
});
if (!invitedOrg) {
invitedOrg = await tx.organization.create({
data: {
name: dto.organizationName.trim(),
email: `pending-${plainToken.slice(0, 12)}@dyolink.local`,
owner: { connect: { id: owner.id } },
type: { connect: { name: invitedType } },
},
select: { id: true },
});
}
const [aId, bId] =
organizationId < invitedOrg.id
? [organizationId, invitedOrg.id]
: [invitedOrg.id, organizationId];
const existingLink = await tx.organizationLink.findUnique({
where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } },
});
if (existingLink?.status === LinkStatus.ACTIVE) {
throw new ConflictException('These organizations are already linked');
}
await tx.organizationLink.upsert({
where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } },
update: {
status: LinkStatus.PENDING,
sharedDataTypes: [`requested_by:${organizationId}`],
},
create: {
organizationAId: aId,
organizationBId: bId,
status: LinkStatus.PENDING,
sharedDataTypes: [`requested_by:${organizationId}`],
},
});
return tx.organizationInvitation.create({
data: {
inviterOrganizationId: organizationId,
inviterUserId: userId,
invitedOrganizationId: invitedOrg.id,
invitedOrganizationName: dto.organizationName.trim(),
invitedOwnerEmail: ownerEmail,
invitedOrganizationType: invitedType,
tokenHash,
expiresAt: this.getInviteExpiryDate(),
},
});
});
return {
success: true,
data: {
invitationId: invitation.id,
invitationUrl: this.buildInviteUrl(plainToken),
status: 'PENDING',
},
};
}
async previewInvite(token: string) {
const invitation = await this.findValidInvitation(token);
return {
success: true,
data: {
ownerEmail: invitation.invitedOwnerEmail,
organizationName: invitation.invitedOrganizationName,
organizationType: invitation.invitedOrganizationType,
inviterOrganizationName: invitation.inviterOrganization.name,
expiresAt: invitation.expiresAt.toISOString(),
status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING',
},
};
}
async acceptInvite(dto: AcceptOrganizationInviteDto) {
const invitation = await this.findValidInvitation(dto.token);
if (invitation.acceptedAt) {
throw new BadRequestException('This invitation has already been accepted');
}
const organization = await this.prisma.$transaction(async (tx) => {
const passwordHash = await bcrypt.hash(dto.password, 10);
const ownerEmail = invitation.invitedOwnerEmail;
let owner = await tx.user.findUnique({ where: { email: ownerEmail } });
if (!owner) {
owner = await tx.user.create({
data: {
email: ownerEmail,
name: dto.ownerName.trim(),
passwordHash,
trialUsedAt: new Date(),
},
});
} else if (!owner.passwordHash) {
owner = await tx.user.update({
where: { id: owner.id },
data: { passwordHash, name: dto.ownerName.trim(), trialUsedAt: owner.trialUsedAt ?? new Date() },
});
}
let targetOrganizationId = invitation.invitedOrganizationId;
if (targetOrganizationId) {
await tx.organization.update({
where: { id: targetOrganizationId },
data: {
name: dto.organizationName.trim(),
email: ownerEmail,
owner: { connect: { id: owner.id } },
plan: { connect: { name: 'trial' } },
},
});
} else {
const createdOrg = await tx.organization.create({
data: {
name: dto.organizationName.trim(),
email: ownerEmail,
owner: { connect: { id: owner.id } },
type: { connect: { name: invitation.invitedOrganizationType } },
plan: { connect: { name: 'trial' } },
},
});
targetOrganizationId = createdOrg.id;
}
const ownerMembership = await tx.membership.findFirst({
where: { userId: owner.id, organizationId: targetOrganizationId },
select: { id: true },
});
if (!ownerMembership) {
await tx.membership.create({
data: {
userId: owner.id,
organizationId: targetOrganizationId,
isOwner: true,
isActive: true,
},
});
}
const [aId, bId] =
invitation.inviterOrganizationId < targetOrganizationId
? [invitation.inviterOrganizationId, targetOrganizationId]
: [targetOrganizationId, invitation.inviterOrganizationId];
await tx.organizationLink.upsert({
where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } },
update: { status: LinkStatus.ACTIVE },
create: {
organizationAId: aId,
organizationBId: bId,
status: LinkStatus.ACTIVE,
sharedDataTypes: [],
},
});
await tx.organizationInvitation.update({
where: { id: invitation.id },
data: {
acceptedAt: new Date(),
invitedOrganizationId: targetOrganizationId,
invitedOrganizationName: dto.organizationName.trim(),
},
});
return targetOrganizationId;
});
return {
success: true,
data: { organizationId: organization },
message: 'Invitation accepted. Organization trial has started and link is active.',
};
}
private async getActorMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId },
include: {
organization: {
select: {
id: true,
type: true,
},
},
permissions: { include: { permission: true } },
},
});
}
private canEditOrganizations(m: {
isOwner: boolean;
permissions: { permission: { name: string } }[];
}): boolean {
if (m.isOwner) return true;
return m.permissions.some((p) => p.permission.name === 'TAB_ORGANIZATIONS_EDIT');
}
private getCounterpartType(orgType: string): 'CLINIC' | 'LAB' {
if (orgType === 'CLINIC') return 'LAB';
if (orgType === 'LAB') return 'CLINIC';
throw new BadRequestException('Unknown organization type');
}
private mapInvitationStatus(
acceptedAt: Date | null,
revokedAt: Date | null,
expiresAt: Date,
): LinkStatus | 'EXPIRED' {
if (acceptedAt) return LinkStatus.ACTIVE;
if (revokedAt) return LinkStatus.REJECTED;
return expiresAt.getTime() > Date.now() ? LinkStatus.PENDING : 'EXPIRED';
}
private generateInviteToken(): string {
return randomBytes(32).toString('hex');
}
private hashInviteToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
private getInviteExpiryDate(): Date {
const d = new Date();
d.setDate(d.getDate() + 7);
return d;
}
private buildInviteUrl(token: string): string {
const appUrl = process.env.FRONTEND_URL || 'http://localhost:3001';
return `${appUrl}/accept-organization-invite?token=${encodeURIComponent(token)}`;
}
private buildInviteUrlFromTokenHashPlaceholder(): null {
// Raw token cannot be reconstructed from hash, so pending links are preserved client-side after creation.
return null;
}
private getRequesterOrganizationId(sharedDataTypes: unknown): string | null {
if (!Array.isArray(sharedDataTypes)) return null;
for (const v of sharedDataTypes) {
if (typeof v !== 'string') continue;
if (!v.startsWith('requested_by:')) continue;
const id = v.slice('requested_by:'.length).trim();
if (id) return id;
}
return null;
}
private async findValidInvitation(token: string) {
const invitation = await this.prisma.organizationInvitation.findUnique({
where: { tokenHash: this.hashInviteToken(token) },
include: {
inviterOrganization: { select: { id: true, name: true } },
},
});
if (!invitation) {
throw new NotFoundException('Invitation not found');
}
if (invitation.revokedAt) {
throw new BadRequestException('Invitation has been revoked');
}
if (invitation.expiresAt.getTime() <= Date.now()) {
throw new BadRequestException('Invitation has expired');
}
return invitation;
}
}

View File

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

View File

@@ -1,10 +1,11 @@
// src/app/(dashboard)/billing/page.tsx
'use client';
import { useState } from 'react';
import { Search, Filter, Plus } from 'lucide-react';
import { Pencil } from 'lucide-react';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { Badge } from '@/components/ui/common/Badge';
import { Table } from '@/components/ui/common/Table';
import { SearchBar } from '@/components/ui/common/SearchBar';
import { useAuth } from '@/lib/hooks/useAuth';
import { hasPermission } from '@/shared/permissions';
// Mock data matching your design
@@ -44,11 +45,9 @@ export default function BillingPage() {
<h1 className="text-2xl font-semibold text-text-primary">Billing</h1>
<Button
variant="primary"
className="flex items-center gap-2"
disabled={!canEditBilling}
title={!canEditBilling ? 'Read-only access for this organization.' : undefined}
>
<Plus className="h-4 w-4 icon-flat" />
New Invoice
</Button>
</div>
@@ -80,17 +79,12 @@ export default function BillingPage() {
/>
</div>
{/* Filters */}
<div className="surface-card p-4">
<div className="flex gap-4 items-center">
<div className="flex-1">
<Input
placeholder="Search patients..."
value={search}
onChange={(e) => setSearch(e.target.value)}
icon={<Search className="h-4 w-4 icon-flat" />}
/>
</div>
<div className="flex gap-2">
<SearchBar
value={search}
onChange={setSearch}
placeholder="Search patients..."
actions={(
<>
{['all', 'paid', 'unpaid', 'overdue'].map((status) => (
<button
key={status}
@@ -103,14 +97,13 @@ export default function BillingPage() {
{status}
</button>
))}
</div>
</div>
</div>
</>
)}
/>
{/* Invoices Table - Matching your design */}
<div className="surface-card overflow-hidden">
<table className="w-full">
<thead className="bg-background-secondary/70 border-b border-border">
<tr>
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Invoice ID
</th>
@@ -129,36 +122,37 @@ export default function BillingPage() {
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Paid
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
<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">
Action
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
}
body={
<>
{invoices.map((invoice) => (
<tr key={invoice.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-4 text-sm font-medium text-text-primary">
<tr key={invoice.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm font-medium text-text-primary">
{invoice.id}
</td>
<td className="px-6 py-4 text-sm text-text-primary">
<td className="px-6 py-1.5 text-sm text-text-primary">
{invoice.patient}
</td>
<td className="px-6 py-4 text-sm text-text-secondary">
<td className="px-6 py-1.5 text-sm text-text-secondary">
{invoice.date}
</td>
<td className="px-6 py-4 text-sm text-text-primary">
<td className="px-6 py-1.5 text-sm text-text-primary">
{invoice.service}
</td>
<td className="px-6 py-4 text-sm text-text-primary">
<td className="px-6 py-1.5 text-sm text-text-primary">
${invoice.amount}
</td>
<td className="px-6 py-4 text-sm text-text-primary">
<td className="px-6 py-1.5 text-sm text-text-primary">
${invoice.paid}
</td>
<td className="px-6 py-4">
<td className="px-6 py-1.5 text-center align-middle">
<Badge
variant={statusColors[invoice.status as keyof typeof statusColors]}
className="capitalize"
@@ -166,21 +160,24 @@ export default function BillingPage() {
{invoice.status}
</Badge>
</td>
<td className="px-6 py-4">
<td className="px-6 py-1.5">
<button
className={`text-sm ${canEditBilling ? 'text-primary hover:opacity-90' : 'text-text-muted cursor-not-allowed'}`}
className={`p-2 rounded-md ${canEditBilling
? 'text-text-secondary hover:bg-background-card/80 hover:text-text-primary'
: 'text-text-muted cursor-not-allowed opacity-50'}`}
disabled={!canEditBilling}
title={!canEditBilling ? 'Read-only access for this organization.' : undefined}
aria-label="Edit invoice"
>
Edit
<Pencil className="w-4 h-4" />
</button>
</td>
</tr>
</tr>
))}
</tbody>
</table>
{/* Pagination - Matching your design */}
<div className="px-6 py-4 border-t border-border/60 flex justify-between items-center bg-background-secondary/70">
</>
}
footer={(
<>
<button className="text-sm text-text-secondary hover:text-text-primary">
Previous
</button>
@@ -190,8 +187,9 @@ export default function BillingPage() {
<button className="text-sm text-text-secondary hover:text-text-primary">
Next
</button>
</div>
</div>
</>
)}
/>
</div>
);
}

View File

@@ -1,10 +0,0 @@
export default function LabPage() {
return (
<div className="space-y-3">
<h1 className="text-2xl font-semibold text-text-primary">Lab Management</h1>
<p className="text-sm text-text-secondary">
Lab management module is coming soon.
</p>
</div>
);
}

View File

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

View File

@@ -0,0 +1,602 @@
'use client';
import { useEffect, useState } from 'react';
import { Check, Copy, Link2, Trash2, X } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import {
organizationApi,
type CounterpartItemDto,
type CounterpartSearchResultDto,
type OrganizationInvitationHistoryItemDto,
} from '@/lib/api/organization';
import { Button } from '@/components/ui/common/Button';
import { Badge, organizationLinkStatusVariant } 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 type { ApiError } from '@/types/api';
type StoredInviteLink = {
invitationId: string;
ownerEmail: string;
invitationUrl: string;
};
function inviteLinksStorageKey(orgId: string): string {
return `counterpartInviteLinks:${orgId}`;
}
function readStoredInviteLinks(orgId: string): Record<string, StoredInviteLink> {
if (typeof window === 'undefined') return {};
try {
const raw = window.localStorage.getItem(inviteLinksStorageKey(orgId));
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, StoredInviteLink>;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function writeStoredInviteLinks(orgId: string, links: Record<string, StoredInviteLink>) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links));
}
function formatOrganizationStatusLabel(status: string): string {
if (!status) return status;
const lower = status.toLowerCase();
return lower.charAt(0).toUpperCase() + lower.slice(1);
}
function formatLinkStatusLabel(status: CounterpartItemDto['status']): string {
if (status === 'PENDING') return 'Link request pending';
if (status === 'ACTIVE') return 'Linked';
if (status === 'REJECTED') return 'Link request rejected';
return formatOrganizationStatusLabel(status);
}
function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string {
if (status === 'PENDING') return 'Invitation pending';
if (status === 'ACTIVE') return 'Invitation Accepted';
if (status === 'REJECTED') return 'Invitation rejected';
return formatOrganizationStatusLabel(status);
}
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 formatTableDate(value: string): string {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleDateString();
}
type TableMode = 'existing' | 'search';
export default function OrganizationsPage() {
const { currentOrganization } = useAuth();
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [query, setQuery] = useState('');
const [mode, setMode] = useState<TableMode>('existing');
const [searching, setSearching] = useState(false);
const [searchResults, setSearchResults] = useState<CounterpartSearchResultDto[]>([]);
const [requestLinkRowId, setRequestLinkRowId] = useState<string | null>(null);
const [deleteLinkRowId, setDeleteLinkRowId] = useState<string | null>(null);
const [items, setItems] = useState<CounterpartItemDto[]>([]);
const [manualOrganizationName, setManualOrganizationName] = useState('');
const [manualOwnerEmail, setManualOwnerEmail] = useState('');
const [inviteLoading, setInviteLoading] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [pendingInviteLinks, setPendingInviteLinks] = useState<Record<string, StoredInviteLink>>({});
const [showInviteForm, setShowInviteForm] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyItems, setHistoryItems] = useState<OrganizationInvitationHistoryItemDto[]>([]);
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinic' : 'Lab';
const tabLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
const existingRows = items;
async function loadList() {
setLoading(true);
setError('');
try {
const res = await organizationApi.list();
setItems(res.data.items);
} catch (e) {
setError(formatApiMessage(e));
} finally {
setLoading(false);
}
}
useEffect(() => {
if (!currentOrganization?.id) return;
setPendingInviteLinks(readStoredInviteLinks(currentOrganization.id));
}, [currentOrganization?.id]);
useEffect(() => {
void loadList();
}, []);
useEffect(() => {
if (!success) return;
const t = setTimeout(() => setSuccess(''), 4000);
return () => clearTimeout(t);
}, [success]);
async function runSearch() {
const q = query.trim();
if (!q) {
setMode('existing');
setSearchResults([]);
setShowInviteForm(false);
return;
}
setSearching(true);
setError('');
setMode('search');
setShowInviteForm(false);
try {
const res = await organizationApi.search(q);
setSearchResults(res.data);
} catch (e) {
setError(formatApiMessage(e));
setSearchResults([]);
} finally {
setSearching(false);
}
}
async function submitRequestLink(targetOrganizationId: string) {
setRequestLinkRowId(targetOrganizationId);
setError('');
try {
await organizationApi.createLink(targetOrganizationId);
setSuccess(`${counterpartLabel} link request sent`);
setSearchResults([]);
setQuery('');
setMode('existing');
await loadList();
} catch (e) {
setError(formatApiMessage(e));
} finally {
setRequestLinkRowId(null);
}
}
async function sendInvite() {
setInviteLoading(true);
setError('');
try {
const res = await organizationApi.invite({
organizationName: manualOrganizationName.trim(),
ownerEmail: manualOwnerEmail.trim(),
});
if (currentOrganization?.id) {
const nextLinks = {
...pendingInviteLinks,
[res.data.invitationId]: {
invitationId: res.data.invitationId,
ownerEmail: manualOwnerEmail.trim().toLowerCase(),
invitationUrl: res.data.invitationUrl,
},
};
setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}
setSuccess(`Invitation link created for ${manualOwnerEmail.trim()}`);
setManualOrganizationName('');
setManualOwnerEmail('');
setShowInviteForm(false);
setMode('existing');
setQuery('');
setSearchResults([]);
await loadList();
} catch (e) {
setError(formatApiMessage(e));
} finally {
setInviteLoading(false);
}
}
async function openInvitationHistory() {
setHistoryOpen(true);
setHistoryLoading(true);
setError('');
try {
const res = await organizationApi.listInvitations();
setHistoryItems(res.data.items);
} catch (e) {
setError(formatApiMessage(e));
} finally {
setHistoryLoading(false);
}
}
async function respondToPendingLink(linkId: string, action: 'ACCEPT' | 'REJECT') {
setRequestLinkRowId(linkId);
setError('');
try {
await organizationApi.respondLink(linkId, action);
setSuccess(action === 'ACCEPT' ? 'Link request accepted' : 'Link request rejected');
await loadList();
} catch (e) {
setError(formatApiMessage(e));
} finally {
setRequestLinkRowId(null);
}
}
async function deleteLinkedOrganization(linkId: string) {
setDeleteLinkRowId(linkId);
setError('');
try {
await organizationApi.deleteLink(linkId);
setSuccess('Linked organization removed');
await loadList();
} catch (e) {
setError(formatApiMessage(e));
} finally {
setDeleteLinkRowId(null);
}
}
async function copyInvitationLink(invitationId: string) {
setError('');
try {
let invitationUrl = pendingInviteLinks[invitationId]?.invitationUrl;
if (!invitationUrl) {
const res = await organizationApi.getInvitationLink(invitationId);
invitationUrl = res.data.invitationUrl;
if (currentOrganization?.id) {
const nextLinks = {
...pendingInviteLinks,
[invitationId]: {
invitationId,
ownerEmail:
historyItems.find((item) => item.id === invitationId)?.ownerEmail?.toLowerCase() ?? '',
invitationUrl,
},
};
setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}
}
await navigator.clipboard.writeText(invitationUrl);
setCopiedId(invitationId);
setTimeout(() => setCopiedId(null), 1500);
} catch {
setError('Could not copy invitation link');
}
}
function clearSearchView() {
setMode('existing');
setQuery('');
setSearchResults([]);
setShowInviteForm(false);
}
if (!currentOrganization) {
return <p className="text-sm text-text-secondary">Loading organization...</p>;
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-text-primary">{tabLabel}</h1>
<p className="text-sm text-text-secondary mt-1">
Search organizations and send link requests or invitation links in one place.
</p>
</div>
<Button type="button" size="sm" onClick={() => void openInvitationHistory()}>
Invitation History
</Button>
</div>
{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>
)}
<SearchBar
value={query}
onChange={setQuery}
onSubmit={() => void runSearch()}
placeholder={`Search ${counterpartLabel.toLowerCase()} by name, email, or phone...`}
actions={
<>
<button
type="button"
onClick={() => void runSearch()}
disabled={searching}
className="px-4 py-2 rounded-[var(--radius-sm)] text-sm font-medium border bg-primary-soft text-primary border-primary/50 disabled:opacity-60"
>
Search
</button>
{mode === 'search' && (
<button
type="button"
onClick={clearSearchView}
className="px-4 py-2 rounded-[var(--radius-sm)] text-sm font-medium border text-text-secondary border-border/40 hover:bg-background-card/70 hover:border-border"
>
Back to list
</button>
)}
</>
}
/>
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Organization
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Owner email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Date
</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-right text-xs font-medium text-text-muted uppercase tracking-wider">
Action
</th>
</tr>
}
body={
<>
{loading ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-sm text-text-secondary">
Loading...
</td>
</tr>
) : mode === 'existing' ? (
existingRows.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-sm text-text-secondary">
No organizations linked or pending yet. Use search to find and connect.
</td>
</tr>
) : (
existingRows.map((row) => {
const canRespond =
row.status === 'PENDING' &&
row.requestedByOrganizationId !== null &&
row.requestedByOrganizationId !== currentOrganization.id;
return (
<tr key={row.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm font-medium text-text-primary">
{row.organizationName}
</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{row.ownerEmail}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">
{formatTableDate(row.createdAt)}
</td>
<td className="px-6 py-1.5 text-center align-middle">
<Badge variant={organizationLinkStatusVariant(row.status)} fixedWidth={false}>
{formatLinkStatusLabel(row.status)}
</Badge>
</td>
<td className="px-6 py-1.5 text-right">
<div className="inline-flex items-center gap-2">
{canRespond && (
<>
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:text-text-muted disabled:opacity-50"
disabled={requestLinkRowId !== null && requestLinkRowId !== row.id}
onClick={() => void respondToPendingLink(row.id, 'ACCEPT')}
aria-label="Accept link request"
title="Accept link request"
>
<Check className="w-4 h-4" />
</button>
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50"
disabled={requestLinkRowId !== null && requestLinkRowId !== row.id}
onClick={() => void respondToPendingLink(row.id, 'REJECT')}
aria-label="Reject link request"
title="Reject link request"
>
<X className="w-4 h-4" />
</button>
</>
)}
{row.status === 'ACTIVE' && (
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50"
disabled={deleteLinkRowId !== null && deleteLinkRowId !== row.id}
onClick={() => void deleteLinkedOrganization(row.id)}
aria-label="Delete link"
title="Delete link"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
);
})
)
) : searchResults.length > 0 ? (
searchResults.map((r) => (
<tr key={r.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm font-medium text-text-primary">{r.name}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{r.owner.email}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">Today</td>
<td className="px-6 py-1.5 text-center align-middle">
<Badge variant="default" fixedWidth={false}>Found</Badge>
</td>
<td className="px-6 py-1.5 text-right">
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:text-text-muted disabled:opacity-50"
disabled={requestLinkRowId !== null && requestLinkRowId !== r.id}
onClick={() => void submitRequestLink(r.id)}
aria-label="Send link request"
title="Send link request"
>
<Link2 className="w-4 h-4" />
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-6 py-6">
<div className="flex flex-col gap-3">
<p className="text-sm text-text-secondary">
No organization found in directory search.
</p>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" onClick={() => setShowInviteForm((v) => !v)}>
{showInviteForm ? 'Hide invitation fields' : 'Send invitation link'}
</Button>
</div>
{showInviteForm && (
<div className="grid gap-3 sm:grid-cols-3 mt-1">
<Input
label={`${counterpartLabel} name`}
value={manualOrganizationName}
onChange={(e) => setManualOrganizationName(e.target.value)}
/>
<Input
label="Owner email"
type="email"
value={manualOwnerEmail}
onChange={(e) => setManualOwnerEmail(e.target.value)}
/>
<div className="flex items-end">
<Button
type="button"
isLoading={inviteLoading}
disabled={!manualOrganizationName.trim() || !manualOwnerEmail.trim()}
onClick={() => void sendInvite()}
className="w-full"
>
Send invitation
</Button>
</div>
</div>
)}
</div>
</td>
</tr>
)}
</>
}
/>
{historyOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div
className="w-full max-w-3xl 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"
>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-text-primary">Invitation History</h2>
<Button type="button" size="sm" onClick={() => setHistoryOpen(false)}>
Close
</Button>
</div>
{historyLoading ? (
<p className="text-sm text-text-secondary">Loading invitation history...</p>
) : historyItems.length === 0 ? (
<p className="text-sm text-text-secondary">No invitations yet.</p>
) : (
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Organization
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Owner email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Date
</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-right text-xs font-medium text-text-muted uppercase tracking-wider">
Action
</th>
</tr>
}
body={
<>
{historyItems.map((inv) => (
<tr key={inv.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm text-text-primary">{inv.organizationName}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{inv.ownerEmail}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">
{formatTableDate(inv.createdAt)}
</td>
<td className="px-6 py-1.5 text-center align-middle">
<Badge variant={organizationLinkStatusVariant(inv.status)} fixedWidth={false}>
{formatInvitationStatusLabel(inv.status)}
</Badge>
</td>
<td className="px-6 py-1.5 text-right">
{inv.status === 'PENDING' ? (
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary"
onClick={() => void copyInvitationLink(inv.id)}
aria-label="Copy invitation link"
title="Copy invitation link"
>
{copiedId === inv.id ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
) : (
<span className="text-xs text-text-muted"></span>
)}
</td>
</tr>
))}
</>
}
/>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,6 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/common/Button';
import { patientsApi } from '@/lib/api/patients';
import { useAuth } from '@/lib/hooks/useAuth';
@@ -160,7 +159,6 @@ export default function PatientsPage() {
<h1 className="text-2xl font-semibold text-text-primary">Patients</h1>
<Button
variant="primary"
className="flex items-center gap-2"
disabled={!canEditPatients}
onClick={() => {
if (!canEditPatients) return;
@@ -168,7 +166,6 @@ export default function PatientsPage() {
}}
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
>
<Plus className="h-4 w-4 icon-flat" />
New Patient
</Button>
</div>

View File

@@ -5,6 +5,7 @@ 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 type { SubscriptionAlertData } from '@/types/subscription';
const PLAN_OPTIONS = [
@@ -176,9 +177,9 @@ export default function SubscriptionsSettingsPage() {
);
})}
</div>
<button
<Button
type="button"
className="inline-flex items-center justify-center rounded-[var(--radius-md)] bg-primary px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-60"
variant="primary"
onClick={() => {
const selectedPlanLabel = selectedPlan?.name ?? 'the selected plan';
setPurchaseNotice(
@@ -187,7 +188,7 @@ export default function SubscriptionsSettingsPage() {
}}
>
Start purchase process
</button>
</Button>
{purchaseNotice && (
<div className="rounded-[var(--radius-md)] border border-emerald-500/30 bg-emerald-500/10 px-4 py-3">
<p className="text-sm text-emerald-200">{purchaseNotice}</p>

View File

@@ -2,7 +2,11 @@
export const STAFF_FEATURE_GROUPS = [
{ label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
{ label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
{ label: 'Labs / Clinics', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' },
{
label: 'Organizations',
read: 'TAB_ORGANIZATIONS_READ',
edit: 'TAB_ORGANIZATIONS_EDIT',
},
{ label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
{ label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
{ label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },

View File

@@ -16,12 +16,14 @@ import {
formatAccessSummary,
type FeaturePermState,
} from './staff-permission-form';
import { UserPlus, Pencil, Trash2, Copy, Check, X, Clock3 } from 'lucide-react';
import { Pencil, Trash2, Copy, Check, X } from 'lucide-react';
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';
type StoredInviteLink = {
@@ -335,7 +337,6 @@ export default function StaffPage() {
className="shrink-0"
title={!canEdit ? 'Read-only access for this organization.' : undefined}
>
<UserPlus className="w-4 h-4 mr-2" />
Invite member
</Button>
</div>
@@ -410,14 +411,7 @@ export default function StaffPage() {
}
}}
>
{copiedInviteMembershipId === lastInviteInfo.membershipId ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
<span className="ml-1">
{copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'}
</span>
{copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'}
</Button>
</div>
<p className="text-xs text-text-muted">
@@ -431,47 +425,40 @@ export default function StaffPage() {
{loading ? (
<p className="text-sm text-text-secondary">Loading team</p>
) : (
<div className="overflow-x-auto rounded-[var(--radius-md)] border border-border/70">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/70 text-left text-text-secondary">
<th className="p-3 font-medium">Name</th>
<th className="p-3 font-medium">Email</th>
<th className="p-3 font-medium">Role</th>
<th className="p-3 font-medium">Status</th>
<th className="p-3 font-medium">Access</th>
<th className="p-3 font-medium w-28">Actions</th>
</tr>
</thead>
<tbody>
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">Email</th>
<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>
</tr>
}
body={
<>
{members.map((m) => (
<tr key={m.id} className="h-14 border-b border-border/40 last:border-0">
<td className="p-3 text-text-primary">{m.name}</td>
<td className="p-3 text-text-secondary">{m.email}</td>
<td className="p-3">
<tr key={m.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm text-text-primary">{m.name}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{m.email}</td>
<td className="px-6 py-1.5 text-sm">
{m.isOwner ? (
<span className="text-primary font-medium">Owner</span>
) : (
<span className="text-text-secondary">Staff</span>
)}
</td>
<td className="p-3 align-middle">
<td className="px-6 py-1.5 align-middle text-center">
{m.isOwner || m.invitationStatus === 'ACTIVE' ? (
<span className="inline-flex items-center rounded-full border border-emerald-600/40 bg-emerald-600/15 px-2 py-0.5 text-xs text-emerald-400">
Active
</span>
<Badge variant="success">Active</Badge>
) : m.invitationStatus === 'PENDING' ? (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/15 px-2 py-0.5 text-xs text-amber-300">
<Clock3 className="h-3 w-3" />
Pending
</span>
<Badge variant="warning">Pending</Badge>
) : (
<span className="inline-flex items-center rounded-full border border-red-500/40 bg-red-500/15 px-2 py-0.5 text-xs text-red-300">
Expired
</span>
<Badge variant="danger">Expired</Badge>
)}
</td>
<td className="p-3 text-text-secondary max-w-md">
<td className="px-6 py-1.5 text-sm text-text-secondary max-w-md">
{m.isOwner ? (
<span className="text-text-muted">All features</span>
) : (
@@ -480,7 +467,7 @@ export default function StaffPage() {
</span>
)}
</td>
<td className="p-3 align-middle">
<td className="px-6 py-1.5 align-middle">
{!m.isOwner && (
<div className="flex min-h-[36px] items-center justify-end gap-1">
{m.invitationStatus === 'PENDING' && pendingInviteLinks[m.id]?.invitationUrl && (
@@ -543,9 +530,9 @@ export default function StaffPage() {
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
}
/>
)}
{inviteOpen && (

View File

@@ -6,7 +6,7 @@
export const STAFF_FEATURE_GROUPS = [
{ label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
{ label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
{ label: 'Labs', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' },
{ label: 'Organizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT' },
{ label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
{ label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
{ label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },
@@ -21,7 +21,7 @@ export function resolveStaffFeatureLabel(
group: (typeof STAFF_FEATURE_GROUPS)[number],
organizationType: OrgType,
): string {
if (group.read === 'TAB_LAB_READ') {
if (group.read === 'TAB_ORGANIZATIONS_READ') {
return organizationType === 'LAB' ? 'Clinics' : 'Labs';
}
return group.label;

View File

@@ -78,7 +78,7 @@ function AcceptInviteContent() {
name: name.trim(),
password,
});
setSuccess('Invitation accepted. Redirecting to login...');
setSuccess('Invitation Accepted. Redirecting to login...');
setTimeout(() => {
router.replace('/login');
}, 1000);

View File

@@ -0,0 +1,159 @@
'use client';
import { Suspense, useEffect, useMemo, useState } 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 { organizationApi } from '@/lib/api/organization';
function AcceptOrganizationInviteContent() {
const params = useSearchParams();
const router = useRouter();
const token = useMemo(() => params.get('token') || '', [params]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [inviteInfo, setInviteInfo] = useState<{
ownerEmail: string;
organizationName: string;
organizationType: 'CLINIC' | 'LAB';
inviterOrganizationName: string;
expiresAt: string;
status: 'PENDING' | 'ACCEPTED';
} | null>(null);
const [ownerName, setOwnerName] = useState('');
const [organizationName, setOrganizationName] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
useEffect(() => {
if (!token) {
setLoading(false);
setError('Invalid invitation link');
return;
}
void (async () => {
setLoading(true);
setError('');
try {
const res = await organizationApi.previewInvite(token);
setInviteInfo(res.data);
setOrganizationName(res.data.organizationName || '');
if (res.data.status === 'ACCEPTED') {
setSuccess('This invitation is already accepted. You can log in now.');
}
} catch (e: any) {
setError(e?.message || 'Could not load invitation');
} finally {
setLoading(false);
}
})();
}, [token]);
async function onAccept() {
if (!token) return;
setError('');
setSuccess('');
if (!ownerName.trim()) return setError('Owner name is required');
if (!organizationName.trim()) return setError('Organization name is required');
if (password.length < 8) return setError('Password must be at least 8 characters');
if (password !== confirmPassword) return setError('Passwords do not match');
setSubmitting(true);
try {
await organizationApi.acceptInvite({
token,
ownerName: ownerName.trim(),
organizationName: organizationName.trim(),
password,
});
setSuccess('Invitation Accepted. Redirecting to login...');
setTimeout(() => router.replace('/login'), 1000);
} catch (e: any) {
setError(e?.message || 'Could not accept invitation');
} finally {
setSubmitting(false);
}
}
return (
<div className="min-h-screen app-web-bg flex items-center justify-center p-4">
<div className="w-full max-w-md surface-card p-6 space-y-5">
<h1 className="text-xl font-semibold text-text-primary">Accept organization invitation</h1>
{loading ? (
<p className="text-sm text-text-secondary">Loading invitation...</p>
) : (
<>
{inviteInfo && (
<div className="rounded-[var(--radius-md)] border border-border/70 bg-background-secondary/70 px-3 py-2 text-sm text-text-secondary space-y-1">
<p>
Invited by: <span className="text-text-primary">{inviteInfo.inviterOrganizationName}</span>
</p>
<p>
Owner email: <span className="text-text-primary">{inviteInfo.ownerEmail}</span>
</p>
</div>
)}
{error && (
<div className="rounded-[var(--radius-md)] border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{error}
</div>
)}
{success && (
<div className="rounded-[var(--radius-md)] border border-primary/30 bg-primary-soft/40 px-3 py-2 text-sm text-text-primary">
{success}
</div>
)}
{inviteInfo?.status !== 'ACCEPTED' && (
<div className="space-y-3">
<Input label="Owner name" value={ownerName} onChange={(e) => setOwnerName(e.target.value)} />
<Input
label="Organization name"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
/>
<Input
label="Create password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Input
label="Confirm password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
<Button type="button" fullWidth isLoading={submitting} onClick={() => void onAccept()}>
Activate organization
</Button>
</div>
)}
<p className="text-xs text-text-muted">
Already have access? <Link href="/login" className="text-primary">Go to login</Link>
</p>
</>
)}
</div>
</div>
);
}
export default function AcceptOrganizationInvitePage() {
return (
<Suspense
fallback={
<div className="min-h-screen app-web-bg flex items-center justify-center">
<p className="text-sm text-text-secondary">Loading invitation...</p>
</div>
}
>
<AcceptOrganizationInviteContent />
</Suspense>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,31 +1,59 @@
//src/components/ui/Badge.tsx
import React from 'react';
type BadgeVariant = 'success' | 'warning' | 'danger' | 'default';
export type BadgeVariant = 'success' | 'warning' | 'danger' | 'default';
interface BadgeProps {
children: React.ReactNode;
variant?: BadgeVariant;
className?: string;
children: React.ReactNode;
variant?: BadgeVariant;
className?: string;
/**
* Same pixel width for every badge (table columns).
* Set false only when the pill should shrink to the label.
*/
fixedWidth?: boolean;
}
const variantStyles: Record<BadgeVariant, string> = {
success: 'bg-emerald-900/30 text-emerald-300 border-emerald-700/60',
warning: 'bg-amber-900/30 text-amber-300 border-amber-700/60',
danger: 'bg-red-950/30 text-red-300 border-red-700/60',
default: 'bg-background-secondary text-text-secondary border-border',
success: 'bg-emerald-900/30 text-emerald-300 border-emerald-700/60',
warning: 'bg-amber-900/30 text-amber-300 border-amber-700/60',
danger: 'bg-red-950/30 text-red-300 border-red-700/60',
default: 'bg-background-secondary text-text-secondary border-border',
};
/** Explicit width + height so every row matches; flex centers label optically. */
const FIXED_LAYOUT_CLASS =
'w-[8rem] min-w-[8rem] max-w-[8rem] shrink-0 h-7 px-2 py-0';
export function Badge({
children,
variant = 'default',
className,
children,
variant = 'default',
className,
fixedWidth = true,
}: BadgeProps) {
return (
<span
className={`px-2 py-1 text-xs font-medium rounded-lg border inline-block ${variantStyles[variant]} ${className || ''}`}
>
{children}
</span>
);
}
const layoutClass = fixedWidth
? `${FIXED_LAYOUT_CLASS} justify-center text-center`
: 'min-h-[1.75rem] px-2.5 py-1 justify-center';
return (
<span
className={`inline-flex items-center box-border rounded-md border text-xs font-medium leading-none whitespace-nowrap ${variantStyles[variant]} ${layoutClass} ${className ?? ''}`}
>
{children}
</span>
);
}
/** Map organization link / invitation row status to badge variant. */
export function organizationLinkStatusVariant(status: string): BadgeVariant {
switch (status) {
case 'ACTIVE':
return 'success';
case 'PENDING':
return 'warning';
case 'REJECTED':
case 'EXPIRED':
return 'danger';
default:
return 'default';
}
}

View File

@@ -1,6 +1,4 @@
// src/components/ui/Button.tsx
import React from 'react';
import { Loader2 } from 'lucide-react';
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
type ButtonSize = 'sm' | 'md' | 'lg';
@@ -25,23 +23,22 @@ export const Button: React.FC<ButtonProps> = ({
}) => {
const baseClasses =
'inline-flex items-center justify-center rounded-[var(--radius-md)] font-medium transition-all duration-200 ' +
'focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:opacity-50 disabled:cursor-not-allowed';
'focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:cursor-not-allowed';
const variantClasses: Record<ButtonVariant, string> = {
primary:
'bg-primary text-primary-contrast hover:brightness-105 shadow-[0_0_0_1px_var(--color-primary-soft)]',
'bg-primary text-white hover:opacity-90 disabled:opacity-60',
secondary:
'bg-surface-elevated text-text-primary border border-border hover:border-border-strong',
'bg-surface-elevated text-text-primary border border-border hover:border-border-strong disabled:opacity-50',
outline:
'border border-border text-text-primary hover:bg-background-card/70',
'border border-border text-text-primary hover:bg-background-card/70 disabled:opacity-50',
danger:
'bg-red-600 text-white hover:bg-red-700',
danger: 'bg-red-600 text-white hover:bg-red-700 disabled:opacity-50',
ghost:
'text-text-secondary hover:text-text-primary hover:bg-background-card/70',
'text-text-secondary hover:text-text-primary hover:bg-background-card/70 disabled:opacity-50',
};
const sizeClasses: Record<ButtonSize, string> = {
@@ -51,17 +48,16 @@ export const Button: React.FC<ButtonProps> = ({
};
const widthClass = fullWidth ? 'w-full' : '';
const loadingClass = isLoading ? 'opacity-70 animate-pulse pointer-events-none' : '';
return (
<button
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${className}`}
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${loadingClass} ${className}`}
disabled={disabled || isLoading}
aria-busy={isLoading || undefined}
{...props}
>
{isLoading && (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
)}
{children}
</button>
);
};
};

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
import { Search } from 'lucide-react';
import type { ReactNode } from 'react';
import { Input } from './Input';
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
placeholder: string;
onSubmit?: () => void;
actions?: ReactNode;
}
export function SearchBar({
value,
onChange,
placeholder,
onSubmit,
actions,
}: SearchBarProps) {
return (
<div className="surface-card p-4">
<div className="flex gap-4 items-center">
<div className="flex-1">
<Input
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onSubmit?.();
}}
icon={<Search className="h-4 w-4 icon-flat" />}
/>
</div>
{actions && <div className="flex gap-2">{actions}</div>}
</div>
</div>
);
}

View File

@@ -13,7 +13,7 @@ import {
CreditCard,
} from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { canViewTab } from '@/shared/permissions';
import { canAccessAppointmentsSection, canViewTab } from '@/shared/permissions';
const menu = [
{ name: 'Today', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const },
@@ -35,14 +35,24 @@ function Sidebar() {
const withCounterpartTab = [
menu[0],
menu[1],
{ name: counterpartLabel, path: '/lab', icon: FlaskConical, read: 'TAB_LAB_READ' as const },
{
name: counterpartLabel,
path: '/organizations',
icon: FlaskConical,
read: 'TAB_ORGANIZATIONS_READ' as const,
},
menu[2],
menu[3],
menu[4],
menu[5],
menu[6],
];
return withCounterpartTab.filter((item) => canViewTab(currentOrganization, item.read));
return withCounterpartTab.filter((item) => {
if (item.path === '/appointments') {
return canAccessAppointmentsSection(currentOrganization);
}
return canViewTab(currentOrganization, item.read);
});
},
[counterpartLabel, currentOrganization],
);

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from 'react';
interface TableProps {
headers: ReactNode;
body: ReactNode;
footer?: ReactNode;
}
export function Table({ headers, body, footer }: TableProps) {
return (
<div className="surface-card overflow-hidden">
<table className="w-full">
<thead className="bg-background-secondary/70 border-b border-border">
{headers}
</thead>
<tbody className="divide-y divide-border/60">
{body}
</tbody>
</table>
{footer && (
<div className="px-6 py-3 border-t border-border/60 flex justify-between items-center bg-background-secondary/70">
{footer}
</div>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useAuth } from '@/lib/hooks/useAuth';
import { Building2, Beaker, Mail, Plus } from 'lucide-react';
import { Building2, Beaker, Mail } from 'lucide-react';
import { Input } from '@/components/ui/common/Input';
import { Button } from '@/components/ui/common/Button';
@@ -55,7 +55,6 @@ export function OrganizationSelectorContent() {
setIsCreateOpen((prev) => !prev);
}}
>
<Plus className="h-4 w-4 mr-2 icon-flat" />
{isCreateOpen ? 'Cancel' : 'Create Organization'}
</Button>
</div>

View File

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

View File

@@ -0,0 +1,115 @@
import { apiClient } from './client';
export interface CounterpartSearchResultDto {
id: string;
name: string;
email: string;
phone: string | null;
owner: { email: string; name: string };
}
export interface CounterpartItemDto {
id: string;
requestedByOrganizationId: string | null;
counterpartOrganizationId: string | null;
organizationName: string;
ownerEmail: string;
phone: string | null;
status: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'EXPIRED';
createdAt: string;
acceptedAt: string | null;
}
export interface OrganizationInvitationHistoryItemDto {
id: string;
organizationName: string;
ownerEmail: string;
status: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'EXPIRED';
createdAt: string;
acceptedAt: string | null;
}
export const organizationApi = {
search: async (q: string): Promise<{ success: boolean; data: CounterpartSearchResultDto[] }> => {
const response = await apiClient.get(`/organizations/search?q=${encodeURIComponent(q)}`);
return response.data;
},
list: async (): Promise<{ success: boolean; data: { items: CounterpartItemDto[] } }> => {
const response = await apiClient.get('/organizations/links');
return response.data;
},
listInvitations: async (): Promise<{
success: boolean;
data: { items: OrganizationInvitationHistoryItemDto[] };
}> => {
const response = await apiClient.get('/organizations/invitations');
return response.data;
},
createLink: async (
targetOrganizationId: string,
): Promise<{ success: boolean; data: { id: string; status: 'PENDING' | 'ACTIVE' | 'REJECTED' } }> => {
const response = await apiClient.post('/organizations/links', { targetOrganizationId });
return response.data;
},
respondLink: async (
linkId: string,
action: 'ACCEPT' | 'REJECT',
): Promise<{ success: boolean; data: { id: string; status: 'PENDING' | 'ACTIVE' | 'REJECTED' } }> => {
const response = await apiClient.patch(`/organizations/links/${linkId}/respond`, { action });
return response.data;
},
deleteLink: async (linkId: string): Promise<{ success: boolean; data: { id: string }; message: string }> => {
const response = await apiClient.delete(`/organizations/links/${linkId}`);
return response.data;
},
invite: async (body: {
organizationName: string;
ownerEmail: string;
phone?: string;
}): Promise<{ success: boolean; data: { invitationId: string; invitationUrl: string; status: 'PENDING' } }> => {
const response = await apiClient.post('/organizations/invite', body);
return response.data;
},
getInvitationLink: async (
invitationId: string,
): Promise<{ success: boolean; data: { invitationId: string; invitationUrl: string } }> => {
const response = await apiClient.post(`/organizations/invitations/${invitationId}/link`);
return response.data;
},
previewInvite: async (
token: string,
): Promise<{
success: boolean;
data: {
ownerEmail: string;
organizationName: string;
organizationType: 'CLINIC' | 'LAB';
inviterOrganizationName: string;
expiresAt: string;
status: 'PENDING' | 'ACCEPTED';
};
}> => {
const response = await apiClient.get(
`/organizations/invitations/preview?token=${encodeURIComponent(token)}`,
);
return response.data;
},
acceptInvite: async (body: {
token: string;
organizationName: string;
ownerName: string;
password: string;
}): Promise<{ success: boolean; message: string; data: { organizationId: string } }> => {
const response = await apiClient.post('/organizations/invitations/accept', body);
return response.data;
},
};

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import type { Organization } from '@/types/organization';
const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [
{ prefix: '/today', permission: 'TAB_TODAY_READ' },
{ prefix: '/staff', permission: 'TAB_STAFF_READ' },
{ prefix: '/lab', permission: 'TAB_LAB_READ' },
{ prefix: '/organizations', permission: 'TAB_ORGANIZATIONS_READ' },
{ prefix: '/patients', permission: 'TAB_PATIENTS_READ' },
{ prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ' },
{ prefix: '/treatment', permission: 'TAB_TREATMENT_READ' },
@@ -48,3 +48,35 @@ export function canViewStaff(org: Organization | null): boolean {
hasPermission(org, 'TAB_STAFF_READ') || hasPermission(org, 'TAB_STAFF_EDIT')
);
}
/**
* Create/delete/book slots: owners, appointment editors, or treatment editors (schedule columns).
* Aligns with backend appointment mutations.
*/
export function canEditAppointments(org: Organization | null): boolean {
if (!org) {
return false;
}
if (org.isOwner) {
return true;
}
return (
hasPermission(org, 'TAB_APPOINTMENTS_EDIT') ||
hasPermission(org, 'TAB_TREATMENT_EDIT')
);
}
/** Route + sidebar: view appointments page if user can read appointments or manage treatment (column staff). */
export function canAccessAppointmentsSection(org: Organization | null): boolean {
if (!org) {
return false;
}
if (org.isOwner) {
return true;
}
return (
hasPermission(org, 'TAB_APPOINTMENTS_READ') ||
hasPermission(org, 'TAB_APPOINTMENTS_EDIT') ||
hasPermission(org, 'TAB_TREATMENT_EDIT')
);
}

View File

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