feature/clinic-lab-invitation #14

Merged
admin merged 4 commits from feature/clinic-lab-invitation into master 2026-05-06 19:28:13 +03:30
33 changed files with 1984 additions and 152 deletions

View File

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

View File

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

View File

@@ -21,6 +21,7 @@ model User {
ownedOrganizations Organization[] @relation("OrganizationOwner")
sessions Session[] // 👈 ADD THIS - opposite relation for Session
sentStaffInvites StaffInvitation[]
sentOrganizationInvitations OrganizationInvitation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -57,6 +58,7 @@ model Organization {
sharedWithMe OrganizationLink[] @relation("OrganizationB")
sharedWithOthers OrganizationLink[] @relation("OrganizationA")
sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter")
patients Patient[]
createdAt DateTime @default(now())
@@ -211,6 +213,32 @@ model OrganizationLink {
@@map("organization_links")
}
model OrganizationInvitation {
id String @id @default(uuid())
inviterOrganizationId String
inviterUserId String
invitedOrganizationId String?
invitedOrganizationName String
invitedOwnerEmail String
invitedOrganizationType String
tokenHash String @unique
expiresAt DateTime
acceptedAt DateTime?
revokedAt DateTime?
inviterOrganization Organization @relation("OrganizationInvitationInviter", fields: [inviterOrganizationId], references: [id], onDelete: Cascade)
inviterUser User @relation(fields: [inviterUserId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([inviterOrganizationId, createdAt])
@@index([invitedOwnerEmail, createdAt])
@@map("organization_invitations")
}
model Session {
id String @id @default(uuid())
userId String

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,7 +35,12 @@ function Sidebar() {
const withCounterpartTab = [
menu[0],
menu[1],
{ name: counterpartLabel, path: '/lab', icon: FlaskConical, read: 'TAB_LAB_READ' as const },
{
name: counterpartLabel,
path: '/organizations',
icon: FlaskConical,
read: 'TAB_ORGANIZATIONS_READ' as const,
},
menu[2],
menu[3],
menu[4],

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import type { Organization } from '@/types/organization';
const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [
{ prefix: '/today', permission: 'TAB_TODAY_READ' },
{ prefix: '/staff', permission: 'TAB_STAFF_READ' },
{ prefix: '/lab', permission: 'TAB_LAB_READ' },
{ prefix: '/organizations', permission: 'TAB_ORGANIZATIONS_READ' },
{ prefix: '/patients', permission: 'TAB_PATIENTS_READ' },
{ prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ' },
{ prefix: '/treatment', permission: 'TAB_TREATMENT_READ' },