improvement/ux-overhaul up #61
@@ -1,20 +1,24 @@
|
|||||||
import { IsDateString, IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
|
import { IsDateString, IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
import { ErrorCode } from '../../../common/errors';
|
||||||
|
|
||||||
export class CreatePatientDto {
|
export class CreatePatientDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@MinLength(1, { message: ErrorCode.VALIDATION_FIELD_REQUIRED })
|
||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
firstName: string;
|
firstName: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@MinLength(1, { message: ErrorCode.VALIDATION_FIELD_REQUIRED })
|
||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@MinLength(1, { message: ErrorCode.VALIDATION_FIELD_REQUIRED })
|
||||||
@MaxLength(30)
|
@MaxLength(30)
|
||||||
mobile: string;
|
mobile: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEmail()
|
@IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID })
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ export class PatientsController {
|
|||||||
return this.patientsService.findAll(query);
|
return this.patientsService.findAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/appointments')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'List this patient\'s appointments for the current clinic (requires TAB_PATIENTS_READ; not gated by appointments permission)',
|
||||||
|
})
|
||||||
|
listAppointments(@Param('id') id: string, @Req() req: { user: { id: string; organizationId?: string } }) {
|
||||||
|
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||||
|
return this.patientsService.listAppointments(id, organizationId, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get one patient by id' })
|
@ApiOperation({ summary: 'Get one patient by id' })
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { isValidMobile, mobileSearchDigits, normalizeMobile } from '../../common/phone';
|
import { isValidMobile, mobileSearchDigits, normalizeMobile } from '../../common/phone';
|
||||||
|
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||||
|
import { AppException, ErrorCode } from '../../common/errors';
|
||||||
import { CreatePatientDto } from './dto/create-patient.dto';
|
import { CreatePatientDto } from './dto/create-patient.dto';
|
||||||
import { ListPatientsDto } from './dto/list-patients.dto';
|
import { ListPatientsDto } from './dto/list-patients.dto';
|
||||||
import { UpdatePatientDto } from './dto/update-patient.dto';
|
import { UpdatePatientDto } from './dto/update-patient.dto';
|
||||||
@@ -10,6 +12,8 @@ export class PatientsService {
|
|||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async create(createPatientDto: CreatePatientDto, organizationId: string) {
|
async create(createPatientDto: CreatePatientDto, organizationId: string) {
|
||||||
|
const firstName = this.requireNonEmptyName(createPatientDto.firstName, 'firstName');
|
||||||
|
const lastName = this.requireNonEmptyName(createPatientDto.lastName, 'lastName');
|
||||||
const mobile = this.resolveMobile(createPatientDto.mobile);
|
const mobile = this.resolveMobile(createPatientDto.mobile);
|
||||||
|
|
||||||
const existing = await this.prisma.patient.findUnique({
|
const existing = await this.prisma.patient.findUnique({
|
||||||
@@ -22,8 +26,8 @@ export class PatientsService {
|
|||||||
|
|
||||||
const patient = await this.prisma.patient.create({
|
const patient = await this.prisma.patient.create({
|
||||||
data: {
|
data: {
|
||||||
firstName: createPatientDto.firstName.trim(),
|
firstName,
|
||||||
lastName: createPatientDto.lastName.trim(),
|
lastName,
|
||||||
mobile,
|
mobile,
|
||||||
email: createPatientDto.email?.trim() || null,
|
email: createPatientDto.email?.trim() || null,
|
||||||
notes: createPatientDto.notes?.trim() || null,
|
notes: createPatientDto.notes?.trim() || null,
|
||||||
@@ -92,10 +96,10 @@ export class PatientsService {
|
|||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (updatePatientDto.firstName !== undefined) {
|
if (updatePatientDto.firstName !== undefined) {
|
||||||
data.firstName = updatePatientDto.firstName.trim();
|
data.firstName = this.requireNonEmptyName(updatePatientDto.firstName, 'firstName');
|
||||||
}
|
}
|
||||||
if (updatePatientDto.lastName !== undefined) {
|
if (updatePatientDto.lastName !== undefined) {
|
||||||
data.lastName = updatePatientDto.lastName.trim();
|
data.lastName = this.requireNonEmptyName(updatePatientDto.lastName, 'lastName');
|
||||||
}
|
}
|
||||||
if (updatePatientDto.mobile !== undefined) {
|
if (updatePatientDto.mobile !== undefined) {
|
||||||
data.mobile = this.resolveMobile(updatePatientDto.mobile);
|
data.mobile = this.resolveMobile(updatePatientDto.mobile);
|
||||||
@@ -120,6 +124,42 @@ export class PatientsService {
|
|||||||
return { success: true, data: patient };
|
return { success: true, data: patient };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listAppointments(
|
||||||
|
patientId: string,
|
||||||
|
organizationId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
) {
|
||||||
|
await this.assertCanViewPatients(actorUserId, organizationId);
|
||||||
|
await this.ensurePatient(patientId);
|
||||||
|
|
||||||
|
const items = await this.prisma.appointment.findMany({
|
||||||
|
where: { organizationId, patientId },
|
||||||
|
orderBy: [{ startAt: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const providerIds = [...new Set(items.map((item) => item.providerUserId))];
|
||||||
|
const providers =
|
||||||
|
providerIds.length === 0
|
||||||
|
? []
|
||||||
|
: await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: providerIds } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
const providerNameById = new Map(providers.map((p) => [p.id, p.name]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
startAt: item.startAt,
|
||||||
|
endAt: item.endAt,
|
||||||
|
purpose: item.purpose,
|
||||||
|
providerUserId: item.providerUserId,
|
||||||
|
providerName: providerNameById.get(item.providerUserId) ?? '',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||||
if (!user?.organizationId) {
|
if (!user?.organizationId) {
|
||||||
throw new BadRequestException('Organization is not selected');
|
throw new BadRequestException('Organization is not selected');
|
||||||
@@ -148,15 +188,51 @@ export class PatientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveMobile(raw: string): string {
|
private resolveMobile(raw: string): string {
|
||||||
|
if (!raw?.trim()) {
|
||||||
|
throw new AppException(ErrorCode.VALIDATION_FIELD_REQUIRED, HttpStatus.BAD_REQUEST, [
|
||||||
|
{ field: 'mobile', code: ErrorCode.VALIDATION_FIELD_REQUIRED },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
const mobile = normalizeMobile(raw);
|
const mobile = normalizeMobile(raw);
|
||||||
if (!mobile || !isValidMobile(mobile)) {
|
if (!mobile || !isValidMobile(mobile)) {
|
||||||
throw new BadRequestException(
|
throw new AppException(ErrorCode.VALIDATION_MOBILE_INVALID, HttpStatus.BAD_REQUEST, [
|
||||||
'Invalid mobile number. Use a valid Iran mobile (e.g. 09121234567 or +989121234567).',
|
{ field: 'mobile', code: ErrorCode.VALIDATION_MOBILE_INVALID },
|
||||||
);
|
]);
|
||||||
}
|
}
|
||||||
return mobile;
|
return mobile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private requireNonEmptyName(value: string, field: 'firstName' | 'lastName'): string {
|
||||||
|
const trimmed = value?.trim() ?? '';
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new AppException(ErrorCode.VALIDATION_FIELD_REQUIRED, HttpStatus.BAD_REQUEST, [
|
||||||
|
{ field, code: ErrorCode.VALIDATION_FIELD_REQUIRED },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanViewPatients(userId: string, organizationId: string) {
|
||||||
|
const membership = await this.prisma.membership.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_organizationId: { userId, organizationId },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
organization: { include: { type: true, plan: true } },
|
||||||
|
permissions: { include: { permission: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasEffectivePermission(membership, 'TAB_PATIENTS_READ')) {
|
||||||
|
throw new AppException(ErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async ensurePatient(id: string) {
|
private async ensurePatient(id: string) {
|
||||||
const patient = await this.prisma.patient.findUnique({
|
const patient = await this.prisma.patient.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@@ -393,7 +393,20 @@
|
|||||||
"statusLabel": "Status:",
|
"statusLabel": "Status:",
|
||||||
"statusActive": "Active",
|
"statusActive": "Active",
|
||||||
"statusInactive": "Inactive",
|
"statusInactive": "Inactive",
|
||||||
"emptyValue": "-"
|
"emptyValue": "-",
|
||||||
|
"requiredMark": "*",
|
||||||
|
"requiredFieldsHint": "Fields marked with * are required. Email is optional.",
|
||||||
|
"firstNameRequired": "First name is required.",
|
||||||
|
"lastNameRequired": "Last name is required.",
|
||||||
|
"emailOptional": "Email (optional)",
|
||||||
|
"emailOptionalSummary": "Email (optional):",
|
||||||
|
"appointmentHistoryTitle": "Appointment history",
|
||||||
|
"appointmentHistorySubtitle": "Past and upcoming appointments for this patient at your clinic.",
|
||||||
|
"appointmentHistoryLoading": "Loading appointment history…",
|
||||||
|
"appointmentHistoryEmpty": "No appointments recorded for this patient yet.",
|
||||||
|
"appointmentHistoryError": "Could not load appointment history.",
|
||||||
|
"appointmentHistoryProvider": "Provider: {name}",
|
||||||
|
"appointmentHistoryUnknownProvider": "Unknown provider"
|
||||||
},
|
},
|
||||||
"cases": {
|
"cases": {
|
||||||
"title": "Cases",
|
"title": "Cases",
|
||||||
|
|||||||
@@ -393,7 +393,20 @@
|
|||||||
"statusLabel": "وضعیت:",
|
"statusLabel": "وضعیت:",
|
||||||
"statusActive": "فعال",
|
"statusActive": "فعال",
|
||||||
"statusInactive": "غیرفعال",
|
"statusInactive": "غیرفعال",
|
||||||
"emptyValue": "-"
|
"emptyValue": "-",
|
||||||
|
"requiredMark": "*",
|
||||||
|
"requiredFieldsHint": "فیلدهای دارای * الزامی هستند. ایمیل اختیاری است.",
|
||||||
|
"firstNameRequired": "نام الزامی است.",
|
||||||
|
"lastNameRequired": "نام خانوادگی الزامی است.",
|
||||||
|
"emailOptional": "ایمیل (اختیاری)",
|
||||||
|
"emailOptionalSummary": "ایمیل (اختیاری):",
|
||||||
|
"appointmentHistoryTitle": "سوابق نوبت",
|
||||||
|
"appointmentHistorySubtitle": "نوبتهای گذشته و آینده این بیمار در کلینیک شما.",
|
||||||
|
"appointmentHistoryLoading": "در حال بارگذاری سوابق نوبت…",
|
||||||
|
"appointmentHistoryEmpty": "هنوز نوبتی برای این بیمار ثبت نشده است.",
|
||||||
|
"appointmentHistoryError": "بارگذاری سوابق نوبت انجام نشد.",
|
||||||
|
"appointmentHistoryProvider": "ارائهدهنده: {name}",
|
||||||
|
"appointmentHistoryUnknownProvider": "ارائهدهنده نامشخص"
|
||||||
},
|
},
|
||||||
"cases": {
|
"cases": {
|
||||||
"title": "پروندهها",
|
"title": "پروندهها",
|
||||||
|
|||||||
@@ -393,7 +393,20 @@
|
|||||||
"statusLabel": "Status:",
|
"statusLabel": "Status:",
|
||||||
"statusActive": "Actief",
|
"statusActive": "Actief",
|
||||||
"statusInactive": "Inactief",
|
"statusInactive": "Inactief",
|
||||||
"emptyValue": "-"
|
"emptyValue": "-",
|
||||||
|
"requiredMark": "*",
|
||||||
|
"requiredFieldsHint": "Velden met * zijn verplicht. E-mail is optioneel.",
|
||||||
|
"firstNameRequired": "Voornaam is verplicht.",
|
||||||
|
"lastNameRequired": "Achternaam is verplicht.",
|
||||||
|
"emailOptional": "E-mail (optioneel)",
|
||||||
|
"emailOptionalSummary": "E-mail (optioneel):",
|
||||||
|
"appointmentHistoryTitle": "Afspraakgeschiedenis",
|
||||||
|
"appointmentHistorySubtitle": "Eerdere en komende afspraken voor deze patiënt in uw kliniek.",
|
||||||
|
"appointmentHistoryLoading": "Afspraakgeschiedenis laden…",
|
||||||
|
"appointmentHistoryEmpty": "Er zijn nog geen afspraken voor deze patiënt.",
|
||||||
|
"appointmentHistoryError": "Kon afspraakgeschiedenis niet laden.",
|
||||||
|
"appointmentHistoryProvider": "Behandelaar: {name}",
|
||||||
|
"appointmentHistoryUnknownProvider": "Onbekende behandelaar"
|
||||||
},
|
},
|
||||||
"cases": {
|
"cases": {
|
||||||
"title": "Dossiers",
|
"title": "Dossiers",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { CreatePatientInput, Patient } from '@/types/patient';
|
|||||||
import { PatientSearchSelect } from '@/components/ui/patient/PatientSearchSelect';
|
import { PatientSearchSelect } from '@/components/ui/patient/PatientSearchSelect';
|
||||||
import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal';
|
import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal';
|
||||||
import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard';
|
import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard';
|
||||||
|
import { PatientAppointmentHistory } from '@/components/ui/patient/PatientAppointmentHistory';
|
||||||
|
|
||||||
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
||||||
firstName: '',
|
firstName: '',
|
||||||
@@ -154,6 +155,9 @@ export default function PatientsPage() {
|
|||||||
|
|
||||||
<div className="xl:col-span-2 space-y-4">
|
<div className="xl:col-span-2 space-y-4">
|
||||||
<PatientSummaryCard patient={selectedPatient} />
|
<PatientSummaryCard patient={selectedPatient} />
|
||||||
|
{selectedPatient ? (
|
||||||
|
<PatientAppointmentHistory patientId={selectedPatient.id} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||||
@@ -22,6 +23,8 @@ interface CreatePatientModalProps {
|
|||||||
variant?: 'inline' | 'dialog';
|
variant?: 'inline' | 'dialog';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FieldErrors = Partial<Record<'firstName' | 'lastName' | 'mobile' | 'email', string>>;
|
||||||
|
|
||||||
function CreatePatientFormFields({
|
function CreatePatientFormFields({
|
||||||
formData,
|
formData,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -39,46 +42,113 @@ function CreatePatientFormFields({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTranslations('patients');
|
const t = useTranslations('patients');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
|
const tValidation = useTranslations('validation');
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
|
|
||||||
|
const requiredMark = t('requiredMark');
|
||||||
|
|
||||||
|
const validate = (): boolean => {
|
||||||
|
const nextErrors: FieldErrors = {};
|
||||||
|
|
||||||
|
if (!formData.firstName?.trim()) {
|
||||||
|
nextErrors.firstName = t('firstNameRequired');
|
||||||
|
}
|
||||||
|
if (!formData.lastName?.trim()) {
|
||||||
|
nextErrors.lastName = t('lastNameRequired');
|
||||||
|
}
|
||||||
|
if (!formData.mobile?.trim()) {
|
||||||
|
nextErrors.mobile = tValidation('mobileRequired');
|
||||||
|
} else if (!isValidMobile(normalizeMobile(formData.mobile) ?? '')) {
|
||||||
|
nextErrors.mobile = tValidation('mobileInvalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.email?.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email.trim())) {
|
||||||
|
nextErrors.email = tValidation('emailInvalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
setFieldErrors(nextErrors);
|
||||||
|
return Object.keys(nextErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSubmitDisabled = useMemo(
|
||||||
|
() =>
|
||||||
|
!formData.firstName?.trim() ||
|
||||||
|
!formData.lastName?.trim() ||
|
||||||
|
!isValidMobile(normalizeMobile(formData.mobile || '') ?? ''),
|
||||||
|
[formData.firstName, formData.lastName, formData.mobile],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<p className="text-sm text-text-muted">{t('requiredFieldsHint')}</p>
|
||||||
|
|
||||||
<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={t('firstName')}
|
label={`${t('firstName')} ${requiredMark}`}
|
||||||
value={formData.firstName || ''}
|
value={formData.firstName || ''}
|
||||||
onChange={(e) => onChange({ firstName: e.target.value })}
|
onChange={(e) => {
|
||||||
|
onChange({ firstName: e.target.value });
|
||||||
|
if (fieldErrors.firstName) {
|
||||||
|
setFieldErrors((prev) => ({ ...prev, firstName: undefined }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
error={fieldErrors.firstName}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('lastName')}
|
label={`${t('lastName')} ${requiredMark}`}
|
||||||
value={formData.lastName || ''}
|
value={formData.lastName || ''}
|
||||||
onChange={(e) => onChange({ lastName: e.target.value })}
|
onChange={(e) => {
|
||||||
|
onChange({ lastName: e.target.value });
|
||||||
|
if (fieldErrors.lastName) {
|
||||||
|
setFieldErrors((prev) => ({ ...prev, lastName: undefined }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
error={fieldErrors.lastName}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('mobile')}
|
label={`${t('mobile')} ${requiredMark}`}
|
||||||
value={formData.mobile || ''}
|
value={formData.mobile || ''}
|
||||||
onChange={(e) => onChange({ mobile: e.target.value })}
|
onChange={(e) => {
|
||||||
|
onChange({ mobile: e.target.value });
|
||||||
|
if (fieldErrors.mobile) {
|
||||||
|
setFieldErrors((prev) => ({ ...prev, mobile: undefined }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder={t('mobilePlaceholder')}
|
placeholder={t('mobilePlaceholder')}
|
||||||
|
required
|
||||||
|
error={fieldErrors.mobile}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={tCommon('email')}
|
label={t('emailOptional')}
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.email || ''}
|
value={formData.email || ''}
|
||||||
onChange={(e) => onChange({ email: e.target.value })}
|
onChange={(e) => {
|
||||||
|
onChange({ email: e.target.value });
|
||||||
|
if (fieldErrors.email) {
|
||||||
|
setFieldErrors((prev) => ({ ...prev, email: undefined }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
error={fieldErrors.email}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center">
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={onSubmit}
|
onClick={handleSubmit}
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
fullWidth
|
fullWidth
|
||||||
className="sm:w-auto"
|
className="sm:w-auto"
|
||||||
disabled={
|
disabled={isSubmitDisabled}
|
||||||
!formData.firstName ||
|
|
||||||
!formData.lastName ||
|
|
||||||
!isValidMobile(normalizeMobile(formData.mobile || '') ?? '')
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{t('savePatient')}
|
{t('savePatient')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
122
frontend/src/components/ui/patient/PatientAppointmentHistory.tsx
Normal file
122
frontend/src/components/ui/patient/PatientAppointmentHistory.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { formatTimeForInput } from '@/components/appointments/appointmentTime';
|
||||||
|
import { purposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||||
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
|
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
|
||||||
|
import { patientsApi } from '@/lib/api/patients';
|
||||||
|
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||||
|
import type { PatientAppointmentHistoryItem } from '@/types/patient';
|
||||||
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
|
||||||
|
interface PatientAppointmentHistoryProps {
|
||||||
|
patientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAppointmentDate(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
weekday: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PatientAppointmentHistory({ patientId }: PatientAppointmentHistoryProps) {
|
||||||
|
const t = useTranslations('patients');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
|
const [items, setItems] = useState<PatientAppointmentHistoryItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void treatmentCatalogApi
|
||||||
|
.list('appointment')
|
||||||
|
.then((response) => setTreatmentCatalog(response.data))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await patientsApi.listAppointments(patientId);
|
||||||
|
if (!cancelled) {
|
||||||
|
setItems(response.data);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(getUserFacingError(err, tErrors, t('appointmentHistoryError')));
|
||||||
|
setItems([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [patientId, t, tErrors]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="surface-card p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-text-primary">{t('appointmentHistoryTitle')}</h3>
|
||||||
|
<p className="text-sm text-text-muted mt-1">{t('appointmentHistorySubtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-text-secondary">{t('appointmentHistoryLoading')}</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-sm text-red-500">{error}</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="text-sm text-text-secondary">{t('appointmentHistoryEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border/60">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="py-3 first:pt-0 last:pb-0">
|
||||||
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-text-primary">
|
||||||
|
{formatAppointmentDate(item.startAt)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
{formatTimeForInput(new Date(item.startAt))}
|
||||||
|
{' – '}
|
||||||
|
{formatTimeForInput(new Date(item.endAt))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 sm:items-end">
|
||||||
|
{item.purpose ? (
|
||||||
|
<TreatmentTypeBadge
|
||||||
|
type={item.purpose}
|
||||||
|
label={purposeLabel(item.purpose, treatmentCatalog)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
{t('appointmentHistoryProvider', {
|
||||||
|
name: item.providerName || t('appointmentHistoryUnknownProvider'),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,7 +28,8 @@ export function PatientSummaryCard({ patient }: PatientSummaryCardProps) {
|
|||||||
{t('mobileLabel')} {formatMobileForDisplay(patient.mobile)}
|
{t('mobileLabel')} {formatMobileForDisplay(patient.mobile)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-text-secondary">
|
<p className="text-sm text-text-secondary">
|
||||||
{t('emailLabel')} {patient.email || t('emptyValue')}
|
{t('emailOptionalSummary')}{' '}
|
||||||
|
{patient.email || t('emptyValue')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-text-secondary">
|
<p className="text-sm text-text-secondary">
|
||||||
{t('statusLabel')}{' '}
|
{t('statusLabel')}{' '}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
CreatePatientInput,
|
CreatePatientInput,
|
||||||
CreatePatientResponse,
|
CreatePatientResponse,
|
||||||
Patient,
|
Patient,
|
||||||
|
PatientAppointmentHistoryResponse,
|
||||||
PatientsListResponse,
|
PatientsListResponse,
|
||||||
} from '@/types/patient';
|
} from '@/types/patient';
|
||||||
|
|
||||||
@@ -21,4 +22,9 @@ export const patientsApi = {
|
|||||||
const response = await apiClient.get(`/patients/${id}`);
|
const response = await apiClient.get(`/patients/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listAppointments: async (patientId: string): Promise<PatientAppointmentHistoryResponse> => {
|
||||||
|
const response = await apiClient.get(`/patients/${patientId}/appointments`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,3 +39,17 @@ export interface CreatePatientResponse {
|
|||||||
data: Patient;
|
data: Patient;
|
||||||
existing?: boolean;
|
existing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PatientAppointmentHistoryItem {
|
||||||
|
id: string;
|
||||||
|
startAt: string;
|
||||||
|
endAt: string;
|
||||||
|
purpose: string;
|
||||||
|
providerUserId: string;
|
||||||
|
providerName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatientAppointmentHistoryResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: PatientAppointmentHistoryItem[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user