feature: phase0 - Global patients + mobile normalization
This commit is contained in:
@@ -296,15 +296,17 @@
|
||||
"errorSavePatient": "Failed to save patient.",
|
||||
"firstName": "First name",
|
||||
"lastName": "Last name",
|
||||
"phone": "Phone",
|
||||
"mobile": "Mobile",
|
||||
"mobilePlaceholder": "09121234567",
|
||||
"mobileLabel": "Mobile:",
|
||||
"patientAlreadyExists": "A patient with this mobile already exists: {firstName} {lastName}. They were selected for you.",
|
||||
"savePatient": "Save Patient",
|
||||
"dialogTitle": "New patient",
|
||||
"searchPlaceholder": "Search patients by name, phone, email",
|
||||
"searchPlaceholder": "Search patients by name, mobile, email",
|
||||
"loadingPatients": "Loading patients...",
|
||||
"noResults": "No patients found for this search.",
|
||||
"noContact": "No contact",
|
||||
"selectPatient": "Select a patient to view details.",
|
||||
"phoneLabel": "Phone:",
|
||||
"emailLabel": "Email:",
|
||||
"statusLabel": "Status:",
|
||||
"statusActive": "Active",
|
||||
|
||||
@@ -296,15 +296,17 @@
|
||||
"errorSavePatient": "ذخیره بیمار ناموفق بود.",
|
||||
"firstName": "نام",
|
||||
"lastName": "نام خانوادگی",
|
||||
"phone": "تلفن",
|
||||
"mobile": "موبایل",
|
||||
"mobilePlaceholder": "09121234567",
|
||||
"mobileLabel": "موبایل:",
|
||||
"patientAlreadyExists": "بیماری با این شماره موبایل از قبل وجود دارد: {firstName} {lastName}. برای شما انتخاب شد.",
|
||||
"savePatient": "ذخیره بیمار",
|
||||
"dialogTitle": "بیمار جدید",
|
||||
"searchPlaceholder": "جستجوی بیماران بر اساس نام، تلفن، ایمیل",
|
||||
"searchPlaceholder": "جستجوی بیماران بر اساس نام، موبایل، ایمیل",
|
||||
"loadingPatients": "در حال بارگذاری بیماران...",
|
||||
"noResults": "هیچ بیماری برای این جستجو یافت نشد.",
|
||||
"noContact": "بدون اطلاعات تماس",
|
||||
"selectPatient": "برای مشاهده جزئیات، یک بیمار را انتخاب کنید.",
|
||||
"phoneLabel": "تلفن:",
|
||||
"emailLabel": "ایمیل:",
|
||||
"statusLabel": "وضعیت:",
|
||||
"statusActive": "فعال",
|
||||
|
||||
@@ -296,15 +296,17 @@
|
||||
"errorSavePatient": "Patiënt opslaan mislukt.",
|
||||
"firstName": "Voornaam",
|
||||
"lastName": "Achternaam",
|
||||
"phone": "Telefoon",
|
||||
"mobile": "Mobiel",
|
||||
"mobilePlaceholder": "0612345678",
|
||||
"mobileLabel": "Mobiel:",
|
||||
"patientAlreadyExists": "Er bestaat al een patiënt met dit mobiele nummer: {firstName} {lastName}. Deze is voor u geselecteerd.",
|
||||
"savePatient": "Patiënt opslaan",
|
||||
"dialogTitle": "Nieuwe patiënt",
|
||||
"searchPlaceholder": "Zoek patiënten op naam, telefoon, e-mail",
|
||||
"searchPlaceholder": "Zoek patiënten op naam, mobiel, e-mail",
|
||||
"loadingPatients": "Patiënten laden...",
|
||||
"noResults": "Geen patiënten gevonden voor deze zoekopdracht.",
|
||||
"noContact": "Geen contact",
|
||||
"selectPatient": "Selecteer een patiënt om details te bekijken.",
|
||||
"phoneLabel": "Telefoon:",
|
||||
"emailLabel": "E-mail:",
|
||||
"statusLabel": "Status:",
|
||||
"statusActive": "Actief",
|
||||
|
||||
@@ -24,7 +24,7 @@ import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/co
|
||||
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: '',
|
||||
mobile: '',
|
||||
email: '',
|
||||
};
|
||||
|
||||
@@ -152,12 +152,21 @@ export default function AppointmentsPage() {
|
||||
setPatientForm(EMPTY_PATIENT_FORM);
|
||||
await loadPatientsSearch(search);
|
||||
setSelectedPatient(response.data);
|
||||
toast.showSuccess(
|
||||
t('successPatientSaved', {
|
||||
firstName: response.data.firstName,
|
||||
lastName: response.data.lastName,
|
||||
}),
|
||||
);
|
||||
if (response.existing) {
|
||||
toast.showInfo(
|
||||
tPatients('patientAlreadyExists', {
|
||||
firstName: response.data.firstName,
|
||||
lastName: response.data.lastName,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.showSuccess(
|
||||
t('successPatientSaved', {
|
||||
firstName: response.data.firstName,
|
||||
lastName: response.data.lastName,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err && typeof err === 'object' && 'message' in err
|
||||
|
||||
@@ -1,151 +1,160 @@
|
||||
'use client';
|
||||
|
||||
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
|
||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||
|
||||
import { patientsApi } from '@/lib/api/patients';
|
||||
|
||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
|
||||
import { useToast } from '@/lib/hooks/useToast';
|
||||
|
||||
import { hasPermission } from '@/components/shared/permissions';
|
||||
|
||||
import { CreatePatientInput, Patient } from '@/types/patient';
|
||||
|
||||
import { PatientSearchSelect } from '@/components/ui/patient/PatientSearchSelect';
|
||||
|
||||
import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal';
|
||||
|
||||
import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard';
|
||||
|
||||
|
||||
|
||||
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
||||
|
||||
firstName: '',
|
||||
|
||||
lastName: '',
|
||||
|
||||
phone: '',
|
||||
|
||||
email: '',
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
export default function PatientsPage() {
|
||||
|
||||
const t = useTranslations('patients');
|
||||
|
||||
const tCommon = useTranslations('common');
|
||||
|
||||
const { currentOrganization } = useAuth();
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
|
||||
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
|
||||
|
||||
const [loadingPatients, setLoadingPatients] = useState(false);
|
||||
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
|
||||
const [savingPatient, setSavingPatient] = useState(false);
|
||||
|
||||
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
|
||||
|
||||
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
|
||||
|
||||
|
||||
|
||||
const sortedPatients = useMemo(
|
||||
|
||||
() =>
|
||||
|
||||
[...patients].sort((a, b) =>
|
||||
|
||||
`${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`),
|
||||
|
||||
),
|
||||
|
||||
[patients],
|
||||
|
||||
);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
|
||||
void loadPatients(search);
|
||||
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
|
||||
}, [search]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
void loadPatients('');
|
||||
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
async function loadPatients(q: string) {
|
||||
|
||||
setLoadingPatients(true);
|
||||
|
||||
toast.setError('');
|
||||
|
||||
try {
|
||||
|
||||
const response = await patientsApi.list({ q, page: 1, limit: 25 });
|
||||
|
||||
const items = response.data.items;
|
||||
|
||||
setPatients(items);
|
||||
|
||||
|
||||
|
||||
if (selectedPatient) {
|
||||
|
||||
const freshSelected = items.find((item) => item.id === selectedPatient.id);
|
||||
|
||||
setSelectedPatient(freshSelected);
|
||||
|
||||
}
|
||||
|
||||
} catch (error: unknown) {
|
||||
|
||||
toast.showError(formatApiErrorMessage(error, t('errorLoadPatients')));
|
||||
|
||||
} finally {
|
||||
|
||||
setLoadingPatients(false);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function handleCreatePatient() {
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||
import { patientsApi } from '@/lib/api/patients';
|
||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { useToast } from '@/lib/hooks/useToast';
|
||||
import { hasPermission } from '@/components/shared/permissions';
|
||||
import { CreatePatientInput, Patient } from '@/types/patient';
|
||||
import { PatientSearchSelect } from '@/components/ui/patient/PatientSearchSelect';
|
||||
import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal';
|
||||
import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard';
|
||||
|
||||
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
mobile: '',
|
||||
email: '',
|
||||
};
|
||||
|
||||
export default function PatientsPage() {
|
||||
const t = useTranslations('patients');
|
||||
const tCommon = useTranslations('common');
|
||||
const { currentOrganization } = useAuth();
|
||||
const toast = useToast();
|
||||
const [search, setSearch] = useState('');
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
|
||||
const [loadingPatients, setLoadingPatients] = useState(false);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [savingPatient, setSavingPatient] = useState(false);
|
||||
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
|
||||
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
|
||||
|
||||
const sortedPatients = useMemo(
|
||||
() =>
|
||||
[...patients].sort((a, b) =>
|
||||
`${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`),
|
||||
),
|
||||
[patients],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
void loadPatients(search);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPatients('');
|
||||
}, []);
|
||||
|
||||
async function loadPatients(q: string) {
|
||||
setLoadingPatients(true);
|
||||
toast.setError('');
|
||||
try {
|
||||
const response = await patientsApi.list({ q, page: 1, limit: 25 });
|
||||
const items = response.data.items;
|
||||
setPatients(items);
|
||||
|
||||
if (selectedPatient) {
|
||||
const freshSelected = items.find((item) => item.id === selectedPatient.id);
|
||||
setSelectedPatient(freshSelected);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.showError(formatApiErrorMessage(error, t('errorLoadPatients')));
|
||||
} finally {
|
||||
setLoadingPatients(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreatePatient() {
|
||||
setSavingPatient(true);
|
||||
toast.setError('');
|
||||
try {
|
||||
const response = await patientsApi.create(patientForm);
|
||||
setIsCreateOpen(false);
|
||||
setPatientForm(EMPTY_PATIENT_FORM);
|
||||
await loadPatients(search);
|
||||
setSelectedPatient(response.data);
|
||||
if (response.existing) {
|
||||
toast.showInfo(
|
||||
t('patientAlreadyExists', {
|
||||
firstName: response.data.firstName,
|
||||
lastName: response.data.lastName,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.showSuccess(
|
||||
t('successPatientSaved', {
|
||||
firstName: response.data.firstName,
|
||||
lastName: response.data.lastName,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.showError(formatApiErrorMessage(error, t('errorSavePatient')));
|
||||
} finally {
|
||||
setSavingPatient(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canEditPatients}
|
||||
onClick={() => {
|
||||
if (!canEditPatients) return;
|
||||
toast.clear();
|
||||
setPatientForm(EMPTY_PATIENT_FORM);
|
||||
setIsCreateOpen(true);
|
||||
}}
|
||||
title={!canEditPatients ? tCommon('readOnlyAccess') : undefined}
|
||||
>
|
||||
{t('newPatient')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ToastStack {...toast.messages} />
|
||||
|
||||
{isCreateOpen && (
|
||||
<CreatePatientModal
|
||||
isOpen={isCreateOpen}
|
||||
formData={patientForm}
|
||||
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="xl:col-span-1">
|
||||
<PatientSearchSelect
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
patients={sortedPatients}
|
||||
selectedPatientId={selectedPatient?.id}
|
||||
onSelectPatient={setSelectedPatient}
|
||||
loading={loadingPatients}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-2 space-y-4">
|
||||
<PatientSummaryCard patient={selectedPatient} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@/components/appointments/appointmentOverlapLayout';
|
||||
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
|
||||
import { formatMobileForDisplay } from '@/lib/phone';
|
||||
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||
|
||||
const HOUR_PX = 80;
|
||||
@@ -300,7 +301,9 @@ export function AppointmentScheduleGrid({
|
||||
clusterSize > 1
|
||||
? t('overlappingChoose', { count: clusterSize })
|
||||
: null,
|
||||
!isUnderOneHour && apt.patient.phone ? apt.patient.phone : null,
|
||||
!isUnderOneHour && apt.patient.mobile
|
||||
? formatMobileForDisplay(apt.patient.mobile)
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
@@ -338,10 +341,10 @@ export function AppointmentScheduleGrid({
|
||||
{patientName}
|
||||
</span>
|
||||
{!isUnderOneHour &&
|
||||
apt.patient.phone &&
|
||||
apt.patient.mobile &&
|
||||
lane.laneCount === 1 && (
|
||||
<span className="block w-full truncate pointer-events-none text-[10px] leading-tight opacity-90">
|
||||
{apt.patient.phone}
|
||||
{formatMobileForDisplay(apt.patient.mobile)}
|
||||
</span>
|
||||
)}
|
||||
{!isUnderOneHour && clusterSize > 1 && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Search } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Input } from '@/components/ui/shared/Input';
|
||||
import { formatMobileForDisplay } from '@/lib/phone';
|
||||
import type { Patient } from '@/types/patient';
|
||||
|
||||
interface AppointmentsPatientSearchProps {
|
||||
@@ -82,7 +83,7 @@ export function AppointmentsPatientSearch({
|
||||
{patient.firstName} {patient.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{patient.phone || patient.email || tPatients('noContact')}
|
||||
{formatMobileForDisplay(patient.mobile) || patient.email || tPatients('noContact')}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/shared/Button';
|
||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||
import { Input } from '@/components/ui/shared/Input';
|
||||
import { CreatePatientInput } from '@/types/patient';
|
||||
import { isValidMobile, normalizeMobile } from '@/lib/phone';
|
||||
|
||||
interface CreatePatientModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -49,9 +50,10 @@ function CreatePatientFormFields({
|
||||
onChange={(e) => onChange({ lastName: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label={t('phone')}
|
||||
value={formData.phone || ''}
|
||||
onChange={(e) => onChange({ phone: e.target.value })}
|
||||
label={t('mobile')}
|
||||
value={formData.mobile || ''}
|
||||
onChange={(e) => onChange({ mobile: e.target.value })}
|
||||
placeholder={t('mobilePlaceholder')}
|
||||
/>
|
||||
<Input
|
||||
label={tCommon('email')}
|
||||
@@ -66,7 +68,7 @@ function CreatePatientFormFields({
|
||||
variant="primary"
|
||||
onClick={onSubmit}
|
||||
isLoading={loading}
|
||||
disabled={!formData.firstName || !formData.lastName}
|
||||
disabled={!formData.firstName || !formData.lastName || !isValidMobile(normalizeMobile(formData.mobile || '') ?? '')}
|
||||
>
|
||||
{t('savePatient')}
|
||||
</Button>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/shared/Input';
|
||||
import { formatMobileForDisplay } from '@/lib/phone';
|
||||
import { Patient } from '@/types/patient';
|
||||
|
||||
interface PatientSearchSelectProps {
|
||||
@@ -56,7 +57,9 @@ export function PatientSearchSelect({
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{patient.firstName} {patient.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">{patient.phone || patient.email || t('noContact')}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{formatMobileForDisplay(patient.mobile) || patient.email || t('noContact')}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { formatMobileForDisplay } from '@/lib/phone';
|
||||
import { Patient } from '@/types/patient';
|
||||
|
||||
interface PatientSummaryCardProps {
|
||||
@@ -24,7 +25,7 @@ export function PatientSummaryCard({ patient }: PatientSummaryCardProps) {
|
||||
{patient.firstName} {patient.lastName}
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{t('phoneLabel')} {patient.phone || t('emptyValue')}
|
||||
{t('mobileLabel')} {formatMobileForDisplay(patient.mobile)}
|
||||
</p>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{t('emailLabel')} {patient.email || t('emptyValue')}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { apiClient } from './client';
|
||||
import {
|
||||
CreatePatientInput,
|
||||
CreatePatientResponse,
|
||||
Patient,
|
||||
PatientsListResponse,
|
||||
} from '@/types/patient';
|
||||
@@ -11,7 +12,7 @@ export const patientsApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreatePatientInput): Promise<{ success: boolean; data: Patient }> => {
|
||||
create: async (data: CreatePatientInput): Promise<CreatePatientResponse> => {
|
||||
const response = await apiClient.post('/patients', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
44
frontend/src/lib/phone.ts
Normal file
44
frontend/src/lib/phone.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/** Canonical Iran mobile: +989XXXXXXXXX */
|
||||
export const IR_MOBILE_REGEX = /^\+989\d{9}$/;
|
||||
|
||||
export function normalizeMobile(input: string): string | null {
|
||||
const trimmed = input?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let digits = trimmed.replace(/[^\d+]/g, '');
|
||||
if (digits.startsWith('+')) {
|
||||
digits = digits.slice(1);
|
||||
}
|
||||
|
||||
digits = digits.replace(/\D/g, '');
|
||||
|
||||
if (digits.startsWith('0098')) {
|
||||
digits = digits.slice(4);
|
||||
} else if (digits.startsWith('98') && digits.length >= 12) {
|
||||
digits = digits.slice(2);
|
||||
}
|
||||
|
||||
if (digits.startsWith('0') && digits.length === 11) {
|
||||
digits = digits.slice(1);
|
||||
}
|
||||
|
||||
if (digits.length === 10 && digits.startsWith('9')) {
|
||||
return `+98${digits}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isValidMobile(normalized: string): boolean {
|
||||
return IR_MOBILE_REGEX.test(normalized);
|
||||
}
|
||||
|
||||
export function formatMobileForDisplay(normalized: string): string {
|
||||
if (!isValidMobile(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
const local = `0${normalized.slice(3)}`;
|
||||
return `${local.slice(0, 4)} ${local.slice(4, 7)} ${local.slice(7)}`;
|
||||
}
|
||||
@@ -25,5 +25,5 @@ export interface AppointmentRecord {
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
purpose: string;
|
||||
patient: Pick<Patient, 'id' | 'firstName' | 'lastName' | 'phone'>;
|
||||
patient: Pick<Patient, 'id' | 'firstName' | 'lastName' | 'mobile'>;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export interface Patient {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone?: string | null;
|
||||
mobile: string;
|
||||
email?: string | null;
|
||||
dateOfBirth?: string | null;
|
||||
notes?: string | null;
|
||||
isActive: boolean;
|
||||
createdByOrganizationId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export interface Patient {
|
||||
export interface CreatePatientInput {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone?: string;
|
||||
mobile: string;
|
||||
email?: string;
|
||||
dateOfBirth?: string;
|
||||
notes?: string;
|
||||
@@ -33,3 +33,9 @@ export interface PatientsListResponse {
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreatePatientResponse {
|
||||
success: boolean;
|
||||
data: Patient;
|
||||
existing?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user