Compare commits
8 Commits
8278fd9012
...
4487d3260b
| Author | SHA1 | Date | |
|---|---|---|---|
| 4487d3260b | |||
| 81fe14823f | |||
| 7dba7cc144 | |||
| 95ed1bd4ab | |||
| 535b49310f | |||
| 3b12c52fd3 | |||
| eb636db653 | |||
| 4590255b31 |
@@ -126,7 +126,11 @@ export class AuthController {
|
|||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Create organization for current user' })
|
@ApiOperation({ summary: 'Create organization for current user' })
|
||||||
async createOrganization(@Req() req, @Body() dto: CreateOrganizationDto) {
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
InternalServerErrorException
|
InternalServerErrorException
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
@@ -270,7 +271,11 @@ export class AuthService {
|
|||||||
return this.login({ email, password } as any, validatedUser);
|
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({
|
const owner = await this.prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -280,6 +285,28 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('User not found');
|
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 organization = await this.prisma.$transaction(async (tx) => {
|
||||||
const createdOrganization = await tx.organization.create({
|
const createdOrganization = await tx.organization.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -729,7 +756,18 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Access denied to this organization');
|
throw new UnauthorizedException('Access denied to this organization');
|
||||||
}
|
}
|
||||||
if (!membership.isOwner && !membership.isActive) {
|
if (!membership.isOwner && !membership.isActive) {
|
||||||
throw new UnauthorizedException('Your invitation is still pending activation');
|
const acceptedInvite = await this.prisma.staffInvitation.findFirst({
|
||||||
|
where: {
|
||||||
|
membershipId: membership.id,
|
||||||
|
acceptedAt: { not: null },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
acceptedInvite
|
||||||
|
? 'Your access to this organization has been disabled.'
|
||||||
|
: 'Your invitation is still pending activation.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Build payload WITH org context
|
// 2. Build payload WITH org context
|
||||||
|
|||||||
@@ -80,6 +80,19 @@ export class StaffController {
|
|||||||
return this.staffService.updateMember(req.user.id, organizationId, membershipId, dto);
|
return this.staffService.updateMember(req.user.id, organizationId, membershipId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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')
|
@Delete('members/:membershipId')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiOperation({ summary: 'Remove staff member from organization' })
|
@ApiOperation({ summary: 'Remove staff member from organization' })
|
||||||
|
|||||||
@@ -399,6 +399,44 @@ export class StaffService {
|
|||||||
return { success: true, message: 'Member updated' };
|
return { success: true, message: 'Member updated' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
async removeMember(actorUserId: string, organizationId: string, membershipId: string) {
|
||||||
const actor = await this.getActorMembership(actorUserId, organizationId);
|
const actor = await this.getActorMembership(actorUserId, organizationId);
|
||||||
if (!actor || !this.canEditStaff(actor)) {
|
if (!actor || !this.canEditStaff(actor)) {
|
||||||
@@ -435,11 +473,12 @@ export class StaffService {
|
|||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
invitations: { acceptedAt: Date | null; revokedAt: Date | null; expiresAt: Date }[];
|
invitations: { acceptedAt: Date | null; revokedAt: Date | null; expiresAt: Date }[];
|
||||||
}): 'ACTIVE' | 'PENDING' | 'EXPIRED' {
|
}): 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED' {
|
||||||
if (m.isOwner || m.isActive) return 'ACTIVE';
|
if (m.isOwner) return 'ACTIVE';
|
||||||
|
if (m.isActive) return 'ACTIVE';
|
||||||
const invitation = m.invitations[0];
|
const invitation = m.invitations[0];
|
||||||
if (!invitation) return 'EXPIRED';
|
if (invitation?.acceptedAt) return 'DISABLED';
|
||||||
if (invitation.acceptedAt) return 'ACTIVE';
|
if (!invitation) return 'DISABLED';
|
||||||
if (invitation.revokedAt) return 'EXPIRED';
|
if (invitation.revokedAt) return 'EXPIRED';
|
||||||
return invitation.expiresAt.getTime() > Date.now() ? 'PENDING' : 'EXPIRED';
|
return invitation.expiresAt.getTime() > Date.now() ? 'PENDING' : 'EXPIRED';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { appointmentsApi } from '@/lib/api/appointments';
|
import { appointmentsApi } from '@/lib/api/appointments';
|
||||||
import { patientsApi } from '@/lib/api/patients';
|
import { patientsApi } from '@/lib/api/patients';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
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 { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
|
||||||
import type { CreatePatientInput, Patient } from '@/types/patient';
|
import type { CreatePatientInput, Patient } from '@/types/patient';
|
||||||
import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal';
|
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 { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid';
|
||||||
import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch';
|
import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch';
|
||||||
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend';
|
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend';
|
||||||
import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker';
|
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
|
||||||
import { ToastStack } from '@/components/ui/common/Toast';
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import type { AppointmentPurpose } from '@/types/appointment';
|
import type { AppointmentPurpose } from '@/types/appointment';
|
||||||
import { formatApiErrorMessage } from '@/lib/formatApiError';
|
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||||
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime';
|
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||||
|
|
||||||
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
||||||
firstName: '',
|
firstName: '',
|
||||||
@@ -273,6 +273,7 @@ export default function AppointmentsPage() {
|
|||||||
if (!canEditPatients) {
|
if (!canEditPatients) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setPatientForm(EMPTY_PATIENT_FORM);
|
||||||
setIsCreateOpen(true);
|
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">
|
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
|
||||||
<ScheduleDayPicker
|
<ScheduleDayPicker
|
||||||
value={scheduleDate}
|
value={scheduleDate}
|
||||||
minDate={todayStart}
|
|
||||||
onChange={(d) => setScheduleDate(startOfLocalDay(d))}
|
onChange={(d) => setScheduleDate(startOfLocalDay(d))}
|
||||||
/>
|
/>
|
||||||
{loadingSchedule && (
|
{loadingSchedule && (
|
||||||
@@ -323,18 +323,18 @@ export default function AppointmentsPage() {
|
|||||||
deleting={deletingAppointment}
|
deleting={deletingAppointment}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isCreateOpen && (
|
<CreatePatientModal
|
||||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/55">
|
variant="dialog"
|
||||||
<CreatePatientModal
|
isOpen={isCreateOpen}
|
||||||
isOpen={isCreateOpen}
|
formData={patientForm}
|
||||||
formData={patientForm}
|
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
|
||||||
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
|
onSubmit={() => void handleCreatePatient()}
|
||||||
onSubmit={() => void handleCreatePatient()}
|
onClose={() => {
|
||||||
onClose={() => setIsCreateOpen(false)}
|
setIsCreateOpen(false);
|
||||||
loading={savingPatient}
|
setPatientForm(EMPTY_PATIENT_FORM);
|
||||||
/>
|
}}
|
||||||
</div>
|
loading={savingPatient}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Pencil } from 'lucide-react';
|
import { Pencil } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/common/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Badge } from '@/components/ui/common/Badge';
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
import { Card } from '@/components/ui/common/Card';
|
import { Card } from '@/components/ui/shared/Card';
|
||||||
import { Table } from '@/components/ui/common/Table';
|
import { Table } from '@/components/ui/shared/Table';
|
||||||
import { SearchBar } from '@/components/ui/common/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { hasPermission } from '@/shared/permissions';
|
import { hasPermission } from '@/components/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' },
|
||||||
|
|||||||
@@ -3,15 +3,15 @@
|
|||||||
import { memo, useEffect } from 'react';
|
import { memo, useEffect } from 'react';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import Sidebar from '@/components/ui/common/Sidebar';
|
import Sidebar from '@/components/ui/shared/Sidebar';
|
||||||
import { ThemeToggle } from '@/components/ui/common/ThemeToggle';
|
import { ThemeToggle } from '@/components/ui/shared/ThemeToggle';
|
||||||
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
|
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
|
||||||
import {
|
import {
|
||||||
canAccessAppointmentsSection,
|
canAccessAppointmentsSection,
|
||||||
firstAccessibleDashboardPath,
|
firstAccessibleDashboardPath,
|
||||||
getRequiredReadPermissionForPath,
|
getRequiredReadPermissionForPath,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
} from '@/shared/permissions';
|
} from '@/components/shared/permissions';
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const { user, currentOrganization, isAuthReady } = useAuth();
|
const { user, currentOrganization, isAuthReady } = useAuth();
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ import {
|
|||||||
import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks';
|
import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks';
|
||||||
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
|
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
|
||||||
import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog';
|
import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog';
|
||||||
import { Button } from '@/components/ui/common/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/common/Badge';
|
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge';
|
||||||
import { Input } from '@/components/ui/common/Input';
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
import { SearchBar } from '@/components/ui/common/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import { Table } from '@/components/ui/common/Table';
|
import { Table } from '@/components/ui/shared/Table';
|
||||||
import { ToastStack } from '@/components/ui/common/Toast';
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||||
import type { ApiError } from '@/types/api';
|
import type { ApiError } from '@/types/api';
|
||||||
|
|
||||||
function formatOrganizationStatusLabel(status: string): string {
|
function formatOrganizationStatusLabel(status: string): string {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
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 { patientsApi } from '@/lib/api/patients';
|
||||||
|
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { hasPermission } from '@/shared/permissions';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
|
import { hasPermission } from '@/components/shared/permissions';
|
||||||
import {
|
import {
|
||||||
CreatePatientInput,
|
CreatePatientInput,
|
||||||
CreateTreatmentHistoryInput,
|
CreateTreatmentHistoryInput,
|
||||||
@@ -25,6 +28,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
|||||||
|
|
||||||
export default function PatientsPage() {
|
export default function PatientsPage() {
|
||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
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,8 +39,6 @@ export default function PatientsPage() {
|
|||||||
const [savingPatient, setSavingPatient] = useState(false);
|
const [savingPatient, setSavingPatient] = useState(false);
|
||||||
const [savingTreatment, setSavingTreatment] = useState(false);
|
const [savingTreatment, setSavingTreatment] = useState(false);
|
||||||
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
|
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 canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
|
||||||
|
|
||||||
const sortedPatients = useMemo(
|
const sortedPatients = useMemo(
|
||||||
@@ -58,21 +60,9 @@ export default function PatientsPage() {
|
|||||||
void loadPatients('');
|
void loadPatients('');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!successMessage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
setSuccessMessage('');
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [successMessage]);
|
|
||||||
|
|
||||||
async function loadPatients(q: string) {
|
async function loadPatients(q: string) {
|
||||||
setLoadingPatients(true);
|
setLoadingPatients(true);
|
||||||
setErrorMessage('');
|
toast.setError('');
|
||||||
try {
|
try {
|
||||||
const response = await patientsApi.list({ q, page: 1, limit: 25 });
|
const response = await patientsApi.list({ q, page: 1, limit: 25 });
|
||||||
const items = response.data.items;
|
const items = response.data.items;
|
||||||
@@ -82,9 +72,8 @@ export default function PatientsPage() {
|
|||||||
const freshSelected = items.find((item) => item.id === selectedPatient.id);
|
const freshSelected = items.find((item) => item.id === selectedPatient.id);
|
||||||
setSelectedPatient(freshSelected);
|
setSelectedPatient(freshSelected);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message;
|
toast.showError(formatApiErrorMessage(error, 'Failed to load patients.'));
|
||||||
setErrorMessage(message || 'Failed to load patients.');
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingPatients(false);
|
setLoadingPatients(false);
|
||||||
}
|
}
|
||||||
@@ -92,13 +81,12 @@ export default function PatientsPage() {
|
|||||||
|
|
||||||
async function loadTreatments(patientId: string) {
|
async function loadTreatments(patientId: string) {
|
||||||
setLoadingTreatments(true);
|
setLoadingTreatments(true);
|
||||||
setErrorMessage('');
|
toast.setError('');
|
||||||
try {
|
try {
|
||||||
const response = await patientsApi.listTreatments(patientId);
|
const response = await patientsApi.listTreatments(patientId);
|
||||||
setTreatments(response.data);
|
setTreatments(response.data);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message;
|
toast.showError(formatApiErrorMessage(error, 'Failed to load treatment history.'));
|
||||||
setErrorMessage(message || 'Failed to load treatment history.');
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingTreatments(false);
|
setLoadingTreatments(false);
|
||||||
}
|
}
|
||||||
@@ -106,8 +94,7 @@ export default function PatientsPage() {
|
|||||||
|
|
||||||
async function handleCreatePatient() {
|
async function handleCreatePatient() {
|
||||||
setSavingPatient(true);
|
setSavingPatient(true);
|
||||||
setErrorMessage('');
|
toast.setError('');
|
||||||
setSuccessMessage('');
|
|
||||||
try {
|
try {
|
||||||
const response = await patientsApi.create(patientForm);
|
const response = await patientsApi.create(patientForm);
|
||||||
setIsCreateOpen(false);
|
setIsCreateOpen(false);
|
||||||
@@ -115,12 +102,11 @@ export default function PatientsPage() {
|
|||||||
await loadPatients(search);
|
await loadPatients(search);
|
||||||
setSelectedPatient(response.data);
|
setSelectedPatient(response.data);
|
||||||
await loadTreatments(response.data.id);
|
await loadTreatments(response.data.id);
|
||||||
setSuccessMessage(
|
toast.showSuccess(
|
||||||
`Patient ${response.data.firstName} ${response.data.lastName} was saved successfully.`,
|
`Patient ${response.data.firstName} ${response.data.lastName} was saved successfully.`,
|
||||||
);
|
);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message;
|
toast.showError(formatApiErrorMessage(error, 'Failed to save patient.'));
|
||||||
setErrorMessage(message || 'Failed to save patient.');
|
|
||||||
} finally {
|
} finally {
|
||||||
setSavingPatient(false);
|
setSavingPatient(false);
|
||||||
}
|
}
|
||||||
@@ -139,29 +125,29 @@ export default function PatientsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
setSavingTreatment(true);
|
setSavingTreatment(true);
|
||||||
setErrorMessage('');
|
toast.setError('');
|
||||||
setSuccessMessage('');
|
|
||||||
try {
|
try {
|
||||||
await patientsApi.addTreatment(selectedPatient.id, payload);
|
await patientsApi.addTreatment(selectedPatient.id, payload);
|
||||||
await loadTreatments(selectedPatient.id);
|
await loadTreatments(selectedPatient.id);
|
||||||
setSuccessMessage('Treatment entry added successfully.');
|
toast.showSuccess('Treatment entry added successfully.');
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
const message = Array.isArray(error?.message) ? error.message.join(', ') : error?.message;
|
toast.showError(formatApiErrorMessage(error, 'Failed to add treatment entry.'));
|
||||||
setErrorMessage(message || 'Failed to add treatment entry.');
|
|
||||||
} finally {
|
} finally {
|
||||||
setSavingTreatment(false);
|
setSavingTreatment(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative space-y-6 pb-20">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">Patients</h1>
|
<h1 className="text-2xl font-semibold text-text-primary">Patients</h1>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={!canEditPatients}
|
disabled={!canEditPatients}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canEditPatients) return;
|
if (!canEditPatients) return;
|
||||||
|
toast.clear();
|
||||||
|
setPatientForm(EMPTY_PATIENT_FORM);
|
||||||
setIsCreateOpen(true);
|
setIsCreateOpen(true);
|
||||||
}}
|
}}
|
||||||
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
|
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
|
||||||
@@ -170,14 +156,21 @@ export default function PatientsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CreatePatientModal
|
<ToastStack {...toast.messages} />
|
||||||
isOpen={isCreateOpen}
|
|
||||||
formData={patientForm}
|
{isCreateOpen && (
|
||||||
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
|
<CreatePatientModal
|
||||||
onSubmit={handleCreatePatient}
|
isOpen={isCreateOpen}
|
||||||
onClose={() => setIsCreateOpen(false)}
|
formData={patientForm}
|
||||||
loading={savingPatient}
|
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
|
||||||
/>
|
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="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
<div className="xl:col-span-1">
|
<div className="xl:col-span-1">
|
||||||
@@ -213,21 +206,6 @@ export default function PatientsPage() {
|
|||||||
<TreatmentHistoryPreview items={treatments} loading={loadingTreatments} />
|
<TreatmentHistoryPreview items={treatments} loading={loadingTreatments} />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import Link from 'next/link';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { authApi } from '@/lib/api/auth';
|
import { authApi } from '@/lib/api/auth';
|
||||||
import { Button } from '@/components/ui/common/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Toast } from '@/components/ui/common/Toast';
|
import { Toast } from '@/components/ui/shared/Toast';
|
||||||
import type { SubscriptionAlertData } from '@/types/subscription';
|
import type { SubscriptionAlertData } from '@/types/subscription';
|
||||||
|
|
||||||
const PLAN_OPTIONS = [
|
const PLAN_OPTIONS = [
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
firstAccessibleDashboardPath,
|
firstAccessibleDashboardPath,
|
||||||
canEditStaff,
|
canEditStaff,
|
||||||
canViewStaff,
|
canViewStaff,
|
||||||
} from '@/shared/permissions';
|
} from '@/components/shared/permissions';
|
||||||
import {
|
import {
|
||||||
STAFF_FEATURE_GROUPS,
|
STAFF_FEATURE_GROUPS,
|
||||||
permissionNamesFromFeatureState,
|
permissionNamesFromFeatureState,
|
||||||
@@ -16,16 +16,18 @@ import {
|
|||||||
formatAccessSummary,
|
formatAccessSummary,
|
||||||
type FeaturePermState,
|
type FeaturePermState,
|
||||||
} from '../../../components/staff/staff-permission-form';
|
} from '../../../components/staff/staff-permission-form';
|
||||||
import { Pencil, Trash2, Copy, Check, X } from 'lucide-react';
|
import { Pencil, Trash2, Copy, Check, X, UserX } from 'lucide-react';
|
||||||
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton';
|
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { staffApi, type StaffMemberDto } from '@/lib/api/staff';
|
import { staffApi, type StaffMemberDto } from '@/lib/api/staff';
|
||||||
import { Button } from '@/components/ui/common/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Badge } from '@/components/ui/common/Badge';
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
import { Input } from '@/components/ui/common/Input';
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
import { Checkbox } from '@/components/ui/common/Checkbox';
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
import { Table } from '@/components/ui/common/Table';
|
import { Table } from '@/components/ui/shared/Table';
|
||||||
import type { ApiError } from '@/types/api';
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||||
|
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||||
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
|
|
||||||
type StoredInviteLink = {
|
type StoredInviteLink = {
|
||||||
membershipId: string;
|
membershipId: string;
|
||||||
@@ -54,16 +56,15 @@ function writeStoredInviteLinks(orgId: string, links: Record<string, StoredInvit
|
|||||||
window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links));
|
window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links));
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatApiMessage(err: unknown): string {
|
function canShareStaffInviteLink(member: StaffMemberDto): boolean {
|
||||||
if (!err || typeof err !== 'object') return 'Something went wrong';
|
return (
|
||||||
const m = (err as ApiError).message;
|
!member.isOwner &&
|
||||||
if (Array.isArray(m)) return m.join(', ');
|
(member.invitationStatus === 'PENDING' || member.invitationStatus === 'EXPIRED')
|
||||||
if (typeof m === 'string') return m;
|
);
|
||||||
return 'Something went wrong';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function canShareStaffInviteLink(member: StaffMemberDto): boolean {
|
function canDisableStaff(member: StaffMemberDto): boolean {
|
||||||
return !member.isOwner && member.invitationStatus !== 'ACTIVE';
|
return !member.isOwner && member.isActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PermissionGrid({
|
function PermissionGrid({
|
||||||
@@ -136,8 +137,7 @@ export default function StaffPage() {
|
|||||||
unlimited: boolean;
|
unlimited: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const toast = useToast();
|
||||||
const [success, setSuccess] = useState('');
|
|
||||||
|
|
||||||
const [inviteOpen, setInviteOpen] = useState(false);
|
const [inviteOpen, setInviteOpen] = useState(false);
|
||||||
const [inviteEmail, setInviteEmail] = useState('');
|
const [inviteEmail, setInviteEmail] = useState('');
|
||||||
@@ -159,6 +159,8 @@ export default function StaffPage() {
|
|||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState());
|
const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState());
|
||||||
const [editLoading, setEditLoading] = useState(false);
|
const [editLoading, setEditLoading] = useState(false);
|
||||||
|
const [disableTarget, setDisableTarget] = useState<StaffMemberDto | null>(null);
|
||||||
|
const [disablingMembershipId, setDisablingMembershipId] = useState<string | null>(null);
|
||||||
|
|
||||||
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
|
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
|
||||||
const hasActivePlan = Boolean(currentOrganization?.plan);
|
const hasActivePlan = Boolean(currentOrganization?.plan);
|
||||||
@@ -169,14 +171,14 @@ export default function StaffPage() {
|
|||||||
}, [seats]);
|
}, [seats]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setError('');
|
toast.setError('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await staffApi.list();
|
const res = await staffApi.list();
|
||||||
setMembers(res.data.members);
|
setMembers(res.data.members);
|
||||||
setSeats(res.data.seats);
|
setSeats(res.data.seats);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(formatApiMessage(e));
|
toast.showError(formatApiErrorMessage(e, 'Failed to load staff.'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -221,17 +223,11 @@ export default function StaffPage() {
|
|||||||
}
|
}
|
||||||
}, [currentOrganization, router]);
|
}, [currentOrganization, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!success) return;
|
|
||||||
const t = setTimeout(() => setSuccess(''), 4000);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, [success]);
|
|
||||||
|
|
||||||
async function copyStaffInviteLink(member: StaffMemberDto) {
|
async function copyStaffInviteLink(member: StaffMemberDto) {
|
||||||
if (!canShareStaffInviteLink(member)) return;
|
if (!canShareStaffInviteLink(member)) return;
|
||||||
|
|
||||||
setCopyingInviteMembershipId(member.id);
|
setCopyingInviteMembershipId(member.id);
|
||||||
setError('');
|
toast.setError('');
|
||||||
try {
|
try {
|
||||||
let invitationUrl = pendingInviteLinks[member.id]?.invitationUrl;
|
let invitationUrl = pendingInviteLinks[member.id]?.invitationUrl;
|
||||||
if (!invitationUrl || member.invitationStatus === 'EXPIRED') {
|
if (!invitationUrl || member.invitationStatus === 'EXPIRED') {
|
||||||
@@ -257,7 +253,7 @@ export default function StaffPage() {
|
|||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(formatApiMessage(e));
|
toast.showError(formatApiErrorMessage(e, 'Could not copy invitation link.'));
|
||||||
} finally {
|
} finally {
|
||||||
setCopyingInviteMembershipId(null);
|
setCopyingInviteMembershipId(null);
|
||||||
}
|
}
|
||||||
@@ -265,7 +261,7 @@ export default function StaffPage() {
|
|||||||
|
|
||||||
async function submitInvite() {
|
async function submitInvite() {
|
||||||
setInviteLoading(true);
|
setInviteLoading(true);
|
||||||
setError('');
|
toast.setError('');
|
||||||
setLastInviteInfo(null);
|
setLastInviteInfo(null);
|
||||||
const displayName = inviteName.trim();
|
const displayName = inviteName.trim();
|
||||||
const displayEmail = inviteEmail.trim();
|
const displayEmail = inviteEmail.trim();
|
||||||
@@ -295,14 +291,13 @@ export default function StaffPage() {
|
|||||||
setPendingInviteLinks(nextLinks);
|
setPendingInviteLinks(nextLinks);
|
||||||
writeStoredInviteLinks(currentOrganization.id, nextLinks);
|
writeStoredInviteLinks(currentOrganization.id, nextLinks);
|
||||||
}
|
}
|
||||||
setSuccess('');
|
|
||||||
setInviteOpen(false);
|
setInviteOpen(false);
|
||||||
setInviteEmail('');
|
setInviteEmail('');
|
||||||
setInviteName('');
|
setInviteName('');
|
||||||
setInvitePerms(emptyFeaturePermissionState());
|
setInvitePerms(emptyFeaturePermissionState());
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(formatApiMessage(e));
|
toast.showError(formatApiErrorMessage(e, 'Failed to send invitation.'));
|
||||||
} finally {
|
} finally {
|
||||||
setInviteLoading(false);
|
setInviteLoading(false);
|
||||||
}
|
}
|
||||||
@@ -320,36 +315,40 @@ export default function StaffPage() {
|
|||||||
async function submitEdit() {
|
async function submitEdit() {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
setEditLoading(true);
|
setEditLoading(true);
|
||||||
setError('');
|
toast.setError('');
|
||||||
try {
|
try {
|
||||||
await staffApi.updateMember(editing.id, {
|
await staffApi.updateMember(editing.id, {
|
||||||
name: editName.trim(),
|
name: editName.trim(),
|
||||||
permissionNames: permissionNamesFromFeatureState(editPerms),
|
permissionNames: permissionNamesFromFeatureState(editPerms),
|
||||||
});
|
});
|
||||||
setSuccess('Member updated');
|
toast.showSuccess('Member updated.');
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(formatApiMessage(e));
|
toast.showError(formatApiErrorMessage(e, 'Failed to update member.'));
|
||||||
} finally {
|
} finally {
|
||||||
setEditLoading(false);
|
setEditLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeMember(m: StaffMemberDto) {
|
function handleDeleteMember() {
|
||||||
if (m.isOwner) return;
|
toast.showError('Delete is not implemented yet.');
|
||||||
if (m.userId === user?.id) {
|
}
|
||||||
if (!confirm('Remove yourself from this organization? You will lose access.')) return;
|
|
||||||
} else {
|
async function confirmDisableMember() {
|
||||||
if (!confirm(`Remove ${m.name} from this organization?`)) return;
|
if (!disableTarget || !canDisableStaff(disableTarget)) return;
|
||||||
}
|
|
||||||
setError('');
|
setDisablingMembershipId(disableTarget.id);
|
||||||
|
toast.setError('');
|
||||||
try {
|
try {
|
||||||
await staffApi.removeMember(m.id);
|
await staffApi.disableMember(disableTarget.id);
|
||||||
setSuccess('Member removed');
|
toast.showSuccess(`${disableTarget.name} was disabled. A seat is now available.`);
|
||||||
|
setDisableTarget(null);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(formatApiMessage(e));
|
toast.showError(formatApiErrorMessage(e, 'Failed to disable member.'));
|
||||||
|
} finally {
|
||||||
|
setDisablingMembershipId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +382,8 @@ export default function StaffPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ToastStack {...toast.messages} />
|
||||||
|
|
||||||
{seats && (
|
{seats && (
|
||||||
<p className="text-sm text-text-secondary">
|
<p className="text-sm text-text-secondary">
|
||||||
Seats:{' '}
|
Seats:{' '}
|
||||||
@@ -400,18 +401,6 @@ export default function StaffPage() {
|
|||||||
</p>
|
</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 && (
|
{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">
|
<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
|
<button
|
||||||
@@ -453,7 +442,7 @@ export default function StaffPage() {
|
|||||||
}
|
}
|
||||||
void (async () => {
|
void (async () => {
|
||||||
setCopyingInviteMembershipId(lastInviteInfo.membershipId);
|
setCopyingInviteMembershipId(lastInviteInfo.membershipId);
|
||||||
setError('');
|
toast.setError('');
|
||||||
try {
|
try {
|
||||||
const res = await staffApi.getInvitationLink(lastInviteInfo.membershipId);
|
const res = await staffApi.getInvitationLink(lastInviteInfo.membershipId);
|
||||||
if (currentOrganization?.id) {
|
if (currentOrganization?.id) {
|
||||||
@@ -473,7 +462,7 @@ export default function StaffPage() {
|
|||||||
setCopiedInviteMembershipId(lastInviteInfo.membershipId);
|
setCopiedInviteMembershipId(lastInviteInfo.membershipId);
|
||||||
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
|
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(formatApiMessage(e));
|
toast.showError(formatApiErrorMessage(e, 'Could not copy invitation link.'));
|
||||||
} finally {
|
} finally {
|
||||||
setCopyingInviteMembershipId(null);
|
setCopyingInviteMembershipId(null);
|
||||||
}
|
}
|
||||||
@@ -501,7 +490,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-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-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-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>
|
</tr>
|
||||||
}
|
}
|
||||||
body={
|
body={
|
||||||
@@ -522,6 +513,8 @@ export default function StaffPage() {
|
|||||||
<Badge variant="success">Active</Badge>
|
<Badge variant="success">Active</Badge>
|
||||||
) : m.invitationStatus === 'PENDING' ? (
|
) : m.invitationStatus === 'PENDING' ? (
|
||||||
<Badge variant="warning">Pending</Badge>
|
<Badge variant="warning">Pending</Badge>
|
||||||
|
) : m.invitationStatus === 'DISABLED' ? (
|
||||||
|
<Badge variant="default">Disabled</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="danger">Expired</Badge>
|
<Badge variant="danger">Expired</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -535,9 +528,9 @@ export default function StaffPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-1.5 align-middle">
|
<td className="px-6 py-1.5 align-middle text-center">
|
||||||
{!m.isOwner && (
|
{!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) && (
|
{canShareStaffInviteLink(m) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -554,6 +547,25 @@ export default function StaffPage() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`p-2 rounded-md ${
|
className={`p-2 rounded-md ${
|
||||||
@@ -577,11 +589,12 @@ export default function StaffPage() {
|
|||||||
? 'text-text-secondary hover:bg-red-500/15 hover:text-red-600'
|
? 'text-text-secondary hover:bg-red-500/15 hover:text-red-600'
|
||||||
: 'text-text-muted opacity-50 cursor-not-allowed'
|
: 'text-text-muted opacity-50 cursor-not-allowed'
|
||||||
}`}
|
}`}
|
||||||
aria-label="Remove member"
|
aria-label="Delete member"
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
|
title="Delete member (not implemented)"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
void removeMember(m);
|
handleDeleteMember();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
@@ -647,6 +660,60 @@ export default function StaffPage() {
|
|||||||
</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 && (
|
{editing && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { Card } from '@/components/ui/common/Card';
|
import { Card } from '@/components/ui/shared/Card';
|
||||||
|
|
||||||
export default function TodayPage() {
|
export default function TodayPage() {
|
||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Button } from '@/components/ui/common/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Input } from '@/components/ui/common/Input';
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
import { staffApi } from '@/lib/api/staff';
|
import { staffApi } from '@/lib/api/staff';
|
||||||
|
|
||||||
function AcceptInviteContent() {
|
function AcceptInviteContent() {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import type { OrganizationDetailsFormValues } from '@/components/ui/auth/Organiz
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { Lock, Mail, User } from 'lucide-react';
|
import { Lock, Mail, User } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/common/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Input } from '@/components/ui/common/Input';
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
|
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
|
||||||
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
|
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
|
||||||
import { organizationApi } from '@/lib/api/organization';
|
import { organizationApi } from '@/lib/api/organization';
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ import Link from 'next/link';
|
|||||||
import { Mail, Lock } from 'lucide-react';
|
import { Mail, Lock } from 'lucide-react';
|
||||||
|
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { Button } from '@/components/ui/common/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Input } from '@/components/ui/common/Input';
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email('Please enter a valid email address'),
|
email: z.string().email('Please enter a valid email address'),
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { Button } from '@/components/ui/common/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { ThemeToggle } from '@/components/ui/common/ThemeToggle';
|
import { ThemeToggle } from '@/components/ui/shared/ThemeToggle';
|
||||||
import { Building2, Beaker, Calendar, Shield, Clock, Users } from 'lucide-react';
|
import { Building2, Beaker, Calendar, Shield, Clock, Users } from 'lucide-react';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { Mail, Lock, User } from 'lucide-react';
|
|||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
|
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
|
||||||
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
|
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
|
||||||
import { Button } from '@/components/ui/common/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Input } from '@/components/ui/common/Input';
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||||
email: z.string().email('Please enter a valid email address'),
|
email: z.string().email('Please enter a valid email address'),
|
||||||
|
|||||||
16
frontend/src/components/shared/organizationTypeIcon.ts
Normal file
16
frontend/src/components/shared/organizationTypeIcon.ts
Normal 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';
|
||||||
|
}
|
||||||
@@ -16,6 +16,11 @@ export function hasPermission(org: Organization | null, permission: string): boo
|
|||||||
return Boolean(org.permissions?.includes(permission));
|
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 */
|
/** Sidebar / route guard: READ access to a tab */
|
||||||
export function canViewTab(org: Organization | null, readPermission: string): boolean {
|
export function canViewTab(org: Organization | null, readPermission: string): boolean {
|
||||||
return hasPermission(org, readPermission);
|
return hasPermission(org, readPermission);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isSameLocalCalendarDay } from '@/lib/appointmentTime';
|
import { isSameLocalCalendarDay } from '@/components/appointments/appointmentTime';
|
||||||
import type { TreatmentAppointment } from '@/types/treatment';
|
import type { TreatmentAppointment } from '@/types/treatment';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/common/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton';
|
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||||
import { Dropdown } from '@/components/ui/common/Dropdown';
|
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||||
import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
|
import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
|
||||||
import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles';
|
import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||||
import type { Patient } from '@/types/patient';
|
import type { Patient } from '@/types/patient';
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
compareLocalDayStart,
|
compareLocalDayStart,
|
||||||
formatTimeForInput,
|
formatTimeForInput,
|
||||||
isSameLocalCalendarDay,
|
isSameLocalCalendarDay,
|
||||||
} from '@/lib/appointmentTime';
|
} from '@/components/appointments/appointmentTime';
|
||||||
|
|
||||||
interface AppointmentBookingModalProps {
|
interface AppointmentBookingModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton';
|
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||||
import {
|
import {
|
||||||
APPOINTMENT_PURPOSE_LABEL,
|
APPOINTMENT_PURPOSE_LABEL,
|
||||||
purposeStyle,
|
purposeStyle,
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
|
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
|
||||||
import { formatHourLabel } from '@/lib/appointmentTime';
|
import { formatHourLabel } from '@/components/appointments/appointmentTime';
|
||||||
import {
|
import {
|
||||||
computeAppointmentLaneLayouts,
|
computeAppointmentLaneLayouts,
|
||||||
findOverlapCluster,
|
findOverlapCluster,
|
||||||
lanePositionStyles,
|
lanePositionStyles,
|
||||||
} from '@/lib/appointmentOverlapLayout';
|
} from '@/components/appointments/appointmentOverlapLayout';
|
||||||
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||||
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
|
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/common/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Input } from '@/components/ui/common/Input';
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
import type { Patient } from '@/types/patient';
|
import type { Patient } from '@/types/patient';
|
||||||
|
|
||||||
interface AppointmentsPatientSearchProps {
|
interface AppointmentsPatientSearchProps {
|
||||||
@@ -49,7 +49,7 @@ export function AppointmentsPatientSearch({
|
|||||||
onClick={onAddPatient}
|
onClick={onAddPatient}
|
||||||
title={!canAddPatient ? 'You do not have permission to add patients.' : undefined}
|
title={!canAddPatient ? 'You do not have permission to add patients.' : undefined}
|
||||||
>
|
>
|
||||||
+ Add New Patient
|
New Patient
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Building2, Mail } from 'lucide-react';
|
import { Building2, Mail } from 'lucide-react';
|
||||||
import type { FieldErrors, UseFormRegister, UseFormSetValue } from 'react-hook-form';
|
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 = {
|
export type OrganizationDetailsFormValues = {
|
||||||
organizationName: string;
|
organizationName: string;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton';
|
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||||
import { ToastStack, type ToastMessages } from '@/components/ui/common/Toast';
|
import { ToastStack, type ToastMessages } from '@/components/ui/shared/Toast';
|
||||||
import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization';
|
import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization';
|
||||||
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/common/Badge';
|
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge';
|
||||||
import { Table } from '@/components/ui/common/Table';
|
import { Table } from '@/components/ui/shared/Table';
|
||||||
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
|
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
|
||||||
|
|
||||||
function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string {
|
function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string {
|
||||||
|
|||||||
@@ -1,20 +1,37 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { Building2, Beaker, Mail } from 'lucide-react';
|
import { canCreateOrganizationFromCurrentOrg } from '@/components/shared/permissions';
|
||||||
import { Input } from '@/components/ui/common/Input';
|
import { Building2, Mail } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/common/Button';
|
import type { Organization } from '@/types/organization';
|
||||||
|
import { organizationTypeIcon } from '@/components/shared/organizationTypeIcon';
|
||||||
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
|
|
||||||
export function OrganizationSelectorContent() {
|
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 [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
const [organizationName, setOrganizationName] = useState('');
|
const [organizationName, setOrganizationName] = useState('');
|
||||||
const [organizationEmail, setOrganizationEmail] = useState('');
|
const [organizationEmail, setOrganizationEmail] = useState('');
|
||||||
const [organizationType, setOrganizationType] = useState<'CLINIC' | 'LAB'>('CLINIC');
|
const [organizationType, setOrganizationType] = useState<'CLINIC' | 'LAB'>('CLINIC');
|
||||||
|
|
||||||
const getIcon = (type: string) =>
|
const renderOrgTypeIcon = (type: Organization['type']) => {
|
||||||
type === 'CLINIC' ? <Building2 className="h-8 w-8 icon-flat" /> : <Beaker className="h-8 w-8 icon-flat" />;
|
const Icon = organizationTypeIcon(type);
|
||||||
|
return <Icon className="h-8 w-8 icon-flat" />;
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateOrganization = async () => {
|
const handleCreateOrganization = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -44,22 +61,26 @@ export function OrganizationSelectorContent() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-semibold text-text-primary">Organizations</h1>
|
<h1 className="text-3xl font-semibold text-text-primary">Organizations</h1>
|
||||||
<p className="text-text-secondary mt-2">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{canCreateOrganization && (
|
||||||
type="button"
|
<Button
|
||||||
variant={isCreateOpen ? 'outline' : 'primary'}
|
type="button"
|
||||||
onClick={() => {
|
variant={isCreateOpen ? 'outline' : 'primary'}
|
||||||
clearError();
|
onClick={() => {
|
||||||
setIsCreateOpen((prev) => !prev);
|
clearError();
|
||||||
}}
|
setIsCreateOpen((prev) => !prev);
|
||||||
>
|
}}
|
||||||
{isCreateOpen ? 'Cancel' : 'Create Organization'}
|
>
|
||||||
</Button>
|
{isCreateOpen ? 'Cancel' : 'Create Organization'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isCreateOpen && (
|
{canCreateOrganization && isCreateOpen && (
|
||||||
<div className="surface-card p-6 space-y-4">
|
<div className="surface-card p-6 space-y-4">
|
||||||
<Input
|
<Input
|
||||||
label="Organization name"
|
label="Organization name"
|
||||||
@@ -126,7 +147,11 @@ export function OrganizationSelectorContent() {
|
|||||||
|
|
||||||
{!organizations.length ? (
|
{!organizations.length ? (
|
||||||
<div className="surface-card p-8 text-center">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
@@ -137,7 +162,7 @@ export function OrganizationSelectorContent() {
|
|||||||
className="surface-card p-6 transition-all text-left flex items-center gap-4 hover:border-primary/60"
|
className="surface-card p-6 transition-all text-left flex items-center gap-4 hover:border-primary/60"
|
||||||
>
|
>
|
||||||
<div className="p-3 bg-primary-soft rounded-[var(--radius-sm)] text-primary">
|
<div className="p-3 bg-primary-soft rounded-[var(--radius-sm)] text-primary">
|
||||||
{getIcon(org.type)}
|
{renderOrgTypeIcon(org.type)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/common/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Input } from '@/components/ui/common/Input';
|
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||||
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
import { CreatePatientInput } from '@/types/patient';
|
import { CreatePatientInput } from '@/types/patient';
|
||||||
|
|
||||||
interface CreatePatientModalProps {
|
interface CreatePatientModalProps {
|
||||||
@@ -11,22 +12,27 @@ interface CreatePatientModalProps {
|
|||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
/** Inline panel on Patients page; centered dialog on Appointments. */
|
||||||
|
variant?: 'inline' | 'dialog';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreatePatientModal({
|
function CreatePatientFormFields({
|
||||||
isOpen,
|
|
||||||
formData,
|
formData,
|
||||||
onChange,
|
onChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onClose,
|
onClose,
|
||||||
loading = false,
|
loading,
|
||||||
}: CreatePatientModalProps) {
|
showCancel,
|
||||||
if (!isOpen) {
|
}: {
|
||||||
return null;
|
formData: CreatePatientInput;
|
||||||
}
|
onChange: (patch: Partial<CreatePatientInput>) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
showCancel: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="surface-card p-4 space-y-3">
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<Input
|
<Input
|
||||||
label="First name"
|
label="First name"
|
||||||
@@ -60,10 +66,80 @@ export function CreatePatientModal({
|
|||||||
>
|
>
|
||||||
Save Patient
|
Save Patient
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" onClick={onClose}>
|
{showCancel && (
|
||||||
Cancel
|
<Button variant="ghost" onClick={onClose}>
|
||||||
</Button>
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
import { Input } from '@/components/ui/common/Input';
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
import { Patient } from '@/types/patient';
|
import { Patient } from '@/types/patient';
|
||||||
|
|
||||||
interface PatientSearchSelectProps {
|
interface PatientSearchSelectProps {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// src/components/ui/OrganizationCard.tsx
|
// src/components/ui/OrganizationCard.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Building2, Beaker, ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
import type { Organization } from '@/types/organization';
|
import type { Organization } from '@/types/organization';
|
||||||
|
import { organizationTypeIcon } from '@/components/shared/organizationTypeIcon';
|
||||||
|
|
||||||
interface OrganizationCardProps {
|
interface OrganizationCardProps {
|
||||||
organization: Organization;
|
organization: Organization;
|
||||||
@@ -12,7 +13,7 @@ export const OrganizationCard: React.FC<OrganizationCardProps> = ({
|
|||||||
organization,
|
organization,
|
||||||
onSelect,
|
onSelect,
|
||||||
}) => {
|
}) => {
|
||||||
const Icon = organization.type === 'CLINIC' ? Building2 : Beaker;
|
const Icon = organizationTypeIcon(organization.type);
|
||||||
const typeText = organization.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab';
|
const typeText = organization.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2,17 +2,11 @@
|
|||||||
|
|
||||||
import { useEffect, useId, useRef, useState } from 'react';
|
import { useEffect, useId, useRef, useState } from 'react';
|
||||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import {
|
import { addCalendarDays, startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||||
addCalendarDays,
|
|
||||||
compareLocalDayStart,
|
|
||||||
startOfLocalDay,
|
|
||||||
} from '@/lib/appointmentTime';
|
|
||||||
|
|
||||||
interface ScheduleDayPickerProps {
|
interface ScheduleDayPickerProps {
|
||||||
value: Date;
|
value: Date;
|
||||||
onChange: (day: Date) => void;
|
onChange: (day: Date) => void;
|
||||||
/** Optional lower bound for day selection and previous-day navigation. */
|
|
||||||
minDate?: Date;
|
|
||||||
label?: string;
|
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);
|
return new Date(year, month, day, 0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampToValidDay(
|
function yearRange(anchor: Date): number[] {
|
||||||
year: number,
|
const anchorYear = anchor.getFullYear();
|
||||||
month: number,
|
const startYear = anchorYear - 10;
|
||||||
day: number,
|
const endYear = anchorYear + 2;
|
||||||
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());
|
|
||||||
const years: number[] = [];
|
const years: number[] = [];
|
||||||
for (let y = startYear; y <= endYear; y += 1) {
|
for (let y = startYear; y <= endYear; y += 1) {
|
||||||
years.push(y);
|
years.push(y);
|
||||||
@@ -72,21 +49,18 @@ const selectClassName = `
|
|||||||
bg-background-card/90 text-text-primary text-sm
|
bg-background-card/90 text-text-primary text-sm
|
||||||
pl-2 pr-7 py-1.5
|
pl-2 pr-7 py-1.5
|
||||||
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
|
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,
|
* Calendar day navigator (arrows + year/month/day panel).
|
||||||
onChange,
|
* Does not restrict past dates — parent pages enforce read-only vs editable for schedule grids/forms.
|
||||||
minDate,
|
*/
|
||||||
label = 'Schedule date',
|
export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) {
|
||||||
}: ScheduleDayPickerProps) {
|
|
||||||
const panelId = useId();
|
const panelId = useId();
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
|
|
||||||
const normalizedValue = startOfLocalDay(value);
|
const normalizedValue = startOfLocalDay(value);
|
||||||
const normalizedMin = minDate ? startOfLocalDay(minDate) : undefined;
|
|
||||||
|
|
||||||
const labelText = normalizedValue.toLocaleDateString(undefined, {
|
const labelText = normalizedValue.toLocaleDateString(undefined, {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
@@ -95,28 +69,20 @@ export function ScheduleDayPicker({
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
});
|
});
|
||||||
|
|
||||||
const previousDay = addCalendarDays(normalizedValue, -1);
|
const years = yearRange(normalizedValue);
|
||||||
const canGoPrevious =
|
|
||||||
!normalizedMin || compareLocalDayStart(previousDay, normalizedMin) >= 0;
|
|
||||||
|
|
||||||
const years = yearRange(normalizedMin, normalizedValue);
|
|
||||||
const selectedYear = normalizedValue.getFullYear();
|
const selectedYear = normalizedValue.getFullYear();
|
||||||
const selectedMonth = normalizedValue.getMonth();
|
const selectedMonth = normalizedValue.getMonth();
|
||||||
const selectedDay = normalizedValue.getDate();
|
const selectedDay = normalizedValue.getDate();
|
||||||
const dayCount = daysInMonth(selectedYear, selectedMonth);
|
const dayCount = daysInMonth(selectedYear, selectedMonth);
|
||||||
|
|
||||||
function applyParts(year: number, month: number, day: number, closePanel = false) {
|
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) {
|
if (closePanel) {
|
||||||
setPanelOpen(false);
|
setPanelOpen(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePreviousDay() {
|
|
||||||
if (!canGoPrevious) return;
|
|
||||||
onChange(previousDay);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!panelOpen) return;
|
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)]">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handlePreviousDay}
|
onClick={() => onChange(addCalendarDays(normalizedValue, -1))}
|
||||||
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"
|
||||||
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"
|
|
||||||
aria-label="Previous day"
|
aria-label="Previous day"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4 icon-flat" />
|
<ChevronLeft className="h-4 w-4 icon-flat" />
|
||||||
@@ -232,17 +197,11 @@ export function ScheduleDayPicker({
|
|||||||
}
|
}
|
||||||
className={selectClassName}
|
className={selectClassName}
|
||||||
>
|
>
|
||||||
{MONTH_LABELS.map((name, index) => {
|
{MONTH_LABELS.map((name, index) => (
|
||||||
const disabled =
|
<option key={name} value={index}>
|
||||||
normalizedMin &&
|
{name}
|
||||||
selectedYear === normalizedMin.getFullYear() &&
|
</option>
|
||||||
index < normalizedMin.getMonth();
|
))}
|
||||||
return (
|
|
||||||
<option key={name} value={index} disabled={disabled}>
|
|
||||||
{name}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown
|
<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"
|
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}
|
className={selectClassName}
|
||||||
>
|
>
|
||||||
{Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => {
|
{Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => (
|
||||||
const disabled =
|
<option key={day} value={day}>
|
||||||
normalizedMin &&
|
{day}
|
||||||
selectedYear === normalizedMin.getFullYear() &&
|
</option>
|
||||||
selectedMonth === normalizedMin.getMonth() &&
|
))}
|
||||||
day < normalizedMin.getDate();
|
|
||||||
return (
|
|
||||||
<option key={day} value={day} disabled={disabled}>
|
|
||||||
{day}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown
|
<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"
|
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"
|
||||||
@@ -13,10 +13,14 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
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 = [
|
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: 'Staff', path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const },
|
||||||
{ name: 'Patients', path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const },
|
{ name: 'Patients', path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const },
|
||||||
{ name: 'Appointment', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_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 pathname = usePathname();
|
||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
|
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
|
||||||
|
const organizationsTabIcon = organizationTypeIcon(
|
||||||
|
counterpartOrganizationType(currentOrganization?.type),
|
||||||
|
);
|
||||||
|
|
||||||
const visibleMenu = useMemo(
|
const visibleMenu = useMemo(
|
||||||
() => {
|
() => {
|
||||||
@@ -38,7 +45,7 @@ function Sidebar() {
|
|||||||
{
|
{
|
||||||
name: counterpartLabel,
|
name: counterpartLabel,
|
||||||
path: '/organizations',
|
path: '/organizations',
|
||||||
icon: FlaskConical,
|
icon: organizationsTabIcon,
|
||||||
read: 'TAB_ORGANIZATIONS_READ' as const,
|
read: 'TAB_ORGANIZATIONS_READ' as const,
|
||||||
},
|
},
|
||||||
menu[2],
|
menu[2],
|
||||||
@@ -54,7 +61,7 @@ function Sidebar() {
|
|||||||
return canViewTab(currentOrganization, item.read);
|
return canViewTab(currentOrganization, item.read);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[counterpartLabel, currentOrganization],
|
[counterpartLabel, organizationsTabIcon, currentOrganization],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { BadgeVariant } from '@/components/ui/common/Badge';
|
import type { BadgeVariant } from '@/components/ui/shared/Badge';
|
||||||
|
|
||||||
interface ToastProps {
|
interface ToastProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { CalendarDays } from 'lucide-react';
|
import { CalendarDays } from 'lucide-react';
|
||||||
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||||
import { Card } from '@/components/ui/common/Card';
|
import { Card } from '@/components/ui/shared/Card';
|
||||||
import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker';
|
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
|
||||||
import { startOfLocalDay } from '@/lib/appointmentTime';
|
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||||
import type { TreatmentAppointment } from '@/types/treatment';
|
import type { TreatmentAppointment } from '@/types/treatment';
|
||||||
|
|
||||||
interface AppointmentsStripProps {
|
interface AppointmentsStripProps {
|
||||||
@@ -12,8 +12,6 @@ interface AppointmentsStripProps {
|
|||||||
onToggleStripHidden: () => void;
|
onToggleStripHidden: () => void;
|
||||||
selectedDay: Date;
|
selectedDay: Date;
|
||||||
onSelectDay: (day: Date) => void;
|
onSelectDay: (day: Date) => void;
|
||||||
/** Same lower bound as Appointments schedule (cannot pick days before this). */
|
|
||||||
minScheduleDate: Date;
|
|
||||||
appointments: TreatmentAppointment[];
|
appointments: TreatmentAppointment[];
|
||||||
selectedAppointmentId: string | null;
|
selectedAppointmentId: string | null;
|
||||||
onSelectAppointment: (id: string) => void;
|
onSelectAppointment: (id: string) => void;
|
||||||
@@ -25,7 +23,6 @@ export function AppointmentsStrip({
|
|||||||
onToggleStripHidden,
|
onToggleStripHidden,
|
||||||
selectedDay,
|
selectedDay,
|
||||||
onSelectDay,
|
onSelectDay,
|
||||||
minScheduleDate,
|
|
||||||
appointments,
|
appointments,
|
||||||
selectedAppointmentId,
|
selectedAppointmentId,
|
||||||
onSelectAppointment,
|
onSelectAppointment,
|
||||||
@@ -65,7 +62,6 @@ export function AppointmentsStrip({
|
|||||||
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
|
||||||
<ScheduleDayPicker
|
<ScheduleDayPicker
|
||||||
value={selectedDay}
|
value={selectedDay}
|
||||||
minDate={minScheduleDate}
|
|
||||||
onChange={(d) => onSelectDay(startOfLocalDay(d))}
|
onChange={(d) => onSelectDay(startOfLocalDay(d))}
|
||||||
/>
|
/>
|
||||||
{loading && (
|
{loading && (
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
|
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
|
||||||
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
||||||
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
|
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
|
||||||
import { Checkbox } from '@/components/ui/common/Checkbox';
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
import { Button } from '@/components/ui/common/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Dropdown } from '@/components/ui/common/Dropdown';
|
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||||
import { SearchBar } from '@/components/ui/common/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import { Toast } from '@/components/ui/common/Toast';
|
import { Toast } from '@/components/ui/shared/Toast';
|
||||||
import { isSameLocalCalendarDay, startOfLocalDay } from '@/lib/appointmentTime';
|
import { compareLocalDayStart, isSameLocalCalendarDay, startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||||
import {
|
import {
|
||||||
fetchLinkedOrganizations,
|
fetchLinkedOrganizations,
|
||||||
fetchMyAppointmentsForDay,
|
fetchMyAppointmentsForDay,
|
||||||
@@ -17,8 +17,8 @@ import {
|
|||||||
saveTreatmentDraft,
|
saveTreatmentDraft,
|
||||||
sendTreatmentRecord,
|
sendTreatmentRecord,
|
||||||
} from '@/lib/mocks/treatmentMockApi';
|
} from '@/lib/mocks/treatmentMockApi';
|
||||||
import { pickAutoAppointment } from '@/lib/treatmentSelection';
|
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
|
||||||
import { canEditTreatment } from '@/shared/permissions';
|
import { canEditTreatment } from '@/components/shared/permissions';
|
||||||
import type { Organization } from '@/types/organization';
|
import type { Organization } from '@/types/organization';
|
||||||
import type {
|
import type {
|
||||||
FdiToothId,
|
FdiToothId,
|
||||||
@@ -54,7 +54,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
|
|
||||||
const [stripHidden, setStripHidden] = useState(false);
|
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 [selectedDay, setSelectedDay] = useState(() => startOfLocalDay(new Date()));
|
||||||
const [appointments, setAppointments] = useState<TreatmentAppointment[]>([]);
|
const [appointments, setAppointments] = useState<TreatmentAppointment[]>([]);
|
||||||
@@ -87,6 +87,13 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
[appointments, selectedAppointmentId],
|
[appointments, selectedAppointmentId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isViewingPastDay = useMemo(
|
||||||
|
() => compareLocalDayStart(selectedDay, todayStart) < 0,
|
||||||
|
[selectedDay, todayStart],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canEditTreatmentForDay = Boolean(selectedAppointment) && !isViewingPastDay;
|
||||||
|
|
||||||
const activeRecord = useMemo(
|
const activeRecord = useMemo(
|
||||||
() => records.find((r) => r.clientId === activeRecordId) ?? records[0],
|
() => records.find((r) => r.clientId === activeRecordId) ?? records[0],
|
||||||
[records, activeRecordId],
|
[records, activeRecordId],
|
||||||
@@ -339,13 +346,19 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
onToggleStripHidden={() => setStripHidden((s) => !s)}
|
onToggleStripHidden={() => setStripHidden((s) => !s)}
|
||||||
selectedDay={selectedDay}
|
selectedDay={selectedDay}
|
||||||
onSelectDay={setSelectedDay}
|
onSelectDay={setSelectedDay}
|
||||||
minScheduleDate={scheduleMinDate}
|
|
||||||
appointments={appointments}
|
appointments={appointments}
|
||||||
selectedAppointmentId={selectedAppointmentId}
|
selectedAppointmentId={selectedAppointmentId}
|
||||||
onSelectAppointment={onPickAppointment}
|
onSelectAppointment={onPickAppointment}
|
||||||
loading={apptsLoading}
|
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="grid grid-cols-1 xl:grid-cols-[minmax(280px,380px)_minmax(0,1fr)] gap-6 items-start">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{selectedAppointment ? (
|
{selectedAppointment ? (
|
||||||
@@ -423,7 +436,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
<FdiToothChart
|
<FdiToothChart
|
||||||
selected={selectedTeethSet}
|
selected={selectedTeethSet}
|
||||||
onToggle={toggleTooth}
|
onToggle={toggleTooth}
|
||||||
disabled={!selectedAppointment}
|
disabled={!canEditTreatmentForDay}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="surface-card p-4 space-y-4">
|
<div className="surface-card p-4 space-y-4">
|
||||||
@@ -437,7 +450,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={!selectedAppointment}
|
disabled={!canEditTreatmentForDay}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const nr = newRecord();
|
const nr = newRecord();
|
||||||
fixActiveAfterRecordsChange([...records, nr]);
|
fixActiveAfterRecordsChange([...records, nr]);
|
||||||
@@ -486,7 +499,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
}}
|
}}
|
||||||
placeholder="Write clinical notes for this record…"
|
placeholder="Write clinical notes for this record…"
|
||||||
rows={5}
|
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]"
|
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>
|
</label>
|
||||||
@@ -503,7 +516,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
disabled={!selectedAppointment}
|
disabled={!canEditTreatmentForDay}
|
||||||
className="capitalize"
|
className="capitalize"
|
||||||
style={{ color: treatmentTypeTextColor }}
|
style={{ color: treatmentTypeTextColor }}
|
||||||
>
|
>
|
||||||
@@ -522,7 +535,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
id="treatment-record-attachments"
|
id="treatment-record-attachments"
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
disabled={!selectedAppointment}
|
disabled={!canEditTreatmentForDay}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
addAttachments(e.target.files);
|
addAttachments(e.target.files);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
@@ -533,7 +546,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={!selectedAppointment}
|
disabled={!canEditTreatmentForDay}
|
||||||
onClick={() => attachmentInputRef.current?.click()}
|
onClick={() => attachmentInputRef.current?.click()}
|
||||||
aria-controls="treatment-record-attachments"
|
aria-controls="treatment-record-attachments"
|
||||||
>
|
>
|
||||||
@@ -589,7 +602,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
key={o.id}
|
key={o.id}
|
||||||
checked={activeRecord.sendToOrganizationIds.includes(o.id)}
|
checked={activeRecord.sendToOrganizationIds.includes(o.id)}
|
||||||
disabled={!selectedAppointment || Boolean(activeRecord.sentAt)}
|
disabled={!canEditTreatmentForDay || Boolean(activeRecord.sentAt)}
|
||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
setRecords((prev) =>
|
setRecords((prev) =>
|
||||||
prev.map((r) => {
|
prev.map((r) => {
|
||||||
@@ -614,7 +627,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={!selectedAppointment || Boolean(activeRecord.sentAt) || sendBusyId === activeRecord.clientId}
|
disabled={!canEditTreatmentForDay || Boolean(activeRecord.sentAt) || sendBusyId === activeRecord.clientId}
|
||||||
isLoading={sendBusyId === activeRecord.clientId}
|
isLoading={sendBusyId === activeRecord.clientId}
|
||||||
onClick={() => void handleSendRecord(activeRecord)}
|
onClick={() => void handleSendRecord(activeRecord)}
|
||||||
>
|
>
|
||||||
@@ -633,7 +646,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={!selectedAppointment || saveBusy}
|
disabled={!canEditTreatmentForDay || saveBusy}
|
||||||
isLoading={saveBusy}
|
isLoading={saveBusy}
|
||||||
onClick={() => void handleSaveAll()}
|
onClick={() => void handleSaveAll()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface StaffMemberDto {
|
|||||||
name: string;
|
name: string;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
invitationStatus: 'ACTIVE' | 'PENDING' | 'EXPIRED';
|
invitationStatus: 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED';
|
||||||
invitedAt: string | null;
|
invitedAt: string | null;
|
||||||
acceptedAt: string | null;
|
acceptedAt: string | null;
|
||||||
permissions: string[] | null;
|
permissions: string[] | null;
|
||||||
@@ -100,6 +100,13 @@ export const staffApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
disableMember: async (
|
||||||
|
membershipId: string,
|
||||||
|
): Promise<{ success: boolean; message: string }> => {
|
||||||
|
const response = await apiClient.patch(`/staff/members/${membershipId}/disable`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
removeMember: async (
|
removeMember: async (
|
||||||
membershipId: string,
|
membershipId: string,
|
||||||
): Promise<{ success: boolean; message: string }> => {
|
): Promise<{ success: boolean; message: string }> => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
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;
|
const DEFAULT_DURATION_MS = 4000;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
addCalendarDays,
|
addCalendarDays,
|
||||||
isSameLocalCalendarDay,
|
isSameLocalCalendarDay,
|
||||||
startOfLocalDay,
|
startOfLocalDay,
|
||||||
} from '@/lib/appointmentTime';
|
} from '@/components/appointments/appointmentTime';
|
||||||
import type {
|
import type {
|
||||||
FdiToothId,
|
FdiToothId,
|
||||||
LinkedOrganizationOption,
|
LinkedOrganizationOption,
|
||||||
|
|||||||
Reference in New Issue
Block a user