bugfix/demo-bugs-fixed #19

Merged
admin merged 10 commits from bugfix/demo-bugs-fixed into master 2026-05-18 19:11:30 +03:30
51 changed files with 761 additions and 335 deletions

View File

@@ -126,7 +126,11 @@ export class AuthController {
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create organization for current user' })
async createOrganization(@Req() req, @Body() dto: CreateOrganizationDto) {
return this.authService.createOrganization(req.user.id, dto);
return this.authService.createOrganization(
req.user.id,
req.user.organizationId,
dto,
);
}
// =========================

View File

@@ -4,6 +4,7 @@ import {
UnauthorizedException,
BadRequestException,
ConflictException,
ForbiddenException,
InternalServerErrorException
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@@ -270,7 +271,11 @@ export class AuthService {
return this.login({ email, password } as any, validatedUser);
}
async createOrganization(userId: string, dto: CreateOrganizationDto) {
async createOrganization(
userId: string,
currentOrganizationId: string | undefined,
dto: CreateOrganizationDto,
) {
const owner = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true },
@@ -280,6 +285,28 @@ export class AuthService {
throw new UnauthorizedException('User not found');
}
if (!currentOrganizationId) {
throw new ForbiddenException(
'Select an organization before creating a new one.',
);
}
const currentMembership = await this.prisma.membership.findUnique({
where: {
userId_organizationId: {
userId,
organizationId: currentOrganizationId,
},
},
select: { isOwner: true },
});
if (!currentMembership?.isOwner) {
throw new ForbiddenException(
'Only owners of the current organization can create new organizations.',
);
}
const organization = await this.prisma.$transaction(async (tx) => {
const createdOrganization = await tx.organization.create({
data: {

View File

@@ -80,6 +80,32 @@ export class StaffController {
return this.staffService.updateMember(req.user.id, organizationId, membershipId, dto);
}
@Patch('members/:membershipId/enable')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Re-enable a disabled staff member (uses one plan seat; no new invitation)',
})
enableMember(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('membershipId') membershipId: string,
) {
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
return this.staffService.enableMember(req.user.id, organizationId, membershipId);
}
@Patch('members/:membershipId/disable')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Disable staff member (frees a seat; member cannot access this organization)',
})
disableMember(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('membershipId') membershipId: string,
) {
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
return this.staffService.disableMember(req.user.id, organizationId, membershipId);
}
@Delete('members/:membershipId')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Remove staff member from organization' })

View File

@@ -7,6 +7,7 @@ import {
} from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions';
@@ -399,6 +400,88 @@ export class StaffService {
return { success: true, message: 'Member updated' };
}
async enableMember(actorUserId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot manage staff');
}
const target = await this.prisma.membership.findFirst({
where: { id: membershipId, organizationId },
include: {
invitations: { orderBy: { createdAt: 'desc' }, take: 1 },
},
});
if (!target) {
throw new NotFoundException('Member not found');
}
if (target.isOwner) {
throw new ForbiddenException('Cannot enable the organization owner');
}
if (target.isActive) {
throw new BadRequestException('This member is already active');
}
const invitation = target.invitations[0];
if (invitation && !invitation.acceptedAt) {
throw new BadRequestException(
'This member has not completed their invitation yet. Share the invite link instead.',
);
}
await this.prisma.$transaction(async (tx) => {
await this.assertOrganizationHasAvailableSeat(organizationId, tx);
await tx.membership.update({
where: { id: membershipId },
data: { isActive: true },
});
});
return {
success: true,
message: 'Member enabled. They can sign in to this organization again.',
};
}
async disableMember(actorUserId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot manage staff');
}
const target = await this.prisma.membership.findFirst({
where: { id: membershipId, organizationId },
});
if (!target) {
throw new NotFoundException('Member not found');
}
if (target.isOwner) {
throw new ForbiddenException('Cannot disable the organization owner');
}
if (actorUserId === target.userId) {
throw new BadRequestException('You cannot disable your own access');
}
if (!target.isActive) {
throw new BadRequestException('This member is already disabled or pending activation');
}
await this.prisma.membership.update({
where: { id: membershipId },
data: { isActive: false },
});
await this.prisma.session.deleteMany({
where: { userId: target.userId },
});
return {
success: true,
message: 'Member disabled. Their seat is now available for another invite.',
};
}
async removeMember(actorUserId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
@@ -421,6 +504,37 @@ export class StaffService {
return { success: true, message: 'Member removed' };
}
private async assertOrganizationHasAvailableSeat(
organizationId: string,
db: Prisma.TransactionClient | PrismaService = this.prisma,
) {
const org = await db.organization.findUnique({
where: { id: organizationId },
include: { plan: true },
});
if (!org) {
throw new NotFoundException('Organization not found');
}
if (!org.plan) {
throw new BadRequestException(
'This organization has no active subscription. Please choose a plan before adding staff.',
);
}
const maxUsers = org.plan.maxUsers;
const seatsUsed = await db.membership.count({
where: {
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
});
if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) {
throw new BadRequestException(
`Your plan allows ${maxUsers} team members. Free a seat by disabling another member or upgrade your plan.`,
);
}
}
private async getActorMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId },
@@ -435,11 +549,12 @@ export class StaffService {
isOwner: boolean;
isActive: boolean;
invitations: { acceptedAt: Date | null; revokedAt: Date | null; expiresAt: Date }[];
}): 'ACTIVE' | 'PENDING' | 'EXPIRED' {
if (m.isOwner || m.isActive) return 'ACTIVE';
}): 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED' {
if (m.isOwner) return 'ACTIVE';
if (m.isActive) return 'ACTIVE';
const invitation = m.invitations[0];
if (!invitation) return 'EXPIRED';
if (invitation.acceptedAt) return 'ACTIVE';
if (invitation?.acceptedAt) return 'DISABLED';
if (!invitation) return 'DISABLED';
if (invitation.revokedAt) return 'EXPIRED';
return invitation.expiresAt.getTime() > Date.now() ? 'PENDING' : 'EXPIRED';
}

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { appointmentsApi } from '@/lib/api/appointments';
import { patientsApi } from '@/lib/api/patients';
import { useAuth } from '@/lib/hooks/useAuth';
import { canEditAppointments, hasPermission } from '@/shared/permissions';
import { canEditAppointments, hasPermission } from '@/components/shared/permissions';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import type { CreatePatientInput, Patient } from '@/types/patient';
import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal';
@@ -13,12 +13,12 @@ import { AppointmentBookingModal } from '@/components/ui/appointments/Appointmen
import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid';
import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch';
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend';
import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker';
import { ToastStack } from '@/components/ui/common/Toast';
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
import { ToastStack } from '@/components/ui/shared/Toast';
import { useToast } from '@/lib/hooks/useToast';
import type { AppointmentPurpose } from '@/types/appointment';
import { formatApiErrorMessage } from '@/lib/formatApiError';
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime';
const EMPTY_PATIENT_FORM: CreatePatientInput = {
firstName: '',
@@ -273,6 +273,7 @@ export default function AppointmentsPage() {
if (!canEditPatients) {
return;
}
setPatientForm(EMPTY_PATIENT_FORM);
setIsCreateOpen(true);
}}
/>
@@ -285,7 +286,6 @@ export default function AppointmentsPage() {
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
<ScheduleDayPicker
value={scheduleDate}
minDate={todayStart}
onChange={(d) => setScheduleDate(startOfLocalDay(d))}
/>
{loadingSchedule && (
@@ -323,18 +323,18 @@ export default function AppointmentsPage() {
deleting={deletingAppointment}
/>
{isCreateOpen && (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/55">
<CreatePatientModal
variant="dialog"
isOpen={isCreateOpen}
formData={patientForm}
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
onSubmit={() => void handleCreatePatient()}
onClose={() => setIsCreateOpen(false)}
onClose={() => {
setIsCreateOpen(false);
setPatientForm(EMPTY_PATIENT_FORM);
}}
loading={savingPatient}
/>
</div>
)}
</div>
);

View File

@@ -2,13 +2,13 @@
'use client';
import { useState } from 'react';
import { Pencil } from 'lucide-react';
import { Button } from '@/components/ui/common/Button';
import { Badge } from '@/components/ui/common/Badge';
import { Card } from '@/components/ui/common/Card';
import { Table } from '@/components/ui/common/Table';
import { SearchBar } from '@/components/ui/common/SearchBar';
import { Button } from '@/components/ui/shared/Button';
import { Badge } from '@/components/ui/shared/Badge';
import { Card } from '@/components/ui/shared/Card';
import { Table } from '@/components/ui/shared/Table';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { useAuth } from '@/lib/hooks/useAuth';
import { hasPermission } from '@/shared/permissions';
import { hasPermission } from '@/components/shared/permissions';
// Mock data matching your design
const invoices = [
{ id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' },

View File

@@ -3,15 +3,15 @@
import { memo, useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import Sidebar from '@/components/ui/common/Sidebar';
import { ThemeToggle } from '@/components/ui/common/ThemeToggle';
import Sidebar from '@/components/ui/shared/Sidebar';
import { ThemeToggle } from '@/components/ui/shared/ThemeToggle';
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
import {
canAccessAppointmentsSection,
firstAccessibleDashboardPath,
getRequiredReadPermissionForPath,
hasPermission,
} from '@/shared/permissions';
} from '@/components/shared/permissions';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, currentOrganization, isAuthReady } = useAuth();

View File

@@ -14,12 +14,12 @@ import {
import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks';
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog';
import { Button } from '@/components/ui/common/Button';
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/common/Badge';
import { Input } from '@/components/ui/common/Input';
import { SearchBar } from '@/components/ui/common/SearchBar';
import { Table } from '@/components/ui/common/Table';
import { ToastStack } from '@/components/ui/common/Toast';
import { Button } from '@/components/ui/shared/Button';
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge';
import { Input } from '@/components/ui/shared/Input';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { Table } from '@/components/ui/shared/Table';
import { ToastStack } from '@/components/ui/shared/Toast';
import type { ApiError } from '@/types/api';
function formatOrganizationStatusLabel(status: string): string {

View File

@@ -1,10 +1,13 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/common/Button';
import { Button } from '@/components/ui/shared/Button';
import { ToastStack } from '@/components/ui/shared/Toast';
import { patientsApi } from '@/lib/api/patients';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { hasPermission } from '@/shared/permissions';
import { useToast } from '@/lib/hooks/useToast';
import { hasPermission } from '@/components/shared/permissions';
import {
CreatePatientInput,
CreateTreatmentHistoryInput,
@@ -25,6 +28,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = {
export default function PatientsPage() {
const { currentOrganization } = useAuth();
const toast = useToast();
const [search, setSearch] = useState('');
const [patients, setPatients] = useState<Patient[]>([]);
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
@@ -35,8 +39,6 @@ export default function PatientsPage() {
const [savingPatient, setSavingPatient] = useState(false);
const [savingTreatment, setSavingTreatment] = useState(false);
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
const [errorMessage, setErrorMessage] = useState<string>('');
const [successMessage, setSuccessMessage] = useState<string>('');
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
const sortedPatients = useMemo(
@@ -58,21 +60,9 @@ export default function PatientsPage() {
void loadPatients('');
}, []);
useEffect(() => {
if (!successMessage) {
return;
}
const timeout = setTimeout(() => {
setSuccessMessage('');
}, 3000);
return () => clearTimeout(timeout);
}, [successMessage]);
async function loadPatients(q: string) {
setLoadingPatients(true);
setErrorMessage('');
toast.setError('');
try {
const response = await patientsApi.list({ q, page: 1, limit: 25 });
const items = response.data.items;
@@ -82,9 +72,8 @@ export default function PatientsPage() {
const freshSelected = items.find((item) => item.id === selectedPatient.id);
setSelectedPatient(freshSelected);
}
} catch (error: any) {
const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message;
setErrorMessage(message || 'Failed to load patients.');
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, 'Failed to load patients.'));
} finally {
setLoadingPatients(false);
}
@@ -92,13 +81,12 @@ export default function PatientsPage() {
async function loadTreatments(patientId: string) {
setLoadingTreatments(true);
setErrorMessage('');
toast.setError('');
try {
const response = await patientsApi.listTreatments(patientId);
setTreatments(response.data);
} catch (error: any) {
const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message;
setErrorMessage(message || 'Failed to load treatment history.');
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, 'Failed to load treatment history.'));
} finally {
setLoadingTreatments(false);
}
@@ -106,8 +94,7 @@ export default function PatientsPage() {
async function handleCreatePatient() {
setSavingPatient(true);
setErrorMessage('');
setSuccessMessage('');
toast.setError('');
try {
const response = await patientsApi.create(patientForm);
setIsCreateOpen(false);
@@ -115,12 +102,11 @@ export default function PatientsPage() {
await loadPatients(search);
setSelectedPatient(response.data);
await loadTreatments(response.data.id);
setSuccessMessage(
toast.showSuccess(
`Patient ${response.data.firstName} ${response.data.lastName} was saved successfully.`,
);
} catch (error: any) {
const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message;
setErrorMessage(message || 'Failed to save patient.');
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, 'Failed to save patient.'));
} finally {
setSavingPatient(false);
}
@@ -139,29 +125,29 @@ export default function PatientsPage() {
};
setSavingTreatment(true);
setErrorMessage('');
setSuccessMessage('');
toast.setError('');
try {
await patientsApi.addTreatment(selectedPatient.id, payload);
await loadTreatments(selectedPatient.id);
setSuccessMessage('Treatment entry added successfully.');
} catch (error: any) {
const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message;
setErrorMessage(message || 'Failed to add treatment entry.');
toast.showSuccess('Treatment entry added successfully.');
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, 'Failed to add treatment entry.'));
} finally {
setSavingTreatment(false);
}
}
return (
<div className="relative space-y-6 pb-20">
<div className="flex items-center justify-between">
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-text-primary">Patients</h1>
<Button
variant="primary"
disabled={!canEditPatients}
onClick={() => {
if (!canEditPatients) return;
toast.clear();
setPatientForm(EMPTY_PATIENT_FORM);
setIsCreateOpen(true);
}}
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
@@ -170,14 +156,21 @@ export default function PatientsPage() {
</Button>
</div>
<ToastStack {...toast.messages} />
{isCreateOpen && (
<CreatePatientModal
isOpen={isCreateOpen}
formData={patientForm}
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
onSubmit={handleCreatePatient}
onClose={() => setIsCreateOpen(false)}
onSubmit={() => void handleCreatePatient()}
onClose={() => {
setIsCreateOpen(false);
setPatientForm(EMPTY_PATIENT_FORM);
}}
loading={savingPatient}
/>
)}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-1">
@@ -213,21 +206,6 @@ export default function PatientsPage() {
<TreatmentHistoryPreview items={treatments} loading={loadingTreatments} />
</div>
</div>
{(errorMessage || successMessage) && (
<div className="absolute bottom-0 left-0 right-0 z-10 w-full">
{errorMessage && (
<div className="rounded-[var(--radius-sm)] border border-red-500/50 bg-red-500/10 px-3 py-2 text-sm text-red-300 shadow-lg">
{errorMessage}
</div>
)}
{successMessage && (
<div className="rounded-[var(--radius-sm)] border border-emerald-500/50 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300 shadow-lg">
{successMessage}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -5,8 +5,8 @@ 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 { Toast } from '@/components/ui/common/Toast';
import { Button } from '@/components/ui/shared/Button';
import { Toast } from '@/components/ui/shared/Toast';
import type { SubscriptionAlertData } from '@/types/subscription';
const PLAN_OPTIONS = [

View File

@@ -6,7 +6,7 @@ import {
firstAccessibleDashboardPath,
canEditStaff,
canViewStaff,
} from '@/shared/permissions';
} from '@/components/shared/permissions';
import {
STAFF_FEATURE_GROUPS,
permissionNamesFromFeatureState,
@@ -16,16 +16,18 @@ import {
formatAccessSummary,
type FeaturePermState,
} from '../../../components/staff/staff-permission-form';
import { Pencil, Trash2, Copy, Check, X } from 'lucide-react';
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton';
import { Pencil, Trash2, Copy, Check, X, UserX, UserCheck } from 'lucide-react';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
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 { Table } from '@/components/ui/common/Table';
import type { ApiError } from '@/types/api';
import { Button } from '@/components/ui/shared/Button';
import { Badge } from '@/components/ui/shared/Badge';
import { Input } from '@/components/ui/shared/Input';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { Table } from '@/components/ui/shared/Table';
import { ToastStack } from '@/components/ui/shared/Toast';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast';
type StoredInviteLink = {
membershipId: string;
@@ -54,16 +56,19 @@ function writeStoredInviteLinks(orgId: string, links: Record<string, StoredInvit
window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links));
}
function formatApiMessage(err: unknown): string {
if (!err || typeof err !== 'object') return 'Something went wrong';
const m = (err as ApiError).message;
if (Array.isArray(m)) return m.join(', ');
if (typeof m === 'string') return m;
return 'Something went wrong';
function canShareStaffInviteLink(member: StaffMemberDto): boolean {
return (
!member.isOwner &&
(member.invitationStatus === 'PENDING' || member.invitationStatus === 'EXPIRED')
);
}
function canShareStaffInviteLink(member: StaffMemberDto): boolean {
return !member.isOwner && member.invitationStatus !== 'ACTIVE';
function canDisableStaff(member: StaffMemberDto): boolean {
return !member.isOwner && member.isActive;
}
function canEnableStaff(member: StaffMemberDto): boolean {
return !member.isOwner && member.invitationStatus === 'DISABLED';
}
function PermissionGrid({
@@ -136,8 +141,7 @@ export default function StaffPage() {
unlimited: boolean;
} | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const toast = useToast();
const [inviteOpen, setInviteOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState('');
@@ -159,6 +163,10 @@ export default function StaffPage() {
const [editName, setEditName] = useState('');
const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState());
const [editLoading, setEditLoading] = useState(false);
const [disableTarget, setDisableTarget] = useState<StaffMemberDto | null>(null);
const [disablingMembershipId, setDisablingMembershipId] = useState<string | null>(null);
const [enableTarget, setEnableTarget] = useState<StaffMemberDto | null>(null);
const [enablingMembershipId, setEnablingMembershipId] = useState<string | null>(null);
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
const hasActivePlan = Boolean(currentOrganization?.plan);
@@ -168,15 +176,21 @@ export default function StaffPage() {
return seats.used >= seats.limit;
}, [seats]);
const hasAvailableSeat = useMemo(() => {
if (!seats || seats.unlimited) return true;
if (seats.limit == null) return true;
return seats.used < seats.limit;
}, [seats]);
const load = useCallback(async () => {
setError('');
toast.setError('');
setLoading(true);
try {
const res = await staffApi.list();
setMembers(res.data.members);
setSeats(res.data.seats);
} catch (e) {
setError(formatApiMessage(e));
toast.showError(formatApiErrorMessage(e, 'Failed to load staff.'));
} finally {
setLoading(false);
}
@@ -221,17 +235,11 @@ export default function StaffPage() {
}
}, [currentOrganization, router]);
useEffect(() => {
if (!success) return;
const t = setTimeout(() => setSuccess(''), 4000);
return () => clearTimeout(t);
}, [success]);
async function copyStaffInviteLink(member: StaffMemberDto) {
if (!canShareStaffInviteLink(member)) return;
setCopyingInviteMembershipId(member.id);
setError('');
toast.setError('');
try {
let invitationUrl = pendingInviteLinks[member.id]?.invitationUrl;
if (!invitationUrl || member.invitationStatus === 'EXPIRED') {
@@ -257,7 +265,7 @@ export default function StaffPage() {
await load();
}
} catch (e) {
setError(formatApiMessage(e));
toast.showError(formatApiErrorMessage(e, 'Could not copy invitation link.'));
} finally {
setCopyingInviteMembershipId(null);
}
@@ -265,7 +273,7 @@ export default function StaffPage() {
async function submitInvite() {
setInviteLoading(true);
setError('');
toast.setError('');
setLastInviteInfo(null);
const displayName = inviteName.trim();
const displayEmail = inviteEmail.trim();
@@ -295,14 +303,13 @@ export default function StaffPage() {
setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}
setSuccess('');
setInviteOpen(false);
setInviteEmail('');
setInviteName('');
setInvitePerms(emptyFeaturePermissionState());
await load();
} catch (e) {
setError(formatApiMessage(e));
toast.showError(formatApiErrorMessage(e, 'Failed to send invitation.'));
} finally {
setInviteLoading(false);
}
@@ -320,36 +327,57 @@ export default function StaffPage() {
async function submitEdit() {
if (!editing) return;
setEditLoading(true);
setError('');
toast.setError('');
try {
await staffApi.updateMember(editing.id, {
name: editName.trim(),
permissionNames: permissionNamesFromFeatureState(editPerms),
});
setSuccess('Member updated');
toast.showSuccess('Member updated.');
setEditing(null);
await load();
} catch (e) {
setError(formatApiMessage(e));
toast.showError(formatApiErrorMessage(e, 'Failed to update member.'));
} finally {
setEditLoading(false);
}
}
async function removeMember(m: StaffMemberDto) {
if (m.isOwner) return;
if (m.userId === user?.id) {
if (!confirm('Remove yourself from this organization? You will lose access.')) return;
} else {
if (!confirm(`Remove ${m.name} from this organization?`)) return;
function handleDeleteMember() {
toast.showError('Delete is not implemented yet.');
}
setError('');
async function confirmDisableMember() {
if (!disableTarget || !canDisableStaff(disableTarget)) return;
setDisablingMembershipId(disableTarget.id);
toast.setError('');
try {
await staffApi.removeMember(m.id);
setSuccess('Member removed');
await staffApi.disableMember(disableTarget.id);
toast.showSuccess(`${disableTarget.name} was disabled. A seat is now available.`);
setDisableTarget(null);
await load();
} catch (e) {
setError(formatApiMessage(e));
toast.showError(formatApiErrorMessage(e, 'Failed to disable member.'));
} finally {
setDisablingMembershipId(null);
}
}
async function confirmEnableMember() {
if (!enableTarget || !canEnableStaff(enableTarget) || !hasAvailableSeat) return;
setEnablingMembershipId(enableTarget.id);
toast.setError('');
try {
await staffApi.enableMember(enableTarget.id);
toast.showSuccess(`${enableTarget.name} was enabled and can sign in again.`);
setEnableTarget(null);
await load();
} catch (e) {
toast.showError(formatApiErrorMessage(e, 'Failed to enable member.'));
} finally {
setEnablingMembershipId(null);
}
}
@@ -383,6 +411,8 @@ export default function StaffPage() {
</Button>
</div>
<ToastStack {...toast.messages} />
{seats && (
<p className="text-sm text-text-secondary">
Seats:{' '}
@@ -400,18 +430,6 @@ export default function StaffPage() {
</p>
)}
{error && (
<div className="rounded-[var(--radius-md)] border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{success && (
<div className="rounded-[var(--radius-md)] border border-primary/30 bg-primary-soft/40 px-4 py-3 text-sm text-text-primary">
{success}
</div>
)}
{lastInviteInfo && (
<div className="relative rounded-[var(--radius-md)] border border-border-strong bg-background-secondary/90 px-4 py-3 pr-12 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] space-y-3">
<button
@@ -453,7 +471,7 @@ export default function StaffPage() {
}
void (async () => {
setCopyingInviteMembershipId(lastInviteInfo.membershipId);
setError('');
toast.setError('');
try {
const res = await staffApi.getInvitationLink(lastInviteInfo.membershipId);
if (currentOrganization?.id) {
@@ -473,7 +491,7 @@ export default function StaffPage() {
setCopiedInviteMembershipId(lastInviteInfo.membershipId);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch (e) {
setError(formatApiMessage(e));
toast.showError(formatApiErrorMessage(e, 'Could not copy invitation link.'));
} finally {
setCopyingInviteMembershipId(null);
}
@@ -501,7 +519,9 @@ export default function StaffPage() {
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">Role</th>
<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">Access</th>
<th className="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider w-28">Actions</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider w-36">
Action
</th>
</tr>
}
body={
@@ -522,6 +542,8 @@ export default function StaffPage() {
<Badge variant="success">Active</Badge>
) : m.invitationStatus === 'PENDING' ? (
<Badge variant="warning">Pending</Badge>
) : m.invitationStatus === 'DISABLED' ? (
<Badge variant="default">Disabled</Badge>
) : (
<Badge variant="danger">Expired</Badge>
)}
@@ -535,9 +557,9 @@ export default function StaffPage() {
</span>
)}
</td>
<td className="px-6 py-1.5 align-middle">
<td className="px-6 py-1.5 align-middle text-center">
{!m.isOwner && (
<div className="flex min-h-[36px] items-center justify-end gap-1">
<div className="flex min-h-[36px] items-center justify-center gap-1 mx-auto w-fit">
{canShareStaffInviteLink(m) && (
<button
type="button"
@@ -554,6 +576,44 @@ export default function StaffPage() {
)}
</button>
)}
{canEnableStaff(m) && (
<button
type="button"
className={`p-2 rounded-md ${
canEdit
? 'text-text-secondary hover:bg-background-card/80 hover:text-primary'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Enable member"
disabled={!canEdit || enablingMembershipId === m.id}
title="Enable member (uses a seat)"
onClick={() => {
if (!canEdit) return;
setEnableTarget(m);
}}
>
<UserCheck className="w-4 h-4" />
</button>
)}
{canDisableStaff(m) && (
<button
type="button"
className={`p-2 rounded-md ${
canEdit
? 'text-text-secondary hover:bg-background-card/80 hover:text-amber-600'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Disable member"
disabled={!canEdit || disablingMembershipId === m.id}
title="Disable member (frees a seat)"
onClick={() => {
if (!canEdit) return;
setDisableTarget(m);
}}
>
<UserX className="w-4 h-4" />
</button>
)}
<button
type="button"
className={`p-2 rounded-md ${
@@ -577,11 +637,12 @@ export default function StaffPage() {
? '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="Delete member"
disabled={!canEdit}
title="Delete member (not implemented)"
onClick={() => {
if (!canEdit) return;
void removeMember(m);
handleDeleteMember();
}}
>
<Trash2 className="w-4 h-4" />
@@ -647,6 +708,120 @@ export default function StaffPage() {
</div>
)}
{enableTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/55">
<div
className="surface-card w-full max-w-md p-5 space-y-4 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="enable-staff-title"
>
<div className="flex items-start justify-between gap-2">
<h2 id="enable-staff-title" className="text-lg font-semibold text-text-primary pr-2">
Enable team member
</h2>
<DialogCloseButton
onClick={() => {
if (enablingMembershipId) return;
setEnableTarget(null);
}}
/>
</div>
<p className="text-sm text-text-secondary">
Enable <span className="font-medium text-text-primary">{enableTarget.name}</span> (
{enableTarget.email})?
</p>
<ul className="text-sm text-text-secondary space-y-2 list-disc pl-5">
<li>They can sign in to this organization again with their existing account.</li>
<li>No new invitation is sent and no data was removed while they were disabled.</li>
<li>
Enabling uses <span className="text-text-primary font-medium">one seat</span> on your
plan.
</li>
</ul>
{!hasAvailableSeat && (
<p className="text-sm text-amber-600 dark:text-amber-400">
No seats are available. Disable another member or upgrade your plan before enabling
this person.
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<Button
type="button"
variant="outline"
disabled={Boolean(enablingMembershipId)}
onClick={() => setEnableTarget(null)}
>
Cancel
</Button>
<Button
type="button"
variant="primary"
isLoading={enablingMembershipId === enableTarget.id}
disabled={Boolean(enablingMembershipId) || !hasAvailableSeat}
onClick={() => void confirmEnableMember()}
>
Enable member
</Button>
</div>
</div>
</div>
)}
{disableTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/55">
<div
className="surface-card w-full max-w-md p-5 space-y-4 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="disable-staff-title"
>
<div className="flex items-start justify-between gap-2">
<h2 id="disable-staff-title" className="text-lg font-semibold text-text-primary pr-2">
Disable team member
</h2>
<DialogCloseButton
onClick={() => {
if (disablingMembershipId) return;
setDisableTarget(null);
}}
/>
</div>
<p className="text-sm text-text-secondary">
Disable <span className="font-medium text-text-primary">{disableTarget.name}</span> (
{disableTarget.email})?
</p>
<ul className="text-sm text-text-secondary space-y-2 list-disc pl-5">
<li>They will not be able to sign in to this organization.</li>
<li>No data will be removed.</li>
<li>
Disabling frees <span className="text-text-primary font-medium">one seat</span> on your
plan so you can invite someone else.
</li>
</ul>
<div className="flex justify-end gap-2 pt-1">
<Button
type="button"
variant="outline"
disabled={Boolean(disablingMembershipId)}
onClick={() => setDisableTarget(null)}
>
Cancel
</Button>
<Button
type="button"
variant="danger"
isLoading={disablingMembershipId === disableTarget.id}
disabled={Boolean(disablingMembershipId)}
onClick={() => void confirmDisableMember()}
>
Disable member
</Button>
</div>
</div>
</div>
)}
{editing && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div

View File

@@ -2,7 +2,7 @@
import Link from 'next/link';
import { useAuth } from '@/lib/hooks/useAuth';
import { Card } from '@/components/ui/common/Card';
import { Card } from '@/components/ui/shared/Card';
export default function TodayPage() {
const { currentOrganization } = useAuth();

View File

@@ -4,8 +4,8 @@ import { useEffect, useMemo, useState } from 'react';
import { Suspense } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
import { staffApi } from '@/lib/api/staff';
function AcceptInviteContent() {

View File

@@ -8,8 +8,8 @@ import type { OrganizationDetailsFormValues } from '@/components/ui/auth/Organiz
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Lock, Mail, User } from 'lucide-react';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
import { organizationApi } from '@/lib/api/organization';

View File

@@ -116,8 +116,8 @@ import Link from 'next/link';
import { Mail, Lock } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
const loginSchema = z.object({
email: z.string().email('Please enter a valid email address'),

View File

@@ -2,8 +2,8 @@
import Link from 'next/link';
import { useAuth } from '@/lib/hooks/useAuth';
import { Button } from '@/components/ui/common/Button';
import { ThemeToggle } from '@/components/ui/common/ThemeToggle';
import { Button } from '@/components/ui/shared/Button';
import { ThemeToggle } from '@/components/ui/shared/ThemeToggle';
import { Building2, Beaker, Calendar, Shield, Clock, Users } from 'lucide-react';
export default function HomePage() {

View File

@@ -9,8 +9,8 @@ import { Mail, Lock, User } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
const registerSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Please enter a valid email address'),

View File

@@ -0,0 +1,16 @@
import { Building2, Beaker, type LucideIcon } from 'lucide-react';
import type { Organization } from '@/types/organization';
/** Clinic → Building2, Lab → Beaker (switch-organization cards). */
export function organizationTypeIcon(type: Organization['type']): LucideIcon {
return type === 'CLINIC' ? Building2 : Beaker;
}
/** Organizations tab lists counterpart orgs (labs for clinics, clinics for labs). */
export function counterpartOrganizationType(
currentType: Organization['type'] | undefined,
): Organization['type'] {
if (currentType === 'CLINIC') return 'LAB';
if (currentType === 'LAB') return 'CLINIC';
return 'CLINIC';
}

View File

@@ -16,6 +16,11 @@ export function hasPermission(org: Organization | null, permission: string): boo
return Boolean(org.permissions?.includes(permission));
}
/** True when the user is owner of the currently selected organization. */
export function canCreateOrganizationFromCurrentOrg(org: Organization | null): boolean {
return Boolean(org?.isOwner);
}
/** Sidebar / route guard: READ access to a tab */
export function canViewTab(org: Organization | null, readPermission: string): boolean {
return hasPermission(org, readPermission);

View File

@@ -1,4 +1,4 @@
import { isSameLocalCalendarDay } from '@/lib/appointmentTime';
import { isSameLocalCalendarDay } from '@/components/appointments/appointmentTime';
import type { TreatmentAppointment } from '@/types/treatment';
/**

View File

@@ -1,9 +1,9 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/common/Button';
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton';
import { Dropdown } from '@/components/ui/common/Dropdown';
import { Button } from '@/components/ui/shared/Button';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { Dropdown } from '@/components/ui/shared/Dropdown';
import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles';
import type { Patient } from '@/types/patient';
@@ -12,7 +12,7 @@ import {
compareLocalDayStart,
formatTimeForInput,
isSameLocalCalendarDay,
} from '@/lib/appointmentTime';
} from '@/components/appointments/appointmentTime';
interface AppointmentBookingModalProps {
open: boolean;

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useRef } from 'react';
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import {
APPOINTMENT_PURPOSE_LABEL,
purposeStyle,

View File

@@ -2,12 +2,12 @@
import { useMemo, useState } from 'react';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import { formatHourLabel } from '@/lib/appointmentTime';
import { formatHourLabel } from '@/components/appointments/appointmentTime';
import {
computeAppointmentLaneLayouts,
findOverlapCluster,
lanePositionStyles,
} from '@/lib/appointmentOverlapLayout';
} from '@/components/appointments/appointmentOverlapLayout';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';

View File

@@ -1,8 +1,8 @@
'use client';
import { Search } from 'lucide-react';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
import type { Patient } from '@/types/patient';
interface AppointmentsPatientSearchProps {
@@ -49,7 +49,7 @@ export function AppointmentsPatientSearch({
onClick={onAddPatient}
title={!canAddPatient ? 'You do not have permission to add patients.' : undefined}
>
+ Add New Patient
New Patient
</Button>
)}
</div>

View File

@@ -2,7 +2,7 @@
import { Building2, Mail } from 'lucide-react';
import type { FieldErrors, UseFormRegister, UseFormSetValue } from 'react-hook-form';
import { Input } from '@/components/ui/common/Input';
import { Input } from '@/components/ui/shared/Input';
export type OrganizationDetailsFormValues = {
organizationName: string;

View File

@@ -1,10 +1,10 @@
'use client';
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton';
import { ToastStack, type ToastMessages } from '@/components/ui/common/Toast';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { ToastStack, type ToastMessages } from '@/components/ui/shared/Toast';
import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization';
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/common/Badge';
import { Table } from '@/components/ui/common/Table';
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge';
import { Table } from '@/components/ui/shared/Table';
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string {

View File

@@ -1,13 +1,26 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useAuth } from '@/lib/hooks/useAuth';
import { canCreateOrganizationFromCurrentOrg } from '@/components/shared/permissions';
import { Building2, Beaker, Mail } from 'lucide-react';
import { Input } from '@/components/ui/common/Input';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/shared/Input';
import { Button } from '@/components/ui/shared/Button';
export function OrganizationSelectorContent() {
const { organizations, selectOrganization, createOrganization, isLoading, error, clearError } = useAuth();
const {
organizations,
currentOrganization,
selectOrganization,
createOrganization,
isLoading,
error,
clearError,
} = useAuth();
const canCreateOrganization = useMemo(
() => canCreateOrganizationFromCurrentOrg(currentOrganization),
[currentOrganization],
);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [organizationName, setOrganizationName] = useState('');
const [organizationEmail, setOrganizationEmail] = useState('');
@@ -44,9 +57,12 @@ export function OrganizationSelectorContent() {
<div>
<h1 className="text-3xl font-semibold text-text-primary">Organizations</h1>
<p className="text-text-secondary mt-2">
Select an organization to continue, or create a new one.
{canCreateOrganization
? 'Select an organization to continue, or create a new one.'
: 'Select an organization to continue.'}
</p>
</div>
{canCreateOrganization && (
<Button
type="button"
variant={isCreateOpen ? 'outline' : 'primary'}
@@ -57,9 +73,10 @@ export function OrganizationSelectorContent() {
>
{isCreateOpen ? 'Cancel' : 'Create Organization'}
</Button>
)}
</div>
{isCreateOpen && (
{canCreateOrganization && isCreateOpen && (
<div className="surface-card p-6 space-y-4">
<Input
label="Organization name"
@@ -126,7 +143,11 @@ export function OrganizationSelectorContent() {
{!organizations.length ? (
<div className="surface-card p-8 text-center">
<p className="text-text-secondary">No organizations found. Create your first one to continue.</p>
<p className="text-text-secondary">
{canCreateOrganization
? 'No organizations found. Create your first one to continue.'
: 'No organizations found. Ask an organization owner to invite you.'}
</p>
</div>
) : (
<div className="grid gap-4">

View File

@@ -1,7 +1,8 @@
'use client';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { Button } from '@/components/ui/shared/Button';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { Input } from '@/components/ui/shared/Input';
import { CreatePatientInput } from '@/types/patient';
interface CreatePatientModalProps {
@@ -11,22 +12,27 @@ interface CreatePatientModalProps {
onSubmit: () => void;
onClose: () => void;
loading?: boolean;
/** Inline panel on Patients page; centered dialog on Appointments. */
variant?: 'inline' | 'dialog';
}
export function CreatePatientModal({
isOpen,
function CreatePatientFormFields({
formData,
onChange,
onSubmit,
onClose,
loading = false,
}: CreatePatientModalProps) {
if (!isOpen) {
return null;
}
loading,
showCancel,
}: {
formData: CreatePatientInput;
onChange: (patch: Partial<CreatePatientInput>) => void;
onSubmit: () => void;
onClose: () => void;
loading: boolean;
showCancel: boolean;
}) {
return (
<div className="surface-card p-4 space-y-3">
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input
label="First name"
@@ -60,10 +66,80 @@ export function CreatePatientModal({
>
Save Patient
</Button>
{showCancel && (
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
)}
</div>
</>
);
}
export function CreatePatientModal({
isOpen,
formData,
onChange,
onSubmit,
onClose,
loading = false,
variant = 'inline',
}: CreatePatientModalProps) {
if (!isOpen) {
return null;
}
if (variant === 'dialog') {
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/55"
role="presentation"
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div
className="surface-card w-full max-w-[min(56rem,calc(100vw-17rem))] p-5 space-y-4 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="create-patient-dialog-title"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-2">
<h2
id="create-patient-dialog-title"
className="text-lg font-semibold text-text-primary pr-2"
>
New patient
</h2>
<DialogCloseButton onClick={onClose} />
</div>
<CreatePatientFormFields
formData={formData}
onChange={onChange}
onSubmit={onSubmit}
onClose={onClose}
loading={loading}
showCancel={false}
/>
</div>
</div>
);
}
return (
<div className="surface-card p-4 space-y-3">
<CreatePatientFormFields
formData={formData}
onChange={onChange}
onSubmit={onSubmit}
onClose={onClose}
loading={loading}
showCancel
/>
</div>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/common/Input';
import { Input } from '@/components/ui/shared/Input';
import { Patient } from '@/types/patient';
interface PatientSearchSelectProps {

View File

@@ -1,7 +1,8 @@
// src/components/ui/OrganizationCard.tsx
import React from 'react';
import { Building2, Beaker, ChevronRight } from 'lucide-react';
import { ChevronRight } from 'lucide-react';
import type { Organization } from '@/types/organization';
import { organizationTypeIcon } from '@/components/shared/organizationTypeIcon';
interface OrganizationCardProps {
organization: Organization;
@@ -12,7 +13,7 @@ export const OrganizationCard: React.FC<OrganizationCardProps> = ({
organization,
onSelect,
}) => {
const Icon = organization.type === 'CLINIC' ? Building2 : Beaker;
const Icon = organizationTypeIcon(organization.type);
const typeText = organization.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab';
return (

View File

@@ -2,17 +2,11 @@
import { useEffect, useId, useRef, useState } from 'react';
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
import {
addCalendarDays,
compareLocalDayStart,
startOfLocalDay,
} from '@/lib/appointmentTime';
import { addCalendarDays, startOfLocalDay } from '@/components/appointments/appointmentTime';
interface ScheduleDayPickerProps {
value: Date;
onChange: (day: Date) => void;
/** Optional lower bound for day selection and previous-day navigation. */
minDate?: Date;
label?: string;
}
@@ -39,27 +33,10 @@ function buildLocalDay(year: number, month: number, day: number): Date {
return new Date(year, month, day, 0, 0, 0, 0);
}
function clampToValidDay(
year: number,
month: number,
day: number,
min?: Date,
): Date {
const maxDay = daysInMonth(year, month);
let next = buildLocalDay(year, month, Math.min(Math.max(1, day), maxDay));
if (min) {
const floor = startOfLocalDay(min);
if (compareLocalDayStart(next, floor) < 0) {
next = floor;
}
}
return next;
}
function yearRange(min?: Date, anchor?: Date): number[] {
const now = new Date();
const startYear = min ? min.getFullYear() : now.getFullYear() - 5;
const endYear = Math.max(now.getFullYear() + 2, anchor?.getFullYear() ?? now.getFullYear());
function yearRange(anchor: Date): number[] {
const anchorYear = anchor.getFullYear();
const startYear = anchorYear - 10;
const endYear = anchorYear + 2;
const years: number[] = [];
for (let y = startYear; y <= endYear; y += 1) {
years.push(y);
@@ -72,21 +49,18 @@ const selectClassName = `
bg-background-card/90 text-text-primary text-sm
pl-2 pr-7 py-1.5
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
disabled:opacity-50 disabled:cursor-not-allowed
`;
export function ScheduleDayPicker({
value,
onChange,
minDate,
label = 'Schedule date',
}: ScheduleDayPickerProps) {
/**
* Calendar day navigator (arrows + year/month/day panel).
* Does not restrict past dates parent pages enforce read-only vs editable for schedule grids/forms.
*/
export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) {
const panelId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const [panelOpen, setPanelOpen] = useState(false);
const normalizedValue = startOfLocalDay(value);
const normalizedMin = minDate ? startOfLocalDay(minDate) : undefined;
const labelText = normalizedValue.toLocaleDateString(undefined, {
weekday: 'short',
@@ -95,28 +69,20 @@ export function ScheduleDayPicker({
year: 'numeric',
});
const previousDay = addCalendarDays(normalizedValue, -1);
const canGoPrevious =
!normalizedMin || compareLocalDayStart(previousDay, normalizedMin) >= 0;
const years = yearRange(normalizedMin, normalizedValue);
const years = yearRange(normalizedValue);
const selectedYear = normalizedValue.getFullYear();
const selectedMonth = normalizedValue.getMonth();
const selectedDay = normalizedValue.getDate();
const dayCount = daysInMonth(selectedYear, selectedMonth);
function applyParts(year: number, month: number, day: number, closePanel = false) {
onChange(clampToValidDay(year, month, day, normalizedMin));
const maxDay = daysInMonth(year, month);
onChange(buildLocalDay(year, month, Math.min(Math.max(1, day), maxDay)));
if (closePanel) {
setPanelOpen(false);
}
}
function handlePreviousDay() {
if (!canGoPrevious) return;
onChange(previousDay);
}
useEffect(() => {
if (!panelOpen) return;
@@ -146,9 +112,8 @@ export function ScheduleDayPicker({
<div className="flex items-center gap-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-1 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]">
<button
type="button"
onClick={handlePreviousDay}
disabled={!canGoPrevious}
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35 disabled:opacity-40 disabled:pointer-events-none"
onClick={() => onChange(addCalendarDays(normalizedValue, -1))}
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
aria-label="Previous day"
>
<ChevronLeft className="h-4 w-4 icon-flat" />
@@ -232,17 +197,11 @@ export function ScheduleDayPicker({
}
className={selectClassName}
>
{MONTH_LABELS.map((name, index) => {
const disabled =
normalizedMin &&
selectedYear === normalizedMin.getFullYear() &&
index < normalizedMin.getMonth();
return (
<option key={name} value={index} disabled={disabled}>
{MONTH_LABELS.map((name, index) => (
<option key={name} value={index}>
{name}
</option>
);
})}
))}
</select>
<ChevronDown
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
@@ -272,18 +231,11 @@ export function ScheduleDayPicker({
}
className={selectClassName}
>
{Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => {
const disabled =
normalizedMin &&
selectedYear === normalizedMin.getFullYear() &&
selectedMonth === normalizedMin.getMonth() &&
day < normalizedMin.getDate();
return (
<option key={day} value={day} disabled={disabled}>
{Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => (
<option key={day} value={day}>
{day}
</option>
);
})}
))}
</select>
<ChevronDown
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"

View File

@@ -13,10 +13,14 @@ import {
CreditCard,
} from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { canAccessAppointmentsSection, canViewTab } from '@/shared/permissions';
import { canAccessAppointmentsSection, canViewTab } from '@/components/shared/permissions';
import {
counterpartOrganizationType,
organizationTypeIcon,
} from '@/components/shared/organizationTypeIcon';
const menu = [
{ name: 'Today', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const },
{ name: 'Dashboard', 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: 'Appointment', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const },
@@ -29,6 +33,9 @@ function Sidebar() {
const pathname = usePathname();
const { currentOrganization } = useAuth();
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
const organizationsTabIcon = organizationTypeIcon(
counterpartOrganizationType(currentOrganization?.type),
);
const visibleMenu = useMemo(
() => {
@@ -38,7 +45,7 @@ function Sidebar() {
{
name: counterpartLabel,
path: '/organizations',
icon: FlaskConical,
icon: organizationsTabIcon,
read: 'TAB_ORGANIZATIONS_READ' as const,
},
menu[2],
@@ -54,7 +61,7 @@ function Sidebar() {
return canViewTab(currentOrganization, item.read);
});
},
[counterpartLabel, currentOrganization],
[counterpartLabel, organizationsTabIcon, currentOrganization],
);
return (

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import type { BadgeVariant } from '@/components/ui/common/Badge';
import type { BadgeVariant } from '@/components/ui/shared/Badge';
interface ToastProps {
children: ReactNode;

View File

@@ -2,9 +2,9 @@
import { CalendarDays } from 'lucide-react';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { Card } from '@/components/ui/common/Card';
import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker';
import { startOfLocalDay } from '@/lib/appointmentTime';
import { Card } from '@/components/ui/shared/Card';
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
import type { TreatmentAppointment } from '@/types/treatment';
interface AppointmentsStripProps {
@@ -12,8 +12,6 @@ interface AppointmentsStripProps {
onToggleStripHidden: () => void;
selectedDay: Date;
onSelectDay: (day: Date) => void;
/** Same lower bound as Appointments schedule (cannot pick days before this). */
minScheduleDate: Date;
appointments: TreatmentAppointment[];
selectedAppointmentId: string | null;
onSelectAppointment: (id: string) => void;
@@ -25,7 +23,6 @@ export function AppointmentsStrip({
onToggleStripHidden,
selectedDay,
onSelectDay,
minScheduleDate,
appointments,
selectedAppointmentId,
onSelectAppointment,
@@ -65,7 +62,6 @@ export function AppointmentsStrip({
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
<ScheduleDayPicker
value={selectedDay}
minDate={minScheduleDate}
onChange={(d) => onSelectDay(startOfLocalDay(d))}
/>
{loading && (

View File

@@ -4,12 +4,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { Checkbox } from '@/components/ui/common/Checkbox';
import { Button } from '@/components/ui/common/Button';
import { Dropdown } from '@/components/ui/common/Dropdown';
import { SearchBar } from '@/components/ui/common/SearchBar';
import { Toast } from '@/components/ui/common/Toast';
import { isSameLocalCalendarDay, startOfLocalDay } from '@/lib/appointmentTime';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { Button } from '@/components/ui/shared/Button';
import { Dropdown } from '@/components/ui/shared/Dropdown';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { Toast } from '@/components/ui/shared/Toast';
import { compareLocalDayStart, isSameLocalCalendarDay, startOfLocalDay } from '@/components/appointments/appointmentTime';
import {
fetchLinkedOrganizations,
fetchMyAppointmentsForDay,
@@ -17,8 +17,8 @@ import {
saveTreatmentDraft,
sendTreatmentRecord,
} from '@/lib/mocks/treatmentMockApi';
import { pickAutoAppointment } from '@/lib/treatmentSelection';
import { canEditTreatment } from '@/shared/permissions';
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
import { canEditTreatment } from '@/components/shared/permissions';
import type { Organization } from '@/types/organization';
import type {
FdiToothId,
@@ -54,7 +54,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [stripHidden, setStripHidden] = useState(false);
const scheduleMinDate = useMemo(() => startOfLocalDay(new Date()), []);
const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
const [selectedDay, setSelectedDay] = useState(() => startOfLocalDay(new Date()));
const [appointments, setAppointments] = useState<TreatmentAppointment[]>([]);
@@ -87,6 +87,13 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
[appointments, selectedAppointmentId],
);
const isViewingPastDay = useMemo(
() => compareLocalDayStart(selectedDay, todayStart) < 0,
[selectedDay, todayStart],
);
const canEditTreatmentForDay = Boolean(selectedAppointment) && !isViewingPastDay;
const activeRecord = useMemo(
() => records.find((r) => r.clientId === activeRecordId) ?? records[0],
[records, activeRecordId],
@@ -339,13 +346,19 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
onToggleStripHidden={() => setStripHidden((s) => !s)}
selectedDay={selectedDay}
onSelectDay={setSelectedDay}
minScheduleDate={scheduleMinDate}
appointments={appointments}
selectedAppointmentId={selectedAppointmentId}
onSelectAppointment={onPickAppointment}
loading={apptsLoading}
/>
{isViewingPastDay && (
<p className="text-sm text-text-secondary rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/50 px-3 py-2">
Past days are view-only. You can review appointments and history, but treatment records
cannot be added or changed.
</p>
)}
<div className="grid grid-cols-1 xl:grid-cols-[minmax(280px,380px)_minmax(0,1fr)] gap-6 items-start">
<div className="space-y-4">
{selectedAppointment ? (
@@ -423,7 +436,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<FdiToothChart
selected={selectedTeethSet}
onToggle={toggleTooth}
disabled={!selectedAppointment}
disabled={!canEditTreatmentForDay}
/>
<div className="surface-card p-4 space-y-4">
@@ -437,7 +450,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<Button
type="button"
variant="primary"
disabled={!selectedAppointment}
disabled={!canEditTreatmentForDay}
onClick={() => {
const nr = newRecord();
fixActiveAfterRecordsChange([...records, nr]);
@@ -486,7 +499,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
}}
placeholder="Write clinical notes for this record…"
rows={5}
disabled={!selectedAppointment}
disabled={!canEditTreatmentForDay}
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[120px]"
/>
</label>
@@ -503,7 +516,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
),
);
}}
disabled={!selectedAppointment}
disabled={!canEditTreatmentForDay}
className="capitalize"
style={{ color: treatmentTypeTextColor }}
>
@@ -522,7 +535,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
id="treatment-record-attachments"
type="file"
multiple
disabled={!selectedAppointment}
disabled={!canEditTreatmentForDay}
onChange={(e) => {
addAttachments(e.target.files);
e.target.value = '';
@@ -533,7 +546,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<Button
type="button"
variant="primary"
disabled={!selectedAppointment}
disabled={!canEditTreatmentForDay}
onClick={() => attachmentInputRef.current?.click()}
aria-controls="treatment-record-attachments"
>
@@ -589,7 +602,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<Checkbox
key={o.id}
checked={activeRecord.sendToOrganizationIds.includes(o.id)}
disabled={!selectedAppointment || Boolean(activeRecord.sentAt)}
disabled={!canEditTreatmentForDay || Boolean(activeRecord.sentAt)}
onChange={(checked) => {
setRecords((prev) =>
prev.map((r) => {
@@ -614,7 +627,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<Button
type="button"
variant="primary"
disabled={!selectedAppointment || Boolean(activeRecord.sentAt) || sendBusyId === activeRecord.clientId}
disabled={!canEditTreatmentForDay || Boolean(activeRecord.sentAt) || sendBusyId === activeRecord.clientId}
isLoading={sendBusyId === activeRecord.clientId}
onClick={() => void handleSendRecord(activeRecord)}
>
@@ -633,7 +646,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<Button
type="button"
variant="primary"
disabled={!selectedAppointment || saveBusy}
disabled={!canEditTreatmentForDay || saveBusy}
isLoading={saveBusy}
onClick={() => void handleSaveAll()}
>

View File

@@ -7,7 +7,7 @@ export interface StaffMemberDto {
name: string;
isOwner: boolean;
isActive: boolean;
invitationStatus: 'ACTIVE' | 'PENDING' | 'EXPIRED';
invitationStatus: 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED';
invitedAt: string | null;
acceptedAt: string | null;
permissions: string[] | null;
@@ -100,6 +100,20 @@ export const staffApi = {
return response.data;
},
disableMember: async (
membershipId: string,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.patch(`/staff/members/${membershipId}/disable`);
return response.data;
},
enableMember: async (
membershipId: string,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.patch(`/staff/members/${membershipId}/enable`);
return response.data;
},
removeMember: async (
membershipId: string,
): Promise<{ success: boolean; message: string }> => {

View File

@@ -1,7 +1,7 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { ToastMessages } from '@/components/ui/common/Toast';
import type { ToastMessages } from '@/components/ui/shared/Toast';
const DEFAULT_DURATION_MS = 4000;

View File

@@ -2,7 +2,7 @@ import {
addCalendarDays,
isSameLocalCalendarDay,
startOfLocalDay,
} from '@/lib/appointmentTime';
} from '@/components/appointments/appointmentTime';
import type {
FdiToothId,
LinkedOrganizationOption,