feat: Implement resource booking functionality
- Add service timeline builder for appointments to manage available slots. - Create a hook to fetch resource booking services with effective durations. - Develop ResourceBookingSlotController to handle API requests for resource booking slots. - Implement ResourceBookingSlotService to calculate available time slots based on resource occupancy and service durations. - Add tests for resource appointment creation and booking slot functionality to ensure correct behavior and edge cases.
This commit is contained in:
@@ -9,7 +9,7 @@ vi.mock('../lib/api', () => ({
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { NewAppointmentModal } from './AppointmentsPage';
|
||||
import NewAppointmentModal from '../components/appointments/NewAppointmentModal';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -72,6 +72,14 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', ()
|
||||
}
|
||||
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
|
||||
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
||||
// بازهٔ کاری منبع از تقویم خودش میآید، نه از برنامهٔ هفتگی پزشک.
|
||||
if (url.includes('/day-slots')) {
|
||||
const day = Math.floor(Date.now() / 1000) + 7 * 86400;
|
||||
return Promise.resolve({ success: true, data: {
|
||||
windows: [{ start: day, end: day + 4 * 3600, start_time: '08:00', end_time: '12:00' }],
|
||||
empty_reason: null,
|
||||
} });
|
||||
}
|
||||
// منابع زیر نظر پزشکاند؛ تب هر منبع فقط زیر ناظرِ خودش دیده میشود.
|
||||
if (url.includes('/api/v1/resources')) return Promise.resolve({ success: true, data: [
|
||||
{ uuid: 'r-1', name: 'اتاق ۱', type_name: 'اتاق درمان', supervisor: { uuid: 'd1', name: 'دکتر محمدی' } },
|
||||
@@ -147,25 +155,32 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', ()
|
||||
|
||||
await screen.findByText('دکتر محمدی');
|
||||
await user.click(await screen.findByRole('button', { name: 'لیزر CO2' }));
|
||||
expect(await screen.findByText(/نوبتدهی این منبع سرویسی است/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/نوبتها بر اساس مدت سرویس چیده میشوند/)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'نوبتهای خود پزشک' }));
|
||||
|
||||
expect(screen.queryByText(/نوبتدهی این منبع سرویسی است/)).toBeNull();
|
||||
expect(screen.queryByText(/نوبتها بر اساس مدت سرویس چیده میشوند/)).toBeNull();
|
||||
});
|
||||
|
||||
/** باگ گزارششده: نمای منبع نباید اسلاتی باشد. */
|
||||
it('نمای منبع سرویسی است و تایملاین اسلاتی ندارد', async () => {
|
||||
/**
|
||||
* نمای منبع همان تایملاینِ سرویسی است: بازهٔ کاری از `day-slots` خودِ منبع میآید
|
||||
* و ردیفهای خالی «افزودن نوبت سریع»اند — نه شبکهٔ اسلاتِ پزشک.
|
||||
*/
|
||||
it('نمای منبع، تایملاین سرویسیِ تقویم خودِ منبع است', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AppointmentsPage />);
|
||||
|
||||
await screen.findByText('دکتر محمدی');
|
||||
await user.click(await screen.findByRole('button', { name: 'لیزر CO2' }));
|
||||
|
||||
expect(await screen.findByText(/نوبتدهی این منبع سرویسی است/)).toBeInTheDocument();
|
||||
expect(screen.getByText('افزودن نوبت سرویس')).toBeInTheDocument();
|
||||
// ورودی اسلاتی نباید باشد.
|
||||
expect(screen.queryByText('افزودن نوبت سریع')).toBeNull();
|
||||
expect(await screen.findByText(/نوبتها بر اساس مدت سرویس چیده میشوند/)).toBeInTheDocument();
|
||||
expect(await screen.findByText('افزودن نوبت سریع')).toBeInTheDocument();
|
||||
expect(screen.getByText(/ساعت کاری/)).toBeInTheDocument();
|
||||
// بازهٔ کاری از تقویم منبع خوانده میشود، نه از اسلاتهای پزشک.
|
||||
const askedResourceDay = get.mock.calls.some(
|
||||
(c: any[]) => typeof c[0] === 'string' && c[0].includes('/api/v1/resource/r-2/day-slots'),
|
||||
);
|
||||
expect(askedResourceDay).toBe(true);
|
||||
expect(screen.queryByText('این روز تعطیل است')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
PlusIcon, ChevronRightIcon, ChevronLeftIcon, ChevronDownIcon, CalendarDaysIcon,
|
||||
AdjustmentsHorizontalIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon,
|
||||
AdjustmentsHorizontalIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { PaginatedResponse, ApiResponse } from '../lib/api';
|
||||
import type { Appointment, ClinicResource } from '../types';
|
||||
import { formatDate, toGregorianDate, todayIso, formatTime, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial } from '../lib/utils';
|
||||
import PriceInput from '../components/ui/PriceInput';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import { formatDate, toGregorianDate, todayIso, formatTime } from '../lib/utils';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useClinicContext } from '../hooks/useClinicContext';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
@@ -25,14 +22,16 @@ import TurnsViewToggle from '../components/appointments/TurnsViewToggle';
|
||||
import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle';
|
||||
import DoctorTabs from '../components/appointments/DoctorTabs';
|
||||
import { useResources } from '../hooks/useResources';
|
||||
import ResourceBookingModal from '../components/appointments/ResourceBookingModal';
|
||||
import ResourceDayPanel from '../components/appointments/ResourceDayPanel';
|
||||
import { useResourceBookingServices } from '../hooks/useResourceBookingServices';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import TurnsTimeline from '../components/appointments/TurnsTimeline';
|
||||
import TurnsTable from '../components/appointments/TurnsTable';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
||||
import NewAppointmentModal from '../components/appointments/NewAppointmentModal';
|
||||
import type { BookingSlot } from '../components/appointments/NewAppointmentModal';
|
||||
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
||||
import { buildServiceTimeline } from '../components/appointments/serviceTimeline';
|
||||
import { CANCELLED_STATUSES } from '../components/appointments/turnStatus';
|
||||
import type { TimelineSlot } from '../components/appointments/types';
|
||||
|
||||
@@ -99,388 +98,6 @@ function DateNavigator({ date, onChange }: { date: string; onChange: (d: string)
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Quick booking modal (کلیک روی اسلات خالیِ زمانبندی)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BookingSlot { start: number; end: number; start_time: string; end_time: string; doctor_uuid: string; doctor_name: string; }
|
||||
|
||||
interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null }
|
||||
|
||||
export function NewAppointmentModal({
|
||||
slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null,
|
||||
}: {
|
||||
slot: BookingSlot;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
serviceMode?: boolean;
|
||||
services?: import('../hooks/useDoctorBookingServices').BookingService[];
|
||||
date?: string;
|
||||
/** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت میدهد. */
|
||||
clinicUuid?: string | null;
|
||||
}) {
|
||||
const [mobile, setMobile] = useState('');
|
||||
const [lookup, setLookup] = useState<PatientLookup | null>(null);
|
||||
const [patientName, setPatientName] = useState('');
|
||||
const [nationalCode, setNationalCode] = useState('');
|
||||
// معیار جستجوی بیمار: کد ملی (پیشفرض) یا موبایل.
|
||||
const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national');
|
||||
const [pick, setPick] = useState<{ serviceUuids: string[]; slot: { start: number; end: number } | null }>({ serviceUuids: [], slot: null });
|
||||
|
||||
const role = useAuthStore(s => s.primaryRole);
|
||||
const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment';
|
||||
|
||||
// هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت». فیلد UI تومان،
|
||||
// API ریالی (visit_price_rials). بدون این مقدار، وقتی فلگ فعال است backend خطای ۴۲۲ میدهد.
|
||||
//
|
||||
// قیمت باید از تنظیمات نوبتدهیِ *پزشکِ همین اسلات* بیاید، نه از entity کاربر جاری؛
|
||||
// منشی/کلینیک قیمت خودشان را ندارند و فیلد صفر میماند. اگر دسترسی به تنظیمات آن
|
||||
// پزشک نبود (۴۰۳)، به تنظیمات خودِ کاربر برمیگردیم تا فلگ الزامیبودن از دست نرود.
|
||||
type Pricing = { data: { free_visit_price_rials: number; require_visit_price: boolean } };
|
||||
const doctorPricingQ = useQuery<Pricing>({
|
||||
queryKey: ['insurance-pricing', slot.doctor_uuid],
|
||||
queryFn: () => api.get(`/api/v1/insurance-pricing?doctor_uuid=${encodeURIComponent(slot.doctor_uuid)}`),
|
||||
retry: false,
|
||||
});
|
||||
const selfPricingQ = useQuery<Pricing>({
|
||||
queryKey: ['insurance-pricing'],
|
||||
queryFn: () => api.get('/api/v1/insurance-pricing'),
|
||||
enabled: doctorPricingQ.isError,
|
||||
});
|
||||
const pricing = (doctorPricingQ.data ?? selfPricingQ.data) as any;
|
||||
const freeVisit = pricing?.data?.free_visit_price_rials ?? 0;
|
||||
const requireVisit = pricing?.data?.require_visit_price ?? false;
|
||||
const pricingLoading = doctorPricingQ.isLoading || selfPricingQ.isLoading;
|
||||
const [visitPriceToman, setVisitPriceToman] = useState(0);
|
||||
const [visitPriceTouched, setVisitPriceTouched] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit));
|
||||
}, [freeVisit, visitPriceTouched]);
|
||||
// هزینه ویزیت اختیاری داخل کلپسِ بسته مینشیند؛ وقتی الزامی است کلپس همیشه باز است.
|
||||
const [visitPriceOpen, setVisitPriceOpen] = useState(false);
|
||||
const visitPriceExpanded = requireVisit || visitPriceOpen;
|
||||
|
||||
const mobileValid = /^09\d{9}$/.test(mobile);
|
||||
const nationalCodeValid = /^\d{10}$/.test(nationalCode);
|
||||
// اعتبار کلید جستجو بسته به معیار انتخابشده.
|
||||
const searchValid = searchBy === 'mobile' ? mobileValid : nationalCodeValid;
|
||||
const serviceTimingValid = !serviceMode || (pick.serviceUuids.length > 0 && !!pick.slot);
|
||||
// یک بیمارِ یافتشده که کد ملی دارد، بدون فرم اضافی قابل استفاده است.
|
||||
const foundWithNationalCode = !!lookup?.found && !!lookup.national_code;
|
||||
const needsDetails = lookup !== null && !foundWithNationalCode; // یافتنشده، یا یافتشده بدون کد ملی
|
||||
|
||||
const effectiveName = foundWithNationalCode ? (lookup?.name ?? '') : patientName.trim();
|
||||
const effectiveNationalCode = foundWithNationalCode ? (lookup?.national_code ?? '') : nationalCode;
|
||||
const detailsValid = effectiveName.length >= 2 && effectiveNationalCode.length === 10;
|
||||
const visitPriceValid = !requireVisit || visitPriceToman > 0;
|
||||
const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)) && serviceTimingValid && visitPriceValid;
|
||||
|
||||
const search = useMutation({
|
||||
mutationFn: () => {
|
||||
const q = searchBy === 'mobile'
|
||||
? `mobile=${encodeURIComponent(mobile)}`
|
||||
: `national_code=${encodeURIComponent(nationalCode)}`;
|
||||
return api.get(`/api/v1/my/appointment/patient-lookup?${q}`);
|
||||
},
|
||||
onSuccess: (res: any) => {
|
||||
const data: PatientLookup = res?.data ?? { found: false };
|
||||
setLookup(data);
|
||||
setPatientName(data.found ? (data.name ?? '') : '');
|
||||
// موبایل و کد ملیِ بیمارِ یافتشده را پر میکنیم تا ثبت مستقل از معیار جستجو کار کند.
|
||||
if (data.found) {
|
||||
if (data.mobile) setMobile(data.mobile);
|
||||
setNationalCode(data.national_code ?? '');
|
||||
} else if (searchBy === 'mobile') {
|
||||
setNationalCode('');
|
||||
}
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در جستجو'),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.post(createEndpoint, {
|
||||
doctor_uuid: slot.doctor_uuid,
|
||||
slot_start: serviceMode ? pick.slot!.start : slot.start,
|
||||
slot_end: serviceMode ? pick.slot!.end : slot.end,
|
||||
patient_mobile: mobile,
|
||||
patient_name: effectiveName,
|
||||
patient_national_code: effectiveNationalCode,
|
||||
...(clinicUuid ? { clinic_uuid: clinicUuid } : {}),
|
||||
...(serviceMode ? { service_item_uuids: pick.serviceUuids } : {}),
|
||||
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('نوبت با موفقیت ثبت شد');
|
||||
onSuccess();
|
||||
onClose();
|
||||
},
|
||||
onError: (e: any) => {
|
||||
const msg = e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در ثبت نوبت';
|
||||
toast.error(msg);
|
||||
},
|
||||
});
|
||||
|
||||
// تغییر کلید جستجو نتیجهی جستجوی قبلی را باطل میکند تا کاربر دوباره جستجو کند.
|
||||
function invalidateLookup() {
|
||||
if (lookup !== null) { setLookup(null); setPatientName(''); }
|
||||
}
|
||||
function onMobileChange(v: string) {
|
||||
// ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته میشود).
|
||||
setMobile(sanitizeMobileInput(v));
|
||||
if (lookup !== null) { setLookup(null); setPatientName(''); if (searchBy === 'mobile') setNationalCode(''); }
|
||||
}
|
||||
function onNationalSearchChange(v: string) {
|
||||
setNationalCode(digitsOnly(v, 10));
|
||||
invalidateLookup();
|
||||
}
|
||||
// جابهجایی معیار جستجو همهچیز را از نو شروع میکند.
|
||||
function onSwitchSearchBy(mode: 'mobile' | 'national') {
|
||||
setSearchBy(mode);
|
||||
setLookup(null); setPatientName('');
|
||||
setMobile(''); setNationalCode('');
|
||||
}
|
||||
|
||||
const priceHint = pricingLoading
|
||||
? 'در حال خواندن تعرفهٔ پزشک…'
|
||||
: freeVisit > 0
|
||||
? `تعرفهٔ نوبتدهی ${slot.doctor_name}: ${formatRial(freeVisit)} — در صورت نیاز تغییر دهید`
|
||||
: 'برای این پزشک تعرفهای ثبت نشده — در صورت نیاز مبلغ را وارد کنید';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title="ثبت نوبت"
|
||||
size="sm"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn ghost" onClick={onClose}>انصراف</button>
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={!isValid || mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'در حال ثبت…' : 'ثبت نوبت'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* اسلات انتخابشده */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18,
|
||||
padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
|
||||
}}>
|
||||
<ClockIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 13.5, color: 'var(--text)' }}>
|
||||
{serviceMode ? slot.doctor_name : `${slot.start_time} تا ${slot.end_time}`}
|
||||
</span>
|
||||
{!serviceMode && (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-2)', marginInlineStart: 'auto' }}>
|
||||
{slot.doctor_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{serviceMode && date && (
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<ServiceSlotPicker
|
||||
doctorUuid={slot.doctor_uuid}
|
||||
date={date}
|
||||
services={services}
|
||||
onSelect={setPick}
|
||||
clinicUuidOverride={clinicUuid}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>جستجوی بیمار <span className="req">*</span></label>
|
||||
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
||||
{(['national', 'mobile'] as const).map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={`btn sm ${searchBy === mode ? 'primary' : 'ghost'}`}
|
||||
onClick={() => onSwitchSearchBy(mode)}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{mode === 'mobile' ? 'شماره موبایل' : 'کد ملی'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div className="field" style={{ flex: 1 }}>
|
||||
{searchBy === 'mobile' ? (
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
maxLength={11}
|
||||
value={mobile}
|
||||
onChange={e => onMobileChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
|
||||
placeholder="مثال: 09123456789"
|
||||
style={{ direction: 'ltr' }}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
lang="en"
|
||||
maxLength={10}
|
||||
value={nationalCode}
|
||||
onChange={e => onNationalSearchChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
|
||||
placeholder="کد ملی ۱۰ رقمی"
|
||||
style={{ direction: 'ltr' }}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn soft"
|
||||
onClick={() => search.mutate()}
|
||||
disabled={!searchValid || search.isPending}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
<MagnifyingGlassIcon style={{ width: 16, height: 16 }} />
|
||||
{search.isPending ? '...' : 'جستجو'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{foundWithNationalCode && (
|
||||
<div style={{
|
||||
marginBottom: 16, padding: '10px 14px', borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--success-bg)', fontSize: 13,
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<CheckCircleIcon style={{ width: 20, height: 20, color: 'var(--success)', flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, color: 'var(--success)', fontSize: 12 }}>بیمار یافت شد</div>
|
||||
<div style={{ fontWeight: 700, color: 'var(--text)' }}>{lookup?.name}</div>
|
||||
<div style={{ color: 'var(--text-2)', fontSize: 12 }}>کد ملی: {lookup?.national_code}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsDetails && (
|
||||
<>
|
||||
<div style={{
|
||||
fontSize: 12.5, color: 'var(--text-2)', marginBottom: 12,
|
||||
padding: '9px 12px', borderRadius: 'var(--r-sm)', background: 'var(--warning-bg)',
|
||||
}}>
|
||||
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'بیماری با این مشخصات یافت نشد — بیمار جدید:'}
|
||||
</div>
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>نام و نام خانوادگی بیمار <span className="req">*</span></label>
|
||||
<div className="field">
|
||||
<input
|
||||
type="text"
|
||||
value={patientName}
|
||||
onChange={e => setPatientName(e.target.value)}
|
||||
placeholder="مثال: علی محمدی"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم میشود. */}
|
||||
{searchBy === 'national' && (
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>شماره موبایل بیمار <span className="req">*</span></label>
|
||||
<div className="field">
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
maxLength={11}
|
||||
value={mobile}
|
||||
onChange={e => setMobile(sanitizeMobileInput(e.target.value))}
|
||||
placeholder="مثال: 09123456789"
|
||||
style={{ direction: 'ltr' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* در جستجو با کد ملی، همان مقدار کلیدِ جستجو استفاده میشود و فیلد تکراری لازم نیست. */}
|
||||
{searchBy === 'mobile' && (
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>کد ملی بیمار <span className="req">*</span></label>
|
||||
<div className="field">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
lang="en"
|
||||
maxLength={10}
|
||||
value={nationalCode}
|
||||
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
|
||||
placeholder="کد ملی ۱۰ رقمی"
|
||||
style={{ direction: 'ltr' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="field-block">
|
||||
{requireVisit ? (
|
||||
<label>
|
||||
هزینه ویزیت (تومان)<span className="req"> *</span>
|
||||
</label>
|
||||
) : (
|
||||
// سرِ کلپس — با کلیک باز/بسته میشود (فقط وقتی اختیاری است).
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisitPriceOpen(o => !o)}
|
||||
aria-expanded={visitPriceExpanded}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||
background: 'none', border: 'none', cursor: 'pointer', font: 'inherit',
|
||||
padding: 0, color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
<span>هزینه ویزیت (تومان) <span className="opt">(اختیاری)</span></span>
|
||||
<ChevronDownIcon
|
||||
style={{
|
||||
width: 15, height: 15, marginInlineStart: 'auto', flexShrink: 0,
|
||||
transition: 'transform .2s var(--ease)',
|
||||
transform: visitPriceExpanded ? 'rotate(180deg)' : 'none',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{visitPriceExpanded && (
|
||||
<>
|
||||
<div
|
||||
className="field"
|
||||
style={requireVisit && visitPriceToman <= 0 ? { borderColor: 'var(--danger)' } : undefined}
|
||||
>
|
||||
<PriceInput
|
||||
value={visitPriceToman}
|
||||
onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }}
|
||||
suffix="تومان"
|
||||
/>
|
||||
</div>
|
||||
{requireVisit && visitPriceToman <= 0
|
||||
? <span className="field-err">هزینه ویزیت الزامی است</span>
|
||||
: <span className="field-hint">{priceHint}</span>}
|
||||
{freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost sm"
|
||||
style={{ marginTop: 8, alignSelf: 'flex-start' }}
|
||||
onClick={() => { setVisitPriceToman(rialToToman(freeVisit)); setVisitPriceTouched(true); }}
|
||||
>
|
||||
استفاده از تعرفهٔ پزشک
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main Page — «نوبت ها» (طرح tauri)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -538,6 +155,9 @@ export default function AppointmentsPage() {
|
||||
|
||||
const [bookingResource, setBookingResource] = useState<ClinicResource | null>(null);
|
||||
const activeResource = bookableResources.find((r) => r.uuid === selectedResourceUuid) ?? null;
|
||||
// سرویسهای همان منبع، نه کل کاتالوگ: منبعی که سرویسی را نمیدهد نباید در فهرست
|
||||
// بیاید — سرور هم همان را با ۴۲۲ رد میکند.
|
||||
const { services: resourceServices } = useResourceBookingServices(bookingResource?.uuid);
|
||||
|
||||
/**
|
||||
* منبع زیرمجموعهٔ پزشک است، نه رقیبش: انتخاب یک منبع فقط نما را داخل همان پزشک
|
||||
@@ -709,43 +329,19 @@ export default function AppointmentsPage() {
|
||||
|
||||
// حالت سرویسی: اسلات ثابت وجود ندارد — برای هر شیفتِ کاری، نوبتهای رزروشده
|
||||
// نمایش داده میشوند و باقیِ زمان بهصورت بازه(های) خالیِ قابلرزرو بین آنها.
|
||||
// همان الگوریتمِ تایملاینِ منبع؛ فقط بازهٔ کاری از برنامهٔ هفتگی میآید.
|
||||
if (serviceMode) {
|
||||
const fmt = (ts: number) => formatTime(ts);
|
||||
const parseHM = (t: string) => { const [h, m] = (t ?? '00:00').split(':').map(Number); return (h * 3600) + (m * 60); };
|
||||
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
|
||||
const booked = [...activeByStart.values()].sort((a, b) => Number(a.slot_start) - Number(b.slot_start));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const out: TimelineSlot[] = [];
|
||||
// بازهٔ خالیِ قابلرزرو؛ اگر روی «اکنون» بیفتد به اکنون بریده میشود و بازههای
|
||||
// کاملاً گذشته حذف میشوند تا فقط زمان قابلرزرو باقی بماند.
|
||||
const pushFree = (start: number, end: number) => {
|
||||
const s = start < now ? now : start;
|
||||
if (end <= s) return;
|
||||
out.push({ start: s, end, start_time: fmt(s), end_time: fmt(end), is_available: true, appointment: null, cancelled_appointment: null });
|
||||
};
|
||||
|
||||
rawSessions.forEach((session: any) => {
|
||||
const windows = rawSessions.flatMap((session: any) => {
|
||||
const slots = (session.slots as any[]) ?? [];
|
||||
if (!slots.length) return;
|
||||
if (!slots.length) return [];
|
||||
const dayStart = Number(slots[0].start) - parseHM(session.start_time);
|
||||
const winStart = dayStart + parseHM(session.start_time);
|
||||
const winEnd = dayStart + parseHM(session.end_time);
|
||||
|
||||
const inWin = booked.filter(a => Number(a.slot_start) >= winStart && Number(a.slot_start) < winEnd);
|
||||
let cursor = winStart;
|
||||
inWin.forEach(a => {
|
||||
const s = Number(a.slot_start), e = Number(a.slot_end);
|
||||
if (s > cursor) pushFree(cursor, s);
|
||||
out.push({
|
||||
start: s, end: e, start_time: fmt(s), end_time: fmt(e),
|
||||
is_available: false, appointment: a, cancelled_appointment: null,
|
||||
});
|
||||
cursor = Math.max(cursor, e);
|
||||
});
|
||||
if (cursor < winEnd) pushFree(cursor, winEnd);
|
||||
return [{ start: dayStart + parseHM(session.start_time), end: dayStart + parseHM(session.end_time) }];
|
||||
});
|
||||
return out;
|
||||
|
||||
return buildServiceTimeline(windows, appointments);
|
||||
}
|
||||
|
||||
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
|
||||
@@ -920,13 +516,15 @@ export default function AppointmentsPage() {
|
||||
)}
|
||||
</>
|
||||
) : activeResource ? (
|
||||
/* منبع اسلات ندارد: تقویمش سرویسی است، پس تایملاین اسلاتیِ پزشک اینجا
|
||||
اصلاً رندر نمیشود — فهرست نوبتهای همین منبع و یک ورودیِ سرویسی. */
|
||||
/* منبع اسلات ندارد: تقویمش سرویسی است، پس تایملاینش هم سرویسی ساخته
|
||||
میشود — نوبتهای همین منبع و بازههای خالیِ بینشان. */
|
||||
<ResourceDayPanel
|
||||
resource={activeResource}
|
||||
date={selectedDate}
|
||||
appointments={filteredAppointments}
|
||||
loading={apptQuery.isLoading}
|
||||
canCreate={!isRepresentation && canCreateAppt}
|
||||
queryKey={apptQueryKey}
|
||||
onBook={() => setBookingResource(activeResource)}
|
||||
onView={openDetail}
|
||||
/>
|
||||
@@ -967,11 +565,26 @@ export default function AppointmentsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ثبت نوبتِ منبع — همان مودالِ نوبتدهی سرویسی، با تقویمِ خودِ منبع */}
|
||||
{bookingResource && (
|
||||
<ResourceBookingModal
|
||||
resource={bookingResource}
|
||||
<NewAppointmentModal
|
||||
slot={{
|
||||
start: 0, end: 0, start_time: '', end_time: '',
|
||||
// پزشکِ نوبت همان ناظرِ منبع است؛ اینجا فقط برای نمایش و تعرفهٔ ویزیت
|
||||
// میآید و سرور خودش هم از منبع استنتاجش میکند.
|
||||
doctor_uuid: bookingResource.supervisor?.uuid ?? '',
|
||||
doctor_name: bookingResource.supervisor?.name ?? '',
|
||||
}}
|
||||
resource={{ uuid: bookingResource.uuid, name: bookingResource.name }}
|
||||
services={resourceServices}
|
||||
date={selectedDate}
|
||||
clinicUuid={effectiveClinicUuid}
|
||||
onClose={() => setBookingResource(null)}
|
||||
onBooked={() => qc.invalidateQueries({ queryKey: apptQueryKey })}
|
||||
onSuccess={() => {
|
||||
qc.invalidateQueries({ queryKey: apptQueryKey });
|
||||
qc.invalidateQueries({ queryKey: ['resource-day-slots', bookingResource.uuid, selectedDate] });
|
||||
qc.invalidateQueries({ queryKey: ['appt-today-stats', selectedDate] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user