improvement: a flow added for owner users to make it possible for them to participate in treatments or tasks.

This commit is contained in:
2026-07-12 15:11:50 +03:30
parent 4208d15efb
commit 39935688ad
20 changed files with 1122 additions and 154 deletions

View File

@@ -1,18 +1,22 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, 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, useRouter } from '@/i18n/navigation';
import { useSearchParams } from 'next/navigation';
import { Lock } from 'lucide-react';
import { ChevronDown, Lock } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth';
import { accountApi } from '@/lib/api/account';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
import { Toast } from '@/components/ui/shared/Toast';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { OwnerWorkingHoursDialog } from '@/components/settings/OwnerWorkingHoursDialog';
type PasswordForm = {
currentPassword: string;
@@ -25,13 +29,27 @@ export default function AccountSettingsPage() {
const tAuth = useTranslations('auth');
const tCommon = useTranslations('common');
const tValidation = useTranslations('validation');
const { user, isAuthReady } = useAuth();
const { user, currentOrganization, isAuthReady, refreshSession } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const isResetFlow = searchParams.get('reset') === '1';
const isOwner = Boolean(currentOrganization?.isOwner);
const orgType = currentOrganization?.type;
const showClinicParticipation = isOwner && orgType === 'CLINIC';
const showLabParticipation = isOwner && orgType === 'LAB';
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [passwordExpanded, setPasswordExpanded] = useState(isResetFlow);
const [participationLoading, setParticipationLoading] = useState(false);
const [participatesInTreatments, setParticipatesInTreatments] = useState(false);
const [participatesInTasks, setParticipatesInTasks] = useState(false);
const [workingHoursOpen, setWorkingHoursOpen] = useState(false);
const [revokeConfirmOpen, setRevokeConfirmOpen] = useState(false);
const [pendingRevokeType, setPendingRevokeType] = useState<'CLINIC' | 'LAB' | null>(null);
const passwordSchema = useMemo(
() =>
@@ -75,12 +93,147 @@ export default function AccountSettingsPage() {
},
});
const loadParticipation = useCallback(async () => {
if (!isOwner) return;
try {
const res = await accountApi.getParticipation();
setParticipatesInTreatments(res.data.participatesInTreatments);
setParticipatesInTasks(res.data.participatesInTasks);
} catch {
/* non-owners or missing org context */
}
}, [isOwner]);
useEffect(() => {
if (isAuthReady && !user) {
router.replace('/login');
}
}, [isAuthReady, user, router]);
useEffect(() => {
if (isResetFlow) {
setPasswordExpanded(true);
}
}, [isResetFlow]);
useEffect(() => {
void loadParticipation();
}, [loadParticipation, currentOrganization?.id]);
const syncSessionAfterParticipationChange = useCallback(async () => {
await refreshSession();
}, [refreshSession]);
const enableClinicParticipation = useCallback(
async (options: {
skipHours: boolean;
hoursPayload?: {
autoRepeatWeekly: boolean;
blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[];
};
}) => {
await accountApi.updateParticipation(true);
if (!options.skipHours && options.hoursPayload) {
await accountApi.upsertMyWorkingHours(options.hoursPayload);
}
setParticipatesInTreatments(true);
await syncSessionAfterParticipationChange();
setSuccessMessage(t('participateEnabledTreatments'));
},
[syncSessionAfterParticipationChange, t],
);
const enableLabParticipation = async () => {
setParticipationLoading(true);
setError(null);
try {
await accountApi.updateParticipation(true);
setParticipatesInTasks(true);
await syncSessionAfterParticipationChange();
setSuccessMessage(t('participateEnabledTasks'));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('participateUpdateFailed');
setError(message || t('participateUpdateFailed'));
setParticipatesInTasks(false);
} finally {
setParticipationLoading(false);
}
};
const confirmRevokeParticipation = async () => {
if (!pendingRevokeType) return;
setParticipationLoading(true);
setError(null);
try {
await accountApi.updateParticipation(false);
if (pendingRevokeType === 'CLINIC') {
setParticipatesInTreatments(false);
} else {
setParticipatesInTasks(false);
}
await syncSessionAfterParticipationChange();
setSuccessMessage(
pendingRevokeType === 'CLINIC'
? t('participateDisabledTreatments')
: t('participateDisabledTasks'),
);
setRevokeConfirmOpen(false);
setPendingRevokeType(null);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('participateUpdateFailed');
setError(message || t('participateUpdateFailed'));
} finally {
setParticipationLoading(false);
}
};
const handleClinicParticipationChange = (checked: boolean) => {
setError(null);
if (checked) {
setWorkingHoursOpen(true);
return;
}
setPendingRevokeType('CLINIC');
setRevokeConfirmOpen(true);
};
const handleWorkingHoursClose = useCallback(() => {
setWorkingHoursOpen(false);
}, []);
const handleWorkingHoursComplete = useCallback(
async (options: {
skipHours: boolean;
hoursPayload?: {
autoRepeatWeekly: boolean;
blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[];
};
}) => {
setParticipationLoading(true);
setError(null);
try {
await enableClinicParticipation(options);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('participateUpdateFailed');
setError(message || t('participateUpdateFailed'));
throw err;
} finally {
setParticipationLoading(false);
}
},
[enableClinicParticipation, t],
);
const handleLabParticipationChange = (checked: boolean) => {
setError(null);
if (checked) {
void enableLabParticipation();
return;
}
setPendingRevokeType('LAB');
setRevokeConfirmOpen(true);
};
const onSubmit = async (data: PasswordForm) => {
try {
setError(null);
@@ -121,65 +274,178 @@ export default function AccountSettingsPage() {
{tCommon('backToApp')}
</Link>
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary mt-4">{t('accountTitle')}</h1>
<p className="text-text-secondary text-sm mt-2">
{isResetFlow ? t('resetPasswordSubtitle') : t('accountSubtitle')}
</p>
<p className="text-text-secondary text-sm mt-2">{t('accountSubtitle')}</p>
</div>
<div className="surface-card p-4 sm:p-6 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>
{(showClinicParticipation || showLabParticipation) && (
<div className="surface-card p-4 sm:p-6 max-w-lg space-y-4">
<div>
<h2 className="text-lg font-medium text-text-primary">{t('participationSectionTitle')}</h2>
<p className="text-sm text-text-secondary mt-1">{t('participationSectionSubtitle')}</p>
</div>
<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" />}
passwordToggleLabels={passwordToggleLabels}
{showClinicParticipation && (
<Checkbox
checked={participatesInTreatments}
onChange={handleClinicParticipationChange}
disabled={participationLoading}
label={t('participateInTreatments')}
/>
)}
<Input
label={t('newPassword')}
{...register('newPassword')}
type="password"
placeholder={tAuth('passwordPlaceholder')}
error={errors.newPassword?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
passwordToggleLabels={passwordToggleLabels}
/>
<Input
label={t('confirmNewPassword')}
{...register('confirmPassword')}
type="password"
placeholder={tAuth('passwordPlaceholder')}
error={errors.confirmPassword?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
passwordToggleLabels={passwordToggleLabels}
/>
{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>
{showLabParticipation && (
<Checkbox
checked={participatesInTasks}
onChange={handleLabParticipationChange}
disabled={participationLoading}
label={t('participateInTasks')}
/>
)}
</div>
)}
<Button type="submit" variant="primary" isLoading={isSubmitting}>
{isResetFlow ? t('setNewPassword') : t('updatePassword')}
</Button>
</form>
<div className="surface-card max-w-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between gap-3 p-4 sm:p-6 text-left hover:bg-background-card/40 transition-colors"
onClick={() => setPasswordExpanded((open) => !open)}
aria-expanded={passwordExpanded}
>
<div className="flex items-center gap-3 min-w-0">
<Lock className="h-5 w-5 icon-flat shrink-0" />
<div>
<p className="text-base font-medium text-text-primary">{t('changePasswordOption')}</p>
<p className="text-sm text-text-secondary truncate">
{user.email}
{user.mobile ? ` · ${user.mobile}` : ''}
</p>
</div>
</div>
<ChevronDown
className={`h-5 w-5 text-text-muted shrink-0 transition-transform ${
passwordExpanded ? 'rotate-180' : ''
}`}
/>
</button>
{passwordExpanded && (
<div className="border-t border-border px-4 sm:px-6 pb-4 sm:pb-6 pt-4">
<h2 className="text-lg font-medium text-text-primary mb-1">
{isResetFlow ? t('resetPasswordTitle') : t('changePasswordTitle')}
</h2>
{isResetFlow && (
<p className="text-sm text-text-secondary mb-4">{t('resetPasswordSubtitle')}</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" />}
passwordToggleLabels={passwordToggleLabels}
/>
)}
<Input
label={t('newPassword')}
{...register('newPassword')}
type="password"
placeholder={tAuth('passwordPlaceholder')}
error={errors.newPassword?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
passwordToggleLabels={passwordToggleLabels}
/>
<Input
label={t('confirmNewPassword')}
{...register('confirmPassword')}
type="password"
placeholder={tAuth('passwordPlaceholder')}
error={errors.confirmPassword?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
passwordToggleLabels={passwordToggleLabels}
/>
{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>
)}
</div>
{error && !passwordExpanded && (
<div className="max-w-lg 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>
)}
<OwnerWorkingHoursDialog
open={workingHoursOpen}
onClose={handleWorkingHoursClose}
onComplete={handleWorkingHoursComplete}
/>
{revokeConfirmOpen && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/55">
<div
className="surface-card w-full sm:max-w-md max-h-[90dvh] overflow-y-auto p-4 sm:p-5 space-y-4 shadow-xl rounded-t-[var(--radius-lg)] sm:rounded-[var(--radius-lg)]"
role="dialog"
aria-modal="true"
aria-labelledby="revoke-participation-title"
>
<div className="flex items-start justify-between gap-2">
<h2 id="revoke-participation-title" className="text-lg font-semibold text-text-primary pr-2">
{t('participateConfirmRevokeTitle')}
</h2>
<DialogCloseButton
onClick={() => {
if (participationLoading) return;
setRevokeConfirmOpen(false);
setPendingRevokeType(null);
}}
/>
</div>
<p className="text-sm text-text-secondary">
{pendingRevokeType === 'CLINIC'
? t('participateConfirmRevokeBodyTreatments')
: t('participateConfirmRevokeBodyTasks')}
</p>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-1">
<Button
type="button"
variant="outline"
disabled={participationLoading}
onClick={() => {
setRevokeConfirmOpen(false);
setPendingRevokeType(null);
}}
>
{tCommon('cancel')}
</Button>
<Button
type="button"
variant="primary"
isLoading={participationLoading}
onClick={() => void confirmRevokeParticipation()}
>
{t('participateConfirmRevokeConfirm')}
</Button>
</div>
</div>
</div>
)}
{successMessage && (
<Toast variant="success">{successMessage}</Toast>
)}