improvement: newly created organization's subscriptions flow improved. #8

Merged
admin merged 1 commits from improvement/new-org-subscription into master 2026-04-30 18:19:28 +03:30
15 changed files with 264 additions and 92 deletions
Showing only changes of commit 64215f07e6 - Show all commits

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

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

@@ -32,6 +32,16 @@ const ALL_PERMISSIONS = [
'TAB_REPORTS_EDIT', 'TAB_REPORTS_EDIT',
]; ];
const READ_ONLY_PERMISSIONS = [
'TAB_TODAY_READ',
'TAB_PATIENTS_READ',
'TAB_APPOINTMENTS_READ',
'TAB_STAFF_READ',
'TAB_LAB_READ',
'TAB_BILLING_READ',
'TAB_REPORTS_READ',
];
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
@@ -141,9 +151,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 +261,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 +276,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 +289,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 +344,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 +463,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 +668,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 +735,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 +775,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 +798,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 +822,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 +834,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 +882,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>
)} )}
<p className="text-sm text-text-secondary"> <div className="space-y-3 pt-2">
Payment and plan upgrades will connect here. The warning on the settings <p className="text-sm text-text-secondary">
icon is only shown to workspace owners when seats are low or the trial window Choose a plan to continue. Purchase integration is not active yet, so this
is ending. currently prepares the selection step only.
</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

@@ -119,6 +119,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;
@@ -240,7 +241,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 +249,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" /> >
Invite member <UserPlus className="w-4 h-4 mr-2" />
</Button> Invite member
)} </Button>
</div> </div>
{seats && ( {seats && (
@@ -273,7 +274,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>
@@ -356,7 +359,7 @@ 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>
@@ -396,30 +399,44 @@ export default function StaffPage() {
</span> </span>
)} )}
</td> </td>
{canEdit && ( <td className="p-3">
<td className="p-3"> {!m.isOwner && (
{!m.isOwner && ( <div className="flex items-center gap-1">
<div className="flex items-center gap-1"> <button
<button type="button"
type="button" className={`p-2 rounded-md ${
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary" canEdit
aria-label="Edit member" ? 'text-text-secondary hover:bg-background-card/80 hover:text-text-primary'
onClick={() => openEdit(m)} : 'text-text-muted opacity-50 cursor-not-allowed'
> }`}
<Pencil className="w-4 h-4" /> aria-label="Edit member"
</button> disabled={!canEdit}
<button onClick={() => {
type="button" if (!canEdit) return;
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600" openEdit(m);
aria-label="Remove member" }}
onClick={() => void removeMember(m)} >
> <Pencil className="w-4 h-4" />
<Trash2 className="w-4 h-4" /> </button>
</button> <button
</div> type="button"
)} className={`p-2 rounded-md ${
</td> 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"
disabled={!canEdit}
onClick={() => {
if (!canEdit) return;
void removeMember(m);
}}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

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

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

@@ -12,7 +12,6 @@ const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [
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 +32,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;