Compare commits

...

9 Commits

Author SHA1 Message Date
7a847d5326 improvement: account setting added to header in order to choose organizations/subscribtions/etc 2026-04-29 21:55:46 +03:30
ca9e684ab4 improvement: multi organization possibility implemented for users (owners and staffs) 2026-04-29 20:19:40 +03:30
c154b6dabf Merge pull request 'bugfix: logout behaviour fixed.' (#5) from bugfix/logout-bad-state into master
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/5
2026-04-29 15:59:12 +03:30
207e7fed75 bugfix: logout behaviour fixed. 2026-04-29 15:55:58 +03:30
063a5faa56 Merge pull request 'improvement: sidebar refactored. COMMING SOON page added for all non-developed tabs.' (#4) from improvement/sidebar-refactor into master
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/4
2026-04-29 15:36:27 +03:30
ae1e4c67de improvement: sidebar refactored. COMMING SOON page added for all non-developed tabs. 2026-04-29 14:02:42 +03:30
3c7fcf18b3 Merge pull request 'feature: a very minimal patients feature implemented. it needs lots of improvments though' (#3) from feature/patients into master
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/3
2026-04-29 13:43:05 +03:30
6a3addd1ca feature: a very minimal patients feature implemented. it needs lots of improvments though 2026-04-29 13:32:22 +03:30
1128fca81a Merge pull request 'bugfix: the whole theming structure overhauled. light/dark mode icon added.' (#2) from bugfix/theming-patched into master
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/2
2026-04-29 01:46:06 +03:30
41 changed files with 1927 additions and 186 deletions

View File

@@ -0,0 +1,47 @@
-- CreateTable
CREATE TABLE "patients" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"phone" TEXT,
"email" TEXT,
"dateOfBirth" TIMESTAMP(3),
"notes" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "patients_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "patient_treatment_histories" (
"id" TEXT NOT NULL,
"patientId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"status" TEXT NOT NULL,
"treatmentAt" TIMESTAMP(3) NOT NULL,
"tooth" TEXT,
"notes" TEXT,
"totalCost" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "patient_treatment_histories_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "patients_organizationId_createdAt_idx" ON "patients"("organizationId", "createdAt");
-- CreateIndex
CREATE INDEX "patients_organizationId_lastName_firstName_idx" ON "patients"("organizationId", "lastName", "firstName");
-- CreateIndex
CREATE INDEX "patient_treatment_histories_patientId_treatmentAt_idx" ON "patient_treatment_histories"("patientId", "treatmentAt");
-- AddForeignKey
ALTER TABLE "patients" ADD CONSTRAINT "patients_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "patient_treatment_histories" ADD CONSTRAINT "patient_treatment_histories_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "patients"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "users"
ADD COLUMN "trialUsedAt" TIMESTAMP(3);

View File

@@ -15,6 +15,7 @@ model User {
googleId String? @unique
facebookId String? @unique
name String
trialUsedAt DateTime?
memberships Membership[]
ownedOrganizations Organization[] @relation("OrganizationOwner")
@@ -55,6 +56,7 @@ model Organization {
sharedWithMe OrganizationLink[] @relation("OrganizationB")
sharedWithOthers OrganizationLink[] @relation("OrganizationA")
patients Patient[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -62,6 +64,45 @@ model Organization {
@@map("organizations")
}
model Patient {
id String @id @default(uuid())
organizationId String
firstName String
lastName String
phone String?
email String?
dateOfBirth DateTime?
notes String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id])
treatments PatientTreatmentHistory[]
@@index([organizationId, createdAt])
@@index([organizationId, lastName, firstName])
@@map("patients")
}
model PatientTreatmentHistory {
id String @id @default(uuid())
patientId String
title String
status String
treatmentAt DateTime
tooth String?
notes String?
totalCost Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
@@index([patientId, treatmentAt])
@@map("patient_treatment_histories")
}
model Plan {
id String @id @default(uuid())
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"

View File

@@ -1,6 +1,5 @@
// backend/prisma/seed.ts
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { config } from 'dotenv';
import path from 'path';
@@ -29,14 +28,14 @@ async function main() {
console.log('✅ Database connected successfully');
// Create organization types
const clinicType = await prisma.organizationType.upsert({
await prisma.organizationType.upsert({
where: { name: 'CLINIC' },
update: {},
create: { name: 'CLINIC' },
});
console.log('✅ Created clinic type');
const labType = await prisma.organizationType.upsert({
await prisma.organizationType.upsert({
where: { name: 'LAB' },
update: {},
create: { name: 'LAB' },
@@ -61,32 +60,39 @@ async function main() {
}
console.log('✅ Created plans');
// Create features and permissions
// Minimal permission model (confirmed):
// - Sidebar tabs use READ/EDIT
// - EDIT implies READ in app logic
// - Owners effectively get all permissions
const features = [
{
name: 'Patient Management',
permissions: ['VIEW_PATIENTS', 'CREATE_PATIENTS', 'EDIT_PATIENTS', 'DELETE_PATIENTS']
name: 'Today',
permissions: ['TAB_TODAY_READ', 'TAB_TODAY_EDIT'],
},
{
name: 'Order Management',
permissions: ['VIEW_ORDERS', 'CREATE_ORDERS', 'EDIT_ORDERS', 'DELETE_ORDERS', 'TRACK_ORDERS']
name: 'Patients',
permissions: ['TAB_PATIENTS_READ', 'TAB_PATIENTS_EDIT'],
},
{
name: 'Case Management',
permissions: ['VIEW_CASES', 'CREATE_CASES', 'EDIT_CASES', 'DELETE_CASES']
name: 'Appointments',
permissions: ['TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_EDIT'],
},
{
name: 'Reports',
permissions: ['VIEW_REPORTS', 'EXPORT_REPORTS']
name: 'Staff Management',
permissions: ['TAB_STAFF_READ', 'TAB_STAFF_EDIT'],
},
{
name: 'Team Management',
permissions: ['INVITE_USERS', 'REMOVE_USERS', 'MANAGE_PERMISSIONS']
name: 'Lab Management',
permissions: ['TAB_LAB_READ', 'TAB_LAB_EDIT'],
},
{
name: 'Billing',
permissions: ['VIEW_INVOICES', 'CREATE_INVOICES', 'MANAGE_PAYMENTS']
}
permissions: ['TAB_BILLING_READ', 'TAB_BILLING_EDIT'],
},
{
name: 'Reports',
permissions: ['TAB_REPORTS_READ', 'TAB_REPORTS_EDIT'],
},
];
for (const feature of features) {
@@ -109,7 +115,7 @@ async function main() {
}
console.log('✅ Created features and permissions');
console.log('🌱 Seeding completed successfully!'); ``
console.log('🌱 Seeding completed successfully!');
}
main()

View File

@@ -6,6 +6,7 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AdminModule } from './admin/admin.module';
import { PrismaModule } from '../prisma/prisma.module'; // ✅
import { PatientsModule } from './modules/patients/patients.module';
@Module({
imports: [
@@ -15,6 +16,7 @@ import { PrismaModule } from '../prisma/prisma.module'; // ✅
}),
PrismaModule, // ✅ ADD THIS
AuthModule,
PatientsModule,
AdminModule.forRoot(),
],
controllers: [AppController],

View File

@@ -25,6 +25,7 @@ import {
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { CreateOrganizationDto } from './dto/create-organization.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { LocalAuthGuard } from './guards/local-auth.guard';
@@ -120,6 +121,14 @@ export class AuthController {
};
}
@Post('organizations')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create organization for current user' })
async createOrganization(@Req() req, @Body() dto: CreateOrganizationDto) {
return this.authService.createOrganization(req.user.id, dto);
}
// =========================
// PROFILE
// =========================
@@ -136,6 +145,42 @@ export class AuthController {
return this.authService.getProfile(req.user.id);
}
@Get('subscription-alert')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary:
'Owner-only: seat / trial status for warning indicator (current org from JWT)',
})
async getSubscriptionAlert(@Req() req) {
return this.authService.getOwnerSubscriptionAlert(
req.user.id,
req.user.organizationId,
);
}
// =========================
// LOGOUT
// =========================
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Logout current user' })
@ApiResponse({ status: 200, description: 'Logout successful' })
async logout(@Req() req, @Res({ passthrough: true }) res: Response) {
const accessToken = req?.cookies?.accessToken;
if (accessToken) {
await this.authService.logout(accessToken);
}
this.clearAuthCookies(res);
return {
success: true,
message: 'Logged out successfully',
};
}
// =========================
// TEST
// =========================
@@ -174,4 +219,19 @@ export class AuthController {
path: '/',
});
}
private clearAuthCookies(res: Response) {
res.clearCookie('accessToken', {
httpOnly: true,
secure: false,
sameSite: 'lax',
path: '/',
});
res.clearCookie('refreshToken', {
httpOnly: true,
secure: false,
sameSite: 'lax',
path: '/',
});
}
}

View File

@@ -12,30 +12,24 @@ import * as bcrypt from 'bcrypt';
import { PrismaService } from '../../../prisma/prisma.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { CreateOrganizationDto } from './dto/create-organization.dto';
import { JwtPayload } from './interfaces/jwt-payload.interface';
const ALL_PERMISSIONS = [
'VIEW_PATIENTS',
'CREATE_PATIENTS',
'EDIT_PATIENTS',
'DELETE_PATIENTS',
'VIEW_ORDERS',
'CREATE_ORDERS',
'EDIT_ORDERS',
'DELETE_ORDERS',
'TRACK_ORDERS',
'VIEW_CASES',
'CREATE_CASES',
'EDIT_CASES',
'DELETE_CASES',
'VIEW_REPORTS',
'EXPORT_REPORTS',
'INVITE_USERS',
'REMOVE_USERS',
'MANAGE_PERMISSIONS',
'VIEW_INVOICES',
'CREATE_INVOICES',
'MANAGE_PAYMENTS',
'TAB_TODAY_READ',
'TAB_TODAY_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
'TAB_APPOINTMENTS_EDIT',
'TAB_STAFF_READ',
'TAB_STAFF_EDIT',
'TAB_LAB_READ',
'TAB_LAB_EDIT',
'TAB_BILLING_READ',
'TAB_BILLING_EDIT',
'TAB_REPORTS_READ',
'TAB_REPORTS_EDIT',
];
@Injectable()
@@ -62,6 +56,7 @@ export class AuthService {
organization: {
include: {
type: true, // Include organization type (CLINIC/LAB)
plan: true,
}
},
permissions: {
@@ -148,6 +143,12 @@ export class AuthService {
permissions: membership.isOwner
? ALL_PERMISSIONS
: membership.permissions?.map(p => p.permission.name) || [],
plan: membership.organization.plan
? {
name: membership.organization.plan.name,
maxUsers: membership.organization.plan.maxUsers,
}
: undefined,
})) || [];
return {
@@ -176,7 +177,7 @@ export class AuthService {
* @returns Created user info without password
*/
async register(registerDto: RegisterDto) {
const { email, password, name, organizationName, organizationType } = registerDto;
const { email, password, name, organizationName, organizationEmail, organizationType } = registerDto;
// 1. Check existing user
const existingUser = await this.prisma.user.findUnique({
@@ -184,7 +185,7 @@ export class AuthService {
});
if (existingUser) {
throw new ConflictException('User already exists');
throw new ConflictException('User already exists. Please login and create a new organization from your account.');
}
// 2. Hash password
@@ -198,16 +199,15 @@ export class AuthService {
email,
passwordHash: hashedPassword,
name,
trialUsedAt: new Date(),
},
});
// Create organization
const organization = await tx.organization.create({
data: {
name: registerDto.organizationName,
// REQUIRED FIELDS 👇
email: registerDto.email, // or separate org email if you have one
name: organizationName,
email: organizationEmail,
owner: {
connect: { id: user.id },
@@ -219,7 +219,7 @@ export class AuthService {
type: {
connect: {
name: registerDto.organizationType, // 'CLINIC' | 'LAB'
name: organizationType, // 'CLINIC' | 'LAB'
},
},
},
@@ -247,6 +247,66 @@ export class AuthService {
return this.login({ email, password } as any, validatedUser);
}
async createOrganization(userId: string, dto: CreateOrganizationDto) {
const owner = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, trialUsedAt: true },
});
if (!owner) {
throw new UnauthorizedException('User not found');
}
const planName = dto.planName?.trim() || 'Small';
const effectivePlanName = owner.trialUsedAt ? planName : 'trial';
const organization = await this.prisma.$transaction(async (tx) => {
const createdOrganization = await tx.organization.create({
data: {
name: dto.organizationName,
email: dto.organizationEmail,
owner: {
connect: { id: userId },
},
plan: {
connect: { name: effectivePlanName },
},
type: {
connect: { name: dto.organizationType },
},
},
});
await tx.membership.create({
data: {
userId,
organizationId: createdOrganization.id,
isOwner: true,
},
});
if (!owner.trialUsedAt) {
await tx.user.update({
where: { id: userId },
data: { trialUsedAt: new Date() },
});
}
return createdOrganization;
});
return {
success: true,
data: {
organization: {
id: organization.id,
name: organization.name,
email: organization.email,
},
},
};
}
/**
* Get user profile with all memberships and permissions
* @param userId - User ID from JWT token
@@ -262,6 +322,7 @@ export class AuthService {
organization: {
include: {
type: true,
plan: true,
},
},
permissions: {
@@ -286,7 +347,15 @@ export class AuthService {
name: membership.organization.name,
type: membership.organization.type.name,
isOwner: membership.isOwner,
permissions: membership.permissions?.map(p => p.permission.name) || [],
permissions: membership.isOwner
? ALL_PERMISSIONS
: membership.permissions?.map(p => p.permission.name) || [],
plan: membership.organization.plan
? {
name: membership.organization.plan.name,
maxUsers: membership.organization.plan.maxUsers,
}
: undefined,
})) || [];
return {
@@ -352,6 +421,7 @@ export class AuthService {
organization: {
include: {
type: true,
plan: true,
},
},
permissions: {
@@ -397,7 +467,15 @@ export class AuthService {
name: membership.organization.name,
type: membership.organization.type.name,
isOwner: membership.isOwner,
permissions: membership.permissions?.map(p => p.permission.name) || [],
permissions: membership.isOwner
? ALL_PERMISSIONS
: membership.permissions?.map(p => p.permission.name) || [],
plan: membership.organization.plan
? {
name: membership.organization.plan.name,
maxUsers: membership.organization.plan.maxUsers,
}
: undefined,
})) || [];
return {
@@ -569,6 +647,7 @@ export class AuthService {
organization: {
include: {
type: true,
plan: true,
},
},
permissions: {
@@ -594,7 +673,15 @@ export class AuthService {
name: membership.organization.name,
type: membership.organization.type.name,
isOwner: membership.isOwner,
permissions: membership.permissions?.map(p => p.permission.name) || [],
permissions: membership.isOwner
? ALL_PERMISSIONS
: membership.permissions?.map(p => p.permission.name) || [],
plan: membership.organization.plan
? {
name: membership.organization.plan.name,
maxUsers: membership.organization.plan.maxUsers,
}
: undefined,
})) || [];
return {
@@ -617,6 +704,7 @@ export class AuthService {
organizationId,
},
include: {
user: true,
organization: {
include: {
type: true,
@@ -638,7 +726,7 @@ export class AuthService {
// 2. Build payload WITH org context
const payload = {
sub: userId,
email: membership.organization.email,
email: membership.user.email,
organizationId: membership.organizationId,
type: 'access',
};
@@ -650,7 +738,9 @@ export class AuthService {
});
// 4. Format permissions
const permissions = membership.permissions.map(p => p.permission.name);
const permissions = membership.isOwner
? ALL_PERMISSIONS
: membership.permissions.map(p => p.permission.name);
return {
success: true,
@@ -660,9 +750,101 @@ export class AuthService {
id: membership.organization.id,
name: membership.organization.name,
type: membership.organization.type.name,
isOwner: membership.isOwner,
plan: membership.organization.plan
? {
name: membership.organization.plan.name,
maxUsers: membership.organization.plan.maxUsers,
}
: undefined,
},
permissions,
},
};
}
/**
* Owner-only subscription / seat alerts for the current org (from JWT).
* Used for a subtle warning indicator in the app shell (not staff-facing banners).
*/
async getOwnerSubscriptionAlert(userId: string, organizationId: string | undefined) {
if (!organizationId) {
return {
success: true,
data: {
showWarning: false,
seatsLow: false,
trialEndingSoon: false,
trialExpired: false,
},
};
}
const membership = await this.prisma.membership.findFirst({
where: { userId, organizationId },
include: {
organization: {
include: { plan: true },
},
},
});
if (!membership || !membership.isOwner) {
return {
success: true,
data: {
showWarning: false,
seatsLow: false,
trialEndingSoon: false,
trialExpired: false,
},
};
}
const org = membership.organization;
const plan = org.plan;
const maxUsers = plan.maxUsers;
const seatsUsed = await this.prisma.membership.count({
where: { organizationId: org.id },
});
const unlimited = maxUsers >= 999999;
const remaining = unlimited ? Infinity : maxUsers - seatsUsed;
const seatsLow =
!unlimited && remaining >= 0 && remaining <= 2 && maxUsers > 0;
let trialEndingSoon = false;
let trialExpired = false;
let daysUntilTrialEnd: number | null = null;
let trialEndsAt: string | null = null;
if (plan.name === 'trial') {
const end = new Date(org.createdAt);
end.setDate(end.getDate() + 30);
trialEndsAt = end.toISOString();
const ms = end.getTime() - Date.now();
daysUntilTrialEnd = Math.ceil(ms / (1000 * 60 * 60 * 24));
if (daysUntilTrialEnd <= 0) {
trialExpired = true;
} else if (daysUntilTrialEnd <= 7) {
trialEndingSoon = true;
}
}
const showWarning = seatsLow || trialEndingSoon || trialExpired;
return {
success: true,
data: {
showWarning,
seatsLow,
trialEndingSoon,
trialExpired,
seatsUsed,
seatsLimit: maxUsers,
daysUntilTrialEnd,
trialEndsAt,
},
};
}
}

View File

@@ -0,0 +1,16 @@
import { IsEmail, IsEnum, IsOptional, IsString } from 'class-validator';
export class CreateOrganizationDto {
@IsString()
organizationName: string;
@IsEmail()
organizationEmail: string;
@IsEnum(['CLINIC', 'LAB'])
organizationType: 'CLINIC' | 'LAB';
@IsOptional()
@IsString()
planName?: string;
}

View File

@@ -14,6 +14,9 @@ export class RegisterDto {
@IsString()
organizationName: string;
@IsEmail()
organizationEmail: string;
@IsEnum(['CLINIC', 'LAB'])
organizationType: 'CLINIC' | 'LAB';
}

View File

@@ -2,6 +2,7 @@
export interface JwtPayload {
sub: string; // user id
email: string;
organizationId?: string;
type?: 'access' | 'refresh';
}

View File

@@ -31,6 +31,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
const { passwordHash, ...result } = user;
return result;
return {
...result,
organizationId: payload.organizationId,
};
}
}

View File

@@ -0,0 +1,29 @@
import { IsDateString, IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
export class CreatePatientDto {
@IsString()
@MaxLength(80)
firstName: string;
@IsString()
@MaxLength(80)
lastName: string;
@IsOptional()
@IsString()
@MaxLength(30)
phone?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsDateString()
dateOfBirth?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
notes?: string;
}

View File

@@ -0,0 +1,28 @@
import { IsDateString, IsNumber, IsOptional, IsString, MaxLength } from 'class-validator';
export class CreateTreatmentHistoryDto {
@IsString()
@MaxLength(120)
title: string;
@IsString()
@MaxLength(40)
status: string;
@IsDateString()
treatmentAt: string;
@IsOptional()
@IsString()
@MaxLength(20)
tooth?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
notes?: string;
@IsOptional()
@IsNumber()
totalCost?: number;
}

View File

@@ -0,0 +1,21 @@
import { Transform } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class ListPatientsDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
@Max(100)
limit = 10;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreatePatientDto } from './create-patient.dto';
export class UpdatePatientDto extends PartialType(CreatePatientDto) {}

View File

@@ -0,0 +1,77 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CreatePatientDto } from './dto/create-patient.dto';
import { ListPatientsDto } from './dto/list-patients.dto';
import { UpdatePatientDto } from './dto/update-patient.dto';
import { PatientsService } from './patients.service';
import { CreateTreatmentHistoryDto } from './dto/create-treatment-history.dto';
@ApiTags('patients')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard)
@Controller('patients')
export class PatientsController {
constructor(private readonly patientsService: PatientsService) {}
@Post()
@ApiOperation({ summary: 'Create a patient for current organization' })
create(@Body() createPatientDto: CreatePatientDto, @Req() req) {
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
return this.patientsService.create(createPatientDto, organizationId);
}
@Get()
@ApiOperation({ summary: 'List patients with search and pagination' })
findAll(@Query() query: ListPatientsDto, @Req() req) {
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
return this.patientsService.findAll(query, organizationId);
}
@Get(':id')
@ApiOperation({ summary: 'Get one patient by id' })
findOne(@Param('id') id: string, @Req() req) {
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
return this.patientsService.findOne(id, organizationId);
}
@Patch(':id')
@ApiOperation({ summary: 'Update patient' })
update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto, @Req() req) {
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
return this.patientsService.update(id, updatePatientDto, organizationId);
}
@Get(':id/treatments')
@ApiOperation({ summary: 'Get patient treatment history' })
findTreatments(
@Param('id') id: string,
@Query('limit', new ParseIntPipe({ optional: true })) limit = 20,
@Req() req,
) {
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
return this.patientsService.findTreatments(id, organizationId, limit);
}
@Post(':id/treatments')
@ApiOperation({ summary: 'Add treatment history item for a patient' })
addTreatment(
@Param('id') id: string,
@Body() dto: CreateTreatmentHistoryDto,
@Req() req,
) {
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
return this.patientsService.addTreatment(id, dto, organizationId);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { PatientsController } from './patients.controller';
import { PatientsService } from './patients.service';
@Module({
controllers: [PatientsController],
providers: [PatientsService, PrismaService],
})
export class PatientsModule {}

View File

@@ -0,0 +1,140 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { CreatePatientDto } from './dto/create-patient.dto';
import { ListPatientsDto } from './dto/list-patients.dto';
import { UpdatePatientDto } from './dto/update-patient.dto';
import { CreateTreatmentHistoryDto } from './dto/create-treatment-history.dto';
@Injectable()
export class PatientsService {
constructor(private readonly prisma: PrismaService) {}
async create(createPatientDto: CreatePatientDto, organizationId: string) {
const patient = await this.prisma.patient.create({
data: {
...createPatientDto,
dateOfBirth: createPatientDto.dateOfBirth ? new Date(createPatientDto.dateOfBirth) : null,
organizationId,
},
});
return { success: true, data: patient };
}
async findAll(query: ListPatientsDto, organizationId: string) {
const { page = 1, limit = 10, q } = query;
const skip = (page - 1) * limit;
const where: Prisma.PatientWhereInput = {
organizationId,
...(q
? {
OR: [
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
{ email: { contains: q, mode: 'insensitive' } },
{ phone: { contains: q, mode: 'insensitive' } },
],
}
: {}),
};
const [items, total] = await Promise.all([
this.prisma.patient.findMany({
where,
skip,
take: limit,
orderBy: [{ updatedAt: 'desc' }],
}),
this.prisma.patient.count({ where }),
]);
return {
success: true,
data: {
items,
pagination: {
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit)),
},
},
};
}
async findOne(id: string, organizationId: string) {
const patient = await this.prisma.patient.findFirst({
where: { id, organizationId },
});
if (!patient) {
throw new NotFoundException('Patient not found');
}
return { success: true, data: patient };
}
async update(id: string, updatePatientDto: UpdatePatientDto, organizationId: string) {
await this.ensurePatient(id, organizationId);
const patient = await this.prisma.patient.update({
where: { id },
data: {
...updatePatientDto,
dateOfBirth: updatePatientDto.dateOfBirth ? new Date(updatePatientDto.dateOfBirth) : undefined,
},
});
return { success: true, data: patient };
}
async findTreatments(patientId: string, organizationId: string, limit = 20) {
await this.ensurePatient(patientId, organizationId);
const items = await this.prisma.patientTreatmentHistory.findMany({
where: { patientId },
orderBy: [{ treatmentAt: 'desc' }],
take: limit,
});
return { success: true, data: items };
}
async addTreatment(
patientId: string,
dto: CreateTreatmentHistoryDto,
organizationId: string,
) {
await this.ensurePatient(patientId, organizationId);
const treatment = await this.prisma.patientTreatmentHistory.create({
data: {
...dto,
treatmentAt: new Date(dto.treatmentAt),
patientId,
},
});
return { success: true, data: treatment };
}
private async ensurePatient(id: string, organizationId: string) {
const patient = await this.prisma.patient.findFirst({
where: { id, organizationId },
select: { id: true },
});
if (!patient) {
throw new NotFoundException('Patient not found');
}
}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected');
}
return user.organizationId;
}
}

View File

@@ -0,0 +1,10 @@
export default function AppointmentsPage() {
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>
);
}

