Compare commits

...

18 Commits

Author SHA1 Message Date
09b511a136 bugfix: org invitation flow updated so that the invited org follows the exact steps of free trial registration. 2026-05-17 02:03:18 +03:30
201be53ddd bugfix: some minor ui updates for invitation history dialog. 2026-05-17 01:39:49 +03:30
2d43254af2 bugfix: invitation link copy option disapearing fixed. route problem for unauthorizrd users opening invitation link fixed. 2026-05-17 00:02:13 +03:30
627219df58 bugfix: .env.example files updated. some minor changes in jwt strategy to avoid failing when .env files does not contain needed keys. 2026-05-16 22:40:42 +03:30
105175ff4d Merge pull request 'improvement/ui-ux-improvements' (#17) from improvement/ui-ux-improvements 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/17
2026-05-08 14:30:13 +03:30
803564802c improvement: datepicker component now let user choose past dates too. 2026-05-08 14:25:44 +03:30
a0348e4079 improvement: toast component created and used every where in the app. 2026-05-08 14:07:21 +03:30
dcc4b10f00 improvement: badge & card components colour pallete updated so their text become more readable in light mode. 2026-05-08 13:56:33 +03:30
f314c116ec Merge pull request 'feature/treatment' (#16) from feature/treatment 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/16
2026-05-08 12:45:23 +03:30
b6b408e41e feature: FDI tooth chart UI finalized. 2026-05-08 12:42:01 +03:30
1a11323b74 feature: FDI tooth chart UI updated. 2026-05-08 03:58:53 +03:30
c2d490a5e7 feature: Preview added for treatments. 2026-05-07 14:20:50 +03:30
58cb35e26d feature: a minor refactor in folder structure done. 2026-05-07 03:46:18 +03:30
a70cbc7860 feature: a mocked implementation of v1 UI done. 2026-05-07 03:40:29 +03:30
2161926fc9 Merge pull request 'feature/appointment' (#15) from feature/appointment into master
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 0s
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/15
2026-05-07 01:17:28 +03:30
f1c97021f8 feature: delete appointment button's UI improved 2026-05-07 01:14:55 +03:30
8123a94a3d feature: a minimal implementation of the appointment feature done. 2026-05-06 23:05:37 +03:30
bc06be3ca6 Merge pull request 'feature/clinic-lab-invitation' (#14) from feature/clinic-lab-invitation into master
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 0s
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/14
2026-05-06 19:28:12 +03:30
75 changed files with 4748 additions and 463 deletions

View File

@@ -1,23 +1,41 @@
# Database
DATABASE_URL=postgresql://dyolink_user:password@postgres:5432/dyolink_db
POSTGRES_HOST=postgres
#
# Local dev (Nest on your machine + Postgres via docker-compose.postgres.yml):
# Use host "localhost" — hostname "postgres" only works inside Docker networks.
DATABASE_URL=postgresql://dyolink_user:password@localhost:5432/dyolink_db
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=dyolink_user
POSTGRES_PASSWORD=CHANGE_ME_IN_PRODUCTION
POSTGRES_PASSWORD=password
POSTGRES_DB=dyolink_db
#
# If you run the API inside the same Compose stack as Postgres, use instead:
# DATABASE_URL=postgresql://dyolink_user:password@postgres:5432/dyolink_db
# POSTGRES_HOST=postgres
# JWT
# JWT (required for register/login)
JWT_SECRET=CHANGE_ME_TO_A_STRONG_SECRET_32_CHARS_MIN
JWT_EXPIRES_IN=7d
JWT_REFRESH_SECRET=CHANGE_ME_TO_ANOTHER_STRONG_SECRET
JWT_REFRESH_EXPIRES_IN=30d
# Application
PORT=3000
NODE_ENV=development
API_PREFIX=/api
CORS_ORIGIN=http://localhost:3000
# CORS and invite links — must match the URL where the Next.js app runs
FRONTEND_URL=http://localhost:3001
# OAuth (optional — uncomment when configured)
# GOOGLE_CLIENT_ID=your-google-client-id
# GOOGLE_CLIENT_SECRET=your-google-client-secret
# GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/callback
# FACEBOOK_CLIENT_ID=your-facebook-app-id
# FACEBOOK_CLIENT_SECRET=your-facebook-app-secret
# FACEBOOK_CALLBACK_URL=http://localhost:3000/auth/facebook/callback
# Email (configure for production)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your_email@gmail.com
SMTP_PASSWORD=your_app_password
SMTP_PASSWORD=your_app_password

View File

@@ -3,7 +3,7 @@
## Prerequisites
- **Node.js 20+** and **npm**
- **PostgreSQL** reachable from your machine (local or remote)
- **PostgreSQL** reachable from your machine — either installed locally **or** run via Docker (see below)
## First-time setup
@@ -28,9 +28,23 @@
- `JWT_SECRET` — strong secret for signing tokens
Do not commit `.env`.
4. **Database URL for local dev**
4. **Database for local dev**
Point `DATABASE_URL` at a database you created in Postgres (create an empty DB first if needed).
**Option A — Postgres in Docker (no local install, e.g. Mac)**
From `backend/`, with `.env` present (copy from `.env.example` first):
- Ensure `DATABASE_URL` uses **`localhost`** as the host (not `postgres`). Match user, password, and DB name to `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in the same file.
```bash
docker compose -f docker-compose.postgres.yml up -d
```
Wait until Postgres is healthy (`docker compose -f docker-compose.postgres.yml ps`). The container creates the database on first start.
To stop Postgres (data is kept in the named volume): `docker compose -f docker-compose.postgres.yml down`
**Option B — Postgres installed on the machine**
Create an empty database, then point `DATABASE_URL` at it.
5. **Generate Prisma Client**
@@ -46,12 +60,43 @@
This runs `prisma migrate dev`. Use it during development when the schema changes.
7. **Seed** (optional — sample data / bootstrap)
7. **Seed** (optional — reference data only)
```bash
npm run prisma:seed
```
This **does not** wipe your database. It only upserts lookup data: organization types (`CLINIC`, `LAB`), subscription plans, and tab permissions. Existing users, organizations, memberships, patients, appointments, and links are **left unchanged**.
To start from an empty database with fresh tables and reference data, see [Reset database (clean slate)](#reset-database-clean-slate) below.
## Reset database (clean slate)
Use this when you want to **delete all application data** (users, organizations, patients, sessions, etc.) and rebuild the schema from migrations, then run the seed.
From `backend/`:
```bash
npx prisma migrate reset
```
Prisma will prompt for confirmation, drop the database, re-apply all migrations, and run `prisma/seed.ts` automatically.
**What gets removed:** everything in the database, including organizations and all related rows.
**What the seed adds back:** only reference data (types, plans, permissions) — not demo users or organizations. Register again or use your own test data after a reset.
**Docker Postgres dev:** if you also want to wipe the Docker volume (not only tables), stop the container and remove the volume:
```bash
docker compose -f docker-compose.postgres.yml down -v
docker compose -f docker-compose.postgres.yml up -d
npm run prisma:migrate
npm run prisma:seed
```
Do **not** run `migrate reset` against production or shared staging databases.
## Run (development)
```bash
@@ -60,7 +105,7 @@ npm run start:dev
API listens on **`http://localhost:3000`** by default (`PORT` in `.env`).
If the frontend runs on another origin (e.g. `http://localhost:3001`), set `CORS_ORIGIN` in `.env` to that URL.
If the frontend runs on another origin (e.g. `http://localhost:3001`), set `FRONTEND_URL` in `.env` to that URL (CORS and invite links use it).
## After pulling latest `main`
@@ -71,7 +116,7 @@ npm run prisma:generate
npm run prisma:migrate
```
If teammates added migrations, step 4 applies them. Resolve migration conflicts locally before pushing.
If teammates added migrations, the migrate step above applies them. Resolve migration conflicts locally before pushing.
## Useful commands
@@ -80,9 +125,16 @@ If teammates added migrations, step 4 applies them. Resolve migration conflicts
| `npm run prisma:generate` | Regenerate client after `schema.prisma` changes |
| `npm run prisma:migrate` | Dev migrations (`migrate dev`) |
| `npm run prisma:deploy` | Production-style apply (`migrate deploy`) — e.g. CI/containers |
| `npm run prisma:seed` | Upsert reference data only (does not clear existing rows) |
| `npx prisma migrate reset` | Drop DB, re-migrate, run seed — **dev clean slate** |
| `npm run build` | Compile Nest app |
| `npm run start:prod` | Run compiled app (`node dist/main`) |
## Docker
Image build is defined in **`Dockerfile`** at this folder. For full-stack deployment and CI, see the **repository root `README.md`**.
| File | Purpose |
|------|--------|
| **`Dockerfile`** | Production API image |
| **`docker-compose.postgres.yml`** | Local dev Postgres only (port mapped to host) |
For full-stack deployment and CI, see the **repository root `README.md`**.

View File

@@ -0,0 +1,29 @@
# Local development Postgres only.
# Run from backend/: docker compose -f docker-compose.postgres.yml up -d
#
# Nest runs on your Mac/PC; use DATABASE_URL with host "localhost" (not "postgres").
# Variables POSTGRES_* and POSTGRES_PORT are read from .env (see .env.example).
services:
postgres:
image: postgres:15-alpine
container_name: dyolink-postgres-dev
restart: unless-stopped
ports:
- "${POSTGRES_PORT:-5432}:5432"
environment:
POSTGRES_USER: ${POSTGRES_USER:-dyolink_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
POSTGRES_DB: ${POSTGRES_DB:-dyolink_db}
volumes:
- dyolink_pgdata_dev:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
volumes:
dyolink_pgdata_dev:
name: dyolink_postgres_data_dev

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,36 @@
// backend/src/config/configuration.ts
/** Matches values accepted by jsonwebtoken `expiresIn` (via ms), e.g. 7d, 15m, or plain seconds. */
const JWT_TIMESPAN_PATTERN =
/^\d+(\.\d+)?(ms|s|m|h|d|w|y)?$/i;
function assertJwtSecret(value: string, envKey: string): string {
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`❌ Environment variable ${envKey} is required but not set`);
}
if (trimmed.length < 16) {
throw new Error(`${envKey} must be at least 16 characters`);
}
if (/CHANGE_ME/i.test(trimmed)) {
throw new Error(`${envKey} must be changed from the placeholder value`);
}
return trimmed;
}
function assertJwtTimespan(value: string, envKey: string): string {
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`❌ Environment variable ${envKey} is required but not set`);
}
if (!/^\d+$/.test(trimmed) && !JWT_TIMESPAN_PATTERN.test(trimmed)) {
throw new Error(
`${envKey}="${value}" is invalid. Use a duration like 7d, 15m, 30d, or a number of seconds.`,
);
}
return trimmed;
}
export interface Config {
port: number;
database: {
@@ -7,6 +39,8 @@ export interface Config {
jwt: {
secret: string;
expiresIn: string;
refreshSecret: string;
refreshExpiresIn: string;
};
throttle: {
ttl: number;
@@ -24,9 +58,10 @@ export default (): Config => {
return value;
};
// Helper for optional env vars with defaults
// Helper for optional env vars with defaults (whitespace-only counts as unset)
const getEnvVarWithDefault = (key: string, defaultValue: string): string => {
return process.env[key] || defaultValue;
const value = process.env[key]?.trim();
return value ? value : defaultValue;
};
const getEnvVarAsNumber = (key: string, defaultValue: number): number => {
@@ -36,14 +71,30 @@ export default (): Config => {
return isNaN(parsed) ? defaultValue : parsed;
};
const jwtSecret = assertJwtSecret(getEnvVar('JWT_SECRET'), 'JWT_SECRET');
const jwtExpiresIn = assertJwtTimespan(
getEnvVarWithDefault('JWT_EXPIRES_IN', '7d'),
'JWT_EXPIRES_IN',
);
const jwtRefreshSecret = assertJwtSecret(
getEnvVarWithDefault('JWT_REFRESH_SECRET', jwtSecret),
'JWT_REFRESH_SECRET',
);
const jwtRefreshExpiresIn = assertJwtTimespan(
getEnvVarWithDefault('JWT_REFRESH_EXPIRES_IN', '30d'),
'JWT_REFRESH_EXPIRES_IN',
);
return {
port: getEnvVarAsNumber('PORT', 3000),
database: {
url: getEnvVar('DATABASE_URL'),
},
jwt: {
secret: getEnvVar('JWT_SECRET'),
expiresIn: getEnvVarWithDefault('JWT_EXPIRES_IN', '7d'),
secret: jwtSecret,
expiresIn: jwtExpiresIn,
refreshSecret: jwtRefreshSecret,
refreshExpiresIn: jwtRefreshExpiresIn,
},
throttle: {
ttl: getEnvVarAsNumber('THROTTLE_TTL', 60),

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

@@ -7,6 +7,7 @@ import {
InternalServerErrorException
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { JwtSignOptions } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../../../prisma/prisma.service';
@@ -53,6 +54,20 @@ export class AuthService {
private configService: ConfigService,
) { }
private accessJwtSignOptions(): JwtSignOptions {
return {
secret: this.configService.get<string>('jwt.secret')!,
expiresIn: this.configService.get<string>('jwt.expiresIn')!,
} as JwtSignOptions;
}
private refreshJwtSignOptions(): JwtSignOptions {
return {
secret: this.configService.get<string>('jwt.refreshSecret')!,
expiresIn: this.configService.get<string>('jwt.refreshExpiresIn')!,
} as JwtSignOptions;
}
/**
* Validate user credentials (used by LocalStrategy)
* @param email - User's email
@@ -128,14 +143,8 @@ export class AuthService {
};
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(accessPayload, {
secret: this.configService.get('JWT_SECRET'),
expiresIn: this.configService.get('JWT_EXPIRES_IN'),
}),
this.jwtService.signAsync(refreshPayload, {
secret: this.configService.get('JWT_REFRESH_SECRET'),
expiresIn: this.configService.get('JWT_REFRESH_EXPIRES_IN'),
}),
this.jwtService.signAsync(accessPayload, this.accessJwtSignOptions()),
this.jwtService.signAsync(refreshPayload, this.refreshJwtSignOptions()),
]);
// Store session in database
@@ -398,7 +407,7 @@ export class AuthService {
try {
// Verify the refresh token
const payload = await this.jwtService.verifyAsync(refreshToken, {
secret: this.configService.get('jwt.refreshSecret'),
secret: this.configService.get<string>('jwt.refreshSecret'),
});
// Ensure this is a refresh token
@@ -446,10 +455,10 @@ export class AuthService {
type: 'access',
};
const newAccessToken = await this.jwtService.signAsync(newAccessPayload, {
secret: this.configService.get('jwt.secret'),
expiresIn: this.configService.get('jwt.expiresIn'),
});
const newAccessToken = await this.jwtService.signAsync(
newAccessPayload,
this.accessJwtSignOptions(),
);
// Update session with new access token
await this.prisma.session.update({
@@ -732,10 +741,7 @@ export class AuthService {
};
// 3. Generate new token
const accessToken = await this.jwtService.signAsync(payload, {
secret: this.configService.get('JWT_SECRET'),
expiresIn: this.configService.get('JWT_EXPIRES_IN'),
});
const accessToken = await this.jwtService.signAsync(payload, this.accessJwtSignOptions());
// 4. Format permissions
const permissions = this.getMembershipPermissions(membership);

View File

@@ -17,7 +17,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
return req?.cookies?.accessToken; // ✅ READ FROM COOKIE
},
ignoreExpiration: false,
secretOrKey: configService.get('JWT_SECRET'),
secretOrKey: configService.get<string>('jwt.secret'),
});
}

View File

@@ -1,4 +1,4 @@
import { IsString, MinLength } from 'class-validator';
import { IsEmail, IsEnum, IsString, MinLength } from 'class-validator';
export class AcceptOrganizationInviteDto {
@IsString()
@@ -9,6 +9,12 @@ export class AcceptOrganizationInviteDto {
@MinLength(1)
organizationName: string;
@IsEmail()
organizationEmail: string;
@IsEnum(['CLINIC', 'LAB'])
organizationType: 'CLINIC' | 'LAB';
@IsString()
@MinLength(1)
ownerName: string;

View File

@@ -288,8 +288,11 @@ export class OrganizationService {
if (!invitation) {
throw new NotFoundException('Invitation not found');
}
if (invitation.acceptedAt || invitation.revokedAt) {
throw new BadRequestException('Only pending invitations can provide a link');
if (invitation.acceptedAt) {
throw new BadRequestException('This invitation has already been accepted');
}
if (invitation.revokedAt) {
throw new BadRequestException('This invitation is no longer valid');
}
const plainToken = this.generateInviteToken();
@@ -421,12 +424,23 @@ export class OrganizationService {
async previewInvite(token: string) {
const invitation = await this.findValidInvitation(token);
let organizationEmail = '';
if (invitation.invitedOrganizationId) {
const invitedOrg = await this.prisma.organization.findUnique({
where: { id: invitation.invitedOrganizationId },
select: { email: true },
});
if (invitedOrg?.email && !invitedOrg.email.includes('@dyolink.local')) {
organizationEmail = invitedOrg.email;
}
}
return {
success: true,
data: {
ownerEmail: invitation.invitedOwnerEmail,
organizationName: invitation.invitedOrganizationName,
organizationType: invitation.invitedOrganizationType,
organizationEmail,
inviterOrganizationName: invitation.inviterOrganization.name,
expiresAt: invitation.expiresAt.toISOString(),
status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING',
@@ -440,6 +454,14 @@ export class OrganizationService {
throw new BadRequestException('This invitation has already been accepted');
}
if (dto.organizationType !== invitation.invitedOrganizationType) {
throw new BadRequestException(
`Organization type must be ${invitation.invitedOrganizationType} for this invitation`,
);
}
const organizationEmail = dto.organizationEmail.trim().toLowerCase();
const organization = await this.prisma.$transaction(async (tx) => {
const passwordHash = await bcrypt.hash(dto.password, 10);
const ownerEmail = invitation.invitedOwnerEmail;
@@ -467,8 +489,9 @@ export class OrganizationService {
where: { id: targetOrganizationId },
data: {
name: dto.organizationName.trim(),
email: ownerEmail,
email: organizationEmail,
owner: { connect: { id: owner.id } },
type: { connect: { name: dto.organizationType } },
plan: { connect: { name: 'trial' } },
},
});
@@ -476,9 +499,9 @@ export class OrganizationService {
const createdOrg = await tx.organization.create({
data: {
name: dto.organizationName.trim(),
email: ownerEmail,
email: organizationEmail,
owner: { connect: { id: owner.id } },
type: { connect: { name: invitation.invitedOrganizationType } },
type: { connect: { name: dto.organizationType } },
plan: { connect: { name: 'trial' } },
},
});

View File

@@ -55,6 +55,19 @@ export class StaffController {
return this.staffService.invite(req.user.id, organizationId, dto);
}
@Post('members/:membershipId/invitation-link')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Regenerate and return invite link for a pending staff member',
})
getInvitationLink(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('membershipId') membershipId: string,
) {
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
return this.staffService.getInvitationLink(req.user.id, organizationId, membershipId);
}
@Patch('members/:membershipId')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Update staff member name and/or permissions' })

View File

@@ -224,6 +224,62 @@ export class StaffService {
};
}
async getInvitationLink(userId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot invite or manage staff');
}
const membership = await this.prisma.membership.findFirst({
where: { id: membershipId, organizationId },
include: {
user: { select: { email: true } },
invitations: { orderBy: { createdAt: 'desc' }, take: 1 },
},
});
if (!membership) {
throw new NotFoundException('Member not found');
}
if (membership.isOwner) {
throw new BadRequestException('Owner does not use an invitation link');
}
if (membership.isActive) {
throw new BadRequestException('This member has already accepted their invitation');
}
const invitation = membership.invitations[0];
if (!invitation) {
throw new BadRequestException('No invitation found for this member');
}
if (invitation.acceptedAt) {
throw new BadRequestException('This invitation has already been accepted');
}
if (invitation.revokedAt) {
throw new BadRequestException('This invitation is no longer valid');
}
const plainToken = this.generateInviteToken();
const tokenHash = this.hashInviteToken(plainToken);
await this.prisma.staffInvitation.update({
where: { id: invitation.id },
data: {
tokenHash,
expiresAt: this.getInviteExpiryDate(),
},
});
return {
success: true,
data: {
membershipId: membership.id,
invitationId: invitation.id,
email: membership.user.email,
invitationUrl: this.buildInviteUrl(plainToken),
},
};
}
async previewInvite(token: string) {
const invitation = await this.findValidInvitation(token);
const org = invitation.membership.organization;
@@ -383,7 +439,8 @@ export class StaffService {
if (m.isOwner || m.isActive) return 'ACTIVE';
const invitation = m.invitations[0];
if (!invitation) return 'EXPIRED';
if (invitation.acceptedAt || invitation.revokedAt) return 'ACTIVE';
if (invitation.acceptedAt) return 'ACTIVE';
if (invitation.revokedAt) return 'EXPIRED';
return invitation.expiresAt.getTime() > Date.now() ? 'PENDING' : 'EXPIRED';
}

7
frontend/.env.example Normal file
View File

@@ -0,0 +1,7 @@
# Copy to .env.local for local development (Next.js loads .env.local automatically).
# Do not commit .env.local — only this template is tracked in git.
NEXT_PUBLIC_API_URL=http://localhost:3000/api
NEXT_PUBLIC_APP_NAME=DyoLink
# URL where users open the frontend (used for metadata, images, etc.)
NEXT_PUBLIC_APP_URL=http://localhost:3001

4
frontend/.gitignore vendored
View File

@@ -31,7 +31,9 @@ yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
.env
.env.*
!.env.example
# vercel
.vercel

View File

@@ -5,7 +5,7 @@
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start -p 3000",
"start": "next start -p 3001",
"lint": "next lint"
},
"dependencies": {

View File

@@ -1,10 +1,376 @@
'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 { Toast } from '@/components/ui/common/Toast';
import type { AppointmentPurpose } from '@/types/appointment';
import { formatApiErrorMessage } from '@/lib/formatApiError';
import { compareLocalDayStart, 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 [editingAppointmentId, setEditingAppointmentId] = useState<string | null>(null);
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 isViewingPastDay = useMemo(
() => compareLocalDayStart(scheduleDate, todayStart) < 0,
[scheduleDate, todayStart],
);
const activeEditingAppointment = useMemo(
() => appointments.find((a) => a.id === editingAppointmentId) ?? null,
[appointments, editingAppointmentId],
);
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 (isViewingPastDay) {
setToastSuccess('');
setToastError('');
setToastInfo('Past appointments are view-only.');
return;
}
if (!selectedPatient) {
setToastSuccess('');
setToastError('');
setToastInfo('Select a patient before booking.');
return;
}
setBookingHour(hour);
setBookingProviderId(providerUserId);
setBookingProviderName(providerName);
setEditingAppointmentId(null);
setBookingOpen(true);
}
function handleAppointmentClick(appointment: AppointmentRecord) {
if (isViewingPastDay) {
setToastSuccess('');
setToastError('');
setToastInfo('Past appointments are view-only.');
return;
}
const provider = providers.find((p) => p.userId === appointment.providerUserId);
setBookingHour(new Date(appointment.startAt).getHours());
setBookingProviderId(appointment.providerUserId);
setBookingProviderName(provider?.name ?? bookingProviderName);
setEditingAppointmentId(appointment.id);
setBookingOpen(true);
}
async function handleSaveAppointment(payload: {
patientId: string;
providerUserId: string;
startAt: string;
endAt: string;
purpose: AppointmentPurpose;
}) {
setSavingAppointment(true);
setToastError('');
setToastSuccess('');
setToastInfo('');
try {
if (activeEditingAppointment) {
await appointmentsApi.update(activeEditingAppointment.id, payload);
} else {
await appointmentsApi.create(payload);
}
setBookingOpen(false);
setEditingAppointmentId(null);
setToastSuccess(activeEditingAppointment ? 'Appointment updated.' : 'Appointment saved.');
await loadSchedule();
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: activeEditingAppointment
? 'Could not update appointment.'
: '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>
<AppointmentScheduleGrid
day={scheduleDate}
providers={providers}
appointments={appointments}
canBook={canManageAppointments && !isViewingPastDay}
canDelete={canManageAppointments}
onDeleteAppointment={(id) => void handleDeleteAppointment(id)}
onSlotClick={(hour, uid, name) => handleSlotClick(hour, uid, name)}
onAppointmentClick={(apt) => handleAppointmentClick(apt)}
/>
</div>
</div>
<AppointmentBookingModal
open={bookingOpen}
scheduleDate={scheduleDate}
patient={selectedPatient}
providerUserId={bookingProviderId}
providerName={bookingProviderName}
initialHour={bookingHour}
editingAppointment={activeEditingAppointment}
onClose={() => {
setBookingOpen(false);
setEditingAppointmentId(null);
}}
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>
)}
{(scheduleError || toastError || toastSuccess || toastInfo) && (
<div className="fixed bottom-4 left-0 right-0 z-[70] px-4 pointer-events-none">
<div className="pointer-events-auto w-full space-y-2">
{scheduleError && <Toast variant="danger">{scheduleError}</Toast>}
{toastError && <Toast variant="danger">{toastError}</Toast>}
{toastInfo && <Toast variant="warning">{toastInfo}</Toast>}
{toastSuccess && <Toast variant="success">{toastSuccess}</Toast>}
</div>
</div>
)}
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { Pencil } from 'lucide-react';
import { Button } from '@/components/ui/common/Button';
import { Badge } from '@/components/ui/common/Badge';
import { Card } from '@/components/ui/common/Card';
import { Table } from '@/components/ui/common/Table';
import { SearchBar } from '@/components/ui/common/SearchBar';
import { useAuth } from '@/lib/hooks/useAuth';
@@ -195,19 +196,19 @@ export default function BillingPage() {
}
function StatCard({ title, count, amount, color }: StatCardProps) {
const colors: Record<StatCardColor, string> = {
blue: 'bg-sky-900/30 text-sky-300 border-sky-700/60',
yellow: 'bg-amber-900/30 text-amber-300 border-amber-700/60',
green: 'bg-emerald-900/30 text-emerald-300 border-emerald-700/60',
red: 'bg-red-950/30 text-red-300 border-red-700/60',
blue: '!bg-purpose-visit-bg !text-purpose-visit-fg !border-purpose-visit-border',
yellow: '!bg-badge-warning-bg !text-badge-warning-fg !border-badge-warning-border',
green: '!bg-badge-success-bg !text-badge-success-fg !border-badge-success-border',
red: '!bg-badge-danger-bg !text-badge-danger-fg !border-badge-danger-border',
};
return (
<div className={`p-4 rounded-xl border ${colors[color]}`}>
<p className="text-sm font-medium opacity-80">{title}</p>
<Card className={`${colors[color]}`}>
<p className="text-sm font-medium">{title}</p>
<p className="text-2xl font-bold mt-1">{count}</p>
<p className="text-sm font-medium mt-1">
${amount.toLocaleString()}
</p>
</div>
</Card>
);
}

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

@@ -1,14 +1,16 @@
'use client';
'use client';
import { useEffect, useState } from 'react';
import { Check, Copy, Link2, Trash2, X } from 'lucide-react';
import { Check, Link2, Trash2, X } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { useOrganizationInviteLinkCopy } from '@/lib/hooks/useOrganizationInviteLinkCopy';
import {
organizationApi,
type CounterpartItemDto,
type CounterpartSearchResultDto,
type OrganizationInvitationHistoryItemDto,
} from '@/lib/api/organization';
import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog';
import { Button } from '@/components/ui/common/Button';
import { Badge, organizationLinkStatusVariant } from '@/components/ui/common/Badge';
import { Input } from '@/components/ui/common/Input';
@@ -16,33 +18,6 @@ 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();
@@ -56,13 +31,6 @@ function formatLinkStatusLabel(status: CounterpartItemDto['status']): string {
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;
@@ -73,7 +41,7 @@ function formatApiMessage(err: unknown): string {
function formatTableDate(value: string): string {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '';
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleDateString();
}
@@ -96,12 +64,20 @@ export default function OrganizationsPage() {
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 [historyCopyError, setHistoryCopyError] = useState('');
const [historyCopySuccess, setHistoryCopySuccess] = useState('');
const {
copiedId,
copyingInvitationId,
storeInviteLink,
copyInvitationLink,
pruneAcceptedLinks,
} = useOrganizationInviteLinkCopy(currentOrganization?.id);
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinic' : 'Lab';
const tabLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
@@ -121,11 +97,6 @@ export default function OrganizationsPage() {
}
}
useEffect(() => {
if (!currentOrganization?.id) return;
setPendingInviteLinks(readStoredInviteLinks(currentOrganization.id));
}, [currentOrganization?.id]);
useEffect(() => {
void loadList();
}, []);
@@ -185,18 +156,7 @@ export default function OrganizationsPage() {
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);
}
storeInviteLink(res.data.invitationId, manualOwnerEmail, res.data.invitationUrl);
setSuccess(`Invitation link created for ${manualOwnerEmail.trim()}`);
setManualOrganizationName('');
setManualOwnerEmail('');
@@ -212,13 +172,21 @@ export default function OrganizationsPage() {
}
}
async function loadInvitationHistory() {
const res = await organizationApi.listInvitations();
setHistoryItems(res.data.items);
pruneAcceptedLinks(res.data.items);
return res.data.items;
}
async function openInvitationHistory() {
setHistoryOpen(true);
setHistoryLoading(true);
setHistoryCopyError('');
setHistoryCopySuccess('');
setError('');
try {
const res = await organizationApi.listInvitations();
setHistoryItems(res.data.items);
await loadInvitationHistory();
} catch (e) {
setError(formatApiMessage(e));
} finally {
@@ -226,6 +194,22 @@ export default function OrganizationsPage() {
}
}
async function handleHistoryCopy(invitation: OrganizationInvitationHistoryItemDto) {
setHistoryCopyError('');
setHistoryCopySuccess('');
try {
await copyInvitationLink(invitation, {
onRegenerated: async () => {
await loadInvitationHistory();
},
});
setHistoryCopySuccess('Invitation link copied to clipboard.');
setTimeout(() => setHistoryCopySuccess(''), 3000);
} catch (e) {
setHistoryCopyError(formatApiMessage(e));
}
}
async function respondToPendingLink(linkId: string, action: 'ACCEPT' | 'REJECT') {
setRequestLinkRowId(linkId);
setError('');
@@ -254,35 +238,6 @@ export default function OrganizationsPage() {
}
}
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('');
@@ -516,87 +471,21 @@ export default function OrganizationsPage() {
}
/>
{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>
)}
<InvitationHistoryDialog
open={historyOpen}
onClose={() => {
setHistoryOpen(false);
setHistoryCopyError('');
setHistoryCopySuccess('');
}}
loading={historyLoading}
items={historyItems}
copiedId={copiedId}
copyingInvitationId={copyingInvitationId}
onCopy={(invitation) => void handleHistoryCopy(invitation)}
copyError={historyCopyError}
copySuccess={historyCopySuccess}
/>
</div>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import Link from 'next/link';
import { OrganizationSelectorContent } from '@/components/ui/organization/OrganizationSelectorContent';
import { OrganizationSelectorContent } from '@/components/ui/organizations/OrganizationSelectorContent';
export default function DashboardOrganizationsSettingsPage() {
return (

View File

@@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth';
import { Button } from '@/components/ui/common/Button';
import { Toast } from '@/components/ui/common/Toast';
import type { SubscriptionAlertData } from '@/types/subscription';
const PLAN_OPTIONS = [
@@ -69,7 +70,7 @@ export default function SubscriptionsSettingsPage() {
: 'text-red-400';
return (
<div className="space-y-6">
<div className="relative space-y-6 pb-24">
<div>
<Link
href="/today"
@@ -189,13 +190,16 @@ export default function SubscriptionsSettingsPage() {
>
Start purchase process
</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>
</div>
)}
</div>
</div>
{purchaseNotice && (
<div className="fixed bottom-4 left-0 right-0 z-[70] px-4 pointer-events-none">
<div className="pointer-events-auto w-full">
<Toast variant="success">{purchaseNotice}</Toast>
</div>
</div>
)}
</div>
);
}

View File

@@ -15,8 +15,9 @@ import {
resolveStaffFeatureLabel,
formatAccessSummary,
type FeaturePermState,
} from './staff-permission-form';
} from '../../../components/staff/staff-permission-form';
import { Pencil, Trash2, Copy, Check, X } from 'lucide-react';
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton';
import { useAuth } from '@/lib/hooks/useAuth';
import { staffApi, type StaffMemberDto } from '@/lib/api/staff';
import { Button } from '@/components/ui/common/Button';
@@ -61,6 +62,10 @@ function formatApiMessage(err: unknown): string {
return 'Something went wrong';
}
function canShareStaffInviteLink(member: StaffMemberDto): boolean {
return !member.isOwner && member.invitationStatus !== 'ACTIVE';
}
function PermissionGrid({
state,
onChange,
@@ -140,6 +145,7 @@ export default function StaffPage() {
const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState());
const [inviteLoading, setInviteLoading] = useState(false);
const [copiedInviteMembershipId, setCopiedInviteMembershipId] = useState<string | null>(null);
const [copyingInviteMembershipId, setCopyingInviteMembershipId] = useState<string | null>(null);
const [lastInviteInfo, setLastInviteInfo] = useState<{
membershipId: string;
name: string;
@@ -221,6 +227,42 @@ export default function StaffPage() {
return () => clearTimeout(t);
}, [success]);
async function copyStaffInviteLink(member: StaffMemberDto) {
if (!canShareStaffInviteLink(member)) return;
setCopyingInviteMembershipId(member.id);
setError('');
try {
let invitationUrl = pendingInviteLinks[member.id]?.invitationUrl;
if (!invitationUrl || member.invitationStatus === 'EXPIRED') {
const res = await staffApi.getInvitationLink(member.id);
invitationUrl = res.data.invitationUrl;
if (currentOrganization?.id) {
const nextLinks = {
...pendingInviteLinks,
[member.id]: {
membershipId: member.id,
email: member.email,
invitationUrl,
},
};
setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}
}
await navigator.clipboard.writeText(invitationUrl);
setCopiedInviteMembershipId(member.id);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
if (member.invitationStatus === 'EXPIRED') {
await load();
}
} catch (e) {
setError(formatApiMessage(e));
} finally {
setCopyingInviteMembershipId(null);
}
}
async function submitInvite() {
setInviteLoading(true);
setError('');
@@ -388,34 +430,60 @@ export default function StaffPage() {
? ' Invitation is pending until they open the link, set a password, and log in.'
: ' Invitation was accepted immediately.'}
</p>
{lastInviteInfo.invitationUrl && (
{lastInviteInfo.invitationStatus === 'PENDING' && (
<div className="space-y-2 pt-1 border-t border-border/60">
<p className="text-xs font-medium text-text-secondary uppercase tracking-wide">
Invite link
</p>
<div className="flex flex-wrap items-center gap-2">
<code className="text-sm px-2 py-1.5 rounded-[var(--radius-sm)] bg-background-card border border-border font-mono break-all">
{lastInviteInfo.invitationUrl && (
<code className="block text-sm px-2 py-1.5 rounded-[var(--radius-sm)] bg-background-card border border-border font-mono break-all">
{lastInviteInfo.invitationUrl}
</code>
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
)}
<Button
type="button"
variant="outline"
size="sm"
isLoading={copyingInviteMembershipId === lastInviteInfo.membershipId}
onClick={() => {
const member = members.find((item) => item.id === lastInviteInfo.membershipId);
if (member) {
void copyStaffInviteLink(member);
return;
}
void (async () => {
setCopyingInviteMembershipId(lastInviteInfo.membershipId);
setError('');
try {
await navigator.clipboard.writeText(lastInviteInfo.invitationUrl as string);
const res = await staffApi.getInvitationLink(lastInviteInfo.membershipId);
if (currentOrganization?.id) {
const nextLinks = {
...pendingInviteLinks,
[lastInviteInfo.membershipId]: {
membershipId: lastInviteInfo.membershipId,
email: lastInviteInfo.email,
invitationUrl: res.data.invitationUrl,
},
};
setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}
setLastInviteInfo({ ...lastInviteInfo, invitationUrl: res.data.invitationUrl });
await navigator.clipboard.writeText(res.data.invitationUrl);
setCopiedInviteMembershipId(lastInviteInfo.membershipId);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch {
setError('Could not copy invitation link');
} catch (e) {
setError(formatApiMessage(e));
} finally {
setCopyingInviteMembershipId(null);
}
}}
>
{copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'}
</Button>
</div>
})();
}}
>
{copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'}
</Button>
<p className="text-xs text-text-muted">
Share this link manually via SMS or email. They must set password first.
Share this link manually via SMS or email. A new link is generated if the previous one expired or was lost.
</p>
</div>
)}
@@ -470,21 +538,14 @@ export default function StaffPage() {
<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 && (
{canShareStaffInviteLink(m) && (
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary"
onClick={async () => {
try {
await navigator.clipboard.writeText(pendingInviteLinks[m.id].invitationUrl);
setCopiedInviteMembershipId(m.id);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch {
setError('Could not copy invitation link');
}
}}
aria-label="Copy invite link"
title="Copy invite link"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50"
disabled={copyingInviteMembershipId === m.id}
onClick={() => void copyStaffInviteLink(m)}
aria-label="Copy invitation link"
title="Copy invitation link (generates a new link if needed)"
>
{copiedInviteMembershipId === m.id ? (
<Check className="w-4 h-4" />
@@ -543,9 +604,12 @@ export default function StaffPage() {
aria-modal="true"
aria-labelledby="invite-staff-title"
>
<h2 id="invite-staff-title" className="text-lg font-semibold text-text-primary">
Invite team member
</h2>
<div className="flex items-start justify-between gap-3">
<h2 id="invite-staff-title" className="text-lg font-semibold text-text-primary pr-2">
Invite team member
</h2>
<DialogCloseButton onClick={() => setInviteOpen(false)} />
</div>
<Input
label="Email"
type="email"
@@ -590,7 +654,10 @@ export default function StaffPage() {
role="dialog"
aria-modal="true"
>
<h2 className="text-lg font-semibold text-text-primary">Edit member</h2>
<div className="flex items-start justify-between gap-3">
<h2 className="text-lg font-semibold text-text-primary pr-2">Edit member</h2>
<DialogCloseButton onClick={() => setEditing(null)} />
</div>
<p className="text-xs text-text-muted">{editing.email}</p>
<Input label="Display name" value={editName} onChange={(e) => setEditName(e.target.value)} />
<div>

View File

@@ -2,6 +2,7 @@
import Link from 'next/link';
import { useAuth } from '@/lib/hooks/useAuth';
import { Card } from '@/components/ui/common/Card';
export default function TodayPage() {
const { currentOrganization } = useAuth();
@@ -27,29 +28,26 @@ export default function TodayPage() {
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<Card title="Today's Appointments" value="12" sub="Monday 2/5/2026" />
<Card title="Active Patients" value="675" />
<Card title="New Lab Case" value="5" sub="35 ↑" />
<Card title="Today invoices" value="1200$" sub="21,300 $" />
<Card>
<p className="text-sm text-card-muted">Today's Appointments</p>
<p className="text-2xl font-semibold mt-2">12</p>
<p className="text-xs text-text-muted mt-1">Monday 2/5/2026</p>
</Card>
<Card>
<p className="text-sm text-card-muted">Active Patients</p>
<p className="text-2xl font-semibold mt-2">675</p>
</Card>
<Card>
<p className="text-sm text-card-muted">New Lab Case</p>
<p className="text-2xl font-semibold mt-2">5</p>
<p className="text-xs text-text-muted mt-1">35 </p>
</Card>
<Card>
<p className="text-sm text-card-muted">Today invoices</p>
<p className="text-2xl font-semibold mt-2">1200$</p>
<p className="text-xs text-text-muted mt-1">21,300 $</p>
</Card>
</div>
</div>
);
}
function Card({
title,
value,
sub,
}: {
title: string;
value: string;
sub?: string;
}) {
return (
<div className="surface-card p-4">
<p className="text-sm text-text-secondary">{title}</p>
<p className="text-2xl font-semibold mt-2">{value}</p>
{sub && <p className="text-xs text-text-muted mt-1">{sub}</p>}
</div>
);
}

View File

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

View File

@@ -3,15 +3,45 @@
import { Suspense, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useForm, type FieldErrors, type UseFormRegister, type UseFormSetValue } from 'react-hook-form';
import type { OrganizationDetailsFormValues } from '@/components/ui/auth/OrganizationDetailsFields';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Lock, Mail, User } from 'lucide-react';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
import { organizationApi } from '@/lib/api/organization';
const acceptOrganizationInviteSchema = z
.object({
ownerName: z.string().min(2, 'Name must be at least 2 characters'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
confirmPassword: z.string(),
organizationName: z.string().min(2, 'Organization name must be at least 2 characters'),
organizationEmail: z.string().email('Please enter a valid organization email'),
organizationType: z.enum(['CLINIC', 'LAB'], {
message: 'Please select organization type',
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
type AcceptOrganizationInviteForm = z.infer<typeof acceptOrganizationInviteSchema>;
function AcceptOrganizationInviteContent() {
const params = useSearchParams();
const router = useRouter();
const token = useMemo(() => params.get('token') || '', [params]);
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
@@ -20,15 +50,29 @@ function AcceptOrganizationInviteContent() {
ownerEmail: string;
organizationName: string;
organizationType: 'CLINIC' | 'LAB';
organizationEmail?: string;
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('');
const {
register,
handleSubmit,
watch,
trigger,
setValue,
reset,
formState: { errors },
} = useForm<AcceptOrganizationInviteForm>({
resolver: zodResolver(acceptOrganizationInviteSchema),
mode: 'onChange',
defaultValues: {
organizationType: undefined,
},
});
const organizationType = watch('organizationType');
useEffect(() => {
if (!token) {
@@ -43,102 +87,168 @@ function AcceptOrganizationInviteContent() {
try {
const res = await organizationApi.previewInvite(token);
setInviteInfo(res.data);
setOrganizationName(res.data.organizationName || '');
reset({
ownerName: '',
password: '',
confirmPassword: '',
organizationName: res.data.organizationName || '',
organizationEmail: res.data.organizationEmail || '',
organizationType: res.data.organizationType,
});
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');
} catch (e: unknown) {
const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : '';
setError(message || 'Could not load invitation');
} finally {
setLoading(false);
}
})();
}, [token]);
}, [token, reset]);
async function onAccept() {
const handleNext = async () => {
const isValid = await trigger(['ownerName', 'password', 'confirmPassword']);
if (isValid) {
setStep(2);
setError('');
}
};
const onSubmit = async (data: AcceptOrganizationInviteForm) => {
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,
ownerName: data.ownerName.trim(),
password: data.password,
organizationName: data.organizationName.trim(),
organizationEmail: data.organizationEmail.trim(),
organizationType: data.organizationType,
});
setSuccess('Invitation Accepted. Redirecting to login...');
setSuccess('Invitation accepted. Redirecting to login...');
setTimeout(() => router.replace('/login'), 1000);
} catch (e: any) {
setError(e?.message || 'Could not accept invitation');
} catch (e: unknown) {
const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : '';
setError(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 className="min-h-screen app-web-bg flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<Link href="/" className="flex justify-center">
<span className="text-3xl font-semibold text-text-primary">DyoLink</span>
</Link>
<h2 className="mt-6 text-center text-2xl font-semibold text-text-primary">
Accept organization invitation
</h2>
<p className="mt-2 text-center text-sm text-text-secondary">
Already have an account?{' '}
<Link href="/login" className="font-medium text-primary hover:opacity-90">
Sign in
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="surface-card py-8 px-4 sm:px-10">
{loading ? (
<p className="text-sm text-text-secondary">Loading invitation...</p>
) : (
<>
{inviteInfo && (
<div className="mb-6 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>
</div>
)}
{inviteInfo?.status !== 'ACCEPTED' && (
<RegistrationProgressSteps step={step} />
)}
{error && (
<div className="mb-4 p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
{success && (
<div className="mb-4 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' && (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{step === 1 && (
<>
<Input
label="Owner email"
value={inviteInfo?.ownerEmail ?? ''}
readOnly
disabled
icon={<Mail className="h-5 w-5 icon-flat" />}
/>
<Input
label="Full name"
{...register('ownerName')}
placeholder="John Doe"
error={errors.ownerName?.message}
icon={<User className="h-5 w-5 icon-flat" />}
/>
<Input
label="Password"
{...register('password')}
type="password"
placeholder="••••••••"
error={errors.password?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
/>
<Input
label="Confirm password"
{...register('confirmPassword')}
type="password"
placeholder="••••••••"
error={errors.confirmPassword?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
/>
<Button type="button" variant="primary" onClick={() => void handleNext()} fullWidth>
Continue
</Button>
</>
)}
{step === 2 && (
<>
<OrganizationDetailsFields
register={register as unknown as UseFormRegister<OrganizationDetailsFormValues>}
errors={errors as FieldErrors<OrganizationDetailsFormValues>}
organizationType={organizationType}
setValue={setValue as unknown as UseFormSetValue<OrganizationDetailsFormValues>}
/>
<div className="flex gap-3">
<Button type="button" variant="outline" onClick={() => setStep(1)}>
Back
</Button>
<Button type="submit" variant="primary" isLoading={submitting} fullWidth>
Activate organization
</Button>
</div>
</>
)}
</form>
)}
</>
)}
</div>
</div>
</div>
);

View File

@@ -5,8 +5,10 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import Link from 'next/link';
import { Building2, Mail, Lock, User, ChevronRight } from 'lucide-react';
import { Mail, Lock, User } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
const registerSchema = z.object({
@@ -89,31 +91,7 @@ export default function RegisterPage() {
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="surface-card py-8 px-4 sm:px-10">
{/* Progress Steps */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${step >= 1 ? 'bg-primary text-primary-contrast' : 'bg-background-secondary text-text-secondary border border-border'}`}>
1
</div>
<div className={`ml-2 text-sm font-medium ${step >= 1 ? 'text-primary' : 'text-text-muted'
}`}>
Account
</div>
</div>
<ChevronRight className="h-5 w-5 text-text-muted icon-flat" />
<div className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${step >= 2 ? 'bg-primary text-primary-contrast' : 'bg-background-secondary text-text-secondary border border-border'}`}>
2
</div>
<div className={`ml-2 text-sm font-medium ${step >= 2 ? 'text-primary' : 'text-text-muted'
}`}>
Organization
</div>
</div>
</div>
</div>
<RegistrationProgressSteps step={step} />
{/* Trial Info Banner */}
<div className="mb-6 p-4 bg-primary-soft rounded-[var(--radius-md)] border border-primary/35">
<h3 className="text-sm font-medium text-text-primary mb-2">Your trial
@@ -177,57 +155,12 @@ export default function RegisterPage() {
)}
{step === 2 && (
<>
<Input
label="Organization name"
{...register('organizationName')}
placeholder="Sunshine Dental Clinic"
error={errors.organizationName?.message}
icon={<Building2 className="h-5 w-5 icon-flat" />}
<OrganizationDetailsFields
register={register as never}
errors={errors as never}
organizationType={organizationType}
setValue={setValue as never}
/>
<Input
label="Organization email"
{...register('organizationEmail')}
type="email"
placeholder="contact@sunshineclinic.com"
error={errors.organizationEmail?.message}
icon={<Mail className="h-5 w-5 icon-flat" />}
/>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Organization type
</label>
<input type="hidden" {...register('organizationType')} />
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => {
setValue('organizationType', 'CLINIC', { shouldValidate: true });
}}
className={`p-4 border rounded-[var(--radius-md)] text-center transition-colors ${organizationType === 'CLINIC' ? 'border-primary/60 bg-primary-soft text-text-primary'
: 'border-border text-text-secondary hover:border-border-strong'
}`}
>
<Building2 className="h-8 w-8 mx-auto mb-2 icon-flat" />
<span className="text-sm font-medium">Dental Clinic</span>
</button>
<button
type="button"
onClick={() => {
setValue('organizationType', 'LAB', { shouldValidate: true });
}}
className={`p-4 border rounded-[var(--radius-md)] text-center transition-colors ${organizationType === 'LAB'
? 'border-primary/60 bg-primary-soft text-text-primary'
: 'border-border text-text-secondary hover:border-border-strong'
}`}
>
<Building2 className="h-8 w-8 mx-auto mb-2 icon-flat" />
<span className="text-sm font-medium">Dental Lab</span>
</button>
</div>
{errors.organizationType && (
<p className="mt-2 text-sm text-red-600">{errors.organizationType.message}</p>
)}
</div>
{error && (
<div className="p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
<p className="text-sm text-red-600">{error}</p>

View File

@@ -1,6 +1,6 @@
'use client';
import { OrganizationSelectorContent } from '@/components/ui/organization/OrganizationSelectorContent';
import { OrganizationSelectorContent } from '@/components/ui/organizations/OrganizationSelectorContent';
export default function SelectOrganizationPage() {
return (

View File

@@ -0,0 +1,41 @@
import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization';
export type StoredOrganizationInviteLink = {
invitationId: string;
ownerEmail: string;
invitationUrl: string;
};
export function organizationInviteLinksStorageKey(orgId: string): string {
return `counterpartInviteLinks:${orgId}`;
}
export function readOrganizationInviteLinks(
orgId: string,
): Record<string, StoredOrganizationInviteLink> {
if (typeof window === 'undefined') return {};
try {
const raw = window.localStorage.getItem(organizationInviteLinksStorageKey(orgId));
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, StoredOrganizationInviteLink>;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
export function writeOrganizationInviteLinks(
orgId: string,
links: Record<string, StoredOrganizationInviteLink>,
): void {
if (typeof window === 'undefined') return;
window.localStorage.setItem(organizationInviteLinksStorageKey(orgId), JSON.stringify(links));
}
/** Show copy/regenerate only until the invitee accepts (first login / setup). */
export function canShareOrganizationInviteLink(
invitation: Pick<OrganizationInvitationHistoryItemDto, 'status' | 'acceptedAt'>,
): boolean {
if (invitation.acceptedAt) return false;
return invitation.status === 'PENDING' || invitation.status === 'EXPIRED';
}

View File

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

View File

@@ -0,0 +1,241 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/common/Button';
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton';
import { Dropdown } from '@/components/ui/common/Dropdown';
import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles';
import type { Patient } from '@/types/patient';
import {
combineLocalDateAndTime,
compareLocalDayStart,
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>;
editingAppointment?: AppointmentRecord | null;
loading?: boolean;
}
export function AppointmentBookingModal({
open,
scheduleDate,
patient,
providerUserId,
providerName,
initialHour,
onClose,
onSubmit,
editingAppointment = null,
loading = false,
}: AppointmentBookingModalProps) {
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('10:00');
const [purpose, setPurpose] = useState<AppointmentPurpose>('consultation');
const [error, setError] = useState('');
const purposeTextColor =
purpose === 'consultation'
? '#ddd6fe'
: purpose === 'filling'
? '#fed7aa'
: purpose === 'endo'
? '#fecaca'
: purpose === 'visit'
? '#bae6fd'
: '#d9f99d';
useEffect(() => {
if (!open) {
return;
}
if (editingAppointment) {
const start = new Date(editingAppointment.startAt);
const end = new Date(editingAppointment.endAt);
setStartTime(formatTimeForInput(start));
setEndTime(formatTimeForInput(end));
setPurpose((editingAppointment.purpose as AppointmentPurpose) ?? 'consultation');
} else {
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, editingAppointment]);
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 (!editingAppointment && !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;
}
const today = new Date();
if (compareLocalDayStart(scheduleDate, today) < 0) {
setError('Past appointments are view-only.');
return;
}
const effectivePatientId = editingAppointment?.patientId ?? patient?.id;
const effectiveProviderId = editingAppointment?.providerUserId ?? providerUserId;
if (!effectivePatientId || !effectiveProviderId) {
setError('Missing appointment details.');
return;
}
await onSubmit({
patientId: effectivePatientId,
providerUserId: effectiveProviderId,
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">
{editingAppointment ? 'Edit appointment' : 'New appointment'}
</h2>
<DialogCloseButton onClick={onClose} />
</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">
{editingAppointment
? `${editingAppointment.patient.firstName} ${editingAppointment.patient.lastName}`
: patient
? `${patient.firstName} ${patient.lastName}`
: '—'}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Start</label>
<input
type="time"
step={60}
className={inputClass}
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">End</label>
<input
type="time"
step={60}
className={inputClass}
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
</div>
<Dropdown
label="Purpose"
value={purpose}
onChange={(e) => setPurpose(e.target.value as AppointmentPurpose)}
style={{ color: purposeTextColor }}
>
<option value="consultation" style={{ color: '#ddd6fe', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.consultation}
</option>
<option value="filling" style={{ color: '#fed7aa', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.filling}
</option>
<option value="endo" style={{ color: '#fecaca', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.endo}
</option>
<option value="visit" style={{ color: '#bae6fd', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.visit}
</option>
<option value="hygiene" style={{ color: '#d9f99d', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.hygiene}
</option>
</Dropdown>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button type="button" variant="primary" onClick={() => void handleSubmit()} isLoading={loading}>
Save
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,164 @@
'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;
onAppointmentClick?: (appointment: AppointmentRecord) => void;
}
export function AppointmentScheduleGrid({
day,
providers,
appointments,
canBook,
canDelete = false,
onDeleteAppointment,
onSlotClick,
onAppointmentClick,
}: 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 (
<button
type="button"
key={apt.id}
onClick={() => onAppointmentClick?.(apt)}
className={`absolute left-0.5 right-0.5 rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex flex-row items-center gap-1.5 px-1.5 py-1 min-h-[36px] text-left ${purposeStyle(apt.purpose)} focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35`}
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-0.5 ml-0.5 inline-flex cursor-pointer items-center justify-center rounded-[var(--radius-sm)] 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>
)}
</button>
);
})}
</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,46 @@
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-purpose-consultation-bg border-purpose-consultation-border text-purpose-consultation-fg',
filling: 'bg-purpose-filling-bg border-purpose-filling-border text-purpose-filling-fg',
endo: 'bg-purpose-endo-bg border-purpose-endo-border text-purpose-endo-fg',
visit: 'bg-purpose-visit-bg border-purpose-visit-border text-purpose-visit-fg',
hygiene: 'bg-purpose-hygiene-bg border-purpose-hygiene-border text-purpose-hygiene-fg',
};
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-purpose-consultation-fg',
filling: '!text-purpose-filling-fg',
endo: '!text-purpose-endo-fg',
visit: '!text-purpose-visit-fg',
hygiene: '!text-purpose-hygiene-fg',
};
return map[p] ?? '!text-text-muted';
}
/** Small swatch for legend (background + border only). */
export const APPOINTMENT_PURPOSE_LEGEND_SWATCH: Record<AppointmentPurpose, string> = {
consultation: 'bg-violet-500/85 border-violet-400/75',
filling: 'bg-orange-500/85 border-orange-400/75',
endo: 'bg-red-500/85 border-red-400/75',
visit: 'bg-sky-500/85 border-sky-400/75',
hygiene: 'bg-lime-500/80 border-lime-400/70',
};

View File

@@ -0,0 +1,84 @@
'use client';
import { Building2, Mail } from 'lucide-react';
import type { FieldErrors, UseFormRegister, UseFormSetValue } from 'react-hook-form';
import { Input } from '@/components/ui/common/Input';
export type OrganizationDetailsFormValues = {
organizationName: string;
organizationEmail: string;
organizationType: 'CLINIC' | 'LAB';
};
type OrganizationDetailsFieldsProps = {
register: UseFormRegister<OrganizationDetailsFormValues>;
errors: FieldErrors<OrganizationDetailsFormValues>;
organizationType: 'CLINIC' | 'LAB' | undefined;
setValue: UseFormSetValue<OrganizationDetailsFormValues>;
};
export function OrganizationDetailsFields({
register,
errors,
organizationType,
setValue,
}: OrganizationDetailsFieldsProps) {
return (
<>
<Input
label="Organization name"
{...register('organizationName')}
placeholder="Sunshine Dental Clinic"
error={errors.organizationName?.message}
icon={<Building2 className="h-5 w-5 icon-flat" />}
/>
<Input
label="Organization email"
{...register('organizationEmail')}
type="email"
placeholder="contact@sunshineclinic.com"
error={errors.organizationEmail?.message}
icon={<Mail className="h-5 w-5 icon-flat" />}
/>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Organization type
</label>
<input type="hidden" {...register('organizationType')} />
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => {
setValue('organizationType', 'CLINIC', { shouldValidate: true });
}}
className={`p-4 border rounded-[var(--radius-md)] text-center transition-colors ${
organizationType === 'CLINIC'
? 'border-primary/60 bg-primary-soft text-text-primary'
: 'border-border text-text-secondary hover:border-border-strong'
}`}
>
<Building2 className="h-8 w-8 mx-auto mb-2 icon-flat" />
<span className="text-sm font-medium">Dental Clinic</span>
</button>
<button
type="button"
onClick={() => {
setValue('organizationType', 'LAB', { shouldValidate: true });
}}
className={`p-4 border rounded-[var(--radius-md)] text-center transition-colors ${
organizationType === 'LAB'
? 'border-primary/60 bg-primary-soft text-text-primary'
: 'border-border text-text-secondary hover:border-border-strong'
}`}
>
<Building2 className="h-8 w-8 mx-auto mb-2 icon-flat" />
<span className="text-sm font-medium">Dental Lab</span>
</button>
</div>
{errors.organizationType && (
<p className="mt-2 text-sm text-red-600">{errors.organizationType.message}</p>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,59 @@
'use client';
import { ChevronRight } from 'lucide-react';
type RegistrationProgressStepsProps = {
step: number;
firstLabel?: string;
secondLabel?: string;
};
export function RegistrationProgressSteps({
step,
firstLabel = 'Account',
secondLabel = 'Organization',
}: RegistrationProgressStepsProps) {
return (
<div className="mb-8">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center ${
step >= 1
? 'bg-primary text-primary-contrast'
: 'bg-background-secondary text-text-secondary border border-border'
}`}
>
1
</div>
<div
className={`ml-2 text-sm font-medium ${
step >= 1 ? 'text-primary' : 'text-text-muted'
}`}
>
{firstLabel}
</div>
</div>
<ChevronRight className="h-5 w-5 text-text-muted icon-flat" />
<div className="flex items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center ${
step >= 2
? 'bg-primary text-primary-contrast'
: 'bg-background-secondary text-text-secondary border border-border'
}`}
>
2
</div>
<div
className={`ml-2 text-sm font-medium ${
step >= 2 ? 'text-primary' : 'text-text-muted'
}`}
>
{secondLabel}
</div>
</div>
</div>
</div>
);
}

View File

@@ -14,10 +14,10 @@ interface BadgeProps {
}
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-badge-success-bg text-badge-success-fg border-badge-success-border',
warning: 'bg-badge-warning-bg text-badge-warning-fg border-badge-warning-border',
danger: 'bg-badge-danger-bg text-badge-danger-fg border-badge-danger-border',
default: 'bg-badge-default-bg text-badge-default-fg border-badge-default-border',
};
/** Explicit width + height so every row matches; flex centers label optically. */

View File

@@ -0,0 +1,36 @@
import type { ElementType, ReactNode } from 'react';
type CardPadding = 'none' | 'sm' | 'md' | 'lg';
type CardProps<T extends ElementType = 'div'> = {
children: ReactNode;
padding?: CardPadding;
as?: T;
className?: string;
} & Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'children' | 'className'>;
const paddingClassMap: Record<CardPadding, string> = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
};
export function Card<T extends ElementType = 'div'>({
children,
as,
className = '',
padding = 'md',
...props
}: CardProps<T>) {
const Component = as ?? 'div';
return (
<Component
className={`rounded-[var(--radius-lg)] border border-card-border bg-card text-card-foreground ${paddingClassMap[padding]} ${className}`}
{...props}
>
{children}
</Component>
);
}

View File

@@ -0,0 +1,21 @@
'use client';
import { X } from 'lucide-react';
type DialogCloseButtonProps = {
onClick: () => void;
className?: string;
};
export function DialogCloseButton({ onClick, className = '' }: DialogCloseButtonProps) {
return (
<button
type="button"
onClick={onClick}
className={`shrink-0 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 ${className}`}
aria-label="Close"
>
<X className="h-5 w-5 icon-flat" />
</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,48 @@
'use client';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { addCalendarDays } from '@/lib/appointmentTime';
interface ScheduleDayPickerProps {
value: Date;
onChange: (day: Date) => void;
/** Optional lower bound; picker navigation is unrestricted for history browsing. */
minDate?: Date;
label?: string;
}
export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) {
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"
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="Previous day"
>
<ChevronLeft className="h-4 w-4 icon-flat" />
</button>
<div className="flex-1 min-w-0 text-center text-sm font-medium text-text-primary tabular-nums px-2 py-1.5">
{labelText}
</div>
<button
type="button"
onClick={() => onChange(addCalendarDays(value, 1))}
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
aria-label="Next day"
>
<ChevronRight className="h-4 w-4 icon-flat" />
</button>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,26 @@
import type { ReactNode } from 'react';
import type { BadgeVariant } from '@/components/ui/common/Badge';
interface ToastProps {
children: ReactNode;
variant?: BadgeVariant;
className?: string;
}
const variantStyles: Record<BadgeVariant, string> = {
success: 'bg-badge-success-bg text-badge-success-fg border-badge-success-border',
warning: 'bg-badge-warning-bg text-badge-warning-fg border-badge-warning-border',
danger: 'bg-badge-danger-bg text-badge-danger-fg border-badge-danger-border',
default: 'bg-badge-default-bg text-badge-default-fg border-badge-default-border',
};
export function Toast({ children, variant = 'default', className = '' }: ToastProps) {
return (
<div
role="status"
className={`w-full rounded-[var(--radius-md)] border px-4 py-3 text-sm shadow-lg ${variantStyles[variant]} ${className}`}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,144 @@
'use client';
import { Check, Copy } from 'lucide-react';
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton';
import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization';
import { canShareOrganizationInviteLink } from '@/components/invitations/organizationInviteLinks';
import { Badge, organizationLinkStatusVariant } from '@/components/ui/common/Badge';
import { Table } from '@/components/ui/common/Table';
function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string {
if (status === 'PENDING') return 'Invitation pending';
if (status === 'ACTIVE') return 'Invitation Accepted';
if (status === 'REJECTED') return 'Invitation rejected';
if (status === 'EXPIRED') return 'Invitation expired';
return status;
}
function formatTableDate(value: string): string {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleDateString();
}
type InvitationHistoryDialogProps = {
open: boolean;
onClose: () => void;
loading: boolean;
items: OrganizationInvitationHistoryItemDto[];
copiedId: string | null;
copyingInvitationId: string | null;
onCopy: (invitation: OrganizationInvitationHistoryItemDto) => void;
copyError?: string;
copySuccess?: string;
};
export function InvitationHistoryDialog({
open,
onClose,
loading,
items,
copiedId,
copyingInvitationId,
onCopy,
copyError,
copySuccess,
}: InvitationHistoryDialogProps) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div
className="w-full max-w-[min(56rem,calc(100vw-17rem))] max-h-[90vh] overflow-y-auto rounded-[var(--radius-md)] border border-border bg-background-secondary p-6 shadow-xl space-y-4"
role="dialog"
aria-modal="true"
aria-labelledby="invitation-history-title"
>
<div className="flex items-start justify-between gap-3">
<h2 id="invitation-history-title" className="text-lg font-semibold text-text-primary pr-2">
Invitation History
</h2>
<DialogCloseButton onClick={onClose} />
</div>
{copyError && (
<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">
{copyError}
</div>
)}
{copySuccess && (
<div className="rounded-[var(--radius-md)] border border-primary/30 bg-primary-soft/40 px-4 py-3 text-sm text-text-primary">
{copySuccess}
</div>
)}
{loading ? (
<p className="text-sm text-text-secondary">Loading invitation history...</p>
) : items.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={
<>
{items.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 align-middle">
{canShareOrganizationInviteLink(inv) ? (
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50"
disabled={copyingInvitationId === inv.id}
onClick={() => onCopy(inv)}
aria-label="Copy invitation link"
title="Copy invitation link (generates a new link if needed)"
>
{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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
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 type UpdateAppointmentBody = Partial<CreateAppointmentBody>;
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;
},
update: async (
id: string,
body: UpdateAppointmentBody,
): Promise<{ success: boolean; data: AppointmentRecord }> => {
const response = await apiClient.patch(`/appointments/${id}`, body);
return response.data;
},
remove: async (id: string): Promise<{ success: boolean }> => {
const response = await apiClient.delete(`/appointments/${id}`);
return response.data;
},
};

View File

@@ -15,6 +15,17 @@ export const apiClient = axios.create({
timeout: 10000,
});
/** Invitation preview/accept must work with no cookies (public API, no JWT). */
function isPublicInvitationRequest(url: string | undefined): boolean {
if (!url) return false;
return (
url.includes('/staff/invitations/preview') ||
url.includes('/staff/invitations/accept') ||
url.includes('/organizations/invitations/preview') ||
url.includes('/organizations/invitations/accept')
);
}
// ❌ REMOVE request interceptor completely (no Authorization header)
// ✅ Response interceptor
@@ -23,7 +34,11 @@ apiClient.interceptors.response.use(
async (error: AxiosError) => {
const originalRequest = error.config as CustomAxiosRequestConfig;
if (error.response?.status === 401 && !originalRequest._retry) {
if (
error.response?.status === 401 &&
!originalRequest._retry &&
!isPublicInvitationRequest(originalRequest.url)
) {
originalRequest._retry = true;
try {

View File

@@ -92,6 +92,7 @@ export const organizationApi = {
ownerEmail: string;
organizationName: string;
organizationType: 'CLINIC' | 'LAB';
organizationEmail?: string;
inviterOrganizationName: string;
expiresAt: string;
status: 'PENDING' | 'ACCEPTED';
@@ -106,6 +107,8 @@ export const organizationApi = {
acceptInvite: async (body: {
token: string;
organizationName: string;
organizationEmail: string;
organizationType: 'CLINIC' | 'LAB';
ownerName: string;
password: string;
}): Promise<{ success: boolean; message: string; data: { organizationId: string } }> => {

View File

@@ -63,6 +63,21 @@ export const staffApi = {
return response.data;
},
getInvitationLink: async (
membershipId: string,
): Promise<{
success: boolean;
data: {
membershipId: string;
invitationId: string;
email: string;
invitationUrl: string;
};
}> => {
const response = await apiClient.post(`/staff/members/${membershipId}/invitation-link`);
return response.data;
},
previewInvite: async (token: string): Promise<PreviewInviteResponse> => {
const response = await apiClient.get(`/staff/invitations/preview?token=${encodeURIComponent(token)}`);
return response.data;

View File

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

View File

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

View File

@@ -0,0 +1,107 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import {
organizationApi,
type OrganizationInvitationHistoryItemDto,
} from '@/lib/api/organization';
import {
canShareOrganizationInviteLink,
readOrganizationInviteLinks,
type StoredOrganizationInviteLink,
writeOrganizationInviteLinks,
} from '@/components/invitations/organizationInviteLinks';
export function useOrganizationInviteLinkCopy(organizationId: string | undefined) {
const [pendingInviteLinks, setPendingInviteLinks] = useState<
Record<string, StoredOrganizationInviteLink>
>({});
const [copiedId, setCopiedId] = useState<string | null>(null);
const [copyingInvitationId, setCopyingInvitationId] = useState<string | null>(null);
useEffect(() => {
if (!organizationId) return;
setPendingInviteLinks(readOrganizationInviteLinks(organizationId));
}, [organizationId]);
const storeInviteLink = useCallback(
(invitationId: string, ownerEmail: string, invitationUrl: string) => {
if (!organizationId) return;
setPendingInviteLinks((prev) => {
const next = {
...prev,
[invitationId]: {
invitationId,
ownerEmail: ownerEmail.trim().toLowerCase(),
invitationUrl,
},
};
writeOrganizationInviteLinks(organizationId, next);
return next;
});
},
[organizationId],
);
const pruneAcceptedLinks = useCallback(
(items: OrganizationInvitationHistoryItemDto[]) => {
if (!organizationId) return;
const acceptedIds = new Set(
items.filter((item) => item.acceptedAt || item.status === 'ACTIVE').map((item) => item.id),
);
setPendingInviteLinks((prev) => {
let changed = false;
const next = { ...prev };
for (const id of Object.keys(next)) {
if (acceptedIds.has(id)) {
delete next[id];
changed = true;
}
}
if (changed) {
writeOrganizationInviteLinks(organizationId, next);
}
return changed ? next : prev;
});
},
[organizationId],
);
const copyInvitationLink = useCallback(
async (
invitation: OrganizationInvitationHistoryItemDto,
options?: { onRegenerated?: () => void | Promise<void> },
): Promise<string | null> => {
if (!canShareOrganizationInviteLink(invitation)) return null;
setCopyingInvitationId(invitation.id);
try {
let invitationUrl = pendingInviteLinks[invitation.id]?.invitationUrl;
if (!invitationUrl || invitation.status === 'EXPIRED') {
const res = await organizationApi.getInvitationLink(invitation.id);
invitationUrl = res.data.invitationUrl;
storeInviteLink(invitation.id, invitation.ownerEmail, invitationUrl);
await options?.onRegenerated?.();
}
await navigator.clipboard.writeText(invitationUrl);
setCopiedId(invitation.id);
setTimeout(() => setCopiedId(null), 1500);
return invitationUrl;
} finally {
setCopyingInvitationId(null);
}
},
[pendingInviteLinks, storeInviteLink],
);
return {
pendingInviteLinks,
copiedId,
copyingInvitationId,
storeInviteLink,
copyInvitationLink,
pruneAcceptedLinks,
};
}

View File

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

View File

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

View File

@@ -1,7 +1,17 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const publicRoutes = ['/', '/login', '/register', '/terms', '/privacy', '/forgot-password'];
/** Routes that must work without an existing session (first-time invitees). */
const publicRoutes = [
'/',
'/login',
'/register',
'/terms',
'/privacy',
'/forgot-password',
'/accept-invite',
'/accept-organization-invite',
];
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;

View File

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

View File

@@ -18,6 +18,40 @@
--color-border: #d2dcec;
--color-border-strong: #aec0de;
--color-card-background: #ffffff;
--color-card-foreground: #0f172a;
--color-card-muted: #475569;
--color-card-border: #d2dcec;
--color-badge-default-bg: #edf3fb;
--color-badge-default-fg: #1e293b;
--color-badge-default-border: #c9d8ec;
--color-badge-success-bg: #e6f7ef;
--color-badge-success-fg: #14532d;
--color-badge-success-border: #98d8b5;
--color-badge-warning-bg: #fff4df;
--color-badge-warning-fg: #92400e;
--color-badge-warning-border: #f6c88b;
--color-badge-danger-bg: #ffe8e8;
--color-badge-danger-fg: #991b1b;
--color-badge-danger-border: #f2b0b0;
--color-purpose-consultation-bg: #f1eafe;
--color-purpose-consultation-fg: #5b21b6;
--color-purpose-consultation-border: #cfb8f7;
--color-purpose-filling-bg: #fff1e5;
--color-purpose-filling-fg: #9a3412;
--color-purpose-filling-border: #f8c9a6;
--color-purpose-endo-bg: #ffe9e9;
--color-purpose-endo-fg: #991b1b;
--color-purpose-endo-border: #f4b6b6;
--color-purpose-visit-bg: #e7f4ff;
--color-purpose-visit-fg: #0c4a6e;
--color-purpose-visit-border: #abd8f3;
--color-purpose-hygiene-bg: #effadf;
--color-purpose-hygiene-fg: #3f6212;
--color-purpose-hygiene-border: #cbe9a1;
--color-primary: #009cae;
--color-primary-contrast: #031014;
--color-primary-soft: rgba(0, 156, 174, 0.16);
@@ -42,6 +76,40 @@
--color-border: #29456a;
--color-border-strong: #3b5f8f;
--color-card-background: #14253d;
--color-card-foreground: #f5f9ff;
--color-card-muted: #b6c6dd;
--color-card-border: #29456a;
--color-badge-default-bg: rgba(27, 47, 78, 0.32);
--color-badge-default-fg: #9eb1cb;
--color-badge-default-border: rgba(59, 95, 143, 0.32);
--color-badge-success-bg: rgba(6, 78, 59, 0.22);
--color-badge-success-fg: #7dd3a8;
--color-badge-success-border: rgba(34, 100, 68, 0.28);
--color-badge-warning-bg: rgba(120, 53, 15, 0.22);
--color-badge-warning-fg: #dfc06a;
--color-badge-warning-border: rgba(146, 88, 20, 0.28);
--color-badge-danger-bg: rgba(127, 29, 29, 0.22);
--color-badge-danger-fg: #e4a6a6;
--color-badge-danger-border: rgba(153, 50, 50, 0.28);
--color-purpose-consultation-bg: rgba(139, 92, 246, 0.25);
--color-purpose-consultation-fg: #ddd6fe;
--color-purpose-consultation-border: rgba(167, 139, 250, 0.5);
--color-purpose-filling-bg: rgba(249, 115, 22, 0.25);
--color-purpose-filling-fg: #fed7aa;
--color-purpose-filling-border: rgba(251, 146, 60, 0.5);
--color-purpose-endo-bg: rgba(239, 68, 68, 0.25);
--color-purpose-endo-fg: #fecaca;
--color-purpose-endo-border: rgba(248, 113, 113, 0.5);
--color-purpose-visit-bg: rgba(14, 165, 233, 0.25);
--color-purpose-visit-fg: #bae6fd;
--color-purpose-visit-border: rgba(56, 189, 248, 0.5);
--color-purpose-hygiene-bg: rgba(132, 204, 22, 0.2);
--color-purpose-hygiene-fg: #d9f99d;
--color-purpose-hygiene-border: rgba(163, 230, 53, 0.45);
--color-primary: #09a9bc;
--color-primary-contrast: #001117;
--color-primary-soft: rgba(9, 169, 188, 0.2);
@@ -66,6 +134,40 @@
--color-border: #29456a;
--color-border-strong: #3b5f8f;
--color-card-background: #14253d;
--color-card-foreground: #f5f9ff;
--color-card-muted: #b6c6dd;
--color-card-border: #29456a;
--color-badge-default-bg: rgba(27, 47, 78, 0.32);
--color-badge-default-fg: #9eb1cb;
--color-badge-default-border: rgba(59, 95, 143, 0.32);
--color-badge-success-bg: rgba(6, 78, 59, 0.22);
--color-badge-success-fg: #7dd3a8;
--color-badge-success-border: rgba(34, 100, 68, 0.28);
--color-badge-warning-bg: rgba(120, 53, 15, 0.22);
--color-badge-warning-fg: #dfc06a;
--color-badge-warning-border: rgba(146, 88, 20, 0.28);
--color-badge-danger-bg: rgba(127, 29, 29, 0.22);
--color-badge-danger-fg: #e4a6a6;
--color-badge-danger-border: rgba(153, 50, 50, 0.28);
--color-purpose-consultation-bg: rgba(139, 92, 246, 0.25);
--color-purpose-consultation-fg: #ddd6fe;
--color-purpose-consultation-border: rgba(167, 139, 250, 0.5);
--color-purpose-filling-bg: rgba(249, 115, 22, 0.25);
--color-purpose-filling-fg: #fed7aa;
--color-purpose-filling-border: rgba(251, 146, 60, 0.5);
--color-purpose-endo-bg: rgba(239, 68, 68, 0.25);
--color-purpose-endo-fg: #fecaca;
--color-purpose-endo-border: rgba(248, 113, 113, 0.5);
--color-purpose-visit-bg: rgba(14, 165, 233, 0.25);
--color-purpose-visit-fg: #bae6fd;
--color-purpose-visit-border: rgba(56, 189, 248, 0.5);
--color-purpose-hygiene-bg: rgba(132, 204, 22, 0.2);
--color-purpose-hygiene-fg: #d9f99d;
--color-purpose-hygiene-border: rgba(163, 230, 53, 0.45);
--color-primary: #09a9bc;
--color-primary-contrast: #001117;
--color-primary-soft: rgba(9, 169, 188, 0.2);
@@ -89,6 +191,37 @@
--color-border: var(--color-border);
--color-border-strong: var(--color-border-strong);
--color-card: var(--color-card-background);
--color-card-foreground: var(--color-card-foreground);
--color-card-muted: var(--color-card-muted);
--color-card-border: var(--color-card-border);
--color-badge-default-bg: var(--color-badge-default-bg);
--color-badge-default-fg: var(--color-badge-default-fg);
--color-badge-default-border: var(--color-badge-default-border);
--color-badge-success-bg: var(--color-badge-success-bg);
--color-badge-success-fg: var(--color-badge-success-fg);
--color-badge-success-border: var(--color-badge-success-border);
--color-badge-warning-bg: var(--color-badge-warning-bg);
--color-badge-warning-fg: var(--color-badge-warning-fg);
--color-badge-warning-border: var(--color-badge-warning-border);
--color-badge-danger-bg: var(--color-badge-danger-bg);
--color-badge-danger-fg: var(--color-badge-danger-fg);
--color-badge-danger-border: var(--color-badge-danger-border);
--color-purpose-consultation-bg: var(--color-purpose-consultation-bg);
--color-purpose-consultation-fg: var(--color-purpose-consultation-fg);
--color-purpose-consultation-border: var(--color-purpose-consultation-border);
--color-purpose-filling-bg: var(--color-purpose-filling-bg);
--color-purpose-filling-fg: var(--color-purpose-filling-fg);
--color-purpose-filling-border: var(--color-purpose-filling-border);
--color-purpose-endo-bg: var(--color-purpose-endo-bg);
--color-purpose-endo-fg: var(--color-purpose-endo-fg);
--color-purpose-endo-border: var(--color-purpose-endo-border);
--color-purpose-visit-bg: var(--color-purpose-visit-bg);
--color-purpose-visit-fg: var(--color-purpose-visit-fg);
--color-purpose-visit-border: var(--color-purpose-visit-border);
--color-purpose-hygiene-bg: var(--color-purpose-hygiene-bg);
--color-purpose-hygiene-fg: var(--color-purpose-hygiene-fg);
--color-purpose-hygiene-border: var(--color-purpose-hygiene-border);
--color-primary: var(--color-primary);
--color-primary-contrast: var(--color-primary-contrast);
--color-primary-soft: var(--color-primary-soft);
@@ -115,11 +248,17 @@ body {
}
.surface-card {
background: color-mix(in srgb, var(--color-background-card) 92%, transparent);
border: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-card-background) 92%, transparent);
border: 1px solid var(--color-card-border);
color: var(--color-card-foreground);
border-radius: var(--radius-lg);
}
:root[data-theme='dark'] .surface-card,
:root:not([data-theme='light']) .surface-card {
background: color-mix(in srgb, var(--color-card-background) 82%, var(--color-background-primary));
}
.surface-panel {
background: color-mix(in srgb, var(--color-background-secondary) 96%, transparent);
border: 1px solid var(--color-border);

View File

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

View File

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

View File

@@ -12,9 +12,13 @@ DOMAIN=dyolink.com
# Backend Environment (create backend.env from this)
# NODE_ENV=production
# JWT_SECRET=CHANGE_THIS_TO_STRONG_SECRET_32_CHARS
# PORT=3000
# DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
# CORS_ORIGIN=https://dyolink.com
# JWT_SECRET=CHANGE_THIS_TO_STRONG_SECRET_32_CHARS
# JWT_EXPIRES_IN=15m
# JWT_REFRESH_SECRET=another_long_random_secret_different_from_JWT_SECRET
# JWT_REFRESH_EXPIRES_IN=30d
# FRONTEND_URL=https://dyolink.com
# Frontend Environment (create frontend.env from this)
# NEXT_PUBLIC_API_URL=/api

View File

@@ -9,4 +9,5 @@ JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=another_long_random_secret_different_from_JWT_SECRET
JWT_REFRESH_EXPIRES_IN=30d
# CORS, cookies, and invite links — must match how users open the app (nginx host port)
FRONTEND_URL=http://178.131.50.201:8088