feature/clinic-lab-invitation #14

Merged
admin merged 4 commits from feature/clinic-lab-invitation into master 2026-05-06 19:28:13 +03:30
8 changed files with 89 additions and 83 deletions
Showing only changes of commit 6258477860 - Show all commits

View File

@@ -1,7 +1,7 @@
// src/app/(dashboard)/billing/page.tsx
'use client';
import { useState } from 'react';
import { Search, Filter, Plus } from 'lucide-react';
import { Search } from 'lucide-react';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { Badge } from '@/components/ui/common/Badge';
@@ -44,11 +44,9 @@ export default function BillingPage() {
<h1 className="text-2xl font-semibold text-text-primary">Billing</h1>
<Button
variant="primary"
className="flex items-center gap-2"
disabled={!canEditBilling}
title={!canEditBilling ? 'Read-only access for this organization.' : undefined}
>
<Plus className="h-4 w-4 icon-flat" />
New Invoice
</Button>
</div>
@@ -129,7 +127,7 @@ export default function BillingPage() {
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Paid
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
@@ -158,7 +156,7 @@ export default function BillingPage() {
<td className="px-6 py-4 text-sm text-text-primary">
${invoice.paid}
</td>
<td className="px-6 py-4">
<td className="px-6 py-4 text-center align-middle">
<Badge
variant={statusColors[invoice.status as keyof typeof statusColors]}
className="capitalize"

View File

@@ -1,7 +1,6 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Check, Copy, Search, Send } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import {
organizationApi,
@@ -9,6 +8,7 @@ import {
type CounterpartSearchResultDto,
} from '@/lib/api/organization';
import { Button } from '@/components/ui/common/Button';
import { Badge, organizationLinkStatusVariant } from '@/components/ui/common/Badge';
import { Input } from '@/components/ui/common/Input';
import type { ApiError } from '@/types/api';
@@ -39,6 +39,13 @@ function writeStoredInviteLinks(orgId: string, links: Record<string, StoredInvit
window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links));
}
/** First letter uppercase, rest lowercase (e.g. ACTIVE → Active). */
function formatOrganizationStatusLabel(status: string): string {
if (!status) return status;
const lower = status.toLowerCase();
return lower.charAt(0).toUpperCase() + lower.slice(1);
}
function formatApiMessage(err: unknown): string {
if (!err || typeof err !== 'object') return 'Something went wrong';
const m = (err as ApiError).message;
@@ -202,7 +209,6 @@ export default function OrganizationsPage() {
placeholder={`Search by ${counterpartLabel.toLowerCase()} name, email, or phone`}
/>
<Button type="button" isLoading={searchLoading} onClick={() => void runSearch()}>
<Search className="w-4 h-4 mr-2" />
Search
</Button>
</div>
@@ -238,7 +244,6 @@ export default function OrganizationsPage() {
disabled={!selectedResult}
onClick={() => void createLink()}
>
<Send className="w-4 h-4 mr-2" />
Request link
</Button>
</div>
@@ -303,9 +308,12 @@ export default function OrganizationsPage() {
<td className="p-3 text-text-primary">{item.organizationName}</td>
<td className="p-3 text-text-secondary">{item.ownerEmail}</td>
<td className="p-3">
<span className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-text-primary">
{item.status}
</span>
<Badge
variant={organizationLinkStatusVariant(item.status)}
fixedWidth
>
{formatOrganizationStatusLabel(item.status)}
</Badge>
</td>
<td className="p-3">
{item.kind === 'INVITATION' &&
@@ -327,14 +335,7 @@ export default function OrganizationsPage() {
}
}}
>
{copiedId === item.id ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
<span className="ml-1">
{copiedId === item.id ? 'Copied' : 'Copy link'}
</span>
{copiedId === item.id ? 'Copied' : 'Copy link'}
</Button>
)}
</td>

View File

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

View File

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

View File

@@ -16,10 +16,11 @@ import {
formatAccessSummary,
type FeaturePermState,
} from './staff-permission-form';
import { UserPlus, Pencil, Trash2, Copy, Check, X, Clock3 } from 'lucide-react';
import { Pencil, Trash2, Copy, Check, X } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { staffApi, type StaffMemberDto } from '@/lib/api/staff';
import { Button } from '@/components/ui/common/Button';
import { Badge } from '@/components/ui/common/Badge';
import { Input } from '@/components/ui/common/Input';
import { Checkbox } from '@/components/ui/common/Checkbox';
import type { ApiError } from '@/types/api';
@@ -335,7 +336,6 @@ export default function StaffPage() {
className="shrink-0"
title={!canEdit ? 'Read-only access for this organization.' : undefined}
>
<UserPlus className="w-4 h-4 mr-2" />
Invite member
</Button>
</div>
@@ -410,14 +410,7 @@ export default function StaffPage() {
}
}}
>
{copiedInviteMembershipId === lastInviteInfo.membershipId ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
<span className="ml-1">
{copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'}
</span>
{copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'}
</Button>
</div>
<p className="text-xs text-text-muted">
@@ -438,7 +431,7 @@ export default function StaffPage() {
<th className="p-3 font-medium">Name</th>
<th className="p-3 font-medium">Email</th>
<th className="p-3 font-medium">Role</th>
<th className="p-3 font-medium">Status</th>
<th className="p-3 font-medium text-center">Status</th>
<th className="p-3 font-medium">Access</th>
<th className="p-3 font-medium w-28">Actions</th>
</tr>
@@ -455,20 +448,13 @@ export default function StaffPage() {
<span className="text-text-secondary">Staff</span>
)}
</td>
<td className="p-3 align-middle">
<td className="p-3 align-middle text-center">
{m.isOwner || m.invitationStatus === 'ACTIVE' ? (
<span className="inline-flex items-center rounded-full border border-emerald-600/40 bg-emerald-600/15 px-2 py-0.5 text-xs text-emerald-400">
Active
</span>
<Badge variant="success">Active</Badge>
) : m.invitationStatus === 'PENDING' ? (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/15 px-2 py-0.5 text-xs text-amber-300">
<Clock3 className="h-3 w-3" />
Pending
</span>
<Badge variant="warning">Pending</Badge>
) : (
<span className="inline-flex items-center rounded-full border border-red-500/40 bg-red-500/15 px-2 py-0.5 text-xs text-red-300">
Expired
</span>
<Badge variant="danger">Expired</Badge>
)}
</td>
<td className="p-3 text-text-secondary max-w-md">

View File

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

View File

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

View File

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