View File

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

View File

@@ -1,14 +1,14 @@
'use client';
import { useEffect } from 'react';
import { memo, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import Sidebar from '@/components/ui/Sidebar';
import { ThemeToggle } from '@/components/ui/ThemeToggle';
import { LogOut } from 'lucide-react';
import { DashboardAccountMenu } from '@/components/ui/DashboardAccountMenu';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, currentOrganization, isAuthReady, logout } = useAuth();
const { user, currentOrganization, isAuthReady } = useAuth();
const router = useRouter();
// ✅ AUTH GUARD (runs once per navigation group)
@@ -48,21 +48,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<Sidebar />
<div className="flex-1 flex flex-col">
<header className="flex justify-between px-6 py-4 border-b border-border/70 backdrop-blur-sm">
<h2 className="text-lg font-medium">{currentOrganization.name}</h2>
<div className="flex items-center gap-4">
<ThemeToggle />
<span className="text-sm text-text-secondary">{user.name}</span>
<button
onClick={logout}
className="inline-flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
<LogOut className="w-4 h-4 icon-flat" />
Logout
</button>
</div>
</header>
<DashboardHeader organizationName={currentOrganization.name} />
<main className="p-6 flex-1 overflow-y-auto">
<div className="surface-panel p-6 min-h-full">
@@ -72,4 +58,21 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</div>
</div>
);
}
}
const DashboardHeader = memo(function DashboardHeader({
organizationName,
}: {
organizationName: string;
}) {
return (
<header className="h-[71px] flex justify-between items-center gap-4 px-6 border-b border-border/70 backdrop-blur-sm">
<h2 className="text-lg font-medium truncate min-w-0">{organizationName}</h2>
<div className="flex items-center gap-3 shrink-0">
<ThemeToggle />
<DashboardAccountMenu />
</div>
</header>
);
});

