Merge branch 'master' into feature/cases
This commit is contained in:
@@ -340,7 +340,7 @@ export default function CasesPage() {
|
||||
{formatCaseDateTime(item.sentAt, locale)}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1 truncate">
|
||||
{item.treatmentTypes.map(treatmentLabel).join(', ')}
|
||||
{item.treatmentType ? treatmentLabel(item.treatmentType) : '—'}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<CaseTaskProgressBar
|
||||
|
||||
@@ -1,28 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { Link, useRouter } from '@/i18n/navigation';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { authApi } from '@/lib/api/auth';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Input } from '@/components/ui/shared/Input';
|
||||
import { Toast } from '@/components/ui/shared/Toast';
|
||||
|
||||
type PasswordForm = {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
export default function AccountSettingsPage() {
|
||||
const t = useTranslations('settings');
|
||||
const tAuth = useTranslations('auth');
|
||||
const tCommon = useTranslations('common');
|
||||
const tValidation = useTranslations('validation');
|
||||
const { user, isAuthReady } = useAuth();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const isResetFlow = searchParams.get('reset') === '1';
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const passwordSchema = useMemo(
|
||||
() =>
|
||||
z
|
||||
.object({
|
||||
currentPassword: z.string(),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, tValidation('passwordMinLength'))
|
||||
.regex(/[A-Z]/, tValidation('passwordUppercase'))
|
||||
.regex(/[0-9]/, tValidation('passwordNumber')),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: tValidation('passwordsDoNotMatch'),
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!isResetFlow && !data.currentPassword.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: tValidation('passwordRequired'),
|
||||
path: ['currentPassword'],
|
||||
});
|
||||
}
|
||||
}),
|
||||
[isResetFlow, tValidation],
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<PasswordForm>({
|
||||
resolver: zodResolver(passwordSchema),
|
||||
defaultValues: {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthReady && !user) {
|
||||
router.replace('/login');
|
||||
}
|
||||
}, [isAuthReady, user, router]);
|
||||
|
||||
const onSubmit = async (data: PasswordForm) => {
|
||||
try {
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
await authApi.changePassword({
|
||||
...(isResetFlow ? {} : { currentPassword: data.currentPassword }),
|
||||
newPassword: data.newPassword,
|
||||
});
|
||||
|
||||
reset();
|
||||
setSuccessMessage(t('passwordChanged'));
|
||||
router.replace('/login');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('passwordChangeFailed');
|
||||
setError(message || t('passwordChangeFailed'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAuthReady || !user) {
|
||||
return (
|
||||
<p className="text-text-secondary text-sm">{tCommon('loadingEllipsis')}</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Link
|
||||
href="/today"
|
||||
className="text-sm text-primary hover:opacity-90"
|
||||
>
|
||||
<Link href="/today" className="text-sm text-primary hover:opacity-90">
|
||||
{tCommon('backToApp')}
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold text-text-primary mt-4">{t('accountTitle')}</h1>
|
||||
<p className="text-text-secondary text-sm mt-2">{t('accountSubtitle')}</p>
|
||||
<p className="text-text-secondary text-sm mt-2">
|
||||
{isResetFlow ? t('resetPasswordSubtitle') : t('accountSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="surface-card p-6 space-y-3">
|
||||
<p className="text-sm text-text-secondary">{t('accountPlaceholder')}</p>
|
||||
<div className="surface-card p-6 sm:p-8 max-w-lg">
|
||||
<h2 className="text-lg font-medium text-text-primary mb-1">
|
||||
{isResetFlow ? t('resetPasswordTitle') : t('changePasswordTitle')}
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary mb-6">
|
||||
{user.email}
|
||||
{user.mobile ? ` · ${user.mobile}` : ''}
|
||||
</p>
|
||||
|
||||
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
|
||||
{!isResetFlow && (
|
||||
<Input
|
||||
label={t('currentPassword')}
|
||||
{...register('currentPassword')}
|
||||
type="password"
|
||||
placeholder={tAuth('passwordPlaceholder')}
|
||||
error={errors.currentPassword?.message}
|
||||
icon={<Lock className="h-5 w-5 icon-flat" />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label={t('newPassword')}
|
||||
{...register('newPassword')}
|
||||
type="password"
|
||||
placeholder={tAuth('passwordPlaceholder')}
|
||||
error={errors.newPassword?.message}
|
||||
icon={<Lock className="h-5 w-5 icon-flat" />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('confirmNewPassword')}
|
||||
{...register('confirmPassword')}
|
||||
type="password"
|
||||
placeholder={tAuth('passwordPlaceholder')}
|
||||
error={errors.confirmPassword?.message}
|
||||
icon={<Lock className="h-5 w-5 icon-flat" />}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" variant="primary" isLoading={isSubmitting}>
|
||||
{isResetFlow ? t('setNewPassword') : t('updatePassword')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{successMessage && (
|
||||
<Toast variant="success">{successMessage}</Toast>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
206
frontend/src/app/[locale]/(public)/forgot-password/page.tsx
Normal file
206
frontend/src/app/[locale]/(public)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Link, useRouter } from '@/i18n/navigation';
|
||||
import { Phone, ShieldCheck } from 'lucide-react';
|
||||
import { authApi } from '@/lib/api/auth';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Input } from '@/components/ui/shared/Input';
|
||||
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
|
||||
|
||||
type ForgotPasswordForm = {
|
||||
mobile: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
function normalizeIranMobile(input: string): string {
|
||||
let digits = input.replace(/\D/g, '');
|
||||
if (digits.startsWith('98') && digits.length === 12) digits = digits.slice(2);
|
||||
if (digits.startsWith('0') && digits.length === 11) digits = digits.slice(1);
|
||||
return digits;
|
||||
}
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const t = useTranslations('auth');
|
||||
const tCommon = useTranslations('common');
|
||||
const tValidation = useTranslations('validation');
|
||||
const router = useRouter();
|
||||
const { refreshSession } = useAuth();
|
||||
const [step, setStep] = useState<'mobile' | 'code'>('mobile');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [sentMobile, setSentMobile] = useState('');
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
mobile: z
|
||||
.string()
|
||||
.min(1, tValidation('mobileRequired'))
|
||||
.refine((value) => /^9\d{9}$/.test(normalizeIranMobile(value)), {
|
||||
message: tValidation('mobileInvalid'),
|
||||
}),
|
||||
code: z.string(),
|
||||
}),
|
||||
[tValidation],
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
formState: { errors },
|
||||
} = useForm<ForgotPasswordForm>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { mobile: '', code: '' },
|
||||
});
|
||||
|
||||
const onSendCode = async () => {
|
||||
const mobile = getValues('mobile');
|
||||
const parsed = schema.safeParse({ mobile, code: '' });
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.issues[0]?.message ?? tValidation('mobileInvalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setIsSending(true);
|
||||
await authApi.sendForgotPasswordCode(mobile);
|
||||
setSentMobile(mobile);
|
||||
setStep('code');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('codeSendFailed');
|
||||
setError(message || t('codeSendFailed'));
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onVerify = async (data: ForgotPasswordForm) => {
|
||||
if (!data.code.trim()) {
|
||||
setError(tValidation('codeRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setIsVerifying(true);
|
||||
const response = await authApi.verifyForgotPasswordCode(
|
||||
sentMobile || data.mobile,
|
||||
data.code.trim(),
|
||||
);
|
||||
|
||||
const orgs = response.data.organizations;
|
||||
if (orgs.length === 1) {
|
||||
await authApi.selectOrganization(orgs[0].id);
|
||||
localStorage.setItem('currentOrganizationId', orgs[0].id);
|
||||
await refreshSession();
|
||||
router.push('/settings/account?reset=1');
|
||||
return;
|
||||
}
|
||||
|
||||
if (orgs.length > 1) {
|
||||
sessionStorage.setItem('authRedirect', '/settings/account?reset=1');
|
||||
await refreshSession();
|
||||
router.push('/select-organization');
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshSession();
|
||||
router.push('/settings/account?reset=1');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('verifyFailed');
|
||||
setError(message || t('verifyFailed'));
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen app-web-bg flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="absolute top-4 right-4">
|
||||
<TopBarControls />
|
||||
</div>
|
||||
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<Link href="/" className="flex justify-center">
|
||||
<span className="text-3xl font-semibold text-text-primary">{tCommon('appName')}</span>
|
||||
</Link>
|
||||
<h2 className="mt-6 text-center text-3xl font-semibold text-text-primary">
|
||||
{t('forgotPasswordTitle')}
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-text-secondary">
|
||||
{step === 'mobile' ? t('forgotPasswordSubtitle') : t('codeSentHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="surface-card py-8 px-4 sm:px-10">
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={handleSubmit(step === 'code' ? onVerify : () => undefined)}
|
||||
>
|
||||
{step === 'mobile' ? (
|
||||
<Input
|
||||
label={t('mobile')}
|
||||
{...register('mobile')}
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
placeholder={t('mobilePlaceholder')}
|
||||
error={errors.mobile?.message}
|
||||
icon={<Phone className="h-5 w-5 icon-flat" />}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
label={t('verificationCode')}
|
||||
{...register('code')}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder={t('verificationCodePlaceholder')}
|
||||
error={errors.code?.message}
|
||||
icon={<ShieldCheck className="h-5 w-5 icon-flat" />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'mobile' ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
isLoading={isSending}
|
||||
fullWidth
|
||||
onClick={() => void onSendCode()}
|
||||
>
|
||||
{t('sendCode')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" variant="primary" isLoading={isVerifying} fullWidth>
|
||||
{t('verifyAndContinue')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<p className="text-center text-sm text-text-secondary">
|
||||
<Link href="/login" className="font-medium text-primary hover:opacity-90">
|
||||
{t('backToSignIn')}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { Mail, Lock } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { getRememberedEmail } from '@/lib/auth/rememberMe';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Input } from '@/components/ui/shared/Input';
|
||||
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
|
||||
@@ -16,6 +17,7 @@ import { TopBarControls } from '@/components/ui/shared/TopBarControls';
|
||||
type LoginForm = {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe: boolean;
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -25,12 +27,14 @@ export default function LoginPage() {
|
||||
const { login, isLoading, user, isAuthReady } = useAuth();
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savedEmail] = useState(() => getRememberedEmail());
|
||||
|
||||
const loginSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
email: z.string().email(tValidation('emailInvalid')),
|
||||
password: z.string().min(1, tValidation('passwordRequired')),
|
||||
rememberMe: z.boolean(),
|
||||
}),
|
||||
[tValidation],
|
||||
);
|
||||
@@ -47,12 +51,16 @@ export default function LoginPage() {
|
||||
formState: { errors },
|
||||
} = useForm<LoginForm>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
email: savedEmail,
|
||||
rememberMe: Boolean(savedEmail),
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginForm) => {
|
||||
try {
|
||||
setError(null);
|
||||
await login(data.email, data.password);
|
||||
await login(data.email, data.password, data.rememberMe);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('invalidCredentials');
|
||||
setError(message || t('invalidCredentials'));
|
||||
@@ -112,9 +120,9 @@ export default function LoginPage() {
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-border bg-background-secondary text-primary focus:ring-primary/40"
|
||||
{...register('rememberMe')}
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-text-secondary">
|
||||
{t('rememberMe')}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { Mail, Lock, User } from 'lucide-react';
|
||||
import { Mail, Lock, User, Phone } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
|
||||
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
|
||||
@@ -17,6 +17,7 @@ import { TopBarControls } from '@/components/ui/shared/TopBarControls';
|
||||
type RegisterForm = {
|
||||
name: string;
|
||||
email: string;
|
||||
mobile: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
organizationName: string;
|
||||
@@ -24,6 +25,13 @@ type RegisterForm = {
|
||||
organizationType: 'CLINIC' | 'LAB';
|
||||
};
|
||||
|
||||
function normalizeIranMobile(input: string): string {
|
||||
let digits = input.replace(/\D/g, '');
|
||||
if (digits.startsWith('98') && digits.length === 12) digits = digits.slice(2);
|
||||
if (digits.startsWith('0') && digits.length === 11) digits = digits.slice(1);
|
||||
return digits;
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
const t = useTranslations('auth');
|
||||
const tCommon = useTranslations('common');
|
||||
@@ -38,6 +46,12 @@ export default function RegisterPage() {
|
||||
.object({
|
||||
name: z.string().min(2, tValidation('nameMinLength')),
|
||||
email: z.string().email(tValidation('emailInvalid')),
|
||||
mobile: z
|
||||
.string()
|
||||
.min(1, tValidation('mobileRequired'))
|
||||
.refine((value) => /^9\d{9}$/.test(normalizeIranMobile(value)), {
|
||||
message: tValidation('mobileInvalid'),
|
||||
}),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, tValidation('passwordMinLength'))
|
||||
@@ -74,7 +88,7 @@ export default function RegisterPage() {
|
||||
const handleNext = async () => {
|
||||
const fieldsToValidate =
|
||||
step === 1
|
||||
? (['name', 'email', 'password', 'confirmPassword'] as const)
|
||||
? (['name', 'email', 'mobile', 'password', 'confirmPassword'] as const)
|
||||
: (['organizationName', 'organizationEmail', 'organizationType'] as const);
|
||||
|
||||
const isValid = await trigger([...fieldsToValidate]);
|
||||
@@ -90,6 +104,7 @@ export default function RegisterPage() {
|
||||
data.email,
|
||||
data.password,
|
||||
data.name,
|
||||
data.mobile,
|
||||
data.organizationName,
|
||||
data.organizationEmail,
|
||||
data.organizationType,
|
||||
@@ -157,6 +172,16 @@ export default function RegisterPage() {
|
||||
error={errors.email?.message}
|
||||
icon={<Mail className="h-5 w-5 icon-flat" />}
|
||||
/>
|
||||
<Input
|
||||
label={t('mobile')}
|
||||
{...register('mobile')}
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
placeholder={t('mobilePlaceholder')}
|
||||
error={errors.mobile?.message}
|
||||
icon={<Phone className="h-5 w-5 icon-flat" />}
|
||||
/>
|
||||
<Input
|
||||
label={t('password')}
|
||||
{...register('password')}
|
||||
|
||||
@@ -152,27 +152,23 @@ export function CaseDetailPanel({
|
||||
</header>
|
||||
|
||||
<CaseToothChartPanel
|
||||
details={labCase.details}
|
||||
details={labCase.detail ? [{ teeth: labCase.detail.teeth }] : []}
|
||||
prosthesisRows={prosthesisRows}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{labCase.details.length > 0 ? (
|
||||
{labCase.detail ? (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-text-primary">{t('treatmentDetails')}</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{labCase.details.map((detail) => (
|
||||
<li key={detail.id} className="rounded-md bg-background border border-border p-2">
|
||||
<div className="font-medium">{treatmentLabel(detail.treatmentType)}</div>
|
||||
<div className="text-text-muted">
|
||||
{t('teethLabel')}: {detail.teeth.join(', ') || '—'}
|
||||
</div>
|
||||
{detail.comment ? (
|
||||
<div className="text-text-muted mt-1">{detail.comment}</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="rounded-md bg-background border border-border p-2 text-sm">
|
||||
<div className="font-medium">{treatmentLabel(labCase.detail.treatmentType)}</div>
|
||||
<div className="text-text-muted">
|
||||
{t('teethLabel')}: {labCase.detail.teeth.join(', ') || '—'}
|
||||
</div>
|
||||
{labCase.detail.comment ? (
|
||||
<div className="text-text-muted mt-1">{labCase.detail.comment}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ export function ConnectionCaseHistoryContent({
|
||||
{formatCaseDateTime(item.sentAt, locale)}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1 truncate">
|
||||
{item.treatmentTypes.map(treatmentLabel).join(', ')}
|
||||
{item.treatmentType ? treatmentLabel(item.treatmentType) : '—'}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<CaseTaskProgressBar
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import React, { forwardRef } from 'react';
|
||||
import React, { forwardRef, useId } from 'react';
|
||||
|
||||
interface DropdownProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
@@ -10,7 +10,8 @@ interface DropdownProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
|
||||
export const Dropdown = forwardRef<HTMLSelectElement, DropdownProps>(
|
||||
({ label, error, className = '', id, children, ...props }, ref) => {
|
||||
const selectId = id || `dropdown-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const genId = useId();
|
||||
const selectId = id ?? genId;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// src/components/ui/Input.tsx
|
||||
'use client';
|
||||
|
||||
import React, { forwardRef, useId } from 'react';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
@@ -9,8 +10,8 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, icon, className = '', id, ...props }, ref) => {
|
||||
const generatedId = useId();
|
||||
const inputId = id || generatedId;
|
||||
const genId = useId();
|
||||
const inputId = id ?? genId;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
|
||||
@@ -22,7 +22,6 @@ interface LabCasesDispatchPanelProps {
|
||||
labDependentCodes: Set<string>;
|
||||
treatmentCatalog: TreatmentCatalogEntry[];
|
||||
activeLabCaseId: string | null;
|
||||
onActiveLabCaseChange: (id: string) => void;
|
||||
onLabCasesChange: (labCases: LabCaseDraft[]) => void;
|
||||
disabled: boolean;
|
||||
canEdit: boolean;
|
||||
@@ -40,64 +39,30 @@ interface LabCasesDispatchPanelProps {
|
||||
function sentDetailClientIds(labCases: LabCaseDraft[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const lc of labCases) {
|
||||
if (!lc.sentAt) continue;
|
||||
for (const id of lc.detailClientIds) ids.add(id);
|
||||
if (lc.sentAt && lc.detailClientId) ids.add(lc.detailClientId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function detailInOtherDraftShipment(
|
||||
detailClientId: string,
|
||||
labCases: LabCaseDraft[],
|
||||
activeLabCaseClientId: string,
|
||||
): boolean {
|
||||
return labCases.some(
|
||||
(lc) =>
|
||||
!lc.sentAt &&
|
||||
lc.clientId !== activeLabCaseClientId &&
|
||||
lc.detailClientIds.includes(detailClientId),
|
||||
);
|
||||
}
|
||||
|
||||
function selectableDetailsForDraftShipment(
|
||||
details: TreatmentDetailDraft[],
|
||||
labCases: LabCaseDraft[],
|
||||
labDependentCodes: Set<string>,
|
||||
activeLabCase: LabCaseDraft,
|
||||
): TreatmentDetailDraft[] {
|
||||
const sent = sentDetailClientIds(labCases);
|
||||
return details.filter((d) => {
|
||||
if (!labDependentCodes.has(d.treatmentType)) return false;
|
||||
if (sent.has(d.clientId)) return false;
|
||||
if (activeLabCase.detailClientIds.includes(d.clientId)) return true;
|
||||
return !detailInOtherDraftShipment(d.clientId, labCases, activeLabCase.clientId);
|
||||
});
|
||||
}
|
||||
|
||||
function prosthesisTeethRows(
|
||||
labCase: LabCaseDraft,
|
||||
details: TreatmentDetailDraft[],
|
||||
scopeDetailClientId?: string,
|
||||
activeDetail: TreatmentDetailDraft,
|
||||
detailNumber: number,
|
||||
): Array<{ detailClientId: string; tooth: string; detailNumber: number }> {
|
||||
const rows: Array<{ detailClientId: string; tooth: string; detailNumber: number }> = [];
|
||||
for (const clientId of labCase.detailClientIds) {
|
||||
if (scopeDetailClientId && clientId !== scopeDetailClientId) continue;
|
||||
const detail = details.find((d) => d.clientId === clientId);
|
||||
if (!detail || detail.treatmentType !== 'prosthesis') continue;
|
||||
const detailNumber = details.findIndex((d) => d.clientId === clientId) + 1;
|
||||
for (const tooth of detail.teeth) {
|
||||
rows.push({ detailClientId: clientId, tooth, detailNumber });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
if (labCase.detailClientId !== activeDetail.clientId) return [];
|
||||
if (activeDetail.treatmentType !== 'prosthesis') return [];
|
||||
|
||||
return activeDetail.teeth.map((tooth) => ({
|
||||
detailClientId: activeDetail.clientId,
|
||||
tooth,
|
||||
detailNumber,
|
||||
}));
|
||||
}
|
||||
|
||||
function isProsthesisMapComplete(
|
||||
labCase: LabCaseDraft,
|
||||
details: TreatmentDetailDraft[],
|
||||
scopeDetailClientId?: string,
|
||||
rows: Array<{ detailClientId: string; tooth: string }>,
|
||||
): boolean {
|
||||
const rows = prosthesisTeethRows(labCase, details, scopeDetailClientId);
|
||||
if (rows.length === 0) return true;
|
||||
return rows.every((row) =>
|
||||
labCase.toothProsthesis.some(
|
||||
@@ -116,7 +81,6 @@ export function LabCasesDispatchPanel({
|
||||
labDependentCodes,
|
||||
treatmentCatalog,
|
||||
activeLabCaseId,
|
||||
onActiveLabCaseChange,
|
||||
onLabCasesChange,
|
||||
disabled,
|
||||
canEdit,
|
||||
@@ -151,7 +115,7 @@ export function LabCasesDispatchPanel({
|
||||
);
|
||||
|
||||
const labCaseForActiveDetail =
|
||||
labCases.find((lc) => lc.detailClientIds.includes(activeDetailId)) ?? null;
|
||||
labCases.find((lc) => lc.detailClientId === activeDetailId) ?? null;
|
||||
|
||||
const activeLabCase =
|
||||
labCaseForActiveDetail ??
|
||||
@@ -159,21 +123,20 @@ export function LabCasesDispatchPanel({
|
||||
|
||||
const detailAlreadyInShipment = Boolean(labCaseForActiveDetail);
|
||||
const canAddLabShipment =
|
||||
!detailAlreadyInShipment &&
|
||||
!detailInOtherDraftShipment(activeDetailId, labCases, '') &&
|
||||
!sentDetailClientIds(labCases).has(activeDetailId);
|
||||
!detailAlreadyInShipment && !sentDetailClientIds(labCases).has(activeDetailId);
|
||||
|
||||
const sent = Boolean(activeLabCase?.sentAt);
|
||||
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
|
||||
|
||||
const activeLabOrgName = activeLabCase?.destinationOrganizationId
|
||||
? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name
|
||||
: null;
|
||||
|
||||
const prosthesisRows = activeLabCase
|
||||
? prosthesisTeethRows(activeLabCase, details, activeDetailId)
|
||||
const prosthesisRows = activeLabCase && activeDetail
|
||||
? prosthesisTeethRows(activeLabCase, activeDetail, activeDetailNumber)
|
||||
: [];
|
||||
const prosthesisComplete = activeLabCase
|
||||
? isProsthesisMapComplete(activeLabCase, details, activeDetailId)
|
||||
? isProsthesisMapComplete(activeLabCase, prosthesisRows)
|
||||
: true;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -197,25 +160,18 @@ export function LabCasesDispatchPanel({
|
||||
};
|
||||
}, [activeLabCase?.destinationOrganizationId]);
|
||||
|
||||
// Reset the pending (unposted) comment when switching to another shipment.
|
||||
useEffect(() => {
|
||||
setPendingComment('');
|
||||
}, [activeLabCase?.clientId]);
|
||||
|
||||
// Hide dispatch when the selected treatment detail is not lab-dependent.
|
||||
if (!activeDetail || !isLabDependentDetail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function detailNumber(d: TreatmentDetailDraft) {
|
||||
const idx = details.findIndex((row) => row.clientId === d.clientId);
|
||||
return idx >= 0 ? idx + 1 : 0;
|
||||
}
|
||||
|
||||
function detailSummary(d: TreatmentDetailDraft) {
|
||||
const typeLabel = treatmentTypeLabelFromCatalog(d.treatmentType, treatmentCatalog);
|
||||
const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone');
|
||||
return `${t('detailLabel', { n: detailNumber(d) })} · ${typeLabel} · ${teeth}`;
|
||||
return `${t('detailLabel', { n: activeDetailNumber })} · ${typeLabel} · ${teeth}`;
|
||||
}
|
||||
|
||||
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
|
||||
@@ -250,36 +206,6 @@ export function LabCasesDispatchPanel({
|
||||
updateActiveLabCase({ toothProsthesis: next });
|
||||
}
|
||||
|
||||
function toggleDetailInActiveLabCase(detailClientId: string, checked: boolean) {
|
||||
if (!activeLabCase || sent) return;
|
||||
|
||||
onLabCasesChange(
|
||||
labCases.map((lc) => {
|
||||
if (lc.sentAt) return lc;
|
||||
|
||||
if (lc.clientId === activeLabCase.clientId) {
|
||||
const set = new Set(lc.detailClientIds);
|
||||
if (checked) set.add(detailClientId);
|
||||
else set.delete(detailClientId);
|
||||
|
||||
const keptProsthesis = lc.toothProsthesis.filter((tp) =>
|
||||
[...set].includes(tp.detailClientId),
|
||||
);
|
||||
|
||||
return { ...lc, detailClientIds: [...set], toothProsthesis: keptProsthesis };
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
return {
|
||||
...lc,
|
||||
detailClientIds: lc.detailClientIds.filter((id) => id !== detailClientId),
|
||||
};
|
||||
}
|
||||
return lc;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
|
||||
if (!activeLabCase || sent) return;
|
||||
const set = new Set(activeLabCase.attachmentIds);
|
||||
@@ -288,18 +214,7 @@ export function LabCasesDispatchPanel({
|
||||
updateActiveLabCase({ attachmentIds: [...set] });
|
||||
}
|
||||
|
||||
const activeDetailAttachments = activeDetail?.attachmentMetas ?? [];
|
||||
|
||||
const includedInActiveShipment = activeLabCase
|
||||
? [activeDetail]
|
||||
: [];
|
||||
|
||||
const pickableForActiveDraft =
|
||||
activeLabCase && !sent
|
||||
? selectableDetailsForDraftShipment(details, labCases, labDependentCodes, activeLabCase).filter(
|
||||
(d) => d.clientId === activeDetailId,
|
||||
)
|
||||
: [];
|
||||
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
|
||||
|
||||
return (
|
||||
<div className="surface-card p-4 space-y-4">
|
||||
@@ -326,275 +241,249 @@ export function LabCasesDispatchPanel({
|
||||
<p className="text-xs text-text-muted">{t('labDispatchEmpty')}</p>
|
||||
) : activeLabCase ? (
|
||||
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
|
||||
{sent ? (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">
|
||||
{t('labShipmentIncludedDetails')}
|
||||
</p>
|
||||
{includedInActiveShipment.length === 0 ? (
|
||||
<p className="text-xs text-text-muted">{t('labShipmentNoIncludedDetails')}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{includedInActiveShipment.map((d) => (
|
||||
<li
|
||||
key={d.clientId}
|
||||
className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2"
|
||||
>
|
||||
{detailSummary(d)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{sent ? (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">
|
||||
{t('labShipmentIncludedDetails')}
|
||||
</p>
|
||||
<p className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2">
|
||||
{detailSummary(activeDetail)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{activeLabCase.id ? (
|
||||
<LabCaseCommentsPanel
|
||||
caseId={activeLabCase.id}
|
||||
canPost={false}
|
||||
canToggleVisibility={false}
|
||||
loadComments={async () => {
|
||||
const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!);
|
||||
return r.data;
|
||||
}}
|
||||
onPost={async () => {
|
||||
throw new Error('Read-only');
|
||||
}}
|
||||
onError={onCommentError}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeLabOrgName ? (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
|
||||
<p className="text-sm text-text-primary mt-1">{activeLabOrgName}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CaseSentLabel
|
||||
treatmentCase={{
|
||||
destinationOrganizationId: activeLabCase.destinationOrganizationId,
|
||||
sendToOrganizationIds: activeLabCase.destinationOrganizationId
|
||||
? [activeLabCase.destinationOrganizationId]
|
||||
: [],
|
||||
sentAt: activeLabCase.sentAt ?? null,
|
||||
sends: activeLabCase.sends,
|
||||
}}
|
||||
orgs={orgs}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">
|
||||
{t('labShipmentIncludedDetails')}
|
||||
</p>
|
||||
<p className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2">
|
||||
{detailSummary(activeDetail)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!sent && activeDetailAttachments.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">
|
||||
{t('labShipmentAttachments')}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted mb-2">{t('labShipmentAttachmentsHint')}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{activeDetailAttachments.map((att) => (
|
||||
<Checkbox
|
||||
key={att.id}
|
||||
checked={activeLabCase.attachmentIds.includes(att.id)}
|
||||
disabled={disabled}
|
||||
onChange={(next) => toggleAttachmentInActiveLabCase(att.id, next)}
|
||||
label={`${att.fileName} (${(att.sizeBytes / 1024).toFixed(1)} KB)`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeLabCase.id ? (
|
||||
<LabCaseCommentsPanel
|
||||
caseId={activeLabCase.id}
|
||||
canPost={false}
|
||||
canToggleVisibility={false}
|
||||
loadComments={async () => {
|
||||
const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!);
|
||||
return r.data;
|
||||
}}
|
||||
onPost={async () => {
|
||||
throw new Error('Read-only');
|
||||
}}
|
||||
onError={onCommentError}
|
||||
/>
|
||||
) : null}
|
||||
{activeLabCase.id ? (
|
||||
<LabCaseCommentsPanel
|
||||
caseId={activeLabCase.id}
|
||||
canPost={canEdit && !disabled}
|
||||
canToggleVisibility={false}
|
||||
deferSubmit
|
||||
composerValue={pendingComment}
|
||||
onComposerValueChange={setPendingComment}
|
||||
loadComments={async () => {
|
||||
const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!);
|
||||
return r.data;
|
||||
}}
|
||||
onPost={async (body) => {
|
||||
const r = await treatmentsApi.addLabCaseComment(activeLabCase.id!, { body });
|
||||
return r.data;
|
||||
}}
|
||||
onError={onCommentError}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeLabOrgName ? (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
|
||||
<p className="text-sm text-text-primary mt-1">{activeLabOrgName}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CaseSentLabel
|
||||
treatmentCase={{
|
||||
destinationOrganizationId: activeLabCase.destinationOrganizationId,
|
||||
sendToOrganizationIds: activeLabCase.destinationOrganizationId
|
||||
? [activeLabCase.destinationOrganizationId]
|
||||
: [],
|
||||
sentAt: activeLabCase.sentAt ?? null,
|
||||
sends: activeLabCase.sends,
|
||||
}}
|
||||
orgs={orgs}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">
|
||||
{t('includeDetails')}
|
||||
</p>
|
||||
{pickableForActiveDraft.length === 0 ? (
|
||||
<p className="text-xs text-text-muted">{t('labShipmentNoDetailsAvailable')}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{pickableForActiveDraft.map((d) => {
|
||||
const checked = activeLabCase.detailClientIds.includes(d.clientId);
|
||||
return (
|
||||
<Checkbox
|
||||
key={d.clientId}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(next) => toggleDetailInActiveLabCase(d.clientId, next)}
|
||||
label={detailSummary(d)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
|
||||
<SearchBar
|
||||
embedded
|
||||
value={organizationSearch}
|
||||
onChange={onOrganizationSearchChange}
|
||||
placeholder={t('searchOrgsPlaceholder')}
|
||||
/>
|
||||
{recentOrganizations.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-text-muted">{t('recent')}</span>
|
||||
{recentOrganizations.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onRecentOrganizationPick(o.id)}
|
||||
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
|
||||
>
|
||||
{o.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Dropdown
|
||||
value={activeLabCase.destinationOrganizationId ?? ''}
|
||||
onChange={(e) => {
|
||||
const nextOrgId = e.target.value || null;
|
||||
updateActiveLabCase({
|
||||
destinationOrganizationId: nextOrgId,
|
||||
toothProsthesis: [],
|
||||
});
|
||||
setApplyAllProsthesis('');
|
||||
}}
|
||||
disabled={disabled || filteredOrganizations.length === 0}
|
||||
>
|
||||
<option value="">{t('selectLabPlaceholder')}</option>
|
||||
{filteredOrganizations.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</Dropdown>
|
||||
{filteredOrganizations.length === 0 && (
|
||||
<p className="text-xs text-text-muted">{t('noOrgMatch')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!sent && activeDetailAttachments.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">
|
||||
{t('labShipmentAttachments')}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted mb-2">{t('labShipmentAttachmentsHint')}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{activeDetailAttachments.map((att) => (
|
||||
<Checkbox
|
||||
key={att.id}
|
||||
checked={activeLabCase.attachmentIds.includes(att.id)}
|
||||
disabled={disabled}
|
||||
onChange={(next) => toggleAttachmentInActiveLabCase(att.id, next)}
|
||||
label={`${att.fileName} (${(att.sizeBytes / 1024).toFixed(1)} KB)`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeLabCase.id ? (
|
||||
<LabCaseCommentsPanel
|
||||
caseId={activeLabCase.id}
|
||||
canPost={canEdit && !disabled}
|
||||
canToggleVisibility={false}
|
||||
deferSubmit
|
||||
composerValue={pendingComment}
|
||||
onComposerValueChange={setPendingComment}
|
||||
loadComments={async () => {
|
||||
const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!);
|
||||
return r.data;
|
||||
}}
|
||||
onPost={async (body) => {
|
||||
const r = await treatmentsApi.addLabCaseComment(activeLabCase.id!, { body });
|
||||
return r.data;
|
||||
}}
|
||||
onError={onCommentError}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
|
||||
<SearchBar
|
||||
embedded
|
||||
value={organizationSearch}
|
||||
onChange={onOrganizationSearchChange}
|
||||
placeholder={t('searchOrgsPlaceholder')}
|
||||
/>
|
||||
{recentOrganizations.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-text-muted">{t('recent')}</span>
|
||||
{recentOrganizations.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onRecentOrganizationPick(o.id)}
|
||||
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
|
||||
>
|
||||
{o.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Dropdown
|
||||
value={activeLabCase.destinationOrganizationId ?? ''}
|
||||
{prosthesisRows.length > 0 && activeLabCase.destinationOrganizationId ? (
|
||||
<div className="space-y-3 border-t border-border/60 pt-3">
|
||||
<p className="text-xs font-medium text-text-secondary">
|
||||
{t('prosthesisTypesTitle')}
|
||||
</p>
|
||||
<label className="block text-xs text-text-muted space-y-1">
|
||||
{t('prosthesisApplyAll')}
|
||||
<select
|
||||
value={applyAllProsthesis}
|
||||
disabled={disabled || prosthesisOptions.length === 0}
|
||||
onChange={(e) => {
|
||||
const nextOrgId = e.target.value || null;
|
||||
updateActiveLabCase({
|
||||
destinationOrganizationId: nextOrgId,
|
||||
toothProsthesis: [],
|
||||
});
|
||||
setApplyAllProsthesis('');
|
||||
const code = e.target.value;
|
||||
setApplyAllProsthesis(code);
|
||||
if (code) applyProsthesisToAll(code);
|
||||
}}
|
||||
disabled={disabled || filteredOrganizations.length === 0}
|
||||
className={`${FORM_SELECT_CLASS} w-full mt-1`}
|
||||
>
|
||||
<option value="">{t('selectLabPlaceholder')}</option>
|
||||
{filteredOrganizations.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
<option value="">{t('prosthesisSelectPlaceholder')}</option>
|
||||
{prosthesisOptions.map((opt) => (
|
||||
<option key={opt.code} value={opt.code}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Dropdown>
|
||||
{filteredOrganizations.length === 0 && (
|
||||
<p className="text-xs text-text-muted">{t('noOrgMatch')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{prosthesisRows.length > 0 && activeLabCase.destinationOrganizationId ? (
|
||||
<div className="space-y-3 border-t border-border/60 pt-3">
|
||||
<p className="text-xs font-medium text-text-secondary">
|
||||
{t('prosthesisTypesTitle')}
|
||||
</p>
|
||||
<label className="block text-xs text-text-muted space-y-1">
|
||||
{t('prosthesisApplyAll')}
|
||||
<select
|
||||
value={applyAllProsthesis}
|
||||
disabled={disabled || prosthesisOptions.length === 0}
|
||||
onChange={(e) => {
|
||||
const code = e.target.value;
|
||||
setApplyAllProsthesis(code);
|
||||
if (code) applyProsthesisToAll(code);
|
||||
}}
|
||||
className={`${FORM_SELECT_CLASS} w-full mt-1`}
|
||||
>
|
||||
<option value="">{t('prosthesisSelectPlaceholder')}</option>
|
||||
{prosthesisOptions.map((opt) => (
|
||||
<option key={opt.code} value={opt.code}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-text-muted">
|
||||
<th className="pb-2 pr-3 font-medium">{t('prosthesisColTooth')}</th>
|
||||
<th className="pb-2 pr-3 font-medium">{t('prosthesisColDetail')}</th>
|
||||
<th className="pb-2 font-medium">{t('prosthesisColType')}</th>
|
||||
</select>
|
||||
</label>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-text-muted">
|
||||
<th className="pb-2 pr-3 font-medium">{t('prosthesisColTooth')}</th>
|
||||
<th className="pb-2 pr-3 font-medium">{t('prosthesisColDetail')}</th>
|
||||
<th className="pb-2 font-medium">{t('prosthesisColType')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{prosthesisRows.map((row) => {
|
||||
const current =
|
||||
activeLabCase.toothProsthesis.find(
|
||||
(tp) =>
|
||||
tp.detailClientId === row.detailClientId &&
|
||||
tp.tooth === row.tooth,
|
||||
)?.prosthesisTypeCode ?? '';
|
||||
return (
|
||||
<tr key={`${row.detailClientId}-${row.tooth}`} className="border-t border-border/40">
|
||||
<td className="py-2 pr-3 text-text-primary">{row.tooth}</td>
|
||||
<td className="py-2 pr-3 text-text-secondary">
|
||||
{t('detailLabel', { n: row.detailNumber })}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<select
|
||||
value={current}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
setToothProsthesis(
|
||||
row.detailClientId,
|
||||
row.tooth,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className={`${FORM_SELECT_CLASS} w-full min-w-[160px]`}
|
||||
>
|
||||
<option value="">{t('prosthesisSelectPlaceholder')}</option>
|
||||
{prosthesisOptions.map((opt) => (
|
||||
<option key={opt.code} value={opt.code}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{prosthesisRows.map((row) => {
|
||||
const current =
|
||||
activeLabCase.toothProsthesis.find(
|
||||
(tp) =>
|
||||
tp.detailClientId === row.detailClientId &&
|
||||
tp.tooth === row.tooth,
|
||||
)?.prosthesisTypeCode ?? '';
|
||||
return (
|
||||
<tr key={`${row.detailClientId}-${row.tooth}`} className="border-t border-border/40">
|
||||
<td className="py-2 pr-3 text-text-primary">{row.tooth}</td>
|
||||
<td className="py-2 pr-3 text-text-secondary">
|
||||
{t('detailLabel', { n: row.detailNumber })}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<select
|
||||
value={current}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
setToothProsthesis(
|
||||
row.detailClientId,
|
||||
row.tooth,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className={`${FORM_SELECT_CLASS} w-full min-w-[160px]`}
|
||||
>
|
||||
<option value="">{t('prosthesisSelectPlaceholder')}</option>
|
||||
{prosthesisOptions.map((opt) => (
|
||||
<option key={opt.code} value={opt.code}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={
|
||||
disabled ||
|
||||
sendBusyId === activeLabCase.clientId ||
|
||||
!activeLabCase.destinationOrganizationId ||
|
||||
activeLabCase.detailClientIds.length === 0 ||
|
||||
!prosthesisComplete
|
||||
}
|
||||
isLoading={sendBusyId === activeLabCase.clientId}
|
||||
onClick={() => onSendLabCase(activeLabCase, pendingComment.trim())}
|
||||
>
|
||||
{t('sendToLab')}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={
|
||||
disabled ||
|
||||
sendBusyId === activeLabCase.clientId ||
|
||||
!activeLabCase.destinationOrganizationId ||
|
||||
!activeLabCase.detailClientId ||
|
||||
!prosthesisComplete
|
||||
}
|
||||
isLoading={sendBusyId === activeLabCase.clientId}
|
||||
onClick={() => onSendLabCase(activeLabCase, pendingComment.trim())}
|
||||
>
|
||||
{t('sendToLab')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -43,29 +43,36 @@ function isTreatmentDayHistorical(treatmentAt: string, todayStart: Date): boolea
|
||||
return compareLocalDayStart(new Date(treatmentAt), todayStart) < 0;
|
||||
}
|
||||
|
||||
function withoutEmptyLabCaseDrafts(drafts: LabCaseDraft[]): LabCaseDraft[] {
|
||||
return drafts.filter((lc) => lc.sentAt || Boolean(lc.detailClientId));
|
||||
}
|
||||
|
||||
function labCaseDraftsToPast(
|
||||
labCaseDrafts: LabCaseDraft[],
|
||||
details: TreatmentDetailDraft[],
|
||||
): PastLabCase[] {
|
||||
return labCaseDrafts.map((lc) => ({
|
||||
id: lc.id ?? lc.clientId,
|
||||
clientId: lc.clientId,
|
||||
destinationOrganizationId: lc.destinationOrganizationId,
|
||||
sentAt: lc.sentAt ?? null,
|
||||
treatmentDetailIds: lc.detailClientIds
|
||||
.map((cid) => details.find((d) => d.clientId === cid)?.id)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
details: lc.detailClientIds.map((cid) => {
|
||||
const d = details.find((x) => x.clientId === cid);
|
||||
return {
|
||||
id: d?.id ?? cid,
|
||||
clientId: cid,
|
||||
treatmentType: d?.treatmentType ?? 'consultation',
|
||||
teeth: d?.teeth ?? [],
|
||||
};
|
||||
}),
|
||||
sends: lc.sends ?? [],
|
||||
}));
|
||||
return labCaseDrafts.map((lc) => {
|
||||
const linkedDetail = lc.detailClientId
|
||||
? details.find((d) => d.clientId === lc.detailClientId)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: lc.id ?? lc.clientId,
|
||||
clientId: lc.clientId,
|
||||
destinationOrganizationId: lc.destinationOrganizationId,
|
||||
sentAt: lc.sentAt ?? null,
|
||||
treatmentDetailId: linkedDetail?.id ?? null,
|
||||
detail: linkedDetail
|
||||
? {
|
||||
id: linkedDetail.id ?? linkedDetail.clientId,
|
||||
clientId: linkedDetail.clientId,
|
||||
treatmentType: linkedDetail.treatmentType,
|
||||
teeth: linkedDetail.teeth,
|
||||
}
|
||||
: null,
|
||||
sends: lc.sends ?? [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function enrichDetailsWithLabSendState(
|
||||
@@ -74,7 +81,7 @@ function enrichDetailsWithLabSendState(
|
||||
): TreatmentDetailDraft[] {
|
||||
return details.map((detail) => {
|
||||
const sentLabCase = labCaseDrafts.find(
|
||||
(lc) => lc.sentAt && lc.detailClientIds.includes(detail.clientId),
|
||||
(lc) => lc.sentAt && lc.detailClientId === detail.clientId,
|
||||
);
|
||||
if (!sentLabCase) return detail;
|
||||
return {
|
||||
@@ -142,7 +149,7 @@ function newLabCaseDraft(): LabCaseDraft {
|
||||
? crypto.randomUUID()
|
||||
: `lab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
destinationOrganizationId: null,
|
||||
detailClientIds: [],
|
||||
detailClientId: null,
|
||||
toothProsthesis: [],
|
||||
attachmentIds: [],
|
||||
sentAt: null,
|
||||
@@ -179,16 +186,13 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
|
||||
}
|
||||
|
||||
function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
|
||||
const detailClientById = new Map(lc.details.map((d) => [d.id, d.clientId]));
|
||||
|
||||
return {
|
||||
clientId: lc.clientId,
|
||||
id: lc.id,
|
||||
destinationOrganizationId: lc.destinationOrganizationId,
|
||||
detailClientIds: lc.details.map((d) => d.clientId),
|
||||
detailClientId: lc.detail?.clientId ?? null,
|
||||
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
|
||||
detailClientId:
|
||||
detailClientById.get(tp.treatmentDetailId) ?? tp.treatmentDetailId,
|
||||
detailClientId: lc.detail?.clientId ?? tp.treatmentDetailId,
|
||||
tooth: tp.tooth,
|
||||
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||
})),
|
||||
@@ -313,7 +317,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
|
||||
const isDetailLocked = useCallback(
|
||||
(detail: TreatmentDetailDraft) =>
|
||||
labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientIds.includes(detail.clientId)),
|
||||
labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientId === detail.clientId),
|
||||
[labCaseDrafts],
|
||||
);
|
||||
|
||||
@@ -388,7 +392,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
return stillExists ? prev : mapped[0]?.clientId ?? prev;
|
||||
});
|
||||
setSavedSnapshot(serializeDetails(mapped));
|
||||
const mappedLabCases = (treatment.labCases ?? []).map(mapLabCaseDraftFromApi);
|
||||
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
||||
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
|
||||
);
|
||||
setLabCaseDrafts(mappedLabCases);
|
||||
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
|
||||
setOrganizationSearch('');
|
||||
@@ -433,7 +439,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
|
||||
// Sync active lab shipment when the selected treatment detail changes.
|
||||
useEffect(() => {
|
||||
const match = labCaseDrafts.find((lc) => lc.detailClientIds.includes(activeDetailId));
|
||||
const match = labCaseDrafts.find((lc) => lc.detailClientId === activeDetailId);
|
||||
setActiveLabCaseId(match?.clientId ?? null);
|
||||
}, [activeDetailId, labCaseDrafts]);
|
||||
|
||||
@@ -581,7 +587,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
setSavedSnapshot(serializeDetails([first]));
|
||||
}
|
||||
|
||||
const mappedLabCases = (response.data?.labCases ?? []).map(mapLabCaseDraftFromApi);
|
||||
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
||||
(response.data?.labCases ?? []).map(mapLabCaseDraftFromApi),
|
||||
);
|
||||
setLabCaseDrafts(mappedLabCases);
|
||||
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
|
||||
setOrganizationSearch('');
|
||||
@@ -848,26 +856,35 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
savedTreatment.details.map((d) => [d.clientId, d.id]),
|
||||
);
|
||||
|
||||
const payload = drafts.map((lc) => ({
|
||||
clientId: lc.clientId,
|
||||
id: lc.id,
|
||||
destinationOrganizationId: lc.destinationOrganizationId ?? undefined,
|
||||
treatmentDetailIds: lc.detailClientIds
|
||||
.map((clientId) => detailIdByClientId.get(clientId))
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
toothProsthesis: lc.toothProsthesis
|
||||
.map((tp) => {
|
||||
const detailId = detailIdByClientId.get(tp.detailClientId);
|
||||
if (!detailId) return null;
|
||||
return {
|
||||
treatmentDetailId: detailId,
|
||||
tooth: tp.tooth,
|
||||
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||
};
|
||||
})
|
||||
.filter((row): row is { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string } => row !== null),
|
||||
attachmentIds: lc.attachmentIds,
|
||||
}));
|
||||
const payload = drafts
|
||||
.map((lc) => {
|
||||
if (!lc.detailClientId) return null;
|
||||
const treatmentDetailId = detailIdByClientId.get(lc.detailClientId);
|
||||
if (!treatmentDetailId) return null;
|
||||
|
||||
return {
|
||||
clientId: lc.clientId,
|
||||
id: lc.id,
|
||||
destinationOrganizationId: lc.destinationOrganizationId ?? undefined,
|
||||
treatmentDetailId,
|
||||
toothProsthesis: lc.toothProsthesis
|
||||
.map((tp) => {
|
||||
const detailId = detailIdByClientId.get(tp.detailClientId);
|
||||
if (!detailId) return null;
|
||||
return {
|
||||
treatmentDetailId: detailId,
|
||||
tooth: tp.tooth,
|
||||
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(row): row is { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string } =>
|
||||
row !== null,
|
||||
),
|
||||
attachmentIds: lc.attachmentIds,
|
||||
};
|
||||
})
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null);
|
||||
|
||||
if (payload.length === 0) {
|
||||
return savedTreatment;
|
||||
@@ -876,7 +893,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
const response = await treatmentsApi.saveLabCases(selectedAppointment.id, {
|
||||
labCases: payload,
|
||||
});
|
||||
const mapped = response.data.labCases.map(mapLabCaseDraftFromApi);
|
||||
const mapped = withoutEmptyLabCaseDrafts(response.data.labCases.map(mapLabCaseDraftFromApi));
|
||||
setLabCaseDrafts(mapped);
|
||||
setActiveLabCaseId((prev) => {
|
||||
if (prev && mapped.some((lc) => lc.clientId === prev)) return prev;
|
||||
@@ -887,18 +904,86 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
[labCaseDrafts, selectedAppointment],
|
||||
);
|
||||
|
||||
const handleLabCasesChange = useCallback(
|
||||
(next: LabCaseDraft[]) => {
|
||||
const prevCleaned = withoutEmptyLabCaseDrafts(labCaseDrafts);
|
||||
const cleaned = withoutEmptyLabCaseDrafts(next);
|
||||
setLabCaseDrafts(cleaned);
|
||||
|
||||
if (!cleaned.some((lc) => lc.detailClientId === activeDetailId)) {
|
||||
setActiveLabCaseId((prev) =>
|
||||
prev && cleaned.some((lc) => lc.clientId === prev) ? prev : null,
|
||||
);
|
||||
}
|
||||
|
||||
const removedPersistedDraft = prevCleaned.some(
|
||||
(lc) => lc.id && !cleaned.some((row) => row.clientId === lc.clientId),
|
||||
);
|
||||
if (
|
||||
removedPersistedDraft &&
|
||||
selectedAppointment &&
|
||||
canEditTreatmentForDay
|
||||
) {
|
||||
void (async () => {
|
||||
try {
|
||||
const saved = await persistDraft({ force: true });
|
||||
await persistLabCases(saved, cleaned);
|
||||
} catch (error: unknown) {
|
||||
showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
|
||||
}
|
||||
})();
|
||||
}
|
||||
},
|
||||
[
|
||||
activeDetailId,
|
||||
canEditTreatmentForDay,
|
||||
labCaseDrafts,
|
||||
persistDraft,
|
||||
persistLabCases,
|
||||
selectedAppointment,
|
||||
showError,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const handleAddLabCase = useCallback(async () => {
|
||||
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||
|
||||
const cleaned = withoutEmptyLabCaseDrafts(labCaseDrafts);
|
||||
const existing = cleaned.find(
|
||||
(lc) => !lc.sentAt && lc.detailClientId === activeDetailId,
|
||||
);
|
||||
if (existing) {
|
||||
setActiveLabCaseId(existing.clientId);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeDetail = details.find((d) => d.clientId === activeDetailId);
|
||||
const shouldIncludeActive =
|
||||
Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType));
|
||||
|
||||
const orphan = cleaned.find((lc) => !lc.sentAt && !lc.detailClientId);
|
||||
if (orphan && shouldIncludeActive) {
|
||||
const updatedLabCases = cleaned.map((lc) =>
|
||||
lc.clientId === orphan.clientId ? { ...lc, detailClientId: activeDetailId } : lc,
|
||||
);
|
||||
setLabCaseDrafts(updatedLabCases);
|
||||
setActiveLabCaseId(orphan.clientId);
|
||||
|
||||
try {
|
||||
const saved = await persistDraft({ force: true });
|
||||
await persistLabCases(saved, updatedLabCases);
|
||||
} catch (error: unknown) {
|
||||
showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const next: LabCaseDraft = {
|
||||
...newLabCaseDraft(),
|
||||
detailClientIds:
|
||||
activeDetail && labDependentCodes.has(activeDetail.treatmentType)
|
||||
? [activeDetailId]
|
||||
: [],
|
||||
detailClientId: shouldIncludeActive ? activeDetailId : null,
|
||||
};
|
||||
const updatedLabCases = [...labCaseDrafts, next];
|
||||
const updatedLabCases = [...cleaned, next];
|
||||
setLabCaseDrafts(updatedLabCases);
|
||||
setActiveLabCaseId(next.clientId);
|
||||
|
||||
@@ -928,7 +1013,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
showError(t('errorChooseOrg'));
|
||||
return;
|
||||
}
|
||||
if (labCase.detailClientIds.length === 0) {
|
||||
if (!labCase.detailClientId) {
|
||||
showError(t('errorLabCaseNeedsDetails'));
|
||||
return;
|
||||
}
|
||||
@@ -958,10 +1043,10 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
|
||||
const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
|
||||
|
||||
const sentDetailClientIds = new Set(labCase.detailClientIds);
|
||||
const sentDetailClientId = labCase.detailClientId;
|
||||
setDetails((prev) =>
|
||||
prev.map((detail) => {
|
||||
if (!sentDetailClientIds.has(detail.clientId)) return detail;
|
||||
if (detail.clientId !== sentDetailClientId) return detail;
|
||||
return {
|
||||
...detail,
|
||||
labCaseId: response.data.id,
|
||||
@@ -1155,8 +1240,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
labDependentCodes={labDependentCodes}
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
activeLabCaseId={activeLabCaseId}
|
||||
onActiveLabCaseChange={setActiveLabCaseId}
|
||||
onLabCasesChange={setLabCaseDrafts}
|
||||
onLabCasesChange={handleLabCasesChange}
|
||||
disabled={!canEditTreatmentForDay}
|
||||
canEdit={canEdit}
|
||||
orgs={orgs}
|
||||
@@ -1164,14 +1248,18 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
onOrganizationSearchChange={setOrganizationSearch}
|
||||
recentOrganizationIds={recentOrganizationIds}
|
||||
onRecentOrganizationPick={(orgId) => {
|
||||
if (!activeLabCaseId) return;
|
||||
setLabCaseDrafts((prev) =>
|
||||
prev.map((lc) =>
|
||||
lc.clientId === activeLabCaseId && !lc.sentAt
|
||||
setLabCaseDrafts((prev) => {
|
||||
const targetId =
|
||||
activeLabCaseId ??
|
||||
prev.find((lc) => !lc.sentAt && lc.detailClientId === activeDetailId)
|
||||
?.clientId;
|
||||
if (!targetId) return prev;
|
||||
return prev.map((lc) =>
|
||||
lc.clientId === targetId && !lc.sentAt
|
||||
? { ...lc, destinationOrganizationId: orgId }
|
||||
: lc,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
}}
|
||||
sendBusyId={sendBusyId}
|
||||
onAddLabCase={() => void handleAddLabCase()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/lib/api/auth.ts
|
||||
import { apiClient } from './client';
|
||||
import type { AuthResponse, TrialRegistrationData, LoginData } from '@/types/auth';
|
||||
import type { AuthResponse, TrialRegistrationData, LoginData, ForgotPasswordVerifyResponse } from '@/types/auth';
|
||||
import type { SubscriptionAlertData } from '@/types/subscription';
|
||||
|
||||
export const authApi = {
|
||||
@@ -65,4 +65,25 @@ export const authApi = {
|
||||
const response = await apiClient.post('/auth/refresh', { refreshToken });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
sendForgotPasswordCode: async (mobile: string): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.post('/auth/forgot-password/send-code', { mobile });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
verifyForgotPasswordCode: async (
|
||||
mobile: string,
|
||||
code: string,
|
||||
): Promise<ForgotPasswordVerifyResponse> => {
|
||||
const response = await apiClient.post('/auth/forgot-password/verify', { mobile, code });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
changePassword: async (data: {
|
||||
currentPassword?: string;
|
||||
newPassword: string;
|
||||
}): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.patch('/auth/profile/password', data);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -34,7 +34,9 @@ function shouldSkipRefreshRetry(url: string | undefined): boolean {
|
||||
url.includes('/auth/refresh') ||
|
||||
url.includes('/auth/login') ||
|
||||
url.includes('/auth/register') ||
|
||||
url.includes('/auth/logout')
|
||||
url.includes('/auth/logout') ||
|
||||
url.includes('/auth/forgot-password') ||
|
||||
url.includes('/auth/profile/password')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
14
frontend/src/lib/auth/rememberMe.ts
Normal file
14
frontend/src/lib/auth/rememberMe.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
const REMEMBERED_EMAIL_KEY = 'rememberedEmail';
|
||||
|
||||
export function getRememberedEmail(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return localStorage.getItem(REMEMBERED_EMAIL_KEY) ?? '';
|
||||
}
|
||||
|
||||
export function setRememberedEmail(email: string): void {
|
||||
localStorage.setItem(REMEMBERED_EMAIL_KEY, email);
|
||||
}
|
||||
|
||||
export function clearRememberedEmail(): void {
|
||||
localStorage.removeItem(REMEMBERED_EMAIL_KEY);
|
||||
}
|
||||
@@ -4,6 +4,11 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { authApi } from '@/lib/api/auth';
|
||||
import {
|
||||
clearRememberedEmail,
|
||||
getRememberedEmail,
|
||||
setRememberedEmail,
|
||||
} from '@/lib/auth/rememberMe';
|
||||
import { User, Organization } from '@/types/organization';
|
||||
import { isAppLocale, getLocaleFromPathname } from '@/i18n/routing';
|
||||
|
||||
@@ -18,11 +23,12 @@ interface AuthContextType {
|
||||
email: string,
|
||||
password: string,
|
||||
name: string,
|
||||
mobile: string,
|
||||
organizationName: string,
|
||||
organizationEmail: string,
|
||||
organizationType: 'CLINIC' | 'LAB'
|
||||
) => Promise<void>;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
login: (email: string, password: string, rememberMe?: boolean) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
selectOrganization: (orgId: string) => Promise<void>;
|
||||
createOrganization: (
|
||||
@@ -32,6 +38,7 @@ interface AuthContextType {
|
||||
planName?: string,
|
||||
) => Promise<string>;
|
||||
setUserLanguage: (language: string) => void;
|
||||
refreshSession: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
@@ -153,6 +160,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
email: string,
|
||||
password: string,
|
||||
name: string,
|
||||
mobile: string,
|
||||
organizationName: string,
|
||||
organizationEmail: string,
|
||||
organizationType: 'CLINIC' | 'LAB'
|
||||
@@ -163,6 +171,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const response = await authApi.registerTrial({
|
||||
email,
|
||||
mobile,
|
||||
password,
|
||||
name,
|
||||
organizationName,
|
||||
@@ -196,12 +205,22 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [applyUrlLocaleToUser, router, t]);
|
||||
|
||||
// ✅ LOGIN
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const login = useCallback(async (
|
||||
email: string,
|
||||
password: string,
|
||||
rememberMe = false,
|
||||
) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await authApi.login({ email, password });
|
||||
const response = await authApi.login({ email, password, rememberMe });
|
||||
|
||||
if (rememberMe) {
|
||||
setRememberedEmail(email);
|
||||
} else {
|
||||
clearRememberedEmail();
|
||||
}
|
||||
|
||||
const userData = await applyUrlLocaleToUser(response.data.user);
|
||||
setUser(userData);
|
||||
@@ -235,7 +254,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
} catch (err) {
|
||||
console.error('Logout API failed:', err);
|
||||
} finally {
|
||||
const rememberedEmail = getRememberedEmail();
|
||||
localStorage.clear();
|
||||
if (rememberedEmail) {
|
||||
setRememberedEmail(rememberedEmail);
|
||||
}
|
||||
setUser(null);
|
||||
setOrganizations([]);
|
||||
setCurrentOrganization(null);
|
||||
@@ -265,7 +288,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
plan: (organization as { plan?: Organization['plan'] }).plan,
|
||||
});
|
||||
|
||||
router.push('/today');
|
||||
const redirectPath =
|
||||
typeof window !== 'undefined'
|
||||
? sessionStorage.getItem('authRedirect')
|
||||
: null;
|
||||
if (redirectPath) {
|
||||
sessionStorage.removeItem('authRedirect');
|
||||
router.push(redirectPath);
|
||||
} else {
|
||||
router.push('/today');
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
@@ -308,6 +340,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [normalizeProfilePayload, t]);
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
await checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
const setUserLanguage = useCallback((language: string) => {
|
||||
@@ -329,6 +365,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
selectOrganization,
|
||||
createOrganization,
|
||||
setUserLanguage,
|
||||
refreshSession,
|
||||
clearError,
|
||||
}),
|
||||
[
|
||||
@@ -344,6 +381,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
selectOrganization,
|
||||
createOrganization,
|
||||
setUserLanguage,
|
||||
refreshSession,
|
||||
clearError,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface AuthResponse {
|
||||
|
||||
export interface TrialRegistrationData {
|
||||
email: string;
|
||||
mobile: string;
|
||||
password: string;
|
||||
name: string;
|
||||
organizationName: string;
|
||||
@@ -22,4 +23,14 @@ export interface TrialRegistrationData {
|
||||
export interface LoginData {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
|
||||
export interface ForgotPasswordVerifyResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
user: AuthResponse['data']['user'];
|
||||
organizations: AuthResponse['data']['organizations'];
|
||||
redirectTo: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface LabCaseListItem {
|
||||
lastName: string;
|
||||
mobile: string;
|
||||
};
|
||||
treatmentTypes: string[];
|
||||
treatmentType: string | null;
|
||||
taskProgress: { completed: number; total: number };
|
||||
}
|
||||
|
||||
@@ -85,13 +85,13 @@ export interface LabCaseDetail {
|
||||
mobile: string;
|
||||
};
|
||||
appointmentStartAt: string | null;
|
||||
treatmentTypes: string[];
|
||||
details: Array<{
|
||||
treatmentType: string | null;
|
||||
detail: {
|
||||
id: string;
|
||||
treatmentType: string;
|
||||
teeth: string[];
|
||||
comment: string | null;
|
||||
}>;
|
||||
} | null;
|
||||
toothProsthesis: Array<{
|
||||
treatmentDetailId: string;
|
||||
tooth: string;
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface User {
|
||||
email: string;
|
||||
name: string;
|
||||
language?: string;
|
||||
mobile?: string | null;
|
||||
}
|
||||
|
||||
export interface OrganizationPlan {
|
||||
|
||||
@@ -90,13 +90,13 @@ export interface PastLabCase {
|
||||
clientId: string;
|
||||
destinationOrganizationId: string | null;
|
||||
sentAt?: string | null;
|
||||
treatmentDetailIds: string[];
|
||||
details: Array<{
|
||||
treatmentDetailId: string | null;
|
||||
detail: {
|
||||
id: string;
|
||||
clientId: string;
|
||||
treatmentType: string;
|
||||
teeth: FdiToothId[];
|
||||
}>;
|
||||
} | null;
|
||||
toothProsthesis?: Array<{
|
||||
treatmentDetailId: string;
|
||||
tooth: string;
|
||||
@@ -143,7 +143,7 @@ export interface LabCaseDraft {
|
||||
clientId: string;
|
||||
id?: string;
|
||||
destinationOrganizationId: string | null;
|
||||
detailClientIds: string[];
|
||||
detailClientId: string | null;
|
||||
toothProsthesis: LabCaseToothProsthesisDraft[];
|
||||
attachmentIds: string[];
|
||||
sentAt?: string | null;
|
||||
@@ -166,7 +166,7 @@ export interface SaveLabCasePayload {
|
||||
clientId: string;
|
||||
id?: string;
|
||||
destinationOrganizationId?: string;
|
||||
treatmentDetailIds: string[];
|
||||
treatmentDetailId: string;
|
||||
toothProsthesis?: Array<{
|
||||
treatmentDetailId: string;
|
||||
tooth: string;
|
||||
@@ -186,13 +186,13 @@ export interface LabCaseResponse {
|
||||
clientId: string;
|
||||
destinationOrganizationId: string | null;
|
||||
sentAt: string | null;
|
||||
treatmentDetailIds: string[];
|
||||
details: Array<{
|
||||
treatmentDetailId: string | null;
|
||||
detail: {
|
||||
id: string;
|
||||
clientId: string;
|
||||
treatmentType: string;
|
||||
teeth: string[];
|
||||
}>;
|
||||
} | null;
|
||||
sends: LabCaseSendInfo[];
|
||||
toothProsthesis?: LabCaseToothProsthesisDraft[];
|
||||
attachments?: TreatmentAttachmentMeta[];
|
||||
|
||||
Reference in New Issue
Block a user