Compare commits

...

5 Commits

22 changed files with 487 additions and 128 deletions

View File

@@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "organizations" DROP CONSTRAINT "organizations_planId_fkey";
-- AddForeignKey
ALTER TABLE "organizations" ADD CONSTRAINT "organizations_planId_fkey" FOREIGN KEY ("planId") REFERENCES "plans"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
-- Allow organizations without an active subscription plan.
ALTER TABLE "organizations"
ALTER COLUMN "planId" DROP NOT NULL;

View File

@@ -0,0 +1,24 @@
-- Ensure Treatment feature and permissions exist for existing databases.
WITH treatment_feature AS (
INSERT INTO "features" ("id", "name")
VALUES (md5(random()::text || clock_timestamp()::text), 'Treatment')
ON CONFLICT ("name") DO UPDATE SET "name" = EXCLUDED."name"
RETURNING "id"
),
selected_feature AS (
SELECT "id" FROM treatment_feature
UNION ALL
SELECT f."id" FROM "features" f WHERE f."name" = 'Treatment' LIMIT 1
)
INSERT INTO "permissions" ("id", "name", "featureId")
SELECT md5(random()::text || clock_timestamp()::text), 'TAB_TREATMENT_READ', sf."id"
FROM selected_feature sf
ON CONFLICT ("name") DO NOTHING;
WITH treatment_feature AS (
SELECT "id" FROM "features" WHERE "name" = 'Treatment' LIMIT 1
)
INSERT INTO "permissions" ("id", "name", "featureId")
SELECT md5(random()::text || clock_timestamp()::text), 'TAB_TREATMENT_EDIT', tf."id"
FROM treatment_feature tf
ON CONFLICT ("name") DO NOTHING;

View File

