From adfb5b436e3811efff0c40527eb21939222c5470 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 5 May 2026 19:50:07 +0330 Subject: [PATCH 1/4] feature: clinic & lab invitation flow added. minimal ui implemented for organizations tab. --- .../migration.sql | 33 ++ .../migration.sql | 7 + backend/prisma/schema.prisma | 28 ++ backend/prisma/seed.ts | 4 +- backend/src/app.module.ts | 2 + backend/src/common/permissions.ts | 6 +- backend/src/modules/auth/auth.service.ts | 6 +- .../dto/accept-organization-invite.dto.ts | 19 + .../dto/create-link-request.dto.ts | 6 + .../dto/invite-organization.dto.ts | 14 + .../dto/preview-organization-invite.dto.ts | 7 + .../organization/organization.controller.ts | 78 +++ .../organization/organization.module.ts | 10 + .../organization/organization.service.ts | 451 ++++++++++++++++++ frontend/src/app/(dashboard)/lab/page.tsx | 10 - .../app/(dashboard)/organizations/page.tsx | 350 ++++++++++++++ .../staff/components/staffPermissions.ts | 6 +- .../staff/staff-permission-form.ts | 4 +- .../accept-organization-invite/page.tsx | 159 ++++++ frontend/src/components/ui/common/Sidebar.tsx | 7 +- frontend/src/lib/api/organization.ts | 79 +++ frontend/src/shared/permissions.ts | 2 +- 22 files changed, 1265 insertions(+), 23 deletions(-) create mode 100644 backend/prisma/migrations/20260505173000_add_organization_invitations/migration.sql create mode 100644 backend/prisma/migrations/20260505181000_rename_lab_permissions_to_organizations/migration.sql create mode 100644 backend/src/modules/organization/dto/accept-organization-invite.dto.ts create mode 100644 backend/src/modules/organization/dto/create-link-request.dto.ts create mode 100644 backend/src/modules/organization/dto/invite-organization.dto.ts create mode 100644 backend/src/modules/organization/dto/preview-organization-invite.dto.ts create mode 100644 backend/src/modules/organization/organization.controller.ts create mode 100644 backend/src/modules/organization/organization.module.ts create mode 100644 backend/src/modules/organization/organization.service.ts delete mode 100644 frontend/src/app/(dashboard)/lab/page.tsx create mode 100644 frontend/src/app/(dashboard)/organizations/page.tsx create mode 100644 frontend/src/app/(public)/accept-organization-invite/page.tsx create mode 100644 frontend/src/lib/api/organization.ts diff --git a/backend/prisma/migrations/20260505173000_add_organization_invitations/migration.sql b/backend/prisma/migrations/20260505173000_add_organization_invitations/migration.sql new file mode 100644 index 0000000..0251067 --- /dev/null +++ b/backend/prisma/migrations/20260505173000_add_organization_invitations/migration.sql @@ -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; diff --git a/backend/prisma/migrations/20260505181000_rename_lab_permissions_to_organizations/migration.sql b/backend/prisma/migrations/20260505181000_rename_lab_permissions_to_organizations/migration.sql new file mode 100644 index 0000000..750ff57 --- /dev/null +++ b/backend/prisma/migrations/20260505181000_rename_lab_permissions_to_organizations/migration.sql @@ -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'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 78b6d64..394229c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index a27fa08..6023967 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -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', diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index aaa7938..0f6d322 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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], diff --git a/backend/src/common/permissions.ts b/backend/src/common/permissions.ts index afebf47..5d0b172 100644 --- a/backend/src/common/permissions.ts +++ b/backend/src/common/permissions.ts @@ -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 = { 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', diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 3dccbf0..368624f 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -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', diff --git a/backend/src/modules/organization/dto/accept-organization-invite.dto.ts b/backend/src/modules/organization/dto/accept-organization-invite.dto.ts new file mode 100644 index 0000000..4b4f692 --- /dev/null +++ b/backend/src/modules/organization/dto/accept-organization-invite.dto.ts @@ -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; +} diff --git a/backend/src/modules/organization/dto/create-link-request.dto.ts b/backend/src/modules/organization/dto/create-link-request.dto.ts new file mode 100644 index 0000000..40a0132 --- /dev/null +++ b/backend/src/modules/organization/dto/create-link-request.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class CreateLinkRequestDto { + @IsUUID() + targetOrganizationId: string; +} diff --git a/backend/src/modules/organization/dto/invite-organization.dto.ts b/backend/src/modules/organization/dto/invite-organization.dto.ts new file mode 100644 index 0000000..469a529 --- /dev/null +++ b/backend/src/modules/organization/dto/invite-organization.dto.ts @@ -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; +} diff --git a/backend/src/modules/organization/dto/preview-organization-invite.dto.ts b/backend/src/modules/organization/dto/preview-organization-invite.dto.ts new file mode 100644 index 0000000..8a2f00f --- /dev/null +++ b/backend/src/modules/organization/dto/preview-organization-invite.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from 'class-validator'; + +export class PreviewOrganizationInviteDto { + @IsString() + @MinLength(1) + token: string; +} diff --git a/backend/src/modules/organization/organization.controller.ts b/backend/src/modules/organization/organization.controller.ts new file mode 100644 index 0000000..75ab7a2 --- /dev/null +++ b/backend/src/modules/organization/organization.controller.ts @@ -0,0 +1,78 @@ +import { + Body, + Controller, + Get, + 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 { 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); + } + + @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); + } + + @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); + } +} diff --git a/backend/src/modules/organization/organization.module.ts b/backend/src/modules/organization/organization.module.ts new file mode 100644 index 0000000..d36aef2 --- /dev/null +++ b/backend/src/modules/organization/organization.module.ts @@ -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 {} diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts new file mode 100644 index 0000000..e495df6 --- /dev/null +++ b/backend/src/modules/organization/organization.service.ts @@ -0,0 +1,451 @@ +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'; + +@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, invitations] = 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' }, + }), + this.prisma.organizationInvitation.findMany({ + where: { inviterOrganizationId: organizationId }, + orderBy: { createdAt: 'desc' }, + }), + ]); + + const linkItems = [ + ...linksA.map((l) => ({ + id: l.id, + kind: 'LINK' as const, + counterpartOrganizationId: l.organizationB.id, + organizationName: l.organizationB.name, + ownerEmail: l.organizationB.email, + phone: l.organizationB.phone, + status: l.status, + invitationUrl: null as string | null, + createdAt: l.createdAt.toISOString(), + acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null, + })), + ...linksB.map((l) => ({ + id: l.id, + kind: 'LINK' as const, + counterpartOrganizationId: l.organizationA.id, + organizationName: l.organizationA.name, + ownerEmail: l.organizationA.email, + phone: l.organizationA.phone, + status: l.status, + invitationUrl: null as string | null, + createdAt: l.createdAt.toISOString(), + acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null, + })), + ]; + + const inviteItems = invitations.map((i) => ({ + id: i.id, + kind: 'INVITATION' as const, + counterpartOrganizationId: i.invitedOrganizationId, + organizationName: i.invitedOrganizationName, + ownerEmail: i.invitedOwnerEmail, + phone: null as string | null, + status: this.mapInvitationStatus(i.acceptedAt, i.revokedAt, i.expiresAt), + invitationUrl: + !i.acceptedAt && !i.revokedAt && i.expiresAt.getTime() > Date.now() + ? this.buildInviteUrlFromTokenHashPlaceholder() + : null, + createdAt: i.createdAt.toISOString(), + acceptedAt: i.acceptedAt?.toISOString() ?? null, + })); + + return { + success: true, + data: { + items: [...linkItems, ...inviteItems].sort((a, b) => + a.createdAt < b.createdAt ? 1 : -1, + ), + }, + }; + } + + 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: [], + }, + }); + + return { + success: true, + data: { id: created.id, status: created.status }, + message: 'Link request created', + }; + } + + 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 invitedOrg = await this.prisma.organization.findFirst({ + where: { + owner: { email: ownerEmail }, + type: { name: invitedType }, + }, + select: { id: true, name: true }, + orderBy: { createdAt: 'desc' }, + }); + + const invitation = await this.prisma.organizationInvitation.create({ + data: { + inviterOrganizationId: organizationId, + inviterUserId: userId, + invitedOrganizationId: invitedOrg?.id ?? null, + 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 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; + } +} diff --git a/frontend/src/app/(dashboard)/lab/page.tsx b/frontend/src/app/(dashboard)/lab/page.tsx deleted file mode 100644 index 42c2cab..0000000 --- a/frontend/src/app/(dashboard)/lab/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export default function LabPage() { - return ( -
-

Lab Management

-

- Lab management module is coming soon. -

-
- ); -} diff --git a/frontend/src/app/(dashboard)/organizations/page.tsx b/frontend/src/app/(dashboard)/organizations/page.tsx new file mode 100644 index 0000000..db95130 --- /dev/null +++ b/frontend/src/app/(dashboard)/organizations/page.tsx @@ -0,0 +1,350 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Check, Copy, Search, Send } from 'lucide-react'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { + organizationApi, + type CounterpartItemDto, + type CounterpartSearchResultDto, +} from '@/lib/api/organization'; +import { Button } from '@/components/ui/common/Button'; +import { Input } from '@/components/ui/common/Input'; +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 { + if (typeof window === 'undefined') return {}; + try { + const raw = window.localStorage.getItem(inviteLinksStorageKey(orgId)); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +function writeStoredInviteLinks(orgId: string, links: Record) { + if (typeof window === 'undefined') return; + window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links)); +} + +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'; +} + +export default function OrganizationsPage() { + const { currentOrganization } = useAuth(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const [searchTerm, setSearchTerm] = useState(''); + const [searchLoading, setSearchLoading] = useState(false); + const [results, setResults] = useState([]); + const [selectedOrgId, setSelectedOrgId] = useState(null); + const [linkLoading, setLinkLoading] = useState(false); + + const [items, setItems] = useState([]); + const [manualOrganizationName, setManualOrganizationName] = useState(''); + const [manualOwnerEmail, setManualOwnerEmail] = useState(''); + const [manualPhone, setManualPhone] = useState(''); + const [inviteLoading, setInviteLoading] = useState(false); + const [copiedId, setCopiedId] = useState(null); + const [pendingInviteLinks, setPendingInviteLinks] = useState>({}); + + const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinic' : 'Lab'; + const tabLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs'; + + const selectedResult = useMemo( + () => results.find((r) => r.id === selectedOrgId) ?? null, + [results, selectedOrgId], + ); + + 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() { + setSearchLoading(true); + setError(''); + setSelectedOrgId(null); + try { + const res = await organizationApi.search(searchTerm.trim()); + setResults(res.data); + } catch (e) { + setError(formatApiMessage(e)); + } finally { + setSearchLoading(false); + } + } + + async function createLink() { + if (!selectedOrgId) return; + setLinkLoading(true); + setError(''); + try { + await organizationApi.createLink(selectedOrgId); + setSuccess(`${counterpartLabel} link request created`); + setResults([]); + setSelectedOrgId(null); + setSearchTerm(''); + await loadList(); + } catch (e) { + setError(formatApiMessage(e)); + } finally { + setLinkLoading(false); + } + } + + async function sendInvite() { + setInviteLoading(true); + setError(''); + try { + const res = await organizationApi.invite({ + organizationName: manualOrganizationName.trim(), + ownerEmail: manualOwnerEmail.trim(), + phone: manualPhone.trim() || undefined, + }); + 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(''); + setManualPhone(''); + await loadList(); + } catch (e) { + setError(formatApiMessage(e)); + } finally { + setInviteLoading(false); + } + } + + if (!currentOrganization) { + return