View File

@@ -0,0 +1,219 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { patientsApi } from '@/lib/api/patients';
import {
CreatePatientInput,
CreateTreatmentHistoryInput,
Patient,
TreatmentHistoryItem,
} from '@/types/patient';
import { PatientSearchSelect } from '@/components/patients/PatientSearchSelect';
import { CreatePatientModal } from '@/components/patients/CreatePatientModal';
import { PatientSummaryCard } from '@/components/patients/PatientSummaryCard';
import { TreatmentHistoryPreview } from '@/components/patients/TreatmentHistoryPreview';
const EMPTY_PATIENT_FORM: CreatePatientInput = {
firstName: '',
lastName: '',
phone: '',
email: '',
};
export default function PatientsPage() {
const [search, setSearch] = useState('');
const [patients, setPatients] = useState<Patient[]>([]);
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
const [treatments, setTreatments] = useState<TreatmentHistoryItem[]>([]);
const [loadingPatients, setLoadingPatients] = useState(false);
const [loadingTreatments, setLoadingTreatments] = useState(false);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [savingPatient, setSavingPatient] = useState(false);
const [savingTreatment, setSavingTreatment] = useState(false);
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
const [errorMessage, setErrorMessage] = useState<string>('');
const [successMessage, setSuccessMessage] = useState<string>('');
const sortedPatients = useMemo(
() =>
[...patients].sort((a, b) =>
`${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`),
),
[patients],
);
useEffect(() => {
const timeout = setTimeout(() => {
void loadPatients(search);
}, 300);
return () => clearTimeout(timeout);
}, [search]);
useEffect(() => {
void loadPatients('');
}, []);
useEffect(() => {
if (!successMessage) {
return;
}
const timeout = setTimeout(() => {
setSuccessMessage('');
}, 3000);
return () => clearTimeout(timeout);
}, [successMessage]);
async function loadPatients(q: string) {
setLoadingPatients(true);
setErrorMessage('');
try {
const response = await patientsApi.list({ q, page: 1, limit: 25 });
const items = response.data.items;
setPatients(items);
if (selectedPatient) {
const freshSelected = items.find((item) => item.id === selectedPatient.id);
setSelectedPatient(freshSelected);
}
} catch (error: any) {
const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message;
setErrorMessage(message || 'Failed to load patients.');
} finally {
setLoadingPatients(false);
}
}
async function loadTreatments(patientId: string) {
setLoadingTreatments(true);
setErrorMessage('');
try {
const response = await patientsApi.listTreatments(patientId);
setTreatments(response.data);
} catch (error: any) {
const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message;
setErrorMessage(message || 'Failed to load treatment history.');
} finally {
setLoadingTreatments(false);
}
}
async function handleCreatePatient() {
setSavingPatient(true);
setErrorMessage('');
setSuccessMessage('');
try {
const response = await patientsApi.create(patientForm);
setIsCreateOpen(false);
setPatientForm(EMPTY_PATIENT_FORM);
await loadPatients(search);
setSelectedPatient(response.data);
await loadTreatments(response.data.id);
setSuccessMessage(
`Patient ${response.data.firstName} ${response.data.lastName} was saved successfully.`,
);
} catch (error: any) {
const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message;
setErrorMessage(message || 'Failed to save patient.');
} finally {
setSavingPatient(false);
}
}
async function handleQuickAddTreatment() {
if (!selectedPatient) {
return;
}
const payload: CreateTreatmentHistoryInput = {
title: 'Initial consultation',
status: 'scheduled',
treatmentAt: new Date().toISOString(),
notes: 'Created from quick action on patients page.',
};
setSavingTreatment(true);
setErrorMessage('');
setSuccessMessage('');
try {
await patientsApi.addTreatment(selectedPatient.id, payload);
await loadTreatments(selectedPatient.id);
setSuccessMessage('Treatment entry added successfully.');
} catch (error: any) {
const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message;
setErrorMessage(message || 'Failed to add treatment entry.');
} finally {
setSavingTreatment(false);
}
}
return (
<div className="relative space-y-6 pb-20">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-text-primary">Patients</h1>
<Button variant="primary" className="flex items-center gap-2" onClick={() => setIsCreateOpen(true)}>
<Plus className="h-4 w-4 icon-flat" />
New Patient
</Button>
</div>
<CreatePatientModal
isOpen={isCreateOpen}
formData={patientForm}
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
onSubmit={handleCreatePatient}
onClose={() => setIsCreateOpen(false)}
loading={savingPatient}
/>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-1">
<PatientSearchSelect
search={search}
onSearchChange={setSearch}
patients={sortedPatients}
selectedPatientId={selectedPatient?.id}
onSelectPatient={(patient) => {
setSelectedPatient(patient);
void loadTreatments(patient.id);
}}
loading={loadingPatients}
/>
</div>
<div className="xl:col-span-2 space-y-4">
<PatientSummaryCard patient={selectedPatient} />
<div className="flex">
<Button
variant="secondary"
disabled={!selectedPatient}
isLoading={savingTreatment}
onClick={handleQuickAddTreatment}
>
Add Quick Treatment Entry
</Button>
</div>
<TreatmentHistoryPreview items={treatments} loading={loadingTreatments} />
</div>
</div>
{(errorMessage || successMessage) && (
<div className="absolute bottom-0 left-0 right-0 z-50 w-full">
{errorMessage && (
<div className="rounded-[var(--radius-sm)] border border-red-500/50 bg-red-500/10 px-3 py-2 text-sm text-red-300 shadow-lg">
{errorMessage}
</div>
)}
{successMessage && (
<div className="rounded-[var(--radius-sm)] border border-emerald-500/50 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300 shadow-lg">
{successMessage}
</div>
)}
</div>
)}
</div>
);
}