@@ -52,8 +52,8 @@ model Organization {
owner User @relation("OrganizationOwner", fields: [ownerId], references: [id]) owner User @relation("OrganizationOwner", fields: [ownerId], references: [id])
memberships Membership[] memberships Membership[]
planId String planId String?
plan Plan @relation(fields: [planId], references: [id]) plan Plan? @relation(fields: [planId], references: [id])
sharedWithMe OrganizationLink[] @relation("OrganizationB") sharedWithMe OrganizationLink[] @relation("OrganizationB")
sharedWithOthers OrganizationLink[] @relation("OrganizationA") sharedWithOthers OrganizationLink[] @relation("OrganizationA")

View File

@@ -69,21 +69,25 @@ async function main() {
name: 'Today', name: 'Today',
permissions: ['TAB_TODAY_READ', 'TAB_TODAY_EDIT'], permissions: ['TAB_TODAY_READ', 'TAB_TODAY_EDIT'],
}, },
{
name: 'Staff',
permissions: ['TAB_STAFF_READ', 'TAB_STAFF_EDIT'],
},
{
name: 'Labs / Clinics',
permissions: ['TAB_LAB_READ', 'TAB_LAB_EDIT'],
},
{ {
name: 'Patients', name: 'Patients',
permissions: ['TAB_PATIENTS_READ', 'TAB_PATIENTS_EDIT'], permissions: ['TAB_PATIENTS_READ', 'TAB_PATIENTS_EDIT'],
}, },
{ {
name: 'Appointments', name: 'Appointment',
permissions: ['TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_EDIT'], permissions: ['TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_EDIT'],
}, },
{ {
name: 'Staff Management', name: 'Treatment',
permissions: ['TAB_STAFF_READ', 'TAB_STAFF_EDIT'], permissions: ['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'],
},
{
name: 'Lab Management',
permissions: ['TAB_LAB_READ', 'TAB_LAB_EDIT'],
}, },
{ {
name: 'Billing', name: 'Billing',

View File

@@ -2,14 +2,16 @@
export const ALL_TAB_PERMISSIONS = [ export const ALL_TAB_PERMISSIONS = [
'TAB_TODAY_READ', 'TAB_TODAY_READ',
'TAB_TODAY_EDIT', 'TAB_TODAY_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
'TAB_APPOINTMENTS_EDIT',
'TAB_STAFF_READ', 'TAB_STAFF_READ',
'TAB_STAFF_EDIT', 'TAB_STAFF_EDIT',
'TAB_LAB_READ', 'TAB_LAB_READ',
'TAB_LAB_EDIT', 'TAB_LAB_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
'TAB_APPOINTMENTS_EDIT',
'TAB_TREATMENT_READ',
'TAB_TREATMENT_EDIT',
'TAB_BILLING_READ', 'TAB_BILLING_READ',
'TAB_BILLING_EDIT', 'TAB_BILLING_EDIT',
'TAB_REPORTS_READ', 'TAB_REPORTS_READ',
@@ -37,6 +39,7 @@ const EDIT_TO_READ: Record<string, string> = {
TAB_APPOINTMENTS_EDIT: 'TAB_APPOINTMENTS_READ', TAB_APPOINTMENTS_EDIT: 'TAB_APPOINTMENTS_READ',
TAB_STAFF_EDIT: 'TAB_STAFF_READ', TAB_STAFF_EDIT: 'TAB_STAFF_READ',
TAB_LAB_EDIT: 'TAB_LAB_READ', TAB_LAB_EDIT: 'TAB_LAB_READ',
TAB_TREATMENT_EDIT: 'TAB_TREATMENT_READ',
TAB_BILLING_EDIT: 'TAB_BILLING_READ', TAB_BILLING_EDIT: 'TAB_BILLING_READ',
TAB_REPORTS_EDIT: 'TAB_REPORTS_READ', TAB_REPORTS_EDIT: 'TAB_REPORTS_READ',
}; };

View File

@@ -18,20 +18,33 @@ import { JwtPayload } from './interfaces/jwt-payload.interface';
const ALL_PERMISSIONS = [ const ALL_PERMISSIONS = [
'TAB_TODAY_READ', 'TAB_TODAY_READ',
'TAB_TODAY_EDIT', 'TAB_TODAY_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
'TAB_APPOINTMENTS_EDIT',
'TAB_STAFF_READ', 'TAB_STAFF_READ',
'TAB_STAFF_EDIT', 'TAB_STAFF_EDIT',
'TAB_LAB_READ', 'TAB_LAB_READ',
'TAB_LAB_EDIT', 'TAB_LAB_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
'TAB_APPOINTMENTS_EDIT',
'TAB_TREATMENT_READ',
'TAB_TREATMENT_EDIT',
'TAB_BILLING_READ', 'TAB_BILLING_READ',
'TAB_BILLING_EDIT', 'TAB_BILLING_EDIT',
'TAB_REPORTS_READ', 'TAB_REPORTS_READ',
'TAB_REPORTS_EDIT', 'TAB_REPORTS_EDIT',
]; ];
const READ_ONLY_PERMISSIONS = [
'TAB_TODAY_READ',
'TAB_STAFF_READ',
'TAB_LAB_READ',
'TAB_PATIENTS_READ',
'TAB_APPOINTMENTS_READ',
'TAB_TREATMENT_READ',
'TAB_BILLING_READ',
'TAB_REPORTS_READ',
];
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
@@ -141,9 +154,7 @@ export class AuthService {
name: membership.organization.name, name: membership.organization.name,
type: membership.organization.type.name, // 'CLINIC' or 'LAB' type: membership.organization.type.name, // 'CLINIC' or 'LAB'
isOwner: membership.isOwner, isOwner: membership.isOwner,
permissions: membership.isOwner permissions: this.getMembershipPermissions(membership),
? ALL_PERMISSIONS
: membership.permissions?.map(p => p.permission.name) || [],
plan: membership.organization.plan plan: membership.organization.plan
? { ? {
name: membership.organization.plan.name, name: membership.organization.plan.name,
@@ -253,16 +264,13 @@ export class AuthService {
async createOrganization(userId: string, dto: CreateOrganizationDto) { async createOrganization(userId: string, dto: CreateOrganizationDto) {
const owner = await this.prisma.user.findUnique({ const owner = await this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { id: true, trialUsedAt: true }, select: { id: true },
}); });
if (!owner) { if (!owner) {
throw new UnauthorizedException('User not found'); throw new UnauthorizedException('User not found');
} }
const planName = dto.planName?.trim() || 'Small';
const effectivePlanName = owner.trialUsedAt ? planName : 'trial';
const organization = await this.prisma.$transaction(async (tx) => { const organization = await this.prisma.$transaction(async (tx) => {
const createdOrganization = await tx.organization.create({ const createdOrganization = await tx.organization.create({
data: { data: {
@@ -271,9 +279,6 @@ export class AuthService {
owner: { owner: {
connect: { id: userId }, connect: { id: userId },
}, },
plan: {
connect: { name: effectivePlanName },
},
type: { type: {
connect: { name: dto.organizationType }, connect: { name: dto.organizationType },
}, },
@@ -287,14 +292,6 @@ export class AuthService {
isOwner: true, isOwner: true,
}, },
}); });
if (!owner.trialUsedAt) {
await tx.user.update({
where: { id: userId },
data: { trialUsedAt: new Date() },
});
}
return createdOrganization; return createdOrganization;
}); });
@@ -350,9 +347,7 @@ export class AuthService {
name: membership.organization.name, name: membership.organization.name,
type: membership.organization.type.name, type: membership.organization.type.name,
isOwner: membership.isOwner, isOwner: membership.isOwner,
permissions: membership.isOwner permissions: this.getMembershipPermissions(membership),
? ALL_PERMISSIONS
: membership.permissions?.map(p => p.permission.name) || [],
plan: membership.organization.plan plan: membership.organization.plan
? { ? {
name: membership.organization.plan.name, name: membership.organization.plan.name,
@@ -471,9 +466,7 @@ export class AuthService {
name: membership.organization.name, name: membership.organization.name,
type: membership.organization.type.name, type: membership.organization.type.name,
isOwner: membership.isOwner, isOwner: membership.isOwner,
permissions: membership.isOwner permissions: this.getMembershipPermissions(membership),
? ALL_PERMISSIONS
: membership.permissions?.map(p => p.permission.name) || [],
plan: membership.organization.plan plan: membership.organization.plan
? { ? {
name: membership.organization.plan.name, name: membership.organization.plan.name,
@@ -678,9 +671,7 @@ export class AuthService {
name: membership.organization.name, name: membership.organization.name,
type: membership.organization.type.name, type: membership.organization.type.name,
isOwner: membership.isOwner, isOwner: membership.isOwner,
permissions: membership.isOwner permissions: this.getMembershipPermissions(membership),
? ALL_PERMISSIONS
: membership.permissions?.map(p => p.permission.name) || [],
plan: membership.organization.plan plan: membership.organization.plan
? { ? {
name: membership.organization.plan.name, name: membership.organization.plan.name,
@@ -747,9 +738,7 @@ export class AuthService {
}); });
// 4. Format permissions // 4. Format permissions
const permissions = membership.isOwner const permissions = this.getMembershipPermissions(membership);
? ALL_PERMISSIONS
: membership.permissions.map(p => p.permission.name);
return { return {
success: true, success: true,
@@ -789,6 +778,19 @@ export class AuthService {
return memberships.filter((m) => m.isOwner || m.isActive); return memberships.filter((m) => m.isOwner || m.isActive);
} }
private getMembershipPermissions(membership: {
isOwner: boolean;
organization: {
plan?: { name: string; maxUsers: number; price: number } | null;
};
permissions?: Array<{ permission: { name: string } }>;
}): string[] {
if (membership.isOwner) {
return membership.organization.plan ? ALL_PERMISSIONS : READ_ONLY_PERMISSIONS;
}
return membership.permissions?.map((p) => p.permission.name) || [];
}
/** /**
* Owner-only subscription / seat alerts for the current org (from JWT). * Owner-only subscription / seat alerts for the current org (from JWT).
* Used for a subtle warning indicator in the app shell (not staff-facing banners). * Used for a subtle warning indicator in the app shell (not staff-facing banners).
@@ -799,6 +801,7 @@ export class AuthService {
success: true, success: true,
data: { data: {
showWarning: false, showWarning: false,
noActiveSubscription: false,
seatsLow: false, seatsLow: false,
trialEndingSoon: false, trialEndingSoon: false,
trialExpired: false, trialExpired: false,
@@ -822,6 +825,7 @@ export class AuthService {
success: true, success: true,
data: { data: {
showWarning: false, showWarning: false,
noActiveSubscription: false,
seatsLow: false, seatsLow: false,
trialEndingSoon: false, trialEndingSoon: false,
trialExpired: false, trialExpired: false,
@@ -833,6 +837,24 @@ export class AuthService {
const org = membership.organization; const org = membership.organization;
const plan = org.plan; const plan = org.plan;
if (!plan) {
return {
success: true,
data: {
showWarning: true,
noActiveSubscription: true,
seatsLow: false,
trialEndingSoon: false,
trialExpired: false,
seatsUsed: 0,
seatsLimit: null,
daysUntilTrialEnd: null,
trialEndsAt: null,
daysUntilPlanEnd: null,
planEndsAt: null,
},
};
}
const maxUsers = plan.maxUsers; const maxUsers = plan.maxUsers;
const seatsUsed = await this.prisma.membership.count({ const seatsUsed = await this.prisma.membership.count({
where: { where: {
@@ -863,6 +885,7 @@ export class AuthService {
success: true, success: true,
data: { data: {
showWarning, showWarning,
noActiveSubscription: false,
seatsLow, seatsLow,
trialEndingSoon, trialEndingSoon,
trialExpired, trialExpired,

View File

@@ -59,7 +59,7 @@ export class StaffService {
}), }),
]); ]);
const maxUsers = org.plan.maxUsers; const maxUsers = org.plan?.maxUsers ?? 0;
const unlimited = isUnlimitedSeats(maxUsers); const unlimited = isUnlimitedSeats(maxUsers);
return { return {
@@ -119,6 +119,12 @@ export class StaffService {
throw new NotFoundException('Organization not found'); throw new NotFoundException('Organization not found');
} }
if (!org.plan) {
throw new BadRequestException(
'This organization has no active subscription. Please choose a plan before inviting staff.',
);
}
const maxUsers = org.plan.maxUsers; const maxUsers = org.plan.maxUsers;
const seatsUsed = await tx.membership.count({ const seatsUsed = await tx.membership.count({
where: { where: {
@@ -362,7 +368,10 @@ export class StaffService {
private async getActorMembership(userId: string, organizationId: string) { private async getActorMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({ return this.prisma.membership.findFirst({
where: { userId, organizationId }, where: { userId, organizationId },
include: { permissions: { include: { permission: true } } }, include: {
permissions: { include: { permission: true } },
organization: { select: { planId: true } },
},
}); });
} }
@@ -424,6 +433,7 @@ export class StaffService {
private canViewStaff(m: { private canViewStaff(m: {
isOwner: boolean; isOwner: boolean;
organization?: { planId: string | null };
permissions: { permission: { name: string } }[]; permissions: { permission: { name: string } }[];
}): boolean { }): boolean {
if (m.isOwner) return true; if (m.isOwner) return true;
@@ -435,9 +445,10 @@ export class StaffService {
private canEditStaff(m: { private canEditStaff(m: {
isOwner: boolean; isOwner: boolean;
organization?: { planId: string | null };
permissions: { permission: { name: string } }[]; permissions: { permission: { name: string } }[];
}): boolean { }): boolean {
if (m.isOwner) return true; if (m.isOwner) return Boolean(m.organization?.planId);
return m.permissions.some((p) => p.permission.name === 'TAB_STAFF_EDIT'); return m.permissions.some((p) => p.permission.name === 'TAB_STAFF_EDIT');
} }
} }

View File

@@ -5,6 +5,8 @@ import { Search, Filter, Plus } from 'lucide-react';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input'; import { Input } from '@/components/ui/common/Input';
import { Badge } from '@/components/ui/common/Badge'; import { Badge } from '@/components/ui/common/Badge';
import { useAuth } from '@/lib/hooks/useAuth';
import { hasPermission } from '@/shared/permissions';
// Mock data matching your design // Mock data matching your design
const invoices = [ const invoices = [
{ id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' }, { id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' },
@@ -25,8 +27,10 @@ interface StatCardProps {
color: StatCardColor; color: StatCardColor;
} }
export default function BillingPage() { export default function BillingPage() {
const { currentOrganization } = useAuth();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
const canEditBilling = hasPermission(currentOrganization, 'TAB_BILLING_EDIT');
const stats = { const stats = {
total: { count: 235, amount: 80900 }, total: { count: 235, amount: 80900 },
unpaid: { count: 30, amount: 2800 }, unpaid: { count: 30, amount: 2800 },
@@ -38,7 +42,12 @@ export default function BillingPage() {
{/* Header */} {/* Header */}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-text-primary">Billing</h1> <h1 className="text-2xl font-semibold text-text-primary">Billing</h1>
<Button variant="primary" className="flex items-center gap-2"> <Button
variant="primary"
className="flex items-center gap-2"
disabled={!canEditBilling}
title={!canEditBilling ? 'Read-only access for this organization.' : undefined}
>
<Plus className="h-4 w-4 icon-flat" /> <Plus className="h-4 w-4 icon-flat" />
New Invoice New Invoice
</Button> </Button>
@@ -158,7 +167,11 @@ export default function BillingPage() {
</Badge> </Badge>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<button className="text-primary hover:opacity-90 text-sm"> <button
className={`text-sm ${canEditBilling ? 'text-primary hover:opacity-90' : 'text-text-muted cursor-not-allowed'}`}
disabled={!canEditBilling}
title={!canEditBilling ? 'Read-only access for this organization.' : undefined}
>
Edit Edit
</button> </button>
</td> </td>

View File

@@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from 'react';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/common/Button';
import { patientsApi } from '@/lib/api/patients'; import { patientsApi } from '@/lib/api/patients';
import { useAuth } from '@/lib/hooks/useAuth';
import { hasPermission } from '@/shared/permissions';
import { import {
CreatePatientInput, CreatePatientInput,
CreateTreatmentHistoryInput, CreateTreatmentHistoryInput,
@@ -23,6 +25,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = {
}; };
export default function PatientsPage() { export default function PatientsPage() {
const { currentOrganization } = useAuth();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [patients, setPatients] = useState<Patient[]>([]); const [patients, setPatients] = useState<Patient[]>([]);
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>(); const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
@@ -35,6 +38,7 @@ export default function PatientsPage() {
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM); const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
const [errorMessage, setErrorMessage] = useState<string>(''); const [errorMessage, setErrorMessage] = useState<string>('');
const [successMessage, setSuccessMessage] = useState<string>(''); const [successMessage, setSuccessMessage] = useState<string>('');
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
const sortedPatients = useMemo( const sortedPatients = useMemo(
() => () =>
@@ -154,7 +158,16 @@ export default function PatientsPage() {
<div className="relative space-y-6 pb-20"> <div className="relative space-y-6 pb-20">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-text-primary">Patients</h1> <h1 className="text-2xl font-semibold text-text-primary">Patients</h1>
<Button variant="primary" className="flex items-center gap-2" onClick={() => setIsCreateOpen(true)}> <Button
variant="primary"
className="flex items-center gap-2"
disabled={!canEditPatients}
onClick={() => {
if (!canEditPatients) return;
setIsCreateOpen(true);
}}
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
>
<Plus className="h-4 w-4 icon-flat" /> <Plus className="h-4 w-4 icon-flat" />
New Patient New Patient
</Button> </Button>
@@ -189,9 +202,13 @@ export default function PatientsPage() {
<div className="flex"> <div className="flex">
<Button <Button
variant="secondary" variant="secondary"
disabled={!selectedPatient} disabled={!selectedPatient || !canEditPatients}
isLoading={savingTreatment} isLoading={savingTreatment}
onClick={handleQuickAddTreatment} onClick={() => {
if (!canEditPatients) return;
void handleQuickAddTreatment();
}}
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
> >
Add Quick Treatment Entry Add Quick Treatment Entry
</Button> </Button>

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
export default function AccountSettingsPage() { export default function AccountSettingsPage() {
return ( return (
<div className="max-w-xl space-y-6"> <div className="space-y-6">
<div> <div>
<Link <Link
href="/today" href="/today"

View File

@@ -5,7 +5,7 @@ import { OrganizationSelectorContent } from '@/components/ui/organization/Organi
export default function DashboardOrganizationsSettingsPage() { export default function DashboardOrganizationsSettingsPage() {
return ( return (
<div className="max-w-3xl space-y-6"> <div className="space-y-6">
<div> <div>
<Link <Link
href="/today" href="/today"

View File

@@ -7,10 +7,20 @@ import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth'; import { authApi } from '@/lib/api/auth';
import type { SubscriptionAlertData } from '@/types/subscription'; import type { SubscriptionAlertData } from '@/types/subscription';
const PLAN_OPTIONS = [
{ id: 'solo', name: 'Solo', maxUsers: 1, price: 19 },
{ id: 'small', name: 'Small', maxUsers: 5, price: 49 },
{ id: 'medium', name: 'Medium', maxUsers: 10, price: 89 },
{ id: 'large', name: 'Large', maxUsers: 15, price: 129 },
{ id: 'enterprise', name: 'Enterprise', maxUsers: null, price: 199 },
] as const;
export default function SubscriptionsSettingsPage() { export default function SubscriptionsSettingsPage() {
const { currentOrganization } = useAuth(); const { currentOrganization } = useAuth();
const router = useRouter(); const router = useRouter();
const [alert, setAlert] = useState<SubscriptionAlertData | null>(null); const [alert, setAlert] = useState<SubscriptionAlertData | null>(null);
const [selectedPlanId, setSelectedPlanId] = useState<string>(PLAN_OPTIONS[0].id);
const [purchaseNotice, setPurchaseNotice] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (currentOrganization && !currentOrganization.isOwner) { if (currentOrganization && !currentOrganization.isOwner) {
@@ -38,6 +48,8 @@ export default function SubscriptionsSettingsPage() {
} }
const plan = currentOrganization.plan; const plan = currentOrganization.plan;
const hasActiveSubscription = Boolean(plan);
const selectedPlan = PLAN_OPTIONS.find((option) => option.id === selectedPlanId);
const maxUsers = plan?.maxUsers; const maxUsers = plan?.maxUsers;
const isUnlimited = typeof maxUsers === 'number' && maxUsers >= 999999; const isUnlimited = typeof maxUsers === 'number' && maxUsers >= 999999;
const seatsUsed = alert?.seatsUsed; const seatsUsed = alert?.seatsUsed;
@@ -56,7 +68,7 @@ export default function SubscriptionsSettingsPage() {
: 'text-red-400'; : 'text-red-400';
return ( return (
<div className="max-w-4xl space-y-6"> <div className="space-y-6">
<div> <div>
<Link <Link
href="/today" href="/today"
@@ -74,6 +86,15 @@ export default function SubscriptionsSettingsPage() {
</div> </div>
<div className="surface-card p-6 space-y-4"> <div className="surface-card p-6 space-y-4">
{!hasActiveSubscription && (
<div className="rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
<p className="text-sm text-amber-200">
This organization has no active subscription. Select a plan below to start
the purchase process.
</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<div> <div>
<p className="text-xs text-text-muted uppercase tracking-wide">Current plan</p> <p className="text-xs text-text-muted uppercase tracking-wide">Current plan</p>
@@ -110,6 +131,9 @@ export default function SubscriptionsSettingsPage() {
{alert?.showWarning && ( {alert?.showWarning && (
<div className="text-sm text-text-secondary space-y-1"> <div className="text-sm text-text-secondary space-y-1">
{alert.noActiveSubscription && (
<p>No active subscription for this organization.</p>
)}
{alert.trialExpired && ( {alert.trialExpired && (
<p>Trial period has ended. Choose a plan when checkout is available.</p> <p>Trial period has ended. Choose a plan when checkout is available.</p>
)} )}
@@ -124,11 +148,52 @@ export default function SubscriptionsSettingsPage() {
</div> </div>
)} )}
<div className="space-y-3 pt-2">
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
Payment and plan upgrades will connect here. The warning on the settings Choose a plan to continue. Purchase integration is not active yet, so this
icon is only shown to workspace owners when seats are low or the trial window currently prepares the selection step only.
is ending.
</p> </p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{PLAN_OPTIONS.map((option) => {
const selected = selectedPlanId === option.id;
return (
<button
key={option.id}
type="button"
onClick={() => setSelectedPlanId(option.id)}
className={`rounded-[var(--radius-md)] border p-4 text-left transition-colors ${
selected
? 'border-primary/70 bg-primary-soft'
: 'border-border hover:border-border-strong'
}`}
>
<p className="text-base font-medium text-text-primary">{option.name}</p>
<p className="text-sm text-text-secondary mt-1">
{option.maxUsers == null ? 'Unlimited seats' : `${option.maxUsers} seats`}
</p>
<p className="text-sm text-text-secondary mt-1">${option.price} / month</p>
</button>
);
})}
</div>
<button
type="button"
className="inline-flex items-center justify-center rounded-[var(--radius-md)] bg-primary px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-60"
onClick={() => {
const selectedPlanLabel = selectedPlan?.name ?? 'the selected plan';
setPurchaseNotice(
`Purchase flow will be enabled soon. ${selectedPlanLabel} is selected and ready for checkout setup.`,
);
}}
>
Start purchase process
</button>
{purchaseNotice && (
<div className="rounded-[var(--radius-md)] border border-emerald-500/30 bg-emerald-500/10 px-4 py-3">
<p className="text-sm text-emerald-200">{purchaseNotice}</p>
</div>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,10 +1,11 @@
/** Feature groups for staff invite/edit UI — matches backend seed */ /** Feature groups for staff invite/edit UI — matches backend seed */
export const STAFF_FEATURE_GROUPS = [ export const STAFF_FEATURE_GROUPS = [
{ label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' }, { 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: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' }, { label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
{ label: 'Appointments', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, { label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
{ label: 'Staff Management', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' }, { label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },
{ label: 'Lab Management', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' },
{ label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' }, { label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' },
{ label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' }, { label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' },
] as const; ] as const;

View File

@@ -12,6 +12,7 @@ import {
permissionNamesFromFeatureState, permissionNamesFromFeatureState,
emptyFeaturePermissionState, emptyFeaturePermissionState,
featureStateFromPermissionNames, featureStateFromPermissionNames,
resolveStaffFeatureLabel,
formatAccessSummary, formatAccessSummary,
type FeaturePermState, type FeaturePermState,
} from './staff-permission-form'; } from './staff-permission-form';
@@ -23,6 +24,33 @@ import { Input } from '@/components/ui/common/Input';
import { Checkbox } from '@/components/ui/common/Checkbox'; import { Checkbox } from '@/components/ui/common/Checkbox';
import type { ApiError } from '@/types/api'; import type { ApiError } from '@/types/api';
type StoredInviteLink = {
membershipId: string;
email: string;
invitationUrl: string;
};
function inviteLinksStorageKey(orgId: string): string {
return `staffInviteLinks:${orgId}`;
}
function readStoredInviteLinks(orgId: string): Record<string, StoredInviteLink> {
if (typeof window === 'undefined') return {};
try {
const raw = window.localStorage.getItem(inviteLinksStorageKey(orgId));
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, StoredInviteLink>;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function writeStoredInviteLinks(orgId: string, links: Record<string, StoredInviteLink>) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links));
}
function formatApiMessage(err: unknown): string { function formatApiMessage(err: unknown): string {
if (!err || typeof err !== 'object') return 'Something went wrong'; if (!err || typeof err !== 'object') return 'Something went wrong';
const m = (err as ApiError).message; const m = (err as ApiError).message;
@@ -35,10 +63,12 @@ function PermissionGrid({
state, state,
onChange, onChange,
disabled, disabled,
organizationType,
}: { }: {
state: FeaturePermState; state: FeaturePermState;
onChange: (next: FeaturePermState) => void; onChange: (next: FeaturePermState) => void;
disabled?: boolean; disabled?: boolean;
organizationType?: 'CLINIC' | 'LAB';
}) { }) {
const setRead = (editKey: string, read: boolean) => { const setRead = (editKey: string, read: boolean) => {
const cur = state[editKey] ?? { read: false, edit: false }; const cur = state[editKey] ?? { read: false, edit: false };
@@ -65,7 +95,9 @@ function PermissionGrid({
key={g.edit} key={g.edit}
className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-border/60 bg-background-card/50 px-3 py-3" className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-border/60 bg-background-card/50 px-3 py-3"
> >
<span className="text-sm font-medium text-text-primary">{g.label}</span> <span className="text-sm font-medium text-text-primary">
{resolveStaffFeatureLabel(g, organizationType)}
</span>
<div className="flex flex-col gap-2.5 pl-0.5"> <div className="flex flex-col gap-2.5 pl-0.5">
<Checkbox <Checkbox
checked={cell.read} checked={cell.read}
@@ -105,13 +137,15 @@ export default function StaffPage() {
const [inviteName, setInviteName] = useState(''); const [inviteName, setInviteName] = useState('');
const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState()); const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState());
const [inviteLoading, setInviteLoading] = useState(false); const [inviteLoading, setInviteLoading] = useState(false);
const [copiedInviteLink, setCopiedInviteLink] = useState(false); const [copiedInviteMembershipId, setCopiedInviteMembershipId] = useState<string | null>(null);
const [lastInviteInfo, setLastInviteInfo] = useState<{ const [lastInviteInfo, setLastInviteInfo] = useState<{
membershipId: string;
name: string; name: string;
email: string; email: string;
invitationUrl: string | null; invitationUrl: string | null;
invitationStatus: 'PENDING' | 'ACCEPTED'; invitationStatus: 'PENDING' | 'ACCEPTED';
} | null>(null); } | null>(null);
const [pendingInviteLinks, setPendingInviteLinks] = useState<Record<string, StoredInviteLink>>({});
const [editing, setEditing] = useState<StaffMemberDto | null>(null); const [editing, setEditing] = useState<StaffMemberDto | null>(null);
const [editName, setEditName] = useState(''); const [editName, setEditName] = useState('');
@@ -119,6 +153,7 @@ export default function StaffPage() {
const [editLoading, setEditLoading] = useState(false); const [editLoading, setEditLoading] = useState(false);
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]); const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
const hasActivePlan = Boolean(currentOrganization?.plan);
const atSeatLimit = useMemo(() => { const atSeatLimit = useMemo(() => {
if (!seats || seats.unlimited) return false; if (!seats || seats.unlimited) return false;
if (seats.limit == null) return false; if (seats.limit == null) return false;
@@ -139,6 +174,34 @@ export default function StaffPage() {
} }
}, []); }, []);
useEffect(() => {
if (!currentOrganization?.id) return;
setPendingInviteLinks(readStoredInviteLinks(currentOrganization.id));
}, [currentOrganization?.id]);
useEffect(() => {
if (!currentOrganization?.id || loading) return;
const activeMemberIds = new Set(
members
.filter((m) => m.isOwner || m.invitationStatus === 'ACTIVE')
.map((m) => m.id),
);
let changed = false;
const nextLinks: Record<string, StoredInviteLink> = { ...pendingInviteLinks };
for (const memberId of Object.keys(nextLinks)) {
if (activeMemberIds.has(memberId)) {
delete nextLinks[memberId];
changed = true;
}
}
if (!changed) return;
setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}, [currentOrganization?.id, loading, members, pendingInviteLinks]);
useEffect(() => { useEffect(() => {
void load(); void load();
}, [load]); }, [load]);
@@ -170,11 +233,24 @@ export default function StaffPage() {
permissionNames, permissionNames,
}); });
setLastInviteInfo({ setLastInviteInfo({
membershipId: res.data.membershipId,
name: displayName, name: displayName,
email: res.data.email, email: res.data.email,
invitationUrl: res.data.invitationUrl, invitationUrl: res.data.invitationUrl,
invitationStatus: res.data.invitationStatus, invitationStatus: res.data.invitationStatus,
}); });
if (currentOrganization?.id && res.data.invitationUrl) {
const nextLinks = {
...pendingInviteLinks,
[res.data.membershipId]: {
membershipId: res.data.membershipId,
email: res.data.email,
invitationUrl: res.data.invitationUrl,
},
};
setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}
setSuccess(''); setSuccess('');
setInviteOpen(false); setInviteOpen(false);
setInviteEmail(''); setInviteEmail('');
@@ -240,7 +316,7 @@ export default function StaffPage() {
} }
return ( return (
<div className="space-y-6 max-w-5xl"> <div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<h1 className="text-2xl font-semibold text-text-primary">Staff Management</h1> <h1 className="text-2xl font-semibold text-text-primary">Staff Management</h1>
@@ -248,20 +324,20 @@ export default function StaffPage() {
Invite teammates, set tab access, and stay within your plan seat limit. Invite teammates, set tab access, and stay within your plan seat limit.
</p> </p>
</div> </div>
{canEdit && (
<Button <Button
size="sm" size="sm"
onClick={() => { onClick={() => {
if (!canEdit || atSeatLimit) return;
setInviteOpen(true); setInviteOpen(true);
setLastInviteInfo(null); setLastInviteInfo(null);
}} }}
disabled={atSeatLimit} disabled={!canEdit || atSeatLimit}
className="shrink-0" className="shrink-0"
title={!canEdit ? 'Read-only access for this organization.' : undefined}
> >
<UserPlus className="w-4 h-4 mr-2" /> <UserPlus className="w-4 h-4 mr-2" />
Invite member Invite member
</Button> </Button>
)}
</div> </div>
{seats && ( {seats && (
@@ -273,7 +349,9 @@ export default function StaffPage() {
</span> </span>
{!seats.unlimited && atSeatLimit && ( {!seats.unlimited && atSeatLimit && (
<span className="text-amber-600 dark:text-amber-400 ml-2"> <span className="text-amber-600 dark:text-amber-400 ml-2">
Limit reached remove a member or upgrade your plan. {hasActivePlan
? 'Plan seat limit reached for this organization.'
: 'No active plan selected for this organization. Choose a subscription plan to invite members.'}
</span> </span>
)} )}
</p> </p>
@@ -325,15 +403,21 @@ export default function StaffPage() {
onClick={async () => { onClick={async () => {
try { try {
await navigator.clipboard.writeText(lastInviteInfo.invitationUrl as string); await navigator.clipboard.writeText(lastInviteInfo.invitationUrl as string);
setCopiedInviteLink(true); setCopiedInviteMembershipId(lastInviteInfo.membershipId);
setTimeout(() => setCopiedInviteLink(false), 1500); setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch { } catch {
setError('Could not copy invitation link'); setError('Could not copy invitation link');
} }
}} }}
> >
{copiedInviteLink ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />} {copiedInviteMembershipId === lastInviteInfo.membershipId ? (
<span className="ml-1">{copiedInviteLink ? 'Copied' : 'Copy link'}</span> <Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
<span className="ml-1">
{copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'}
</span>
</Button> </Button>
</div> </div>
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">
@@ -356,12 +440,12 @@ export default function StaffPage() {
<th className="p-3 font-medium">Role</th> <th className="p-3 font-medium">Role</th>
<th className="p-3 font-medium">Status</th> <th className="p-3 font-medium">Status</th>
<th className="p-3 font-medium">Access</th> <th className="p-3 font-medium">Access</th>
{canEdit && <th className="p-3 font-medium w-28">Actions</th>} <th className="p-3 font-medium w-28">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{members.map((m) => ( {members.map((m) => (
<tr key={m.id} className="border-b border-border/40 last:border-0"> <tr key={m.id} className="h-14 border-b border-border/40 last:border-0">
<td className="p-3 text-text-primary">{m.name}</td> <td className="p-3 text-text-primary">{m.name}</td>
<td className="p-3 text-text-secondary">{m.email}</td> <td className="p-3 text-text-secondary">{m.email}</td>
<td className="p-3"> <td className="p-3">
@@ -371,7 +455,7 @@ export default function StaffPage() {
<span className="text-text-secondary">Staff</span> <span className="text-text-secondary">Staff</span>
)} )}
</td> </td>
<td className="p-3"> <td className="p-3 align-middle">
{m.isOwner || m.invitationStatus === 'ACTIVE' ? ( {m.isOwner || m.invitationStatus === 'ACTIVE' ? (
<span className="inline-flex items-center rounded-full border border-emerald-600/40 bg-emerald-600/15 px-2 py-0.5 text-xs text-emerald-400"> <span className="inline-flex items-center rounded-full border border-emerald-600/40 bg-emerald-600/15 px-2 py-0.5 text-xs text-emerald-400">
Active Active
@@ -392,34 +476,71 @@ export default function StaffPage() {
<span className="text-text-muted">All features</span> <span className="text-text-muted">All features</span>
) : ( ) : (
<span className="line-clamp-3 text-sm leading-relaxed"> <span className="line-clamp-3 text-sm leading-relaxed">
{formatAccessSummary(m.permissions)} {formatAccessSummary(m.permissions, currentOrganization?.type)}
</span> </span>
)} )}
</td> </td>
{canEdit && ( <td className="p-3 align-middle">
<td className="p-3">
{!m.isOwner && ( {!m.isOwner && (
<div className="flex items-center gap-1"> <div className="flex min-h-[36px] items-center justify-end gap-1">
{m.invitationStatus === 'PENDING' && pendingInviteLinks[m.id]?.invitationUrl && (
<button <button
type="button" type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary" className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary"
onClick={async () => {
try {
await navigator.clipboard.writeText(pendingInviteLinks[m.id].invitationUrl);
setCopiedInviteMembershipId(m.id);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch {
setError('Could not copy invitation link');
}
}}
aria-label="Copy invite link"
title="Copy invite link"
>
{copiedInviteMembershipId === m.id ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
)}
<button
type="button"
className={`p-2 rounded-md ${
canEdit
? 'text-text-secondary hover:bg-background-card/80 hover:text-text-primary'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Edit member" aria-label="Edit member"
onClick={() => openEdit(m)} disabled={!canEdit}
onClick={() => {
if (!canEdit) return;
openEdit(m);
}}
> >
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
</button> </button>
<button <button
type="button" type="button"
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600" className={`p-2 rounded-md ${
canEdit
? 'text-text-secondary hover:bg-red-500/15 hover:text-red-600'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Remove member" aria-label="Remove member"
onClick={() => void removeMember(m)} disabled={!canEdit}
onClick={() => {
if (!canEdit) return;
void removeMember(m);
}}
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>
)} )}
</td> </td>
)}
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -452,7 +573,11 @@ export default function StaffPage() {
/> />
<div> <div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p> <p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<PermissionGrid state={invitePerms} onChange={setInvitePerms} /> <PermissionGrid
state={invitePerms}
onChange={setInvitePerms}
organizationType={currentOrganization?.type}
/>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button variant="outline" type="button" onClick={() => setInviteOpen(false)}> <Button variant="outline" type="button" onClick={() => setInviteOpen(false)}>
@@ -483,7 +608,11 @@ export default function StaffPage() {
<Input label="Display name" value={editName} onChange={(e) => setEditName(e.target.value)} /> <Input label="Display name" value={editName} onChange={(e) => setEditName(e.target.value)} />
<div> <div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p> <p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<PermissionGrid state={editPerms} onChange={setEditPerms} /> <PermissionGrid
state={editPerms}
onChange={setEditPerms}
organizationType={currentOrganization?.type}
/>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button variant="outline" type="button" onClick={() => setEditing(null)}> <Button variant="outline" type="button" onClick={() => setEditing(null)}>

View File

@@ -5,15 +5,27 @@
export const STAFF_FEATURE_GROUPS = [ export const STAFF_FEATURE_GROUPS = [
{ label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' }, { 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: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' }, { label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
{ label: 'Appointments', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, { label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
{ label: 'Staff Management', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' }, { label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },
{ label: 'Lab Management', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' },
{ label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' }, { label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' },
{ label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' }, { label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' },
] as const; ] as const;
export type FeaturePermState = Record<string, { read: boolean; edit: boolean }>; export type FeaturePermState = Record<string, { read: boolean; edit: boolean }>;
export type OrgType = 'CLINIC' | 'LAB' | null | undefined;
export function resolveStaffFeatureLabel(
group: (typeof STAFF_FEATURE_GROUPS)[number],
organizationType: OrgType,
): string {
if (group.read === 'TAB_LAB_READ') {
return organizationType === 'LAB' ? 'Clinics' : 'Labs';
}
return group.label;
}
export function emptyFeaturePermissionState(): FeaturePermState { export function emptyFeaturePermissionState(): FeaturePermState {
const s: FeaturePermState = {}; const s: FeaturePermState = {};
@@ -46,7 +58,10 @@ export function permissionNamesFromFeatureState(state: FeaturePermState): string
} }
/** Human-readable access for the team table — feature name, or "Feature (Read only)" */ /** Human-readable access for the team table — feature name, or "Feature (Read only)" */
export function formatAccessSummary(permissionNames: string[] | null | undefined): string { export function formatAccessSummary(
permissionNames: string[] | null | undefined,
organizationType?: OrgType,
): string {
if (!permissionNames?.length) return 'No tab access'; if (!permissionNames?.length) return 'No tab access';
const set = new Set(permissionNames); const set = new Set(permissionNames);
const parts: string[] = []; const parts: string[] = [];
@@ -54,7 +69,8 @@ export function formatAccessSummary(permissionNames: string[] | null | undefined
const hasEdit = set.has(g.edit); const hasEdit = set.has(g.edit);
const hasRead = set.has(g.read) || hasEdit; const hasRead = set.has(g.read) || hasEdit;
if (!hasRead) continue; if (!hasRead) continue;
parts.push(hasEdit ? g.label : `${g.label} (Read only)`); const label = resolveStaffFeatureLabel(g, organizationType);
parts.push(hasEdit ? label : `${label} (Read only)`);
} }
return parts.length ? parts.join(' · ') : 'No tab access'; return parts.length ? parts.join(' · ') : 'No tab access';
} }

View File

@@ -1,10 +1,31 @@
'use client';
import Link from 'next/link';
import { useAuth } from '@/lib/hooks/useAuth';
export default function TodayPage() { export default function TodayPage() {
const { currentOrganization } = useAuth();
const showNoSubscriptionNotice =
Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan;
return ( return (
<div> <div>
<h1 className="text-2xl font-semibold mb-6"> <h1 className="text-2xl font-semibold mb-6">
Welcome back Babak !! Welcome back Babak !!
</h1> </h1>
{showNoSubscriptionNotice && (
<div className="mb-6 rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
<p className="text-sm text-amber-200">
This organization does not have an active subscription yet.{' '}
<Link href="/settings/subscriptions" className="font-medium underline underline-offset-2">
Choose a plan
</Link>{' '}
to start the purchase process.
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<Card title="Today's Appointments" value="12" sub="Monday 2/5/2026" /> <Card title="Today's Appointments" value="12" sub="Monday 2/5/2026" />
<Card title="Active Patients" value="675" /> <Card title="Active Patients" value="675" />

View File

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

View File

@@ -17,10 +17,10 @@ import { canViewTab } from '@/shared/permissions';
const menu = [ const menu = [
{ name: 'Today', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const }, { name: 'Today', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const },
{ name: 'Staff', path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const },
{ name: 'Patients', path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const }, { name: 'Patients', path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const },
{ name: 'Appointments', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const }, { name: 'Appointment', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const },
{ name: 'Staff Management', path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const }, { name: 'Treatment', path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ' as const },
{ name: 'Lab Management', path: '/lab', icon: FlaskConical, read: 'TAB_LAB_READ' as const },
{ name: 'Billing', path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ' as const }, { name: 'Billing', path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ' as const },
{ name: 'Reports', path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ' as const }, { name: 'Reports', path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ' as const },
]; ];
@@ -28,10 +28,23 @@ const menu = [
function Sidebar() { function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { currentOrganization } = useAuth(); const { currentOrganization } = useAuth();
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
const visibleMenu = useMemo( const visibleMenu = useMemo(
() => menu.filter((item) => canViewTab(currentOrganization, item.read)), () => {
[currentOrganization], const withCounterpartTab = [
menu[0],
menu[1],
{ name: counterpartLabel, path: '/lab', icon: FlaskConical, read: 'TAB_LAB_READ' as const },
menu[2],
menu[3],
menu[4],
menu[5],
menu[6],
];
return withCounterpartTab.filter((item) => canViewTab(currentOrganization, item.read));
},
[counterpartLabel, currentOrganization],
); );
return ( return (

View File

@@ -17,6 +17,7 @@ import type { SubscriptionAlertData } from '@/types/subscription';
function warningTooltip(data: SubscriptionAlertData | null): string { function warningTooltip(data: SubscriptionAlertData | null): string {
if (!data?.showWarning) return ''; if (!data?.showWarning) return '';
if (data.noActiveSubscription) return 'No active subscription — review Subscriptions';
if (data.trialExpired) return 'Trial ended — review Subscriptions'; if (data.trialExpired) return 'Trial ended — review Subscriptions';
if (data.trialEndingSoon) return 'Trial ending soon — review Subscriptions'; if (data.trialEndingSoon) return 'Trial ending soon — review Subscriptions';
if (data.seatsLow) return 'Seats running low — review Subscriptions'; if (data.seatsLow) return 'Seats running low — review Subscriptions';

View File

@@ -2,17 +2,17 @@ import type { Organization } from '@/types/organization';
const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [ const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [
{ prefix: '/today', permission: 'TAB_TODAY_READ' }, { prefix: '/today', permission: 'TAB_TODAY_READ' },
{ prefix: '/patients', permission: 'TAB_PATIENTS_READ' },
{ prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ' },
{ prefix: '/staff', permission: 'TAB_STAFF_READ' }, { prefix: '/staff', permission: 'TAB_STAFF_READ' },
{ prefix: '/lab', permission: 'TAB_LAB_READ' }, { prefix: '/lab', permission: 'TAB_LAB_READ' },
{ prefix: '/patients', permission: 'TAB_PATIENTS_READ' },
{ prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ' },
{ prefix: '/treatment', permission: 'TAB_TREATMENT_READ' },
{ prefix: '/billing', permission: 'TAB_BILLING_READ' }, { prefix: '/billing', permission: 'TAB_BILLING_READ' },
{ prefix: '/reports', permission: 'TAB_REPORTS_READ' }, { prefix: '/reports', permission: 'TAB_REPORTS_READ' },
]; ];
export function hasPermission(org: Organization | null, permission: string): boolean { export function hasPermission(org: Organization | null, permission: string): boolean {
if (!org) return false; if (!org) return false;
if (org.isOwner) return true;
return Boolean(org.permissions?.includes(permission)); return Boolean(org.permissions?.includes(permission));
} }
@@ -33,7 +33,6 @@ export function getRequiredReadPermissionForPath(pathname: string): string | nul
/** First dashboard route the user may open (ordered). Fallback: account settings. */ /** First dashboard route the user may open (ordered). Fallback: account settings. */
export function firstAccessibleDashboardPath(org: Organization | null): string { export function firstAccessibleDashboardPath(org: Organization | null): string {
if (!org) return '/today'; if (!org) return '/today';
if (org.isOwner) return '/today';
for (const { prefix, permission } of ROUTE_TAB_READ) { for (const { prefix, permission } of ROUTE_TAB_READ) {
if (hasPermission(org, permission)) return prefix; if (hasPermission(org, permission)) return prefix;
} }

View File

@@ -1,11 +1,12 @@
/** GET /auth/subscription-alert — owners only get meaningful flags */ /** GET /auth/subscription-alert — owners only get meaningful flags */
export interface SubscriptionAlertData { export interface SubscriptionAlertData {
showWarning: boolean; showWarning: boolean;
noActiveSubscription?: boolean;
seatsLow: boolean; seatsLow: boolean;
trialEndingSoon: boolean; trialEndingSoon: boolean;
trialExpired: boolean; trialExpired: boolean;
seatsUsed?: number; seatsUsed?: number;
seatsLimit?: number; seatsLimit?: number | null;
daysUntilTrialEnd?: number | null; daysUntilTrialEnd?: number | null;
trialEndsAt?: string | null; trialEndsAt?: string | null;
daysUntilPlanEnd?: number | null; daysUntilPlanEnd?: number | null;