Loading organization...

; + } + + return ( +
+
+

{tabLabel}

+

+ Search subscribed {tabLabel.toLowerCase()}, request link access, or invite a new{' '} + {counterpartLabel.toLowerCase()} owner. +

+
+ + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + +
+

Search existing {tabLabel}

+
+ setSearchTerm(e.target.value)} + placeholder={`Search by ${counterpartLabel.toLowerCase()} name, email, or phone`} + /> + +
+ + {results.length > 0 && ( +
+ {results.map((r) => ( + + ))} +
+ )} + +
+ +
+
+ +
+

Invite owner (not yet subscribed)

+ setManualOrganizationName(e.target.value)} + /> + setManualOwnerEmail(e.target.value)} + /> + setManualPhone(e.target.value)} + /> +
+ +
+
+ +
+
+

Requests and invitations

+
+ {loading ? ( +

Loading list...

+ ) : items.length === 0 ? ( +

No records yet.

+ ) : ( +
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
TypeOrganizationOwner emailStatusAction
+ {item.kind === 'LINK' ? 'Link request' : 'Invitation'} + {item.organizationName}{item.ownerEmail} + + {item.status} + + + {item.kind === 'INVITATION' && + item.status === 'PENDING' && + pendingInviteLinks[item.id]?.invitationUrl && ( + + )} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/staff/components/staffPermissions.ts b/frontend/src/app/(dashboard)/staff/components/staffPermissions.ts index c4e7799..383c44d 100644 --- a/frontend/src/app/(dashboard)/staff/components/staffPermissions.ts +++ b/frontend/src/app/(dashboard)/staff/components/staffPermissions.ts @@ -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' }, diff --git a/frontend/src/app/(dashboard)/staff/staff-permission-form.ts b/frontend/src/app/(dashboard)/staff/staff-permission-form.ts index 8e95e18..54787f2 100644 --- a/frontend/src/app/(dashboard)/staff/staff-permission-form.ts +++ b/frontend/src/app/(dashboard)/staff/staff-permission-form.ts @@ -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; diff --git a/frontend/src/app/(public)/accept-organization-invite/page.tsx b/frontend/src/app/(public)/accept-organization-invite/page.tsx new file mode 100644 index 0000000..672478e --- /dev/null +++ b/frontend/src/app/(public)/accept-organization-invite/page.tsx @@ -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 ( +
+
+

Accept organization invitation

+ {loading ? ( +

Loading invitation...

+ ) : ( + <> + {inviteInfo && ( +
+

+ Invited by: {inviteInfo.inviterOrganizationName} +

+

+ Owner email: {inviteInfo.ownerEmail} +

+
+ )} + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + {inviteInfo?.status !== 'ACCEPTED' && ( +
+ setOwnerName(e.target.value)} /> + setOrganizationName(e.target.value)} + /> + setPassword(e.target.value)} + /> + setConfirmPassword(e.target.value)} + /> + +
+ )} +

