Compare commits

...

5 Commits

40 changed files with 1381 additions and 229 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

@@ -55,6 +55,7 @@ model Organization {
sharedWithMe OrganizationLink[] @relation("OrganizationB") sharedWithMe OrganizationLink[] @relation("OrganizationB")
sharedWithOthers OrganizationLink[] @relation("OrganizationA") sharedWithOthers OrganizationLink[] @relation("OrganizationA")
patients Patient[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -62,6 +63,45 @@ model Organization {
@@map("organizations") @@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 { model Plan {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise" name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"

View File

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

View File

@@ -31,6 +31,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
} }
const { passwordHash, ...result } = user; 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

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

View File

@@ -4,18 +4,13 @@ import { useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import Sidebar from '@/components/ui/Sidebar'; import Sidebar from '@/components/ui/Sidebar';
import { ThemeToggle } from '@/components/ui/ThemeToggle';
import { LogOut } from 'lucide-react'; import { LogOut } from 'lucide-react';
export default function DashboardLayout({ children }: { children: React.ReactNode }) { export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, currentOrganization, isAuthReady, logout } = useAuth(); const { user, currentOrganization, isAuthReady, logout } = useAuth();
const router = useRouter(); const router = useRouter();
console.log('LAYOUT STATE:', {
user,
currentOrganization,
isAuthReady
});
// ✅ AUTH GUARD (runs once per navigation group) // ✅ AUTH GUARD (runs once per navigation group)
useEffect(() => { useEffect(() => {
if (!isAuthReady) return; if (!isAuthReady) return;
@@ -34,7 +29,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
// ✅ LOADING ONLY FOR INITIAL LOAD // ✅ LOADING ONLY FOR INITIAL LOAD
if (!isAuthReady) { if (!isAuthReady) {
return ( return (
<div className="h-screen flex items-center justify-center"> <div className="h-screen flex items-center justify-center app-web-bg">
Loading app... Loading app...
</div> </div>
); );
@@ -42,30 +37,37 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
if (!user || !currentOrganization) { if (!user || !currentOrganization) {
return ( return (
<div className="h-screen flex items-center justify-center"> <div className="h-screen flex items-center justify-center app-web-bg">
Loading workspace... Loading workspace...
</div> </div>
); );
} }
return ( return (
<div className="flex h-screen bg-[#020d1a] text-white"> <div className="flex h-screen app-web-bg text-text-primary">
<Sidebar /> <Sidebar />
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<header className="flex justify-between px-6 py-4 border-b border-white/10"> <header className="flex justify-between px-6 py-4 border-b border-border/70 backdrop-blur-sm">
<h2>{currentOrganization.name}</h2> <h2 className="text-lg font-medium">{currentOrganization.name}</h2>
<div className="flex gap-4"> <div className="flex items-center gap-4">
<span>{user.name}</span> <ThemeToggle />
<button onClick={logout}> <span className="text-sm text-text-secondary">{user.name}</span>
<LogOut /> <button
onClick={logout}
className="inline-flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
<LogOut className="w-4 h-4 icon-flat" />
Logout
</button> </button>
</div> </div>
</header> </header>
<main className="p-6 flex-1 overflow-y-auto"> <main className="p-6 flex-1 overflow-y-auto">
{children} {/* 🔥 THIS CHANGES */} <div className="surface-panel p-6 min-h-full">
{children}
</div>
</main> </main>
</div> </div>
</div> </div>

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

@@ -1,17 +1,15 @@
export default function TodayPage() { export default function TodayPage() {
return ( return (
<div> <div>
<h1 className="text-2xl font-bold mb-6"> <h1 className="text-2xl font-semibold mb-6">
Welcome back Babak !! Welcome back Babak !!
</h1> </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="Today's Appointments" value="12" sub="Monday 2/5/2026" />
<Card title="Active Patients" value="675" /> <Card title="Active Patients" value="675" />
<Card title="New Lab Case" value="5" sub="35 ↑" /> <Card title="New Lab Case" value="5" sub="35 ↑" />
<Card title="Today invoices" value="1200$" sub="21,300 $" /> <Card title="Today invoices" value="1200$" sub="21,300 $" />
</div> </div>
</div> </div>
); );
@@ -27,10 +25,10 @@ function Card({
sub?: string; sub?: string;
}) { }) {
return ( return (
<div className="bg-white/5 border border-white/10 p-4 rounded-xl"> <div className="surface-card p-4">
<p className="text-sm text-gray-300">{title}</p> <p className="text-sm text-text-secondary">{title}</p>
<p className="text-2xl font-bold mt-2">{value}</p> <p className="text-2xl font-semibold mt-2">{value}</p>
{sub && <p className="text-xs text-gray-400 mt-1">{sub}</p>} {sub && <p className="text-xs text-text-muted mt-1">{sub}</p>}
</div> </div>
); );
} }

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod'; import * as z from 'zod';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Building2, Mail, Lock, User, ChevronRight } from 'lucide-react'; import { Building2, Mail, Lock, User, ChevronRight } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -30,7 +29,6 @@ type RegisterForm = z.infer<typeof registerSchema>;
export default function RegisterPage() { export default function RegisterPage() {
const { registerTrial, isLoading } = useAuth(); const { registerTrial, isLoading } = useAuth();
const router = useRouter();
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -72,42 +70,42 @@ export default function RegisterPage() {
} }
}; };
return ( 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"> <div className="sm:mx-auto sm:w-full sm:max-w-md">
<Link href="/" className="flex justify-center"> <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> </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 Start your 30-day free trial
</h2> </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?{' '} 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 Sign in
</Link> </Link>
</p> </p>
</div> </div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> <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 */} {/* Progress Steps */}
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center"> <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 1
</div> </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 Account
</div> </div>
</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="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 2
</div> </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 Organization
</div> </div>
@@ -115,10 +113,10 @@ export default function RegisterPage() {
</div> </div>
</div> </div>
{/* Trial Info Banner */} {/* Trial Info Banner */}
<div className="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-100"> <div className="mb-6 p-4 bg-primary-soft rounded-[var(--radius-md)] border border-primary/35">
<h3 className="text-sm font-medium text-blue-800 mb-2">Your trial <h3 className="text-sm font-medium text-text-primary mb-2">Your trial
includes:</h3> 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"> <li className="flex items-center">
<span className="mr-2"></span> Up to 5 team members <span className="mr-2"></span> Up to 5 team members
</li> </li>
@@ -139,7 +137,7 @@ export default function RegisterPage() {
{...register('name')} {...register('name')}
placeholder="John Doe" placeholder="John Doe"
error={errors.name?.message} error={errors.name?.message}
icon={<User className="h-5 w-5 text-gray-400" />} icon={<User className="h-5 w-5 icon-flat" />}
/> />
<Input <Input
label="Email address" label="Email address"
@@ -147,7 +145,7 @@ export default function RegisterPage() {
type="email" type="email"
placeholder="you@example.com" placeholder="you@example.com"
error={errors.email?.message} error={errors.email?.message}
icon={<Mail className="h-5 w-5 text-gray-400" />} icon={<Mail className="h-5 w-5 icon-flat" />}
/> />
<Input <Input
label="Password" label="Password"
@@ -155,7 +153,7 @@ export default function RegisterPage() {
type="password" type="password"
placeholder="••••••••" placeholder="••••••••"
error={errors.password?.message} error={errors.password?.message}
icon={<Lock className="h-5 w-5 text-gray-400" />} icon={<Lock className="h-5 w-5 icon-flat" />}
/> />
<Input <Input
label="Confirm password" label="Confirm password"
@@ -163,7 +161,7 @@ export default function RegisterPage() {
type="password" type="password"
placeholder="••••••••" placeholder="••••••••"
error={errors.confirmPassword?.message} error={errors.confirmPassword?.message}
icon={<Lock className="h-5 w-5 text-gray-400" />} icon={<Lock className="h-5 w-5 icon-flat" />}
/> />
<Button <Button
type="button" type="button"
@@ -182,10 +180,10 @@ export default function RegisterPage() {
{...register('organizationName')} {...register('organizationName')}
placeholder="Sunshine Dental Clinic" placeholder="Sunshine Dental Clinic"
error={errors.organizationName?.message} error={errors.organizationName?.message}
icon={<Building2 className="h-5 w-5 text-gray-400" />} icon={<Building2 className="h-5 w-5 icon-flat" />}
/> />
<div> <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 Organization type
</label> </label>
<input type="hidden" {...register('organizationType')} /> <input type="hidden" {...register('organizationType')} />
@@ -195,11 +193,11 @@ export default function RegisterPage() {
onClick={() => { onClick={() => {
setValue('organizationType', 'CLINIC', { shouldValidate: true }); 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' className={`p-4 border rounded-[var(--radius-md)] text-center transition-colors ${organizationType === 'CLINIC' ? 'border-primary/60 bg-primary-soft text-text-primary'
: 'border-gray-300 hover:border-gray-400' : '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> <span className="text-sm font-medium">Dental Clinic</span>
</button> </button>
<button <button
@@ -207,12 +205,12 @@ export default function RegisterPage() {
onClick={() => { onClick={() => {
setValue('organizationType', 'LAB', { shouldValidate: true }); setValue('organizationType', 'LAB', { shouldValidate: true });
}} }}
className={`p-4 border rounded-lg text-center transition-colors ${organizationType === 'LAB' className={`p-4 border rounded-[var(--radius-md)] text-center transition-colors ${organizationType === 'LAB'
? 'border-primary-600 bg-primary-50 text-primary-700' ? 'border-primary/60 bg-primary-soft text-text-primary'
: 'border-gray-300 hover:border-gray-400' : '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> <span className="text-sm font-medium">Dental Lab</span>
</button> </button>
</div> </div>
@@ -221,7 +219,7 @@ export default function RegisterPage() {
)} )}
</div> </div>
{error && ( {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> <p className="text-sm text-red-600">{error}</p>
</div> </div>
)} )}
@@ -245,13 +243,13 @@ export default function RegisterPage() {
</> </>
)} )}
</form> </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{' '} 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 Terms of Service
</Link>{' '} </Link>{' '}
and{' '} and{' '}
<Link href="/privacy" className="text-primary-600 hover:text-primary-500"> <Link href="/privacy" className="text-primary hover:opacity-90">
Privacy Policy Privacy Policy
</Link> </Link>
</p> </p>

View File

@@ -1,13 +1,11 @@
'use client'; 'use client';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { Building2, Beaker } from 'lucide-react'; import { Building2, Beaker } from 'lucide-react';
export default function SelectOrganizationPage() { export default function SelectOrganizationPage() {
const { organizations, selectOrganization, isLoading } = useAuth(); const { organizations, selectOrganization, isLoading } = useAuth();
const router = useRouter();
// ✅ Auto-redirect if only one organization // ✅ Auto-redirect if only one organization
useEffect(() => { useEffect(() => {
@@ -18,13 +16,13 @@ export default function SelectOrganizationPage() {
const getIcon = (type: string) => { const getIcon = (type: string) => {
return type === 'CLINIC' return type === 'CLINIC'
? <Building2 className="h-8 w-8" /> ? <Building2 className="h-8 w-8 icon-flat" />
: <Beaker className="h-8 w-8" />; : <Beaker className="h-8 w-8 icon-flat" />;
}; };
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen app-web-bg flex items-center justify-center">
<p className="text-text-secondary">Loading organizations...</p> <p className="text-text-secondary">Loading organizations...</p>
</div> </div>
); );
@@ -32,17 +30,17 @@ export default function SelectOrganizationPage() {
if (!organizations.length) { if (!organizations.length) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen app-web-bg flex items-center justify-center">
<p className="text-text-secondary">No organizations found.</p> <p className="text-text-secondary">No organizations found.</p>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen bg-background-secondary flex items-center justify-center p-4"> <div className="min-h-screen app-web-bg flex items-center justify-center p-4">
<div className="max-w-2xl w-full"> <div className="max-w-2xl w-full">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-3xl font-bold text-text-primary"> <h1 className="text-3xl font-semibold text-text-primary">
Choose Organization Choose Organization
</h1> </h1>
<p className="text-text-secondary mt-2"> <p className="text-text-secondary mt-2">
@@ -55,9 +53,9 @@ export default function SelectOrganizationPage() {
<button <button
key={org.id} key={org.id}
onClick={() => selectOrganization(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" className="surface-card p-6 transition-all text-left flex items-center gap-4 hover:border-primary/60"
> >
<div className="p-3 bg-primary-50 rounded-lg text-primary-600"> <div className="p-3 bg-primary-soft rounded-[var(--radius-sm)] text-primary">
{getIcon(org.type)} {getIcon(org.type)}
</div> </div>
@@ -70,7 +68,7 @@ export default function SelectOrganizationPage() {
</p> </p>
</div> </div>
<div className="text-primary-600 text-sm"> <div className="text-primary text-sm">
Continue Continue
</div> </div>
</button> </button>

View File

@@ -1,7 +1,10 @@
// src/app/layout.tsx // src/app/layout.tsx
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Script from 'next/script';
import '@/styles/globals.css'; import '@/styles/globals.css';
import '@/styles/background-web.css';
import { AuthProvider } from '@/lib/hooks/useAuth'; import { AuthProvider } from '@/lib/hooks/useAuth';
import { THEME_STORAGE_KEY } from '@/lib/theme';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'DyoLink - Dental Clinic & Lab Communication Hub', title: 'DyoLink - Dental Clinic & Lab Communication Hub',
@@ -13,9 +16,14 @@ export default function RootLayout({
}: { }: {
children: React.ReactNode; 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 ( return (
<html lang="en"> <html lang="en" suppressHydrationWarning>
<body> <body>
<Script id="theme-init" strategy="beforeInteractive">
{themeInit}
</Script>
<AuthProvider> <AuthProvider>
{children} {children}
</AuthProvider> </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> = { const variantStyles: Record<BadgeVariant, string> = {
success: 'bg-green-50 text-green-700 border-green-200', success: 'bg-emerald-900/30 text-emerald-300 border-emerald-700/60',
warning: 'bg-yellow-50 text-yellow-700 border-yellow-200', warning: 'bg-amber-900/30 text-amber-300 border-amber-700/60',
danger: 'bg-red-50 text-red-700 border-red-200', danger: 'bg-red-950/30 text-red-300 border-red-700/60',
default: 'bg-gray-50 text-gray-700 border-gray-200', default: 'bg-background-secondary text-text-secondary border-border',
}; };
export function Badge({ export function Badge({

View File

@@ -24,24 +24,24 @@ export const Button: React.FC<ButtonProps> = ({
...props ...props
}) => { }) => {
const baseClasses = const baseClasses =
'inline-flex items-center justify-center rounded-lg font-medium transition-all duration-200 ' + '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 disabled:opacity-50 disabled:cursor-not-allowed'; 'focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:opacity-50 disabled:cursor-not-allowed';
const variantClasses: Record<ButtonVariant, string> = { const variantClasses: Record<ButtonVariant, string> = {
primary: 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: 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: outline:
'border border-border text-text-primary hover:bg-background-card', 'border border-border text-text-primary hover:bg-background-card/70',
danger: danger:
'bg-red-600 text-white hover:bg-red-700', 'bg-red-600 text-white hover:bg-red-700',
ghost: 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> = { const sizeClasses: Record<ButtonSize, string> = {

View File

@@ -25,7 +25,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
<div className="relative"> <div className="relative">
{icon && ( {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} {icon}
</div> </div>
)} )}
@@ -34,19 +34,19 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
ref={ref} ref={ref}
id={inputId} id={inputId}
className={` className={`
w-full rounded-lg border w-full rounded-[var(--radius-md)] border
${error ? 'border-red-500' : 'border-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 ${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 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} ${className}
`} `}

View File

@@ -18,21 +18,21 @@ export const OrganizationCard: React.FC<OrganizationCardProps> = ({
return ( return (
<button <button
onClick={() => onSelect(organization.id)} 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"> <div className="p-3 bg-primary-soft rounded-[var(--radius-sm)] text-primary">
<Icon className="h-8 w-8" /> <Icon className="h-8 w-8 icon-flat" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">{organization.name}</h3> <h3 className="text-lg font-semibold text-text-primary">{organization.name}</h3>
<p className="text-sm text-gray-500">{typeText}</p> <p className="text-sm text-text-secondary">{typeText}</p>
{organization.plan && ( {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 Plan: {organization.plan.name} {organization.plan.maxUsers} users
</p> </p>
)} )}
</div> </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> </button>
); );
}; };

View File

@@ -26,9 +26,9 @@ export default function Sidebar() {
const router = useRouter(); const router = useRouter();
return ( return (
<aside className="w-64 bg-[#071a2f] text-white flex flex-col p-4"> <aside className="w-64 bg-background-secondary/90 border-r border-border text-text-primary flex flex-col p-4">
<div className="mb-8"> <div className="mb-6 pb-4 border-b border-border">
<h1 className="text-xl font-bold">DyoLink</h1> <h1 className="text-xl font-semibold tracking-tight">DyoLink</h1>
</div> </div>
<nav className="flex flex-col gap-2"> <nav className="flex flex-col gap-2">
@@ -40,14 +40,14 @@ export default function Sidebar() {
<button <button
key={item.name} key={item.name}
onClick={() => router.push(item.path)} onClick={() => router.push(item.path)}
className={`flex items-center gap-3 p-3 rounded-lg transition ${ className={`flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-sm)] border transition-colors ${
isActive isActive
? 'bg-primary-600' ? 'bg-primary-soft border-primary/60 text-text-primary'
: 'hover:bg-white/10' : 'border-border/50 text-text-secondary hover:bg-background-card/70 hover:text-text-primary hover:border-border'
}`} }`}
> >
<Icon className="w-5 h-5" /> <Icon className="w-[18px] h-[18px] icon-flat" />
<span>{item.name}</span> <span className="text-sm">{item.name}</span>
</button> </button>
); );
})} })}

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

@@ -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

@@ -41,22 +41,35 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
checkAuth(); checkAuth();
}, []); }, []);
const normalizeProfilePayload = (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 = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const hasSession = document.cookie.includes('accessToken');
if (!hasSession) {
console.log('No session → skipping auth check');
return;
}
const response = await authApi.getProfile(); const response = await authApi.getProfile();
if (response.success) { if (response.success) {
const userData = response.data.user; const { user: userData, organizations: orgs } = normalizeProfilePayload(response.data);
const orgs = response.data.organizations || [];
setUser(userData); setUser(userData);
setOrganizations(orgs); setOrganizations(orgs);
@@ -64,10 +77,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const storedOrgId = localStorage.getItem('currentOrganizationId'); const storedOrgId = localStorage.getItem('currentOrganizationId');
if (storedOrgId && orgs.length > 0) { if (storedOrgId && orgs.length > 0) {
const org = orgs.find(o => o.id === storedOrgId); const org = orgs.find(o => o.id === storedOrgId);
if (org) setCurrentOrganization(org); if (org) {
} else if (orgs.length === 1) { 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]); setCurrentOrganization(orgs[0]);
localStorage.setItem('currentOrganizationId', orgs[0].id); 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) { } catch (err) {
@@ -109,6 +132,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (orgs.length === 1) { if (orgs.length === 1) {
const org = orgs[0]; const org = orgs[0];
await authApi.selectOrganization(org.id);
setCurrentOrganization(org); setCurrentOrganization(org);
localStorage.setItem('currentOrganizationId', org.id); localStorage.setItem('currentOrganizationId', org.id);
router.push('/today'); router.push('/today');
@@ -140,6 +164,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (orgs.length === 1) { if (orgs.length === 1) {
const org = orgs[0]; const org = orgs[0];
await authApi.selectOrganization(org.id);
setCurrentOrganization(org); setCurrentOrganization(org);
localStorage.setItem('currentOrganizationId', org.id); localStorage.setItem('currentOrganizationId', org.id);
router.push('/today'); router.push('/today');

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

@@ -10,6 +10,11 @@ export function middleware(request: NextRequest) {
const token = request.cookies.get('accessToken')?.value; const token = request.cookies.get('accessToken')?.value;
const isAuthenticated = !!token; const isAuthenticated = !!token;
// 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 // Always allow public routes first
if (publicRoutes.includes(pathname)) { if (publicRoutes.includes(pathname)) {
// If user is already logged in and tries to access login/register → redirect to dashboard // If user is already logged in and tries to access login/register → redirect to dashboard

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"; @import "tailwindcss";
/* 🎨 Design Tokens (CSS-based, no JS dependency) */ /* Light theme tokens (future-ready) */
:root { :root[data-theme="light"] {
--color-background-primary: #0B1A2B; --radius-sm: 6px;
--color-background-secondary: #0F2236; --radius-md: 8px;
--color-background-card: #132A42; --radius-lg: 12px;
--color-text-primary: #FFFFFF; --color-background-primary: #f6f9fc;
--color-text-secondary: #A0AEC0; --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: #f3bb4b;
}
/* 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 { @theme inline {
--color-background-primary: var(--color-background-primary); --color-background-primary: var(--color-background-primary);
--color-background-secondary: var(--color-background-secondary); --color-background-secondary: var(--color-background-secondary);
--color-background-card: var(--color-background-card); --color-background-card: var(--color-background-card);
--color-surface-elevated: var(--color-surface-elevated);
--color-text-primary: var(--color-text-primary); --color-text-primary: var(--color-text-primary);
--color-text-secondary: var(--color-text-secondary); --color-text-secondary: var(--color-text-secondary);
--color-text-muted: var(--color-text-muted);
--color-border: var(--color-border); --color-border: var(--color-border);
--color-border-strong: var(--color-border-strong);
--color-primary: var(--color-primary); --color-primary: var(--color-primary);
--color-primary-contrast: var(--color-primary-contrast);
--color-primary-soft: var(--color-primary-soft);
} }
/* Base styles */ html,
html, body { body {
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
html {
color-scheme: dark;
}
html[data-theme='light'] {
color-scheme: light;
}
body { body {
background-color: #f9fafb; /* light gray */ background-color: var(--color-background-primary);
color: #111827; /* dark text */ color: var(--color-text-primary);
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; 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

@@ -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 "src/**/*.mts", // 👈 CHANGED: Looks in src folder
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts"
, "tailwind.config.ts" ], ],
"exclude": [ "exclude": [
"node_modules" "node_modules"
] ]