View File

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

View File

@@ -0,0 +1,29 @@
'use client';
import Link from 'next/link';
export default function AccountSettingsPage() {
return (
<div className="max-w-xl space-y-6">
<div>
<Link
href="/today"
className="text-sm text-primary hover:opacity-90"
>
Back to app
</Link>
<h1 className="text-2xl font-semibold text-text-primary mt-4">Account</h1>
<p className="text-text-secondary text-sm mt-2">
Profile and security settings for your login.
</p>
</div>
<div className="surface-card p-6 space-y-3">
<p className="text-sm text-text-secondary">
Password change and profile editing will be wired here next (e.g. invite
flow, reset password).
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth';
import type { SubscriptionAlertData } from '@/types';
export default function SubscriptionsSettingsPage() {
const { currentOrganization } = useAuth();
const router = useRouter();
const [alert, setAlert] = useState<SubscriptionAlertData | null>(null);
useEffect(() => {
if (currentOrganization && !currentOrganization.isOwner) {
router.replace('/today');
}
}, [currentOrganization, router]);
useEffect(() => {
if (!currentOrganization?.isOwner) return;
void authApi.getSubscriptionAlert().then((r) => {
if (r.success) setAlert(r.data);
});
}, [currentOrganization?.id, currentOrganization?.isOwner]);
if (!currentOrganization) {
return (
<p className="text-text-secondary text-sm">Loading...</p>
);
}
if (!currentOrganization.isOwner) {
return (
<p className="text-text-secondary text-sm">Redirecting...</p>
);
}
const plan = currentOrganization.plan;
const maxUsers = plan?.maxUsers;
return (
<div className="max-w-xl space-y-6">
<div>
<Link
href="/today"
className="text-sm text-primary hover:opacity-90"
>
Back to app
</Link>
<h1 className="text-2xl font-semibold text-text-primary mt-4">Subscriptions</h1>
<p className="text-text-secondary text-sm mt-2">
Your DyoLink workspace plan and seats for{' '}
<span className="text-text-primary font-medium">{currentOrganization.name}</span>.
Clinic and lab income tracking stays under the sidebar{' '}
<span className="text-text-primary">Billing</span> tab.
</p>
</div>
<div className="surface-card p-6 space-y-4">
<div className="flex flex-wrap gap-4 justify-between">
<div>
<p className="text-xs text-text-muted uppercase tracking-wide">Current plan</p>
<p className="text-lg font-medium text-text-primary capitalize">
{plan?.name ?? '—'}
</p>
</div>
{typeof maxUsers === 'number' && maxUsers < 999999 && (
<div>
<p className="text-xs text-text-muted uppercase tracking-wide">Seats (this org)</p>
<p className="text-lg font-medium text-text-primary">
{alert?.seatsUsed ?? '—'} / {maxUsers}
</p>
</div>
)}
</div>
{alert?.showWarning && (
<div className="text-sm text-text-secondary space-y-1">
{alert.trialExpired && (
<p>Trial period has ended. Choose a plan when checkout is available.</p>
)}
{!alert.trialExpired && alert.trialEndingSoon && (
<p>
Trial ends in {alert.daysUntilTrialEnd ?? '—'} day(s).
</p>
)}
{!alert.trialExpired && !alert.trialEndingSoon && alert.seatsLow && (
<p>Seat usage is high for this organization.</p>
)}
</div>
)}
<p className="text-sm text-text-secondary">
Payment and plan upgrades will connect here. The warning on the settings
icon is only shown to workspace owners when seats are low or the trial window
is ending.
</p>
</div>
</div>
);
}

View File

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

View File

@@ -18,6 +18,7 @@ const registerSchema = z.object({
.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',
}),
@@ -47,7 +48,7 @@ export default function RegisterPage() {
const handleNext = async () => {
const fieldsToValidate = step === 1
? ['name', 'email', 'password', 'confirmPassword']
: ['organizationName', 'organizationType'];
: ['organizationName', 'organizationEmail', 'organizationType'];
const isValid = await trigger(fieldsToValidate as any);
if (isValid) {
@@ -62,6 +63,7 @@ export default function RegisterPage() {
data.password,
data.name,
data.organizationName,
data.organizationEmail,
data.organizationType
);
// No need to redirect - auth context will handle it
@@ -182,6 +184,14 @@ export default function RegisterPage() {
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

View File

@@ -1,18 +1,17 @@
'use client';
import { useEffect } from 'react';
import { useState } from 'react';
import { useAuth } from '@/lib/hooks/useAuth';
import { Building2, Beaker } from 'lucide-react';
import { Building2, Beaker, Mail, Plus } from 'lucide-react';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
export default function SelectOrganizationPage() {
const { organizations, selectOrganization, isLoading } = useAuth();
// ✅ Auto-redirect if only one organization
useEffect(() => {
if (!isLoading && organizations.length === 1) {
selectOrganization(organizations[0].id);
}
}, [organizations, isLoading]);
const { organizations, selectOrganization, createOrganization, isLoading, error, clearError } = useAuth();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [organizationName, setOrganizationName] = useState('');
const [organizationEmail, setOrganizationEmail] = useState('');
const [organizationType, setOrganizationType] = useState<'CLINIC' | 'LAB'>('CLINIC');
const getIcon = (type: string) => {
return type === 'CLINIC'
@@ -20,60 +19,152 @@ export default function SelectOrganizationPage() {
: <Beaker className="h-8 w-8 icon-flat" />;
};
const handleCreateOrganization = async () => {
try {
clearError();
const createdId = await createOrganization(
organizationName.trim(),
organizationEmail.trim(),
organizationType,
);
setOrganizationName('');
setOrganizationEmail('');
setOrganizationType('CLINIC');
setIsCreateOpen(false);
await selectOrganization(createdId);
} catch {
// Error is already handled in auth context.
}
};
if (isLoading) {
return (
<div className="min-h-screen app-web-bg flex items-center justify-center">
<p className="text-text-secondary">Loading organizations...</p>
</div>
);
}
if (!organizations.length) {
return (
<div className="min-h-screen app-web-bg flex items-center justify-center">
<p className="text-text-secondary">No organizations found.</p>
<p className="text-text-secondary">Loading...</p>
</div>
);
}
return (
<div className="min-h-screen app-web-bg flex items-center justify-center p-4">
<div className="max-w-2xl w-full">
<div className="text-center mb-8">
<h1 className="text-3xl font-semibold text-text-primary">
Choose Organization
</h1>
<p className="text-text-secondary mt-2">
You have access to multiple organizations. Select one to continue.
</p>
<div className="min-h-screen app-web-bg p-4 sm:p-8">
<div className="max-w-3xl mx-auto">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-semibold text-text-primary">Organizations</h1>
<p className="text-text-secondary mt-2">
Select an organization to continue, or create a new one.
</p>
</div>
<Button
type="button"
variant={isCreateOpen ? 'outline' : 'primary'}
onClick={() => {
clearError();
setIsCreateOpen(prev => !prev);
}}
>
<Plus className="h-4 w-4 mr-2 icon-flat" />
{isCreateOpen ? 'Cancel' : 'Create Organization'}
</Button>
</div>
<div className="grid gap-4">
{organizations.map((org) => (
<button
key={org.id}
onClick={() => selectOrganization(org.id)}
className="surface-card p-6 transition-all text-left flex items-center gap-4 hover:border-primary/60"
>
<div className="p-3 bg-primary-soft rounded-[var(--radius-sm)] text-primary">
{getIcon(org.type)}
{isCreateOpen && (
<div className="surface-card p-6 mb-6 space-y-4">
<Input
label="Organization name"
value={organizationName}
onChange={(event) => setOrganizationName(event.target.value)}
placeholder="Sunshine Dental Clinic"
icon={<Building2 className="h-5 w-5 icon-flat" />}
/>
<Input
label="Organization email"
value={organizationEmail}
onChange={(event) => setOrganizationEmail(event.target.value)}
placeholder="contact@sunshineclinic.com"
type="email"
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>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setOrganizationType('CLINIC')}
className={`p-3 border rounded-[var(--radius-md)] text-sm ${
organizationType === 'CLINIC'
? 'border-primary/60 bg-primary-soft text-text-primary'
: 'border-border text-text-secondary hover:border-border-strong'
}`}
>
Dental Clinic
</button>
<button
type="button"
onClick={() => setOrganizationType('LAB')}
className={`p-3 border rounded-[var(--radius-md)] text-sm ${
organizationType === 'LAB'
? 'border-primary/60 bg-primary-soft text-text-primary'
: 'border-border text-text-secondary hover:border-border-strong'
}`}
>
Dental Lab
</button>
</div>
</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>
</div>
)}
<div className="flex justify-end">
<Button
type="button"
variant="primary"
onClick={handleCreateOrganization}
isLoading={isLoading}
disabled={!organizationName.trim() || !organizationEmail.trim()}
>
Create and Continue
</Button>
</div>
</div>
)}
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-primary">
{org.name}
</h3>
<p className="text-sm text-text-secondary">
{org.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'}
</p>
</div>
{!organizations.length ? (
<div className="surface-card p-8 text-center">
<p className="text-text-secondary">No organizations found. Create your first one to continue.</p>
</div>
) : (
<div className="grid gap-4">
{organizations.map((org) => (
<button
key={org.id}
onClick={() => selectOrganization(org.id)}
className="surface-card p-6 transition-all text-left flex items-center gap-4 hover:border-primary/60"
>
<div className="p-3 bg-primary-soft rounded-[var(--radius-sm)] text-primary">
{getIcon(org.type)}
</div>
<div className="text-primary text-sm">
Continue
</div>
</button>
))}
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-primary">
{org.name}
</h3>
<p className="text-sm text-text-secondary">
{org.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'}
</p>
</div>
<div className="text-primary text-sm">
Continue
</div>
</button>
))}
</div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,69 @@
'use client';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { CreatePatientInput } from '@/types/patient';
interface CreatePatientModalProps {
isOpen: boolean;
formData: CreatePatientInput;
onChange: (patch: Partial<CreatePatientInput>) => void;
onSubmit: () => void;
onClose: () => void;
loading?: boolean;
}
export function CreatePatientModal({
isOpen,
formData,
onChange,
onSubmit,
onClose,
loading = false,
}: CreatePatientModalProps) {
if (!isOpen) {
return null;
}
return (
<div className="surface-card p-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input
label="First name"
value={formData.firstName || ''}
onChange={(e) => onChange({ firstName: e.target.value })}
/>
<Input
label="Last name"
value={formData.lastName || ''}
onChange={(e) => onChange({ lastName: e.target.value })}
/>
<Input
label="Phone"
value={formData.phone || ''}
onChange={(e) => onChange({ phone: e.target.value })}
/>
<Input
label="Email"
type="email"
value={formData.email || ''}
onChange={(e) => onChange({ email: e.target.value })}
/>
</div>
<div className="flex gap-2">
<Button
variant="primary"
onClick={onSubmit}
isLoading={loading}
disabled={!formData.firstName || !formData.lastName}
>
Save Patient
</Button>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/Input';
import { Patient } from '@/types/patient';
interface PatientSearchSelectProps {
search: string;
onSearchChange: (value: string) => void;
patients: Patient[];
selectedPatientId?: string;
onSelectPatient: (patient: Patient) => void;
loading?: boolean;
}
export function PatientSearchSelect({
search,
onSearchChange,
patients,
selectedPatientId,
onSelectPatient,
loading = false,
}: PatientSearchSelectProps) {
return (
<div className="surface-card p-4 space-y-4">
<Input
placeholder="Search patients by name, phone, email"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
icon={<Search className="h-4 w-4 icon-flat" />}
/>
<div className="space-y-2 max-h-80 overflow-y-auto">
{loading && <p className="text-sm text-text-muted">Loading patients...</p>}
{!loading && patients.length === 0 && (
<p className="text-sm text-text-muted">No patients found for this search.</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,28 @@
import { Patient } from '@/types/patient';
interface PatientSummaryCardProps {
patient?: Patient;
}
export function PatientSummaryCard({ patient }: PatientSummaryCardProps) {
if (!patient) {
return (
<div className="surface-card p-4">
<p className="text-sm text-text-muted">Select a patient to view details.</p>
</div>
);
}
return (
<div className="surface-card p-4 space-y-2">
<h2 className="text-lg font-semibold text-text-primary">
{patient.firstName} {patient.lastName}
</h2>
<p className="text-sm text-text-secondary">Phone: {patient.phone || '-'}</p>
<p className="text-sm text-text-secondary">Email: {patient.email || '-'}</p>
<p className="text-sm text-text-secondary">
Status: {patient.isActive ? 'Active' : 'Inactive'}
</p>
</div>
);
}

View File

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

View File

@@ -0,0 +1,156 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
import {
Settings,
AlertTriangle,
Building2,
CreditCard,
User,
LogOut,
ChevronDown,
} from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth';
import type { SubscriptionAlertData } from '@/types';
function warningTooltip(data: SubscriptionAlertData | null): string {
if (!data?.showWarning) return '';
if (data.trialExpired) return 'Trial ended — review Subscriptions';
if (data.trialEndingSoon) return 'Trial ending soon — review Subscriptions';
if (data.seatsLow) return 'Seats running low — review Subscriptions';
return 'Review Subscriptions';
}
export function DashboardAccountMenu() {
const { user, currentOrganization, logout } = useAuth();
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const [alert, setAlert] = useState<SubscriptionAlertData | null>(null);
const isOwner = currentOrganization?.isOwner ?? false;
useEffect(() => {
const onDocClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, []);
useEffect(() => {
if (!isOwner || !currentOrganization) {
setAlert(null);
return;
}
let cancelled = false;
void (async () => {
try {
const res = await authApi.getSubscriptionAlert();
if (!cancelled && res.success) setAlert(res.data);
} catch {
if (!cancelled) setAlert(null);
}
})();
return () => {
cancelled = true;
};
}, [isOwner, currentOrganization?.id]);
const showWarning = Boolean(isOwner && alert?.showWarning);
const tooltip = useMemo(() => warningTooltip(alert), [alert]);
const handleLogout = useCallback(() => {
setOpen(false);
void logout();
}, [logout]);
return (
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="inline-flex items-center gap-2 rounded-[var(--radius-md)] border border-border/70 px-3 py-2 text-sm text-text-primary hover:bg-background-card/80 transition-colors"
aria-expanded={open}
aria-haspopup="menu"
>
<span className="relative inline-flex shrink-0" title={showWarning ? tooltip : undefined}>
<Settings className="h-5 w-5 icon-flat" aria-hidden />
{showWarning && (
<span
className="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-amber-500 ring-2 ring-background-secondary"
aria-label={tooltip}
>
<AlertTriangle className="h-2.5 w-2.5 text-amber-950" strokeWidth={2.5} />
</span>
)}
</span>
<span className="hidden sm:inline max-w-[160px] truncate">{user?.name}</span>
<ChevronDown className="h-4 w-4 text-text-muted shrink-0" aria-hidden />
</button>
{open && (
<div
role="menu"
className="absolute right-0 mt-2 w-72 rounded-[var(--radius-md)] border border-border bg-background-secondary/95 py-2 shadow-lg z-50 backdrop-blur-sm"
>
<div className="px-3 py-2 border-b border-border/60">
<p className="text-xs text-text-muted">Signed in</p>
<p className="text-sm font-medium truncate">{user?.email}</p>
<p className="text-xs text-text-secondary mt-1 truncate">
{currentOrganization?.name}
</p>
</div>
<div className="py-1">
<Link
href="/select-organization"
role="menuitem"
className="flex items-center gap-3 px-3 py-2.5 text-sm text-text-primary hover:bg-background-card/70"
onClick={() => setOpen(false)}
>
<Building2 className="h-4 w-4 icon-flat shrink-0" />
Switch organization
</Link>
{isOwner && (
<Link
href="/settings/subscriptions"
role="menuitem"
className="flex items-center gap-3 px-3 py-2.5 text-sm text-text-primary hover:bg-background-card/70"
onClick={() => setOpen(false)}
>
<CreditCard className="h-4 w-4 icon-flat shrink-0" />
Subscriptions
</Link>
)}
<Link
href="/settings/account"
role="menuitem"
className="flex items-center gap-3 px-3 py-2.5 text-sm text-text-primary hover:bg-background-card/70"
onClick={() => setOpen(false)}
>
<User className="h-4 w-4 icon-flat shrink-0" />
Account
</Link>
</div>
<div className="border-t border-border/60 pt-1">
<button
type="button"
role="menuitem"
className="flex w-full items-center gap-3 px-3 py-2.5 text-sm text-text-secondary hover:bg-background-card/70 hover:text-text-primary"
onClick={handleLogout}
>
<LogOut className="h-4 w-4 icon-flat shrink-0" />
Log out
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,8 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import Link from 'next/link';
import { memo } from 'react';
import { usePathname } from 'next/navigation';
import {
LayoutDashboard,
Users,
@@ -8,7 +10,7 @@ import {
UserCog,
FlaskConical,
FileText,
CreditCard
CreditCard,
} from 'lucide-react';
const menu = [
@@ -21,25 +23,26 @@ const menu = [
{ name: 'Reports', path: '/reports', icon: FileText },
];
export default function Sidebar() {
function Sidebar() {
const pathname = usePathname();
const router = useRouter();
return (
<aside className="w-64 bg-background-secondary/90 border-r border-border text-text-primary flex flex-col p-4">
<div className="mb-6 pb-4 border-b border-border">
<h1 className="text-xl font-semibold tracking-tight">DyoLink</h1>
<aside className="w-64 bg-background-secondary/90 border-r border-border text-text-primary flex flex-col">
<div className="h-[71px] px-4 flex items-center">
<h1 className="text-lg font-medium tracking-tight">DyoLink</h1>
</div>
<div className="mx-4 border-b border-border/70" />
<nav className="flex flex-col gap-2">
<nav className="flex flex-col gap-2 p-4">
{menu.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.path;
return (
<button
<Link
key={item.name}
onClick={() => router.push(item.path)}
href={item.path}
prefetch
className={`flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-sm)] border transition-colors ${
isActive
? 'bg-primary-soft border-primary/60 text-text-primary'
@@ -48,10 +51,12 @@ export default function Sidebar() {
>
<Icon className="w-[18px] h-[18px] icon-flat" />
<span className="text-sm">{item.name}</span>
</button>
</Link>
);
})}
</nav>
</aside>
);
}
}
export default memo(Sidebar);

View File

@@ -1,6 +1,6 @@
// src/lib/api/auth.ts
import { apiClient } from './client';
import { AuthResponse, TrialRegistrationData, LoginData } from '@/types';
import { AuthResponse, TrialRegistrationData, LoginData, SubscriptionAlertData } from '@/types';
export const authApi = {
// Register a new trial organization
@@ -21,12 +21,32 @@ export const authApi = {
return response.data;
},
/** Owner-only meaningful data; staff always gets showWarning: false */
getSubscriptionAlert: async (): Promise<{
success: boolean;
data: SubscriptionAlertData;
}> => {
const response = await apiClient.get('/auth/subscription-alert');
return response.data;
},
// Select organization
selectOrganization: async (organizationId: string): Promise<any> => {
const response = await apiClient.post('/auth/select-organization', { organizationId });
return response.data;
},
// Create organization for current user
createOrganization: async (data: {
organizationName: string;
organizationEmail: string;
organizationType: 'CLINIC' | 'LAB';
planName?: string;
}): Promise<any> => {
const response = await apiClient.post('/auth/organizations', data);
return response.data;
},
// Logout
logout: async (): Promise<void> => {
await apiClient.post('/auth/logout');

View File

@@ -0,0 +1,43 @@
import { apiClient } from './client';
import {
CreatePatientInput,
CreateTreatmentHistoryInput,
Patient,
PatientsListResponse,
TreatmentHistoryItem,
} from '@/types/patient';
export const patientsApi = {
list: async (params?: { q?: string; page?: number; limit?: number }): Promise<PatientsListResponse> => {
const response = await apiClient.get('/patients', { params });
return response.data;
},
create: async (data: CreatePatientInput): Promise<{ success: boolean; data: Patient }> => {
const response = await apiClient.post('/patients', data);
return response.data;
},
getOne: async (id: string): Promise<{ success: boolean; data: Patient }> => {
const response = await apiClient.get(`/patients/${id}`);
return response.data;
},
listTreatments: async (
patientId: string,
limit = 20,
): Promise<{ success: boolean; data: TreatmentHistoryItem[] }> => {
const response = await apiClient.get(`/patients/${patientId}/treatments`, {
params: { limit },
});
return response.data;
},
addTreatment: async (
patientId: string,
data: CreateTreatmentHistoryInput,
): Promise<{ success: boolean; data: TreatmentHistoryItem }> => {
const response = await apiClient.post(`/patients/${patientId}/treatments`, data);
return response.data;
},
};

View File

@@ -1,6 +1,6 @@
'use client';
import React, { createContext, useContext, useEffect, useState } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { authApi } from '@/lib/api/auth';
import { User, Organization } from '@/types';
@@ -17,11 +17,18 @@ interface AuthContextType {
password: string,
name: string,
organizationName: string,
organizationEmail: string,
organizationType: 'CLINIC' | 'LAB'
) => Promise<void>;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
selectOrganization: (orgId: string) => Promise<void>;
createOrganization: (
organizationName: string,
organizationEmail: string,
organizationType: 'CLINIC' | 'LAB',
planName?: string,
) => Promise<string>;
clearError: () => void;
}
@@ -37,11 +44,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
useEffect(() => {
checkAuth();
}, []);
const normalizeProfilePayload = (payload: any): { user: User | null; organizations: Organization[] } => {
const normalizeProfilePayload = useCallback((payload: any): { user: User | null; organizations: Organization[] } => {
const organizations = payload?.organizations || [];
if (payload?.user) {
@@ -60,9 +63,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
return { user: null, organizations };
};
}, []);
const checkAuth = async () => {
const checkAuth = useCallback(async () => {
try {
setIsLoading(true);
@@ -77,11 +80,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const storedOrgId = localStorage.getItem('currentOrganizationId');
if (storedOrgId && orgs.length > 0) {
const org = orgs.find(o => o.id === storedOrgId);
if (org) setCurrentOrganization(org);
else setCurrentOrganization(null);
if (org) {
setCurrentOrganization(org);
// Ensure cookie token carries organizationId for org-scoped APIs.
await authApi.selectOrganization(org.id);
} else {
setCurrentOrganization(null);
}
} else if (orgs.length === 1 && userData) {
setCurrentOrganization(orgs[0]);
localStorage.setItem('currentOrganizationId', orgs[0].id);
// Keep JWT in sync with selected org even for single-org users.
await authApi.selectOrganization(orgs[0].id);
} else {
setCurrentOrganization(null);
}
@@ -96,14 +106,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setIsLoading(false);
setIsAuthReady(true);
}
};
}, [normalizeProfilePayload]);
useEffect(() => {
void checkAuth();
}, [checkAuth]);
// ✅ REGISTER
const registerTrial = async (
const registerTrial = useCallback(async (
email: string,
password: string,
name: string,
organizationName: string,
organizationEmail: string,
organizationType: 'CLINIC' | 'LAB'
) => {
try {
@@ -115,6 +130,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
password,
name,
organizationName,
organizationEmail,
organizationType,
});
@@ -125,6 +141,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (orgs.length === 1) {
const org = orgs[0];
await authApi.selectOrganization(org.id);
setCurrentOrganization(org);
localStorage.setItem('currentOrganizationId', org.id);
router.push('/today');
@@ -139,10 +156,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} finally {
setIsLoading(false);
}
};
}, [router]);
// ✅ LOGIN
const login = async (email: string, password: string) => {
const login = useCallback(async (email: string, password: string) => {
try {
setIsLoading(true);
setError(null);
@@ -156,6 +173,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (orgs.length === 1) {
const org = orgs[0];
await authApi.selectOrganization(org.id);
setCurrentOrganization(org);
localStorage.setItem('currentOrganizationId', org.id);
router.push('/today');
@@ -169,17 +187,28 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} finally {
setIsLoading(false);
}
};
}, [router]);
const logout = async () => {
localStorage.clear();
setUser(null);
setOrganizations([]);
setCurrentOrganization(null);
router.push('/');
};
const logout = useCallback(async () => {
try {
// Important: clear auth cookies/session on the server first,
// otherwise middleware may still treat the user as authenticated.
await authApi.logout();
} catch (err) {
console.error('Logout API failed:', err);
} finally {
localStorage.clear();
setUser(null);
setOrganizations([]);
setCurrentOrganization(null);
setError(null);
setIsAuthReady(true);
router.replace('/');
router.refresh();
}
}, [router]);
const selectOrganization = async (orgId: string) => {
const selectOrganization = useCallback(async (orgId: string) => {
try {
setIsLoading(true);
@@ -189,7 +218,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
localStorage.setItem('currentOrganizationId', organization.id);
setCurrentOrganization(organization);
setCurrentOrganization({
id: organization.id,
name: organization.name,
type: organization.type as Organization['type'],
isOwner: Boolean((organization as { isOwner?: boolean }).isOwner),
plan: (organization as { plan?: Organization['plan'] }).plan,
});
router.push('/today');
@@ -199,26 +234,76 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} finally {
setIsLoading(false);
}
};
}, [router]);
const clearError = () => setError(null);
const createOrganization = useCallback(async (
organizationName: string,
organizationEmail: string,
organizationType: 'CLINIC' | 'LAB',
planName?: string,
) => {
try {
setIsLoading(true);
setError(null);
const createResponse = await authApi.createOrganization({
organizationName,
organizationEmail,
organizationType,
planName,
});
const profileResponse = await authApi.getProfile();
if (profileResponse.success) {
const { user: userData, organizations: orgs } = normalizeProfilePayload(profileResponse.data);
setUser(userData);
setOrganizations(orgs);
}
return createResponse.data.organization.id as string;
} catch (err: any) {
setError(err.message || 'Failed to create organization');
throw err;
} finally {
setIsLoading(false);
}
}, [normalizeProfilePayload]);
const clearError = useCallback(() => setError(null), []);
const contextValue = useMemo(
() => ({
user,
organizations,
currentOrganization,
isLoading,
isAuthReady,
error,
registerTrial,
login,
logout,
selectOrganization,
createOrganization,
clearError,
}),
[
user,
organizations,
currentOrganization,
isLoading,
isAuthReady,
error,
registerTrial,
login,
logout,
selectOrganization,
createOrganization,
clearError,
],
);
return (
<AuthContext.Provider
value={{
user,
organizations,
currentOrganization,
isLoading,
isAuthReady, // ✅ expose it
error,
registerTrial,
login,
logout,
selectOrganization,
clearError,
}}
>
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);

View File

@@ -3,7 +3,6 @@ import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const publicRoutes = ['/', '/login', '/register', '/terms', '/privacy', '/forgot-password'];
const authOnlyRoutes = ['/login', '/register']; // routes that should NOT be accessed when logged in
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
@@ -15,12 +14,10 @@ export function middleware(request: NextRequest) {
return NextResponse.redirect(new URL('/today', request.url));
}
// Always allow public routes first
// Always allow public routes first. We intentionally do not block /login or /register
// when a cookie exists, because the cookie might be stale/invalid and the client
// auth check needs to recover gracefully.
if (publicRoutes.includes(pathname)) {
// If user is already logged in and tries to access login/register → redirect to dashboard
if (isAuthenticated && authOnlyRoutes.includes(pathname)) {
return NextResponse.redirect(new URL('/today', request.url));
}
return NextResponse.next();
}

View File

@@ -2,9 +2,9 @@
/* Light theme tokens (future-ready) */
:root[data-theme="light"] {
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--color-background-primary: #f6f9fc;
--color-background-secondary: #ffffff;
@@ -45,7 +45,7 @@
--color-primary: #09a9bc;
--color-primary-contrast: #001117;
--color-primary-soft: rgba(9, 169, 188, 0.2);
--color-icon: #f3bb4b;
--color-icon: #e1bc72;
}
/* Default theme = dark */

View File

@@ -10,12 +10,25 @@ export interface Organization {
name: string;
type: 'CLINIC' | 'LAB';
isOwner: boolean;
permissions?: string[];
plan?: {
name: string;
maxUsers: number;
};
}
/** GET /auth/subscription-alert — owners only get meaningful flags */
export interface SubscriptionAlertData {
showWarning: boolean;
seatsLow: boolean;
trialEndingSoon: boolean;
trialExpired: boolean;
seatsUsed?: number;
seatsLimit?: number;
daysUntilTrialEnd?: number | null;
trialEndsAt?: string | null;
}
export interface AuthResponse {
success: boolean;
data: {
@@ -31,6 +44,7 @@ export interface TrialRegistrationData {
password: string;
name: string;
organizationName: string;
organizationEmail: string;
organizationType: 'CLINIC' | 'LAB';
}

View File

@@ -0,0 +1,57 @@
export interface Patient {
id: string;
organizationId: string;
firstName: string;
lastName: string;
phone?: string | null;
email?: string | null;
dateOfBirth?: string | null;
notes?: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface TreatmentHistoryItem {
id: string;
patientId: string;
title: string;
status: string;
treatmentAt: string;
tooth?: string | null;
notes?: string | null;
totalCost?: number | null;
createdAt: string;
updatedAt: string;
}
export interface CreatePatientInput {
firstName: string;
lastName: string;
phone?: string;
email?: string;
dateOfBirth?: string;
notes?: string;
}
export interface CreateTreatmentHistoryInput {
title: string;
status: string;
treatmentAt: string;
tooth?: string;
notes?: string;
totalCost?: number;
}
export interface PatientsListResponse {
success: boolean;
data: {
items: Patient[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
};
}