+ Already have access? Go to login +

+ + )} +
+
+ ); +} + +export default function AcceptOrganizationInvitePage() { + return ( + +

Loading invitation...

+ + } + > + +
+ ); +} diff --git a/frontend/src/components/ui/common/Sidebar.tsx b/frontend/src/components/ui/common/Sidebar.tsx index d46b9e6..e8d9b59 100644 --- a/frontend/src/components/ui/common/Sidebar.tsx +++ b/frontend/src/components/ui/common/Sidebar.tsx @@ -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], diff --git a/frontend/src/lib/api/organization.ts b/frontend/src/lib/api/organization.ts new file mode 100644 index 0000000..9287958 --- /dev/null +++ b/frontend/src/lib/api/organization.ts @@ -0,0 +1,79 @@ +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; + kind: 'LINK' | 'INVITATION'; + counterpartOrganizationId: string | null; + organizationName: string; + ownerEmail: string; + phone: string | null; + status: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'EXPIRED'; + invitationUrl: string | null; + 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; + }, + + 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; + }, + + 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; + }, + + 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; + }, +}; diff --git a/frontend/src/shared/permissions.ts b/frontend/src/shared/permissions.ts index 429288e..123cbe6 100644 --- a/frontend/src/shared/permissions.ts +++ b/frontend/src/shared/permissions.ts @@ -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' }, -- 2.53.0.windows.1 From 625847786079284f79680b3140499ae50ccd4ec0 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 6 May 2026 02:20:19 +0330 Subject: [PATCH 2/4] feature: button and badge components updated. --- frontend/src/app/(dashboard)/billing/page.tsx | 8 +-- .../app/(dashboard)/organizations/page.tsx | 29 ++++---- .../src/app/(dashboard)/patients/page.tsx | 3 - .../settings/subscriptions/page.tsx | 7 +- frontend/src/app/(dashboard)/staff/page.tsx | 30 +++----- frontend/src/components/ui/common/Badge.tsx | 68 +++++++++++++------ frontend/src/components/ui/common/Button.tsx | 24 +++---- .../OrganizationSelectorContent.tsx | 3 +- 8 files changed, 89 insertions(+), 83 deletions(-) diff --git a/frontend/src/app/(dashboard)/billing/page.tsx b/frontend/src/app/(dashboard)/billing/page.tsx index e92e5fe..0897555 100644 --- a/frontend/src/app/(dashboard)/billing/page.tsx +++ b/frontend/src/app/(dashboard)/billing/page.tsx @@ -1,7 +1,7 @@ // src/app/(dashboard)/billing/page.tsx 'use client'; import { useState } from 'react'; -import { Search, Filter, Plus } from 'lucide-react'; +import { Search } from 'lucide-react'; import { Button } from '@/components/ui/common/Button'; import { Input } from '@/components/ui/common/Input'; import { Badge } from '@/components/ui/common/Badge'; @@ -44,11 +44,9 @@ export default function BillingPage() {

