Compare commits

...

12 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
a264523e12 bugfix: the whole theming structure overhauled. light/dark mode icon added. 2026-04-29 01:22:53 +03:30
bdd889ac09 Merge branch 'bugfix/dashboard-blank-page' 2026-04-28 12:12:32 +03:30
c12509ad31 bugfix: authenticated users seen a blank page instead of their dashboard when reopen the site 2026-04-28 11:57:29 +03:30
56 changed files with 2351 additions and 384 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

@@ -37,9 +37,9 @@ export default function BillingPage() {
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Billing</h1>
<h1 className="text-2xl font-semibold text-text-primary">Billing</h1>
<Button variant="primary" className="flex items-center gap-2">
<Plus className="h-4 w-4" />
<Plus className="h-4 w-4 icon-flat" />
New Invoice
</Button>
</div>
@@ -71,14 +71,14 @@ export default function BillingPage() {
/>
</div>
{/* Filters */}
<div className="bg-white p-4 rounded-xl shadow-sm border">
<div className="surface-card p-4">
<div className="flex gap-4 items-center">
<div className="flex-1">
<Input
placeholder="Search patients..."
value={search}
onChange={(e) => setSearch(e.target.value)}
icon={<Search className="h-4 w-4 text-gray-400" />}
icon={<Search className="h-4 w-4 icon-flat" />}
/>
</div>
<div className="flex gap-2">
@@ -86,9 +86,9 @@ export default function BillingPage() {
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-4 py-2 rounded-lg text-sm font-medium capitalize ${statusFilter === status
? 'bg-primary-50 text-primary-700 border border-primary-200'
: 'text-gray-600 hover:bg-gray-50'
className={`px-4 py-2 rounded-[var(--radius-sm)] text-sm font-medium capitalize border ${statusFilter === status
? 'bg-primary-soft text-primary border-primary/50'
: 'text-text-secondary border-border/40 hover:bg-background-card/70 hover:border-border'
}`}
>
{status}
@@ -98,55 +98,55 @@ export default function BillingPage() {
</div>
</div>
{/* Invoices Table - Matching your design */}
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="surface-card overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b">
<thead className="bg-background-secondary/70 border-b border-border">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Invoice ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Patient name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<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-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Service
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Total amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Paid
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Action
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tbody className="divide-y divide-border/60">
{invoices.map((invoice) => (
<tr key={invoice.id} className="hover:bg-gray-50">
<td className="px-6 py-4 text-sm font-medium text-gray-900">
<tr key={invoice.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-4 text-sm font-medium text-text-primary">
{invoice.id}
</td>
<td className="px-6 py-4 text-sm text-gray-900">
<td className="px-6 py-4 text-sm text-text-primary">
{invoice.patient}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
<td className="px-6 py-4 text-sm text-text-secondary">
{invoice.date}
</td>
<td className="px-6 py-4 text-sm text-gray-900">
<td className="px-6 py-4 text-sm text-text-primary">
{invoice.service}
</td>
<td className="px-6 py-4 text-sm text-gray-900">
<td className="px-6 py-4 text-sm text-text-primary">
${invoice.amount}
</td>
<td className="px-6 py-4 text-sm text-gray-900">
<td className="px-6 py-4 text-sm text-text-primary">
${invoice.paid}
</td>
<td className="px-6 py-4">
@@ -158,7 +158,7 @@ export default function BillingPage() {
</Badge>
</td>
<td className="px-6 py-4">
<button className="text-primary-600 hover:text-primary-800 text-sm">
<button className="text-primary hover:opacity-90 text-sm">
Edit
</button>
</td>
@@ -167,14 +167,14 @@ export default function BillingPage() {
</tbody>
</table>
{/* Pagination - Matching your design */}
<div className="px-6 py-4 border-t flex justify-between items-center bg-gray-50">
<button className="text-sm text-gray-600 hover:text-gray-900">
<div className="px-6 py-4 border-t border-border/60 flex justify-between items-center bg-background-secondary/70">
<button className="text-sm text-text-secondary hover:text-text-primary">
Previous
</button>
<div className="text-sm text-gray-600">
<div className="text-sm text-text-secondary">
Page 1 of 10
</div>
<button className="text-sm text-gray-600 hover:text-gray-900">
<button className="text-sm text-text-secondary hover:text-text-primary">
Next
</button>
</div>
@@ -184,10 +184,10 @@ export default function BillingPage() {
}
function StatCard({ title, count, amount, color }: StatCardProps) {
const colors: Record<StatCardColor, string> = {
blue: 'bg-blue-50 text-blue-700 border-blue-200',
yellow: 'bg-yellow-50 text-yellow-700 border-yellow-200',
green: 'bg-green-50 text-green-700 border-green-200',
red: 'bg-red-50 text-red-700 border-red-200',
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',
};
return (

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,21 +1,16 @@
'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 { LogOut } from 'lucide-react';
import { ThemeToggle } from '@/components/ui/ThemeToggle';
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();
console.log('LAYOUT STATE:', {
user,
currentOrganization,
isAuthReady
});
// ✅ AUTH GUARD (runs once per navigation group)
useEffect(() => {
if (!isAuthReady) return;
@@ -34,7 +29,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
// ✅ LOADING ONLY FOR INITIAL LOAD
if (!isAuthReady) {
return (
<div className="h-screen flex items-center justify-center">
<div className="h-screen flex items-center justify-center app-web-bg">
Loading app...
</div>
);
@@ -42,32 +37,42 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
if (!user || !currentOrganization) {
return (
<div className="h-screen flex items-center justify-center">
<div className="h-screen flex items-center justify-center app-web-bg">
Loading workspace...
</div>
);
}
return (
<div className="flex h-screen bg-[#020d1a] text-white">
<div className="flex h-screen app-web-bg text-text-primary">
<Sidebar />
<div className="flex-1 flex flex-col">
<header className="flex justify-between px-6 py-4 border-b border-white/10">
<h2>{currentOrganization.name}</h2>
<div className="flex gap-4">
<span>{user.name}</span>
<button onClick={logout}>
<LogOut />
</button>
</div>
</header>
<DashboardHeader organizationName={currentOrganization.name} />
<main className="p-6 flex-1 overflow-y-auto">
{children} {/* 🔥 THIS CHANGES */}
<div className="surface-panel p-6 min-h-full">
{children}
</div>
</main>
</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

@@ -1,17 +1,15 @@
export default function TodayPage() {
return (
<div>
<h1 className="text-2xl font-bold mb-6">
<h1 className="text-2xl font-semibold mb-6">
Welcome back Babak !!
</h1>
<div className="grid grid-cols-4 gap-4">
<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 $" />
</div>
</div>
);
@@ -27,10 +25,10 @@ function Card({
sub?: string;
}) {
return (
<div className="bg-white/5 border border-white/10 p-4 rounded-xl">
<p className="text-sm text-gray-300">{title}</p>
<p className="text-2xl font-bold mt-2">{value}</p>
{sub && <p className="text-xs text-gray-400 mt-1">{sub}</p>}
<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

@@ -107,8 +107,8 @@
// }
'use client';
import { useState, useEffect } from 'react'; // ← added useEffect
import { useRouter } from 'next/navigation'; // ← added this
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
@@ -127,15 +127,14 @@ const loginSchema = z.object({
type LoginForm = z.infer<typeof loginSchema>;
export default function LoginPage() {
const { login, isLoading, user, isAuthReady } = useAuth(); // ← added user + isAuthReady
const router = useRouter(); // ← added
const { login, isLoading, user, isAuthReady } = useAuth();
const router = useRouter();
const [error, setError] = useState<string | null>(null);
// ✅ Redirect if user is already logged in (prevents loop & improves UX)
useEffect(() => {
if (isAuthReady && user) {
router.push('/today'); // Change to '/select-organization' if you want
router.push('/today');
}
}, [user, isAuthReady, router]);
@@ -156,34 +155,33 @@ export default function LoginPage() {
}
};
// Optional: Show loading state while checking auth
if (!isAuthReady) {
return (
<div className="min-h-screen flex items-center justify-center">
<p>Loading...</p>
<div className="min-h-screen app-web-bg flex items-center justify-center">
<p className="text-text-secondary">Loading...</p>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<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-bold text-primary-600">DyoLink</span>
<span className="text-3xl font-semibold text-text-primary">DyoLink</span>
</Link>
<h2 className="mt-6 text-center text-3xl font-exbol text-gray-900">
<h2 className="mt-6 text-center text-3xl font-semibold text-text-primary">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
<p className="mt-2 text-center text-sm text-text-secondary">
Or{' '}
<Link href="/register" className="font-medium text-primary-600 hover:text-primary-500">
<Link href="/register" className="font-medium text-primary hover:opacity-90">
start your free trial
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<div className="surface-card py-8 px-4 sm:px-10">
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
<Input
label="Email address"
@@ -191,7 +189,7 @@ export default function LoginPage() {
type="email"
placeholder="you@example.com"
error={errors.email?.message}
icon={<Mail className="h-5 w-5 text-gray-400" />}
icon={<Mail className="h-5 w-5 icon-flat" />}
/>
<Input
label="Password"
@@ -199,7 +197,7 @@ export default function LoginPage() {
type="password"
placeholder="••••••••"
error={errors.password?.message}
icon={<Lock className="h-5 w-5 text-gray-400" />}
icon={<Lock className="h-5 w-5 icon-flat" />}
/>
<div className="flex items-center justify-between">
@@ -208,14 +206,14 @@ export default function LoginPage() {
id="remember-me"
name="remember-me"
type="checkbox"
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
className="h-4 w-4 rounded border-border bg-background-secondary text-primary focus:ring-primary/40"
/>
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
<label htmlFor="remember-me" className="ml-2 block text-sm text-text-secondary">
Remember me
</label>
</div>
<div className="text-sm">
<Link href="/forgot-password" className="font-medium text-primary-600 hover:text-primary-500">
<Link href="/forgot-password" className="font-medium text-primary hover:opacity-90">
Forgot your password?
</Link>
</div>

View File

@@ -3,23 +3,24 @@
import Link from 'next/link';
import { useAuth } from '@/lib/hooks/useAuth';
import { Button } from '@/components/ui/Button';
import { ThemeToggle } from '@/components/ui/ThemeToggle';
import { Building2, Beaker, Calendar, Shield, Clock, Users } from 'lucide-react';
export default function HomePage() {
const { user } = useAuth();
return (
<div className="min-h-screen bg-background-primary">
<div className="min-h-screen app-web-bg text-text-primary">
{/* Header */}
<header className="border-b border-border bg-background-secondary/80 backdrop-blur-sm fixed top-0 w-full z-10">
<header className="border-b border-border/70 bg-background-secondary/65 backdrop-blur-sm fixed top-0 w-full z-10">
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
<div className="text-2xl font-semibold text-primary">
<div className="text-2xl font-semibold text-text-primary">
DyoLink
</div>
<div className="flex gap-3">
<div className="flex items-center gap-3">
<ThemeToggle />
{user ? (
<Link href="/today">
<Button variant="primary">Dashboard</Button>
@@ -44,7 +45,7 @@ export default function HomePage() {
<div className="max-w-4xl mx-auto text-center">
<h1 className="text-5xl md:text-6xl font-semibold text-text-primary mb-6 leading-tight">
<h1 className="text-5xl md:text-6xl font-semibold mb-6 leading-tight">
Connect Dental Clinics & Labs
<span className="text-primary"> Seamlessly</span>
</h1>
@@ -66,32 +67,32 @@ export default function HomePage() {
{/* Features */}
<div className="mt-20 grid md:grid-cols-3 gap-6">
<FeatureCard
icon={<Building2 className="h-6 w-6" />}
icon={<Building2 className="h-6 w-6 icon-flat" />}
title="For Clinics"
description="Manage patients, appointments, and send cases to labs instantly."
/>
<FeatureCard
icon={<Beaker className="h-6 w-6" />}
icon={<Beaker className="h-6 w-6 icon-flat" />}
title="For Labs"
description="Receive cases, track progress, and communicate with clinics."
/>
<FeatureCard
icon={<Users className="h-6 w-6" />}
icon={<Users className="h-6 w-6 icon-flat" />}
title="Team Management"
description="Add up to 5 team members during trial. Scale as you grow."
/>
<FeatureCard
icon={<Calendar className="h-6 w-6" />}
icon={<Calendar className="h-6 w-6 icon-flat" />}
title="30-Day Trial"
description="Full access to all features. No credit card required."
/>
<FeatureCard
icon={<Clock className="h-6 w-6" />}
icon={<Clock className="h-6 w-6 icon-flat" />}
title="Real-time Updates"
description="Get instant notifications on case status changes."
/>
<FeatureCard
icon={<Shield className="h-6 w-6" />}
icon={<Shield className="h-6 w-6 icon-flat" />}
title="Secure & Compliant"
description="HIPAA-compliant with enterprise-grade security."
/>
@@ -100,7 +101,7 @@ export default function HomePage() {
</main>
{/* Footer */}
<footer className="border-t border-border bg-background-secondary">
<footer className="border-t border-border/70 bg-background-secondary/80">
<div className="container mx-auto px-4 py-8 flex flex-col md:flex-row justify-between items-center text-sm text-text-secondary">
<div>© 2026 DyoLink. All rights reserved.</div>
@@ -130,7 +131,7 @@ function FeatureCard({
description: string;
}) {
return (
<div className="bg-background-card border border-border rounded-2xl p-5 transition-all hover:border-primary hover:shadow-[0_0_20px_rgba(0,194,255,0.15)]">
<div className="surface-card p-5 transition-all hover:border-primary/70 hover:shadow-[0_0_20px_rgba(0,194,255,0.12)]">
<div className="text-primary mb-4">
{icon}

View File

@@ -5,7 +5,6 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Building2, Mail, Lock, User, ChevronRight } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { Button } from '@/components/ui/Button';
@@ -19,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',
}),
@@ -30,7 +30,6 @@ type RegisterForm = z.infer<typeof registerSchema>;
export default function RegisterPage() {
const { registerTrial, isLoading } = useAuth();
const router = useRouter();
const [step, setStep] = useState(1);
const [error, setError] = useState<string | null>(null);
@@ -49,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) {
@@ -64,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
@@ -72,42 +72,42 @@ export default function RegisterPage() {
}
};
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<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-bold text-primary-600">DyoLink</span>
<span className="text-3xl font-semibold text-text-primary">DyoLink</span>
</Link>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
<h2 className="mt-6 text-center text-3xl font-semibold text-text-primary">
Start your 30-day free trial
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
<p className="mt-2 text-center text-sm text-text-secondary">
Already have an account?{' '}
<Link href="/login" className="font-medium text-primary-600 hover:text-primary-500">
<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="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<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-600 text-white' : 'bg-gray-200 text-gray-600'}`}>
<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-600' : 'text-gray-500'
<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-gray-400" />
<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-600 text-white' : 'bg-gray-200 text-gray-600'}`}>
<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-600' : 'text-gray-500'
<div className={`ml-2 text-sm font-medium ${step >= 2 ? 'text-primary' : 'text-text-muted'
}`}>
Organization
</div>
@@ -115,10 +115,10 @@ export default function RegisterPage() {
</div>
</div>
{/* Trial Info Banner */}
<div className="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-100">
<h3 className="text-sm font-medium text-blue-800 mb-2">Your trial
<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
includes:</h3>
<ul className="text-sm text-blue-700 space-y-1">
<ul className="text-sm text-text-secondary space-y-1">
<li className="flex items-center">
<span className="mr-2"></span> Up to 5 team members
</li>
@@ -139,7 +139,7 @@ export default function RegisterPage() {
{...register('name')}
placeholder="John Doe"
error={errors.name?.message}
icon={<User className="h-5 w-5 text-gray-400" />}
icon={<User className="h-5 w-5 icon-flat" />}
/>
<Input
label="Email address"
@@ -147,7 +147,7 @@ export default function RegisterPage() {
type="email"
placeholder="you@example.com"
error={errors.email?.message}
icon={<Mail className="h-5 w-5 text-gray-400" />}
icon={<Mail className="h-5 w-5 icon-flat" />}
/>
<Input
label="Password"
@@ -155,7 +155,7 @@ export default function RegisterPage() {
type="password"
placeholder="••••••••"
error={errors.password?.message}
icon={<Lock className="h-5 w-5 text-gray-400" />}
icon={<Lock className="h-5 w-5 icon-flat" />}
/>
<Input
label="Confirm password"
@@ -163,7 +163,7 @@ export default function RegisterPage() {
type="password"
placeholder="••••••••"
error={errors.confirmPassword?.message}
icon={<Lock className="h-5 w-5 text-gray-400" />}
icon={<Lock className="h-5 w-5 icon-flat" />}
/>
<Button
type="button"
@@ -182,10 +182,18 @@ export default function RegisterPage() {
{...register('organizationName')}
placeholder="Sunshine Dental Clinic"
error={errors.organizationName?.message}
icon={<Building2 className="h-5 w-5 text-gray-400" />}
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-gray-700 mb-2">
<label className="block text-sm font-medium text-text-secondary mb-2">
Organization type
</label>
<input type="hidden" {...register('organizationType')} />
@@ -195,11 +203,11 @@ export default function RegisterPage() {
onClick={() => {
setValue('organizationType', 'CLINIC', { shouldValidate: true });
}}
className={`p-4 border rounded-lg text-center transition-colors ${organizationType === 'CLINIC' ? 'border-primary-600 bg-primary-50 text-primary-700'
: 'border-gray-300 hover:border-gray-400'
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" />
<Building2 className="h-8 w-8 mx-auto mb-2 icon-flat" />
<span className="text-sm font-medium">Dental Clinic</span>
</button>
<button
@@ -207,12 +215,12 @@ export default function RegisterPage() {
onClick={() => {
setValue('organizationType', 'LAB', { shouldValidate: true });
}}
className={`p-4 border rounded-lg text-center transition-colors ${organizationType === 'LAB'
? 'border-primary-600 bg-primary-50 text-primary-700'
: 'border-gray-300 hover:border-gray-400'
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" />
<Building2 className="h-8 w-8 mx-auto mb-2 icon-flat" />
<span className="text-sm font-medium">Dental Lab</span>
</button>
</div>
@@ -221,7 +229,7 @@ export default function RegisterPage() {
)}
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<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>
)}
@@ -245,13 +253,13 @@ export default function RegisterPage() {
</>
)}
</form>
<p className="mt-6 text-xs text-center text-gray-500">
<p className="mt-6 text-xs text-center text-text-muted">
By signing up, you agree to our{' '}
<Link href="/terms" className="text-primary-600 hover:text-primary-500">
<Link href="/terms" className="text-primary hover:opacity-90">
Terms of Service
</Link>{' '}
and{' '}
<Link href="/privacy" className="text-primary-600 hover:text-primary-500">
<Link href="/privacy" className="text-primary hover:opacity-90">
Privacy Policy
</Link>
</p>

View File

@@ -1,81 +1,170 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
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();
const router = useRouter();
// ✅ 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'
? <Building2 className="h-8 w-8" />
: <Beaker className="h-8 w-8" />;
? <Building2 className="h-8 w-8 icon-flat" />
: <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 flex items-center justify-center">
<p className="text-text-secondary">Loading organizations...</p>
</div>
);
}
if (!organizations.length) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-text-secondary">No organizations found.</p>
<div className="min-h-screen app-web-bg flex items-center justify-center">
<p className="text-text-secondary">Loading...</p>
</div>
);
}
return (
<div className="min-h-screen bg-background-secondary 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-bold 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="bg-white p-6 rounded-xl shadow-sm border border-border hover:border-primary-300 hover:shadow-md transition-all text-left flex items-center gap-4"
>
<div className="p-3 bg-primary-50 rounded-lg text-primary-600">
{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-600 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

@@ -1,7 +1,10 @@
// src/app/layout.tsx
import type { Metadata } from 'next';
import Script from 'next/script';
import '@/styles/globals.css';
import '@/styles/background-web.css';
import { AuthProvider } from '@/lib/hooks/useAuth';
import { THEME_STORAGE_KEY } from '@/lib/theme';
export const metadata: Metadata = {
title: 'DyoLink - Dental Clinic & Lab Communication Hub',
@@ -13,9 +16,14 @@ export default function RootLayout({
}: {
children: React.ReactNode;
}) {
const themeInit = `(function(){try{var k=${JSON.stringify(THEME_STORAGE_KEY)};var t=localStorage.getItem(k);document.documentElement.setAttribute('data-theme',t==='light'||t==='dark'?t:'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();`;
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body>
<Script id="theme-init" strategy="beforeInteractive">
{themeInit}
</Script>
<AuthProvider>
{children}
</AuthProvider>

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

@@ -10,10 +10,10 @@ interface BadgeProps {
}
const variantStyles: Record<BadgeVariant, string> = {
success: 'bg-green-50 text-green-700 border-green-200',
warning: 'bg-yellow-50 text-yellow-700 border-yellow-200',
danger: 'bg-red-50 text-red-700 border-red-200',
default: 'bg-gray-50 text-gray-700 border-gray-200',
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',
};
export function Badge({

View File

@@ -24,24 +24,24 @@ export const Button: React.FC<ButtonProps> = ({
...props
}) => {
const baseClasses =
'inline-flex items-center justify-center rounded-lg font-medium transition-all duration-200 ' +
'focus:outline-none focus:ring-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed';
'inline-flex items-center justify-center rounded-[var(--radius-md)] font-medium transition-all duration-200 ' +
'focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:opacity-50 disabled:cursor-not-allowed';
const variantClasses: Record<ButtonVariant, string> = {
primary:
'bg-primary text-black hover:opacity-90',
'bg-primary text-primary-contrast hover:brightness-105 shadow-[0_0_0_1px_var(--color-primary-soft)]',
secondary:
'bg-background-secondary text-text-primary hover:bg-background-card',
'bg-surface-elevated text-text-primary border border-border hover:border-border-strong',
outline:
'border border-border text-text-primary hover:bg-background-card',
'border border-border text-text-primary hover:bg-background-card/70',
danger:
'bg-red-600 text-white hover:bg-red-700',
ghost:
'text-text-secondary hover:bg-background-card',
'text-text-secondary hover:text-text-primary hover:bg-background-card/70',
};
const sizeClasses: Record<ButtonSize, string> = {

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

@@ -25,7 +25,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
<div className="relative">
{icon && (
<div className="absolute inset-y-0 left-0 pl-3 flex items-center text-text-secondary pointer-events-none">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center text-text-muted pointer-events-none">
{icon}
</div>
)}
@@ -34,19 +34,19 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
ref={ref}
id={inputId}
className={`
w-full rounded-lg border
w-full rounded-[var(--radius-md)] border
${error ? 'border-red-500' : 'border-border'}
bg-background-secondary text-text-primary
bg-background-secondary/90 text-text-primary
${icon ? 'pl-10' : 'pl-4'} pr-4 py-2
placeholder:text-text-secondary
placeholder:text-text-muted
focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent
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
transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]
${className}
`}

View File

@@ -18,21 +18,21 @@ export const OrganizationCard: React.FC<OrganizationCardProps> = ({
return (
<button
onClick={() => onSelect(organization.id)}
className="w-full bg-white p-6 rounded-xl shadow-sm border border-gray-200 hover:border-primary-300 hover:shadow-md transition-all text-left flex items-center gap-4 group"
className="w-full surface-card p-6 hover:border-primary/60 transition-all text-left flex items-center gap-4 group"
>
<div className="p-3 bg-primary-50 rounded-lg text-primary-600">
<Icon className="h-8 w-8" />
<div className="p-3 bg-primary-soft rounded-[var(--radius-sm)] text-primary">
<Icon className="h-8 w-8 icon-flat" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">{organization.name}</h3>
<p className="text-sm text-gray-500">{typeText}</p>
<h3 className="text-lg font-semibold text-text-primary">{organization.name}</h3>
<p className="text-sm text-text-secondary">{typeText}</p>
{organization.plan && (
<p className="text-xs text-gray-400 mt-1">
<p className="text-xs text-text-muted mt-1">
Plan: {organization.plan.name} {organization.plan.maxUsers} users
</p>
)}
</div>
<ChevronRight className="h-5 w-5 text-gray-400 group-hover:text-primary-600 transition-colors" />
<ChevronRight className="h-5 w-5 icon-flat text-text-muted group-hover:text-primary transition-colors" />
</button>
);
};

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,37 +23,40 @@ 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-[#071a2f] text-white flex flex-col p-4">
<div className="mb-8">
<h1 className="text-xl font-bold">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)}
className={`flex items-center gap-3 p-3 rounded-lg transition ${
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-600'
: 'hover:bg-white/10'
? 'bg-primary-soft border-primary/60 text-text-primary'
: 'border-border/50 text-text-secondary hover:bg-background-card/70 hover:text-text-primary hover:border-border'
}`}
>
<Icon className="w-5 h-5" />
<span>{item.name}</span>
</button>
<Icon className="w-[18px] h-[18px] icon-flat" />
<span className="text-sm">{item.name}</span>
</Link>
);
})}
</nav>
</aside>
);
}
}
export default memo(Sidebar);

View File

@@ -0,0 +1,47 @@
'use client';
import { useEffect, useState } from 'react';
import { Moon, Sun } from 'lucide-react';
import { applyTheme, getStoredTheme, type ThemeMode } from '@/lib/theme';
export function ThemeToggle() {
const [mode, setMode] = useState<ThemeMode | null>(null);
useEffect(() => {
setMode(getStoredTheme());
}, []);
const handleClick = () => {
const current = getStoredTheme();
const next: ThemeMode = current === 'dark' ? 'light' : 'dark';
applyTheme(next);
setMode(next);
};
if (mode === null) {
return (
<span
className="inline-flex h-9 w-9 shrink-0 rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/80"
aria-hidden
/>
);
}
const isDark = mode === 'dark';
return (
<button
type="button"
onClick={handleClick}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/80 text-text-primary hover:border-border-strong hover:bg-background-card/80 transition-colors"
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
title={isDark ? 'Light mode' : 'Dark mode'}
>
{isDark ? (
<Sun className="h-[18px] w-[18px] icon-flat" />
) : (
<Moon className="h-[18px] w-[18px] icon-flat" />
)}
</button>
);
}

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,26 +44,35 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
useEffect(() => {
checkAuth();
const normalizeProfilePayload = useCallback((payload: any): { user: User | null; organizations: Organization[] } => {
const organizations = payload?.organizations || [];
if (payload?.user) {
return { user: payload.user as User, organizations };
}
if (payload?.id && payload?.email && payload?.name) {
return {
user: {
id: payload.id,
email: payload.email,
name: payload.name,
},
organizations,
};
}
return { user: null, organizations };
}, []);
const checkAuth = async () => {
const checkAuth = useCallback(async () => {
try {
setIsLoading(true);
const hasSession = document.cookie.includes('accessToken');
if (!hasSession) {
console.log('No session → skipping auth check');
return;
}
const response = await authApi.getProfile();
if (response.success) {
const userData = response.data.user;
const orgs = response.data.organizations || [];
const { user: userData, organizations: orgs } = normalizeProfilePayload(response.data);
setUser(userData);
setOrganizations(orgs);
@@ -64,10 +80,20 @@ 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 if (orgs.length === 1) {
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);
}
}
} catch (err) {
@@ -80,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 {
@@ -99,6 +130,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
password,
name,
organizationName,
organizationEmail,
organizationType,
});
@@ -109,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');
@@ -123,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);
@@ -140,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');
@@ -153,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);
@@ -173,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');
@@ -183,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>
);

30
frontend/src/lib/theme.ts Normal file
View File

@@ -0,0 +1,30 @@
export const THEME_STORAGE_KEY = 'dyolink-theme';
export type ThemeMode = 'light' | 'dark';
export function getStoredTheme(): ThemeMode {
if (typeof window === 'undefined') return 'dark';
try {
const v = localStorage.getItem(THEME_STORAGE_KEY);
if (v === 'light' || v === 'dark') return v;
} catch {
/* ignore */
}
return 'dark';
}
export function applyTheme(mode: ThemeMode) {
if (typeof document === 'undefined') return;
document.documentElement.setAttribute('data-theme', mode);
try {
localStorage.setItem(THEME_STORAGE_KEY, mode);
} catch {
/* ignore */
}
}
export function toggleTheme(): ThemeMode {
const next: ThemeMode = getStoredTheme() === 'dark' ? 'light' : 'dark';
applyTheme(next);
return next;
}

View File

@@ -3,19 +3,21 @@ 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;
const token = request.cookies.get('accessToken')?.value;
const isAuthenticated = !!token;
// Always allow public routes first
// If a logged-in user opens home, send them to dashboard.
if (isAuthenticated && pathname === '/') {
return NextResponse.redirect(new URL('/today', request.url));
}
// 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

@@ -0,0 +1,59 @@
.app-web-bg {
position: relative;
isolation: isolate;
background-color: var(--color-background-primary);
overflow: hidden;
}
.app-web-bg::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
opacity: calc(var(--web-opacity) * 0.9);
background: radial-gradient(circle at 84% 73%, rgba(12, 126, 196, 0.14), transparent 44%);
}
.app-web-bg::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
opacity: 0.96;
background:
/* crisp bright edges (zoomed-out composition) */
linear-gradient(150deg, transparent 77.35%, var(--web-line-strong) 77.48%, var(--web-line-strong) 77.56%, transparent 77.69%),
linear-gradient(122deg, transparent 69.65%, var(--web-line-strong) 69.78%, var(--web-line-strong) 69.86%, transparent 69.99%),
linear-gradient(31deg, transparent 77.75%, var(--web-line-strong) 77.88%, var(--web-line-strong) 77.96%, transparent 78.09%),
linear-gradient(8deg, transparent 80.95%, var(--web-line-strong) 81.08%, var(--web-line-strong) 81.16%, transparent 81.29%),
linear-gradient(171deg, transparent 86.75%, var(--web-line-strong) 86.88%, var(--web-line-strong) 86.96%, transparent 87.09%),
linear-gradient(39deg, transparent 88.45%, var(--web-line-strong) 88.58%, var(--web-line-strong) 88.66%, transparent 88.79%),
/* secondary mesh lines */
linear-gradient(145deg, transparent 75.65%, var(--web-line) 75.77%, var(--web-line) 75.84%, transparent 75.96%),
linear-gradient(18deg, transparent 83.15%, var(--web-line) 83.27%, var(--web-line) 83.34%, transparent 83.46%),
linear-gradient(58deg, transparent 89.25%, var(--web-line) 89.37%, var(--web-line) 89.44%, transparent 89.56%),
linear-gradient(112deg, transparent 81.85%, var(--web-line) 81.97%, var(--web-line) 82.04%, transparent 82.16%),
linear-gradient(176deg, transparent 92.25%, var(--web-line) 92.37%, var(--web-line) 92.44%, transparent 92.56%),
/* compact nodes (less blur) */
radial-gradient(circle at 84% 73%, var(--web-glow) 0 0.12rem, transparent 0.34rem),
radial-gradient(circle at 76.5% 81.5%, var(--web-glow) 0 0.11rem, transparent 0.32rem),
radial-gradient(circle at 92.5% 86.2%, var(--web-glow) 0 0.09rem, transparent 0.28rem),
radial-gradient(circle at 86.2% 66.8%, var(--web-glow) 0 0.09rem, transparent 0.28rem),
radial-gradient(circle at 97.2% 58.6%, var(--web-glow) 0 0.08rem, transparent 0.26rem),
radial-gradient(circle at 73.8% 93.1%, var(--web-glow) 0 0.09rem, transparent 0.28rem),
radial-gradient(circle at 88.4% 96.1%, var(--web-glow) 0 0.08rem, transparent 0.26rem);
background-repeat: no-repeat;
background-size: 112% 112%;
background-position: right -4% bottom -4%;
mask-image: linear-gradient(to right, transparent 0 52%, rgba(0, 0, 0, 0.58) 64%, black 76%);
}
@media (max-width: 900px) {
.app-web-bg::after {
background-size: 126% 126%;
background-position: right -14% bottom -6%;
mask-image: linear-gradient(to right, transparent 0 34%, rgba(0, 0, 0, 0.56) 50%, black 66%);
}
}

View File

@@ -1,40 +1,140 @@
@import "tailwindcss";
/* 🎨 Design Tokens (CSS-based, no JS dependency) */
:root {
--color-background-primary: #0B1A2B;
--color-background-secondary: #0F2236;
--color-background-card: #132A42;
/* Light theme tokens (future-ready) */
:root[data-theme="light"] {
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--color-text-primary: #FFFFFF;
--color-text-secondary: #A0AEC0;
--color-background-primary: #f6f9fc;
--color-background-secondary: #ffffff;
--color-background-card: #ffffff;
--color-surface-elevated: #f8fbff;
--color-border: #1F3A5F;
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-text-muted: #64748b;
--color-primary: #009CAE;
--color-border: #d2dcec;
--color-border-strong: #aec0de;
--color-primary: #009cae;
--color-primary-contrast: #031014;
--color-primary-soft: rgba(0, 156, 174, 0.16);
--color-icon: #e1bc72;
--web-opacity: 0.12;
--web-line: rgba(0, 188, 255, 0.32);
--web-glow: rgba(0, 188, 255, 0.42);
}
/* Dark theme tokens */
:root[data-theme="dark"] {
--color-background-primary: #000c1c;
--color-background-secondary: #0b1a2b;
--color-background-card: #14253d;
--color-surface-elevated: #1b2f4e;
--color-text-primary: #f5f9ff;
--color-text-secondary: #b6c6dd;
--color-text-muted: #8ea3bf;
--color-border: #29456a;
--color-border-strong: #3b5f8f;
--color-primary: #09a9bc;
--color-primary-contrast: #001117;
--color-primary-soft: rgba(9, 169, 188, 0.2);
--color-icon: #e1bc72;
}
/* Default theme = dark */
:root {
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--color-background-primary: #000c1c;
--color-background-secondary: #0a1520;
--color-background-card: #14253d;
--color-surface-elevated: #1b2f4e;
--color-text-primary: #f5f9ff;
--color-text-secondary: #b6c6dd;
--color-text-muted: #8ea3bf;
--color-border: #29456a;
--color-border-strong: #3b5f8f;
--color-primary: #09a9bc;
--color-primary-contrast: #001117;
--color-primary-soft: rgba(9, 169, 188, 0.2);
--color-icon: #f3bb4b;
--web-opacity: 0.14;
--web-line: rgba(59, 153, 220, 0.34);
--web-line-strong: rgba(73, 214, 255, 0.78);
--web-glow: rgba(48, 204, 255, 0.55);
}
/* Tailwind theme mapping */
@theme inline {
--color-background-primary: var(--color-background-primary);
--color-background-secondary: var(--color-background-secondary);
--color-background-card: var(--color-background-card);
--color-surface-elevated: var(--color-surface-elevated);
--color-text-primary: var(--color-text-primary);
--color-text-secondary: var(--color-text-secondary);
--color-text-muted: var(--color-text-muted);
--color-border: var(--color-border);
--color-border-strong: var(--color-border-strong);
--color-primary: var(--color-primary);
--color-primary-contrast: var(--color-primary-contrast);
--color-primary-soft: var(--color-primary-soft);
}
/* Base styles */
html, body {
html,
body {
padding: 0;
margin: 0;
}
html {
color-scheme: dark;
}
html[data-theme='light'] {
color-scheme: light;
}
body {
background-color: #f9fafb; /* light gray */
color: #111827; /* dark text */
background-color: var(--color-background-primary);
color: var(--color-text-primary);
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}
.surface-card {
background: color-mix(in srgb, var(--color-background-card) 92%, transparent);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.surface-panel {
background: color-mix(in srgb, var(--color-background-secondary) 96%, transparent);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.icon-flat {
stroke-width: 1.9;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Global icon color override (easy to revert) */
.lucide {
color: var(--color-icon);
stroke: currentColor;
}

View File

@@ -1,15 +0,0 @@
export const colors = {
primary: {
DEFAULT: '#00C2FF',
},
background: {
primary: '#0B1A2B',
secondary: '#0F2236',
card: '#132A42',
},
text: {
primary: '#FFFFFF',
secondary: '#A0AEC0',
},
border: '#1F3A5F',
};

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;
};
};
}

View File

@@ -1,19 +0,0 @@
//import type { Config } from 'tailwindcss';
//import { colors } from './src/styles/tokens';
//const config: Config = {
// content: [
// './src/**/*.{js,ts,jsx,tsx}',
// ],
// theme: {
// extend: {
// colors,
// borderRadius: {
// xl: '12px',
// '2xl': '16px',
// },
// },
// },
//};
//export default config;

View File

@@ -35,7 +35,7 @@
"src/**/*.mts", // 👈 CHANGED: Looks in src folder
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
, "tailwind.config.ts" ],
],
"exclude": [
"node_modules"
]