fix(booking): filter specialists and real slots by chosen date
This commit is contained in:
parent
97fc02778e
commit
da3b074c98
@ -90,6 +90,7 @@ func (a *API) Routes() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/v1/doctors", a.doctors)
|
mux.HandleFunc("GET /api/v1/doctors", a.doctors)
|
||||||
mux.HandleFunc("GET /api/v1/doctors/{slug}", a.doctor)
|
mux.HandleFunc("GET /api/v1/doctors/{slug}", a.doctor)
|
||||||
mux.HandleFunc("GET /api/v1/doctors/{slug}/slots", a.doctorSlots)
|
mux.HandleFunc("GET /api/v1/doctors/{slug}/slots", a.doctorSlots)
|
||||||
|
mux.HandleFunc("GET /api/v1/booking/availability", a.bookingAvailability)
|
||||||
mux.HandleFunc("GET /api/v1/prices", a.prices)
|
mux.HandleFunc("GET /api/v1/prices", a.prices)
|
||||||
mux.HandleFunc("GET /api/v1/price-sections", a.priceSections)
|
mux.HandleFunc("GET /api/v1/price-sections", a.priceSections)
|
||||||
|
|
||||||
|
|||||||
21
internal/api/booking_availability.go
Normal file
21
internal/api/booking_availability.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"dana/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *API) bookingAvailability(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
if !a.onecAutonomousEnabled() || !a.store.OnecFresh(r.Context(), onecStaleAfter) {
|
||||||
|
ok(w, map[string]any{"enabled": false, "days": []storage.BookingDay{}}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
days, err := a.store.PublicBookingDays(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
handleErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(w, map[string]any{"enabled": true, "days": days}, nil)
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -74,6 +75,29 @@ func TestAutonomousAllActualFutureDates(t *testing.T) {
|
|||||||
if !result.Data.Enabled || len(result.Data.Slots) != 1 || !result.Data.Slots[0].Start.Equal(far) {
|
if !result.Data.Enabled || len(result.Data.Slots) != 1 || !result.Data.Slots[0].Start.Equal(far) {
|
||||||
t.Fatalf("actual distant slot hidden: %s", shown.Body.String())
|
t.Fatalf("actual distant slot hidden: %s", shown.Body.String())
|
||||||
}
|
}
|
||||||
|
checkAvailability := func(want bool) {
|
||||||
|
t.Helper()
|
||||||
|
w := call("GET", "/api/v1/booking/availability", nil, 200)
|
||||||
|
var availability struct {
|
||||||
|
Data struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Days []storage.BookingDay `json:"days"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &availability); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, day := range availability.Data.Days {
|
||||||
|
if day.Date == far.In(moscow).Format("2006-01-02") && slices.Contains(day.DoctorIDs, doctorID) {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !availability.Data.Enabled || found != want {
|
||||||
|
t.Fatalf("date-to-doctor availability mismatch: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkAvailability(true)
|
||||||
creation := appointmentInput{DoctorSlug: doctor, Start: far.Format(time.RFC3339), Name: "Test", Phone: "79990000001", Consent: true, RequestID: doctor}
|
creation := appointmentInput{DoctorSlug: doctor, Start: far.Format(time.RFC3339), Name: "Test", Phone: "79990000001", Consent: true, RequestID: doctor}
|
||||||
made := call("POST", "/api/v1/appointments", creation, 201)
|
made := call("POST", "/api/v1/appointments", creation, 201)
|
||||||
if !bytes.Contains(made.Body.Bytes(), []byte(`"mode":"pending"`)) {
|
if !bytes.Contains(made.Body.Bytes(), []byte(`"mode":"pending"`)) {
|
||||||
@ -88,6 +112,7 @@ func TestAutonomousAllActualFutureDates(t *testing.T) {
|
|||||||
if len(result.Data.Slots) != 0 {
|
if len(result.Data.Slots) != 0 {
|
||||||
t.Fatal("reserved distant slot reopened")
|
t.Fatal("reserved distant slot reopened")
|
||||||
}
|
}
|
||||||
|
checkAvailability(false)
|
||||||
// Removing the calendar cutoff must not permit invented dates.
|
// Removing the calendar cutoff must not permit invented dates.
|
||||||
creation.Start = far.Add(time.Hour).Format(time.RFC3339)
|
creation.Start = far.Add(time.Hour).Format(time.RFC3339)
|
||||||
creation.RequestID = doctor + "-invented"
|
creation.RequestID = doctor + "-invented"
|
||||||
|
|||||||
37
internal/storage/booking_availability.go
Normal file
37
internal/storage/booking_availability.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type BookingDay struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
DoctorIDs []int64 `json:"doctor_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicBookingDays returns only dates and public doctor IDs, never patient data.
|
||||||
|
// Keep the occupancy rules identical to FreeOnecSlots, including cross-branch reservations.
|
||||||
|
func (s *Storage) PublicBookingDays(ctx context.Context) ([]BookingDay, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT to_char(sl.start_at AT TIME ZONE 'Europe/Moscow','YYYY-MM-DD'),
|
||||||
|
array_agg(DISTINCT d.id ORDER BY d.id)
|
||||||
|
FROM onec_slots sl JOIN onec_doctors od ON od.onec_id=sl.doctor_onec_id
|
||||||
|
JOIN doctors d ON d.id=od.doctor_id
|
||||||
|
WHERE sl.status='free' AND sl.start_at>now() AND od.active
|
||||||
|
AND d.published AND d.deleted_at IS NULL AND NOT d.absent AND d.booking_provider='native'
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM onec_appointments a
|
||||||
|
WHERE a.doctor_onec_id=sl.doctor_onec_id AND a.status IN('pending','booked','arrived')
|
||||||
|
AND a.start_at<sl.end_at AND COALESCE(a.end_at,a.start_at+interval '30 minutes')>sl.start_at)
|
||||||
|
GROUP BY 1 ORDER BY 1`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []BookingDay{}
|
||||||
|
for rows.Next() {
|
||||||
|
var day BookingDay
|
||||||
|
if err := rows.Scan(&day.Date, &day.DoctorIDs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, day)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ Object.assign(globalThis, { window, document: window.document, HTMLElement: wind
|
|||||||
window.location = new URL('https://booking-test.invalid/');
|
window.location = new URL('https://booking-test.invalid/');
|
||||||
const { default: React, act } = await import('react');
|
const { default: React, act } = await import('react');
|
||||||
const { createRoot } = await import('react-dom/client');
|
const { createRoot } = await import('react-dom/client');
|
||||||
|
const { Simulate } = await import('react-dom/test-utils');
|
||||||
const { MemoryRouter } = await import('react-router-dom');
|
const { MemoryRouter } = await import('react-router-dom');
|
||||||
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
|
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
|
||||||
const { default: LeadModal } = await vite.ssrLoadModule('/src/components/LeadModal.jsx');
|
const { default: LeadModal } = await vite.ssrLoadModule('/src/components/LeadModal.jsx');
|
||||||
@ -19,6 +20,10 @@ const doctors = [
|
|||||||
];
|
];
|
||||||
const pending = [];
|
const pending = [];
|
||||||
api.doctors = async () => ({ data: doctors });
|
api.doctors = async () => ({ data: doctors });
|
||||||
|
api.bookingAvailability = async () => ({ data: { enabled: true, days: [
|
||||||
|
{ date: '2070-10-23', doctor_ids: [1, 2] },
|
||||||
|
{ date: '2070-10-24', doctor_ids: [2] },
|
||||||
|
] } });
|
||||||
api.doctorSlots = (slug) => new Promise((resolve, reject) => pending.push({ slug, resolve, reject }));
|
api.doctorSlots = (slug) => new Promise((resolve, reject) => pending.push({ slug, resolve, reject }));
|
||||||
api.createLead = api.createAppointment = () => { throw new Error('Real submissions are forbidden in this test'); };
|
api.createLead = api.createAppointment = () => { throw new Error('Real submissions are forbidden in this test'); };
|
||||||
const container = document.getElementById('root');
|
const container = document.getElementById('root');
|
||||||
@ -43,10 +48,10 @@ async function reply(request, slots, enabled = true) {
|
|||||||
try {
|
try {
|
||||||
await render();
|
await render();
|
||||||
assert.equal(pending.length, 0, 'No schedule requests before selecting a doctor');
|
assert.equal(pending.length, 0, 'No schedule requests before selecting a doctor');
|
||||||
assert.equal(container.querySelector('input[type="date"]'), null);
|
assert.ok(container.querySelector('input[type="date"]'));
|
||||||
assert.equal(container.querySelector('input[type="tel"]'), null);
|
assert.equal(container.querySelector('input[type="tel"]'), null);
|
||||||
assert.deepEqual(times(), []);
|
assert.deepEqual(times(), []);
|
||||||
assert.match(text(), /Сначала выберите врача/);
|
assert.match(text(), /Выберите дату или врача/);
|
||||||
|
|
||||||
await chooseDoctor(1);
|
await chooseDoctor(1);
|
||||||
assert.equal(pending[0].slug, 'first');
|
assert.equal(pending[0].slug, 'first');
|
||||||
@ -73,7 +78,7 @@ try {
|
|||||||
await chooseDoctor(3);
|
await chooseDoctor(3);
|
||||||
assert.match(text(), /Свободные окна этого врача сейчас недоступны/);
|
assert.match(text(), /Свободные окна этого врача сейчас недоступны/);
|
||||||
assert.deepEqual(times(), []);
|
assert.deepEqual(times(), []);
|
||||||
assert.equal(container.querySelector('input[type="date"]'), null, 'Fallback cannot offer invented dates');
|
assert.equal(container.querySelectorAll('.slot-chip--time').length, 0, 'Fallback cannot offer invented times');
|
||||||
assert.ok(container.querySelector('input[type="tel"]'));
|
assert.ok(container.querySelector('input[type="tel"]'));
|
||||||
await chooseDoctor(2);
|
await chooseDoctor(2);
|
||||||
await act(async () => pending[3].reject(new Error('Schedule temporarily unavailable')));
|
await act(async () => pending[3].reject(new Error('Schedule temporarily unavailable')));
|
||||||
@ -86,6 +91,21 @@ try {
|
|||||||
assert.deepEqual(times(), ['16:15']);
|
assert.deepEqual(times(), ['16:15']);
|
||||||
assert.equal(container.querySelector('input[type="tel"]'), null, 'A stale initial slot must not be preselected');
|
assert.equal(container.querySelector('input[type="tel"]'), null, 'A stale initial slot must not be preselected');
|
||||||
|
|
||||||
|
await act(async () => root.render(null));
|
||||||
|
await render();
|
||||||
|
await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '2070-10-24' } }));
|
||||||
|
assert.deepEqual([...container.querySelector('select').options].map((option) => option.value), ['', '2'], 'Only doctors free on the selected date appear');
|
||||||
|
await chooseDoctor(2);
|
||||||
|
await reply(pending.at(-1), [free('09:00'), { ...free('14:15'), start: '2070-10-24T14:15:00+03:00' }]);
|
||||||
|
assert.deepEqual(times(), ['14:15'], 'Selected date restricts the actual time list too');
|
||||||
|
await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '2070-10-25' } }));
|
||||||
|
assert.deepEqual(times(), []);
|
||||||
|
assert.equal(container.querySelector('select').value, '', 'Changing date clears the selected doctor and window');
|
||||||
|
assert.equal(container.querySelector('select').options.length, 1);
|
||||||
|
assert.match(text(), /На эту дату свободных окон нет/);
|
||||||
|
await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '' } }));
|
||||||
|
assert.equal(container.querySelector('select').options.length, 4, 'Clearing date restores the doctor-first flow');
|
||||||
|
|
||||||
await act(async () => root.render(null));
|
await act(async () => root.render(null));
|
||||||
await render({ form: 'callback' });
|
await render({ form: 'callback' });
|
||||||
assert.ok(container.querySelector('input[type="tel"]'));
|
assert.ok(container.querySelector('input[type="tel"]'));
|
||||||
@ -94,7 +114,7 @@ try {
|
|||||||
await render({ form: 'review' });
|
await render({ form: 'review' });
|
||||||
assert.equal(container.querySelectorAll('.lead-rating__star').length, 5);
|
assert.equal(container.querySelectorAll('.lead-rating__star').length, 5);
|
||||||
assert.ok(container.querySelector('select'));
|
assert.ok(container.querySelector('select'));
|
||||||
console.log('PASS: doctor-first selection, actual slots, switching/reset, stale responses, fallback, callback and review');
|
console.log('PASS: date-to-doctor filtering, actual times on selected date, reset/empty dates, doctor-first flow, stale responses, fallback, callback and review');
|
||||||
} finally {
|
} finally {
|
||||||
await act(async () => root.unmount());
|
await act(async () => root.unmount());
|
||||||
await vite.close();
|
await vite.close();
|
||||||
|
|||||||
@ -61,6 +61,7 @@ export const api = {
|
|||||||
waitCallStatus: (sessionId, o) => request('GET', `/auth/wait-call/status?session_id=${encodeURIComponent(sessionId)}`, o),
|
waitCallStatus: (sessionId, o) => request('GET', `/auth/wait-call/status?session_id=${encodeURIComponent(sessionId)}`, o),
|
||||||
// свободные окна врача из 1С и запись на конкретное окно
|
// свободные окна врача из 1С и запись на конкретное окно
|
||||||
doctorSlots: (slug, o) => request('GET', `/doctors/${encodeURIComponent(slug)}/slots`, o),
|
doctorSlots: (slug, o) => request('GET', `/doctors/${encodeURIComponent(slug)}/slots`, o),
|
||||||
|
bookingAvailability: (o) => request('GET', '/booking/availability', o),
|
||||||
createAppointment: (body) =>
|
createAppointment: (body) =>
|
||||||
request('POST', '/appointments', { body: { utm: attribution(), ...body } }).then((res) => {
|
request('POST', '/appointments', { body: { utm: attribution(), ...body } }).then((res) => {
|
||||||
reachGoal('lead', { form: 'appointment' });
|
reachGoal('lead', { form: 'appointment' });
|
||||||
|
|||||||
@ -9,12 +9,13 @@ import { api } from '../api.js';
|
|||||||
import { useSiteData } from '../context/SiteData.jsx';
|
import { useSiteData } from '../context/SiteData.jsx';
|
||||||
import { formatPhoneInput, phoneComplete } from '../lib/phone.js';
|
import { formatPhoneInput, phoneComplete } from '../lib/phone.js';
|
||||||
import NativeBooking from './NativeBooking.jsx';
|
import NativeBooking from './NativeBooking.jsx';
|
||||||
|
import { todayKey } from '../lib/slots.js';
|
||||||
import './LeadModal.css';
|
import './LeadModal.css';
|
||||||
|
|
||||||
const COPY = {
|
const COPY = {
|
||||||
appointment: {
|
appointment: {
|
||||||
title: 'Записаться на приём',
|
title: 'Записаться на приём',
|
||||||
subtitle: 'Выберите врача, затем свободную дату и время приёма',
|
subtitle: 'Выберите дату — покажем врачей со свободными окнами на этот день',
|
||||||
},
|
},
|
||||||
callback: {
|
callback: {
|
||||||
title: 'Обратный звонок',
|
title: 'Обратный звонок',
|
||||||
@ -44,6 +45,8 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
|||||||
const [doctorsLoaded, setDoctorsLoaded] = useState(false);
|
const [doctorsLoaded, setDoctorsLoaded] = useState(false);
|
||||||
const [unavailableDoctorId, setUnavailableDoctorId] = useState(null);
|
const [unavailableDoctorId, setUnavailableDoctorId] = useState(null);
|
||||||
const [requestedSlot, setRequestedSlot] = useState(slot);
|
const [requestedSlot, setRequestedSlot] = useState(slot);
|
||||||
|
const [date, setDate] = useState(slot?.start?.slice(0, 10) || '');
|
||||||
|
const [availability, setAvailability] = useState({ loading: true, days: [], error: false });
|
||||||
const [values, setValues] = useState({
|
const [values, setValues] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
@ -53,8 +56,11 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
|||||||
consent: false,
|
consent: false,
|
||||||
});
|
});
|
||||||
const [status, setStatus] = useState({ state: 'idle', message: '' });
|
const [status, setStatus] = useState({ state: 'idle', message: '' });
|
||||||
const selectedDoctor = doctors.find((d) => String(d.id) === values.doctorId)
|
const chosenDoctor = doctors.find((d) => String(d.id) === values.doctorId)
|
||||||
|| (doctor && String(doctor.id) === values.doctorId ? doctor : null);
|
|| (doctor && String(doctor.id) === values.doctorId ? doctor : null);
|
||||||
|
const selectedDoctor = form === 'appointment' && date
|
||||||
|
&& (availability.loading || !availability.days.find((day) => day.date === date)?.doctor_ids.includes(chosenDoctor?.id))
|
||||||
|
? null : chosenDoctor;
|
||||||
const native = form === 'appointment' && selectedDoctor?.booking?.provider === 'native'
|
const native = form === 'appointment' && selectedDoctor?.booking?.provider === 'native'
|
||||||
&& unavailableDoctorId !== selectedDoctor.id;
|
&& unavailableDoctorId !== selectedDoctor.id;
|
||||||
const onNativeUnavailable = useCallback(
|
const onNativeUnavailable = useCallback(
|
||||||
@ -75,6 +81,28 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changeDate = (e) => {
|
||||||
|
setDate(e.target.value);
|
||||||
|
setRequestedSlot(undefined);
|
||||||
|
setUnavailableDoctorId(null);
|
||||||
|
setValues((previous) => ({ ...previous, doctorId: '' }));
|
||||||
|
setStatus({ state: 'idle', message: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (form !== 'appointment') return undefined;
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
setAvailability((previous) => ({ ...previous, loading: true, error: false }));
|
||||||
|
api.bookingAvailability({ signal: ctrl.signal })
|
||||||
|
.then(({ data }) => {
|
||||||
|
if (!ctrl.signal.aborted) setAvailability({ loading: false, days: data?.enabled ? data.days || [] : [], error: false });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!ctrl.signal.aborted) setAvailability({ loading: false, days: [], error: true });
|
||||||
|
});
|
||||||
|
return () => ctrl.abort();
|
||||||
|
}, [form, date]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!needsDoctor) return undefined;
|
if (!needsDoctor) return undefined;
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
@ -127,8 +155,13 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const doctorOptions = useMemo(
|
const doctorOptions = useMemo(
|
||||||
() => [...doctors].sort((a, b) => a.full_name.localeCompare(b.full_name, 'ru')),
|
() => {
|
||||||
[doctors],
|
const allowed = date && form === 'appointment'
|
||||||
|
? new Set(availability.days.find((day) => day.date === date)?.doctor_ids || []) : null;
|
||||||
|
return [...doctors].filter((d) => !allowed || allowed.has(d.id))
|
||||||
|
.sort((a, b) => a.full_name.localeCompare(b.full_name, 'ru'));
|
||||||
|
},
|
||||||
|
[doctors, date, form, availability.days],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nameRequired = form !== 'callback';
|
const nameRequired = form !== 'callback';
|
||||||
@ -206,13 +239,28 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{form === 'appointment' && status.state !== 'done' && (
|
||||||
|
<label className="lead-field">
|
||||||
|
<span className="lead-field__label">Дата приёма</span>
|
||||||
|
<input
|
||||||
|
ref={firstFieldRef}
|
||||||
|
className="lead-field__control"
|
||||||
|
type="date"
|
||||||
|
min={todayKey()}
|
||||||
|
value={date}
|
||||||
|
onChange={changeDate}
|
||||||
|
/>
|
||||||
|
<span className="lead-modal__subtitle">Можно выбрать дату и увидеть только свободных в этот день врачей.</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
{form === 'appointment' && status.state !== 'done' && (
|
{form === 'appointment' && status.state !== 'done' && (
|
||||||
<label className="lead-field lead-field--doctor">
|
<label className="lead-field lead-field--doctor">
|
||||||
<span className="lead-field__label">Специалист</span>
|
<span className="lead-field__label">Специалист</span>
|
||||||
<span className="lead-field__select">
|
<span className="lead-field__select">
|
||||||
<select ref={firstFieldRef} className="lead-field__control" value={values.doctorId} onChange={set('doctorId')}>
|
<select className="lead-field__control" value={values.doctorId} onChange={set('doctorId')} disabled={Boolean(date && availability.loading)}>
|
||||||
<option value="">{doctorsLoaded ? 'Выберите врача' : 'Загружаем список врачей…'}</option>
|
<option value="">{!doctorsLoaded || (date && availability.loading) ? 'Загружаем список врачей…' : 'Выберите врача'}</option>
|
||||||
{doctor && !doctorOptions.some((d) => d.id === doctor.id) && (
|
{doctor && !date && !doctorOptions.some((d) => d.id === doctor.id) && (
|
||||||
<option value={doctor.id}>{doctor.full_name}</option>
|
<option value={doctor.id}>{doctor.full_name}</option>
|
||||||
)}
|
)}
|
||||||
{doctorOptions.map((d) => (
|
{doctorOptions.map((d) => (
|
||||||
@ -228,12 +276,17 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
|||||||
|
|
||||||
{form === 'appointment' && !selectedDoctor ? (
|
{form === 'appointment' && !selectedDoctor ? (
|
||||||
<p className="lead-modal__availability" role="status">
|
<p className="lead-modal__availability" role="status">
|
||||||
{doctorsLoaded && doctorOptions.length === 0
|
{date && availability.error
|
||||||
|
? 'Не удалось проверить расписание. Попробуйте выбрать дату ещё раз.'
|
||||||
|
: date && !availability.loading && doctorsLoaded && doctorOptions.length === 0
|
||||||
|
? 'На эту дату свободных окон нет. Выберите другую дату.'
|
||||||
|
: doctorsLoaded && doctorOptions.length === 0
|
||||||
? 'Список врачей сейчас недоступен. Позвоните в клинику, чтобы уточнить запись.'
|
? 'Список врачей сейчас недоступен. Позвоните в клинику, чтобы уточнить запись.'
|
||||||
: 'Сначала выберите врача — покажем только его свободные даты и время из 1С.'}
|
: date ? 'Выберите врача из списка — покажем его свободные часы на выбранную дату.'
|
||||||
|
: 'Выберите дату или врача — покажем только реальные свободные окна из 1С.'}
|
||||||
</p>
|
</p>
|
||||||
) : native ? (
|
) : native ? (
|
||||||
<NativeBooking key={selectedDoctor.id} doctor={selectedDoctor} initialSlot={requestedSlot} onClose={onClose} onUnavailable={onNativeUnavailable} />
|
<NativeBooking key={`${selectedDoctor.id}:${date}`} doctor={selectedDoctor} filterDate={date} initialSlot={requestedSlot} onClose={onClose} onUnavailable={onNativeUnavailable} />
|
||||||
) : status.state === 'done' ? (
|
) : status.state === 'done' ? (
|
||||||
<div className="lead-modal__done" role="status">
|
<div className="lead-modal__done" role="status">
|
||||||
<p>{status.message}</p>
|
<p>{status.message}</p>
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export function NextSlots({ doctor, onPick, limit = 6 }) {
|
|||||||
|
|
||||||
// Запись в два шага: 1) окно, 2) контакты, подтверждение звонком и согласие.
|
// Запись в два шага: 1) окно, 2) контакты, подтверждение звонком и согласие.
|
||||||
// onUnavailable — 1С не отвечает или окон нет: окно заявки возвращается к обычной форме.
|
// onUnavailable — 1С не отвечает или окон нет: окно заявки возвращается к обычной форме.
|
||||||
export default function NativeBooking({ doctor, initialSlot, onClose, onUnavailable }) {
|
export default function NativeBooking({ doctor, initialSlot, filterDate = '', onClose, onUnavailable }) {
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [slots, setSlots] = useState([]);
|
const [slots, setSlots] = useState([]);
|
||||||
const [services, setServices] = useState([]);
|
const [services, setServices] = useState([]);
|
||||||
@ -76,7 +76,8 @@ export default function NativeBooking({ doctor, initialSlot, onClose, onUnavaila
|
|||||||
.doctorSlots(doctor.slug, { signal: ctrl.signal })
|
.doctorSlots(doctor.slug, { signal: ctrl.signal })
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
const actualSlots = (data?.slots || []).filter((s) => s.status === 'free' && new Date(s.start).getTime() > Date.now());
|
const actualSlots = (data?.slots || []).filter((s) => s.status === 'free' && new Date(s.start).getTime() > Date.now()
|
||||||
|
&& (!filterDate || slotDate(s) === filterDate));
|
||||||
if (!data?.enabled || !actualSlots.length) {
|
if (!data?.enabled || !actualSlots.length) {
|
||||||
unavailable.current();
|
unavailable.current();
|
||||||
return;
|
return;
|
||||||
@ -91,7 +92,7 @@ export default function NativeBooking({ doctor, initialSlot, onClose, onUnavaila
|
|||||||
if (!ctrl.signal.aborted && e.name !== 'AbortError') unavailable.current();
|
if (!ctrl.signal.aborted && e.name !== 'AbortError') unavailable.current();
|
||||||
});
|
});
|
||||||
return () => ctrl.abort();
|
return () => ctrl.abort();
|
||||||
}, [doctor.slug]);
|
}, [doctor.slug, filterDate]);
|
||||||
|
|
||||||
const branches = useMemo(() => {
|
const branches = useMemo(() => {
|
||||||
const m = new Map();
|
const m = new Map();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user