Billing

@@ -129,7 +127,7 @@ export default function BillingPage() { Paid - + Status @@ -158,7 +156,7 @@ export default function BillingPage() { ${invoice.paid} - + @@ -238,7 +244,6 @@ export default function OrganizationsPage() { disabled={!selectedResult} onClick={() => void createLink()} > - Request link @@ -303,9 +308,12 @@ export default function OrganizationsPage() { {item.organizationName} {item.ownerEmail} - - {item.status} - + + {formatOrganizationStatusLabel(item.status)} + {item.kind === 'INVITATION' && @@ -327,14 +335,7 @@ export default function OrganizationsPage() { } }} > - {copiedId === item.id ? ( - - ) : ( - - )} - - {copiedId === item.id ? 'Copied' : 'Copy link'} - + {copiedId === item.id ? 'Copied' : 'Copy link'} )} diff --git a/frontend/src/app/(dashboard)/patients/page.tsx b/frontend/src/app/(dashboard)/patients/page.tsx index 6a3aba9..4095d41 100644 --- a/frontend/src/app/(dashboard)/patients/page.tsx +++ b/frontend/src/app/(dashboard)/patients/page.tsx @@ -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() {

Patients

diff --git a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx b/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx index edc45aa..e5ab6b1 100644 --- a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx +++ b/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx @@ -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() { ); })} - + {purchaseNotice && (

{purchaseNotice}

diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index e7c7a6d..4359e9b 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -16,10 +16,11 @@ 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 type { ApiError } from '@/types/api'; @@ -335,7 +336,6 @@ export default function StaffPage() { className="shrink-0" title={!canEdit ? 'Read-only access for this organization.' : undefined} > - Invite member
@@ -410,14 +410,7 @@ export default function StaffPage() { } }} > - {copiedInviteMembershipId === lastInviteInfo.membershipId ? ( - - ) : ( - - )} - - {copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'} - + {copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'}

@@ -438,7 +431,7 @@ export default function StaffPage() { Name Email Role - Status + Status Access Actions @@ -455,20 +448,13 @@ export default function StaffPage() { Staff )} - + {m.isOwner || m.invitationStatus === 'ACTIVE' ? ( - - Active - + Active ) : m.invitationStatus === 'PENDING' ? ( - - - Pending - + Pending ) : ( - - Expired - + Expired )} diff --git a/frontend/src/components/ui/common/Badge.tsx b/frontend/src/components/ui/common/Badge.tsx index 9c5b844..afc8f5f 100644 --- a/frontend/src/components/ui/common/Badge.tsx +++ b/frontend/src/components/ui/common/Badge.tsx @@ -1,31 +1,59 @@ -//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; + 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 = { - success: 'bg-emerald-900/30 text-emerald-300 border-emerald-700/60', - warning: 'bg-amber-900/30 text-amber-300 border-amber-700/60', - danger: 'bg-red-950/30 text-red-300 border-red-700/60', - default: 'bg-background-secondary text-text-secondary border-border', + success: 'bg-emerald-900/30 text-emerald-300 border-emerald-700/60', + warning: 'bg-amber-900/30 text-amber-300 border-amber-700/60', + danger: 'bg-red-950/30 text-red-300 border-red-700/60', + default: 'bg-background-secondary text-text-secondary border-border', }; +/** 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, + children, + variant = 'default', + className, + fixedWidth = true, }: BadgeProps) { - return ( - - {children} - - ); -} \ No newline at end of file + const layoutClass = fixedWidth + ? `${FIXED_LAYOUT_CLASS} justify-center text-center` + : 'min-h-[1.75rem] px-2.5 py-1 justify-center'; + + return ( + + {children} + + ); +} + +/** 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'; + } +} diff --git a/frontend/src/components/ui/common/Button.tsx b/frontend/src/components/ui/common/Button.tsx index 1794b75..71e1384 100644 --- a/frontend/src/components/ui/common/Button.tsx +++ b/frontend/src/components/ui/common/Button.tsx @@ -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 = ({ }) => { 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 = { 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 = { @@ -51,17 +48,16 @@ export const Button: React.FC = ({ }; const widthClass = fullWidth ? 'w-full' : ''; + const loadingClass = isLoading ? 'opacity-70 animate-pulse pointer-events-none' : ''; return ( ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/ui/organization/OrganizationSelectorContent.tsx b/frontend/src/components/ui/organization/OrganizationSelectorContent.tsx index 29beead..d997d71 100644 --- a/frontend/src/components/ui/organization/OrganizationSelectorContent.tsx +++ b/frontend/src/components/ui/organization/OrganizationSelectorContent.tsx @@ -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); }} > - {isCreateOpen ? 'Cancel' : 'Create Organization'} -- 2.53.0.windows.1 From bb4427079542974f33512a76f52f3cce27b364b4 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 6 May 2026 18:55:42 +0330 Subject: [PATCH 3/4] feature: Organizations form UX flow now matches the needs. --- .../dto/respond-link-request.dto.ts | 6 + .../organization/organization.controller.ts | 46 ++ .../organization/organization.service.ts | 273 +++++++-- .../app/(dashboard)/organizations/page.tsx | 553 ++++++++++++------ frontend/src/lib/api/organization.ts | 40 +- 5 files changed, 700 insertions(+), 218 deletions(-) create mode 100644 backend/src/modules/organization/dto/respond-link-request.dto.ts diff --git a/backend/src/modules/organization/dto/respond-link-request.dto.ts b/backend/src/modules/organization/dto/respond-link-request.dto.ts new file mode 100644 index 0000000..02086c3 --- /dev/null +++ b/backend/src/modules/organization/dto/respond-link-request.dto.ts @@ -0,0 +1,6 @@ +import { IsIn } from 'class-validator'; + +export class RespondLinkRequestDto { + @IsIn(['ACCEPT', 'REJECT']) + action: 'ACCEPT' | 'REJECT'; +} diff --git a/backend/src/modules/organization/organization.controller.ts b/backend/src/modules/organization/organization.controller.ts index 75ab7a2..6a3152f 100644 --- a/backend/src/modules/organization/organization.controller.ts +++ b/backend/src/modules/organization/organization.controller.ts @@ -1,7 +1,10 @@ import { Body, Controller, + Delete, Get, + Param, + Patch, Post, Query, Req, @@ -13,6 +16,7 @@ import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dt 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') @@ -27,6 +31,14 @@ export class OrganizationController { 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) { @@ -65,6 +77,40 @@ export class OrganizationController { 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' }) diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts index e495df6..ce8d967 100644 --- a/backend/src/modules/organization/organization.service.ts +++ b/backend/src/modules/organization/organization.service.ts @@ -12,6 +12,7 @@ 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 { @@ -67,7 +68,7 @@ export class OrganizationService { throw new ForbiddenException('You do not have permission to manage organizations'); } - const [linksA, linksB, invitations] = await Promise.all([ + const [linksA, linksB] = await Promise.all([ this.prisma.organizationLink.findMany({ where: { organizationAId: organizationId }, include: { @@ -82,61 +83,65 @@ export class OrganizationService { }, orderBy: { createdAt: 'desc' }, }), - this.prisma.organizationInvitation.findMany({ - where: { inviterOrganizationId: organizationId }, - orderBy: { createdAt: 'desc' }, - }), ]); const linkItems = [ ...linksA.map((l) => ({ + requestedByOrganizationId: this.getRequesterOrganizationId(l.sharedDataTypes), id: l.id, - kind: 'LINK' as const, counterpartOrganizationId: l.organizationB.id, organizationName: l.organizationB.name, ownerEmail: l.organizationB.email, phone: l.organizationB.phone, status: l.status, - invitationUrl: null as string | null, createdAt: l.createdAt.toISOString(), acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null, })), ...linksB.map((l) => ({ + requestedByOrganizationId: this.getRequesterOrganizationId(l.sharedDataTypes), id: l.id, - kind: 'LINK' as const, counterpartOrganizationId: l.organizationA.id, organizationName: l.organizationA.name, ownerEmail: l.organizationA.email, phone: l.organizationA.phone, status: l.status, - invitationUrl: null as string | null, createdAt: l.createdAt.toISOString(), acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null, })), ]; - const inviteItems = invitations.map((i) => ({ - id: i.id, - kind: 'INVITATION' as const, - counterpartOrganizationId: i.invitedOrganizationId, - organizationName: i.invitedOrganizationName, - ownerEmail: i.invitedOwnerEmail, - phone: null as string | null, - status: this.mapInvitationStatus(i.acceptedAt, i.revokedAt, i.expiresAt), - invitationUrl: - !i.acceptedAt && !i.revokedAt && i.expiresAt.getTime() > Date.now() - ? this.buildInviteUrlFromTokenHashPlaceholder() - : null, - createdAt: i.createdAt.toISOString(), - acceptedAt: i.acceptedAt?.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: [...linkItems, ...inviteItems].sort((a, b) => - a.createdAt < b.createdAt ? 1 : -1, - ), + 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, + })), }, }; } @@ -183,7 +188,7 @@ export class OrganizationService { organizationAId: aId, organizationBId: bId, status: LinkStatus.PENDING, - sharedDataTypes: [], + sharedDataTypes: [`requested_by:${organizationId}`], }, }); @@ -194,6 +199,118 @@ export class OrganizationService { }; } + 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)) { @@ -219,26 +336,77 @@ export class OrganizationService { ); } - const invitedOrg = await this.prisma.organization.findFirst({ - where: { - owner: { email: ownerEmail }, - type: { name: invitedType }, - }, - select: { id: true, name: true }, - orderBy: { createdAt: 'desc' }, - }); + 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, + }, + }); + } - const invitation = await this.prisma.organizationInvitation.create({ - data: { - inviterOrganizationId: organizationId, - inviterUserId: userId, - invitedOrganizationId: invitedOrg?.id ?? null, - invitedOrganizationName: dto.organizationName.trim(), - invitedOwnerEmail: ownerEmail, - invitedOrganizationType: invitedType, - tokenHash, - expiresAt: this.getInviteExpiryDate(), - }, + 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 { @@ -430,6 +598,17 @@ export class OrganizationService { 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) }, diff --git a/frontend/src/app/(dashboard)/organizations/page.tsx b/frontend/src/app/(dashboard)/organizations/page.tsx index 1a379b3..c5575da 100644 --- a/frontend/src/app/(dashboard)/organizations/page.tsx +++ b/frontend/src/app/(dashboard)/organizations/page.tsx @@ -1,11 +1,13 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; +import { Search } 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'; @@ -39,13 +41,26 @@ function writeStoredInviteLinks(orgId: string, links: Record([]); - const [selectedOrgId, setSelectedOrgId] = useState(null); - const [linkLoading, setLinkLoading] = useState(false); + const [query, setQuery] = useState(''); + const [mode, setMode] = useState('existing'); + const [searching, setSearching] = useState(false); + const [searchResults, setSearchResults] = useState([]); + const [requestLinkRowId, setRequestLinkRowId] = useState(null); + const [deleteLinkRowId, setDeleteLinkRowId] = useState(null); const [items, setItems] = useState([]); const [manualOrganizationName, setManualOrganizationName] = useState(''); const [manualOwnerEmail, setManualOwnerEmail] = useState(''); - const [manualPhone, setManualPhone] = useState(''); const [inviteLoading, setInviteLoading] = useState(false); const [copiedId, setCopiedId] = useState(null); const [pendingInviteLinks, setPendingInviteLinks] = useState>({}); + const [showInviteForm, setShowInviteForm] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyItems, setHistoryItems] = useState([]); const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinic' : 'Lab'; const tabLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs'; - const selectedResult = useMemo( - () => results.find((r) => r.id === selectedOrgId) ?? null, - [results, selectedOrgId], - ); + const existingRows = items; async function loadList() { setLoading(true); @@ -111,34 +129,43 @@ export default function OrganizationsPage() { }, [success]); async function runSearch() { - setSearchLoading(true); + const q = query.trim(); + if (!q) { + setMode('existing'); + setSearchResults([]); + setShowInviteForm(false); + return; + } + + setSearching(true); setError(''); - setSelectedOrgId(null); + setMode('search'); + setShowInviteForm(false); try { - const res = await organizationApi.search(searchTerm.trim()); - setResults(res.data); + const res = await organizationApi.search(q); + setSearchResults(res.data); } catch (e) { setError(formatApiMessage(e)); + setSearchResults([]); } finally { - setSearchLoading(false); + setSearching(false); } } - async function createLink() { - if (!selectedOrgId) return; - setLinkLoading(true); + async function submitRequestLink(targetOrganizationId: string) { + setRequestLinkRowId(targetOrganizationId); setError(''); try { - await organizationApi.createLink(selectedOrgId); - setSuccess(`${counterpartLabel} link request created`); - setResults([]); - setSelectedOrgId(null); - setSearchTerm(''); + await organizationApi.createLink(targetOrganizationId); + setSuccess(`${counterpartLabel} link request sent`); + setSearchResults([]); + setQuery(''); + setMode('existing'); await loadList(); } catch (e) { setError(formatApiMessage(e)); } finally { - setLinkLoading(false); + setRequestLinkRowId(null); } } @@ -149,7 +176,6 @@ export default function OrganizationsPage() { const res = await organizationApi.invite({ organizationName: manualOrganizationName.trim(), ownerEmail: manualOwnerEmail.trim(), - phone: manualPhone.trim() || undefined, }); if (currentOrganization?.id) { const nextLinks = { @@ -166,7 +192,10 @@ export default function OrganizationsPage() { setSuccess(`Invitation link created for ${manualOwnerEmail.trim()}`); setManualOrganizationName(''); setManualOwnerEmail(''); - setManualPhone(''); + setShowInviteForm(false); + setMode('existing'); + setQuery(''); + setSearchResults([]); await loadList(); } catch (e) { setError(formatApiMessage(e)); @@ -175,18 +204,100 @@ export default function OrganizationsPage() { } } + 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

Loading organization...

; } return (
-
-

{tabLabel}

-

- Search subscribed {tabLabel.toLowerCase()}, request link access, or invite a new{' '} - {counterpartLabel.toLowerCase()} owner. -

+
+
+

{tabLabel}

+

+ Search organizations and send link requests or invitation links in one place. +

+
+
{error && ( @@ -200,152 +311,256 @@ export default function OrganizationsPage() {
)} -
-

Search existing {tabLabel}

-
- setSearchTerm(e.target.value)} - placeholder={`Search by ${counterpartLabel.toLowerCase()} name, email, or phone`} - /> - -
- - {results.length > 0 && ( -
- {results.map((r) => ( - - ))} -
- )} - -
- + {mode === 'search' && ( + + )}
-
-

Invite owner (not yet subscribed)

- setManualOrganizationName(e.target.value)} - /> - setManualOwnerEmail(e.target.value)} - /> - setManualPhone(e.target.value)} - /> -
- -
-
- -
-
-

