Merge pull request 'improvement: newly created organization's subscriptions flow improved.' (#8) from improvement/new-org-subscription into master

Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/8
This commit was merged in pull request #8.
This commit is contained in:
2026-04-30 18:19:26 +03:30
15 changed files with 264 additions and 92 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

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

@@ -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" /> <UserPlus className="w-4 h-4 mr-2" />
Invite member Invite member
</Button> </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 text-text-secondary hover:bg-background-card/80 hover:text-text-primary" 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>

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;