Requests and invitations

-
- {loading ? ( -

Loading list...

- ) : items.length === 0 ? ( -

No records yet.

- ) : ( -
- - - - - - - - +
+
TypeOrganizationOwner emailStatusAction
+ + + + + + + + + + + {loading ? ( + + + + ) : mode === 'existing' ? ( + existingRows.length === 0 ? ( + + - - - {items.map((item) => ( - - - - - - + + + + + + + ); + }) + ) + ) : searchResults.length > 0 ? ( + searchResults.map((r) => ( + + + + + + + + )) + ) : ( + + - - ))} - -
+ Organization + + Owner email + + Type + + Status + + Action +
+ Loading... +
+ No organizations linked or pending yet. Use search to find and connect. +
- {item.kind === 'LINK' ? 'Link request' : 'Invitation'} - {item.organizationName}{item.ownerEmail} - - {formatOrganizationStatusLabel(item.status)} - - - {item.kind === 'INVITATION' && - item.status === 'PENDING' && - pendingInviteLinks[item.id]?.invitationUrl && ( + ) : ( + existingRows.map((row) => { + const canRespond = + row.status === 'PENDING' && + row.requestedByOrganizationId !== null && + row.requestedByOrganizationId !== currentOrganization.id; + + return ( +
+ {row.organizationName} + {row.ownerEmail}Link + + {formatLinkStatusLabel(row.status)} + + +
+ {canRespond && ( + <> + + + + )} + {row.status === 'ACTIVE' && ( + + )} +
+
{r.name}{r.owner.email}Directory match + Found + + +
+
+

+ No organization found in directory search. +

+
+ +
+ {showInviteForm && ( +
+ setManualOrganizationName(e.target.value)} + /> + setManualOwnerEmail(e.target.value)} + /> +
- )} -
-
- )} +
+
+ )} + + + + )} + + + + {historyOpen && ( +
+
+
+

Invitation history

+ +
+ + {historyLoading ? ( +

Loading invitation history...

+ ) : historyItems.length === 0 ? ( +

No invitations yet.

+ ) : ( +
+ + + + + + + + + + + {historyItems.map((inv) => ( + + + + + + + ))} + +
OrganizationOwner emailStatusAction
{inv.organizationName}{inv.ownerEmail} + + {formatInvitationStatusLabel(inv.status)} + + + {inv.status === 'PENDING' ? ( + + ) : ( + β€” + )} +
+
+ )} +
+
+ )} ); } diff --git a/frontend/src/lib/api/organization.ts b/frontend/src/lib/api/organization.ts index 9287958..bd65b4b 100644 --- a/frontend/src/lib/api/organization.ts +++ b/frontend/src/lib/api/organization.ts @@ -10,13 +10,21 @@ export interface CounterpartSearchResultDto { export interface CounterpartItemDto { id: string; - kind: 'LINK' | 'INVITATION'; + requestedByOrganizationId: string | null; counterpartOrganizationId: string | null; organizationName: string; ownerEmail: string; phone: string | null; status: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'EXPIRED'; - invitationUrl: string | null; + createdAt: string; + acceptedAt: string | null; +} + +export interface OrganizationInvitationHistoryItemDto { + id: string; + organizationName: string; + ownerEmail: string; + status: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'EXPIRED'; createdAt: string; acceptedAt: string | null; } @@ -32,6 +40,14 @@ export const organizationApi = { 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' } }> => { @@ -39,6 +55,19 @@ export const organizationApi = { 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; @@ -48,6 +77,13 @@ export const organizationApi = { 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<{ -- 2.53.0.windows.1 From 2dca609ba178f373674506962e65cda30e02c100 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 6 May 2026 19:25:01 +0330 Subject: [PATCH 4/4] feature: Organizations link and invitation flow implemented minimally. --- frontend/src/app/(dashboard)/billing/page.tsx | 80 +++---- .../app/(dashboard)/organizations/page.tsx | 218 ++++++++++-------- frontend/src/app/(dashboard)/staff/page.tsx | 47 ++-- .../src/app/(public)/accept-invite/page.tsx | 2 +- .../accept-organization-invite/page.tsx | 2 +- .../src/components/ui/common/SearchBar.tsx | 38 +++ frontend/src/components/ui/common/Table.tsx | 27 +++ 7 files changed, 258 insertions(+), 156 deletions(-) create mode 100644 frontend/src/components/ui/common/SearchBar.tsx create mode 100644 frontend/src/components/ui/common/Table.tsx diff --git a/frontend/src/app/(dashboard)/billing/page.tsx b/frontend/src/app/(dashboard)/billing/page.tsx index 0897555..6464010 100644 --- a/frontend/src/app/(dashboard)/billing/page.tsx +++ b/frontend/src/app/(dashboard)/billing/page.tsx @@ -1,10 +1,11 @@ // src/app/(dashboard)/billing/page.tsx 'use client'; import { useState } from 'react'; -import { Search } 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 @@ -78,17 +79,12 @@ export default function BillingPage() { /> {/* Filters */} -
-
-
- setSearch(e.target.value)} - icon={} - /> -
-
+ {['all', 'paid', 'unpaid', 'overdue'].map((status) => (
-
-
+ + )} + /> {/* Invoices Table - Matching your design */} -
- - - +
@@ -134,29 +129,30 @@ export default function BillingPage() { Action - - + } + body={ + <> {invoices.map((invoice) => ( - - + - - - - - - - - + ))} - -
Invoice ID
+
{invoice.id} + {invoice.patient} + {invoice.date} + {invoice.service} + ${invoice.amount} + ${invoice.paid} + +
- {/* Pagination - Matching your design */} -
+ + } + footer={( + <> @@ -188,8 +187,9 @@ export default function BillingPage() { -
-
+ + )} + /> ); } diff --git a/frontend/src/app/(dashboard)/organizations/page.tsx b/frontend/src/app/(dashboard)/organizations/page.tsx index c5575da..0d8390a 100644 --- a/frontend/src/app/(dashboard)/organizations/page.tsx +++ b/frontend/src/app/(dashboard)/organizations/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Search } from 'lucide-react'; +import { Check, Copy, Link2, Trash2, X } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; import { organizationApi, @@ -12,6 +12,8 @@ import { 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 = { @@ -56,7 +58,7 @@ function formatLinkStatusLabel(status: CounterpartItemDto['status']): string { function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string { if (status === 'PENDING') return 'Invitation pending'; - if (status === 'ACTIVE') return 'Invitation accepted'; + if (status === 'ACTIVE') return 'Invitation Accepted'; if (status === 'REJECTED') return 'Invitation rejected'; return formatOrganizationStatusLabel(status); } @@ -69,6 +71,12 @@ function formatApiMessage(err: unknown): string { 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() { @@ -296,7 +304,7 @@ export default function OrganizationsPage() {

@@ -311,33 +319,36 @@ export default function OrganizationsPage() { )} -
-
-
- setQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') void runSearch(); - }} - icon={} - /> -
- - {mode === 'search' && ( - - )} -
-
+ void runSearch()} + placeholder={`Search ${counterpartLabel.toLowerCase()} by name, email, or phone...`} + actions={ + <> + + {mode === 'search' && ( + + )} + + } + /> -
- - +
- - + } + body={ + <> {loading ? ( - - - - + + - @@ -435,22 +450,23 @@ export default function OrganizationsPage() { ) : searchResults.length > 0 ? ( searchResults.map((r) => ( - - - - + + + - )) @@ -496,9 +512,9 @@ export default function OrganizationsPage() { )} - -
Organization @@ -346,7 +357,7 @@ export default function OrganizationsPage() { Owner email - Type + Date Status @@ -355,8 +366,9 @@ export default function OrganizationsPage() { Action
@@ -379,52 +391,55 @@ export default function OrganizationsPage() { return (
+ {row.organizationName} {row.ownerEmail}Link + {row.ownerEmail} + {formatTableDate(row.createdAt)} + {formatLinkStatusLabel(row.status)} +
{canRespond && ( <> - - + + + )} {row.status === 'ACTIVE' && ( - + + )}
{r.name}{r.owner.email}Directory match + {r.name}{r.owner.email}Today Found - + +
-
+ + } + /> {historyOpen && (
@@ -508,7 +524,7 @@ export default function OrganizationsPage() { aria-modal="true" >
-

Invitation history

+

Invitation History

@@ -519,44 +535,64 @@ export default function OrganizationsPage() { ) : historyItems.length === 0 ? (

No invitations yet.

) : ( -
- - - - - - - - - - +
OrganizationOwner emailStatusAction
+ + + + + + + } + body={ + <> {historyItems.map((inv) => ( - - - - + + + + - ))} - -
+ Organization + + Owner email + + Date + + Status + + Action +
{inv.organizationName}{inv.ownerEmail} +
{inv.organizationName}{inv.ownerEmail} + {formatTableDate(inv.createdAt)} + {formatInvitationStatusLabel(inv.status)} + {inv.status === 'PENDING' ? ( - + {copiedId === inv.id ? ( + + ) : ( + + )} + ) : ( β€” )}
-
+ + } + /> )}
diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index 4359e9b..ea9e9e9 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -23,6 +23,7 @@ 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 = { @@ -424,31 +425,31 @@ export default function StaffPage() { {loading ? (

Loading team…

) : ( -
- - - - - - - - - - - - +
NameEmailRoleStatusAccessActions
+ + + + + + + + } + body={ + <> {members.map((m) => ( - - - - + + + - - - ))} - -
NameEmailRoleStatusAccessActions
{m.name}{m.email} +
{m.name}{m.email} {m.isOwner ? ( Owner ) : ( Staff )} + {m.isOwner || m.invitationStatus === 'ACTIVE' ? ( Active ) : m.invitationStatus === 'PENDING' ? ( @@ -457,7 +458,7 @@ export default function StaffPage() { Expired )} + {m.isOwner ? ( All features ) : ( @@ -466,7 +467,7 @@ export default function StaffPage() { )} + {!m.isOwner && (
{m.invitationStatus === 'PENDING' && pendingInviteLinks[m.id]?.invitationUrl && ( @@ -529,9 +530,9 @@ export default function StaffPage() {
-
+ + } + /> )} {inviteOpen && ( diff --git a/frontend/src/app/(public)/accept-invite/page.tsx b/frontend/src/app/(public)/accept-invite/page.tsx index 7f2553f..bfb3b3c 100644 --- a/frontend/src/app/(public)/accept-invite/page.tsx +++ b/frontend/src/app/(public)/accept-invite/page.tsx @@ -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); diff --git a/frontend/src/app/(public)/accept-organization-invite/page.tsx b/frontend/src/app/(public)/accept-organization-invite/page.tsx index 672478e..5c4af67 100644 --- a/frontend/src/app/(public)/accept-organization-invite/page.tsx +++ b/frontend/src/app/(public)/accept-organization-invite/page.tsx @@ -72,7 +72,7 @@ function AcceptOrganizationInviteContent() { organizationName: organizationName.trim(), password, }); - setSuccess('Invitation accepted. Redirecting to login...'); + setSuccess('Invitation Accepted. Redirecting to login...'); setTimeout(() => router.replace('/login'), 1000); } catch (e: any) { setError(e?.message || 'Could not accept invitation'); diff --git a/frontend/src/components/ui/common/SearchBar.tsx b/frontend/src/components/ui/common/SearchBar.tsx new file mode 100644 index 0000000..c93b8eb --- /dev/null +++ b/frontend/src/components/ui/common/SearchBar.tsx @@ -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 ( +
+
+
+ onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') onSubmit?.(); + }} + icon={} + /> +
+ {actions &&
{actions}
} +
+
+ ); +} diff --git a/frontend/src/components/ui/common/Table.tsx b/frontend/src/components/ui/common/Table.tsx new file mode 100644 index 0000000..656b1fe --- /dev/null +++ b/frontend/src/components/ui/common/Table.tsx @@ -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 ( +
+ + + {headers} + + + {body} + +
+ {footer && ( +
+ {footer} +
+ )} +
+ ); +} -- 2.53.0.windows.1