diff --git a/assets/admin/components/BlogSeoFields.tsx b/assets/admin/components/BlogSeoFields.tsx index fbfc9db6..2a30fda3 100644 --- a/assets/admin/components/BlogSeoFields.tsx +++ b/assets/admin/components/BlogSeoFields.tsx @@ -2,6 +2,7 @@ import { Controller, useFieldArray } from 'react-hook-form'; import type { Control, UseFormRegister } from 'react-hook-form'; import type { BlogFormData } from '../lib/blogForm'; import PersianDatePicker from './ui/PersianDatePicker'; +import { toGregorianDate, toTehranClockTime, tehranWallClockToUnix } from '../lib/utils'; interface Props { control: Control; @@ -90,16 +91,16 @@ export default function BlogSeoFields({ control, register }: Props) { ); } -/** Gregorian date + time → unix seconds, and back. */ +/** Gregorian date + time → unix seconds, and back — همیشه به وقت ایران. */ function ScheduleField({ value, onChange }: { value: number | null; onChange: (v: number | null) => void }) { const d = value ? new Date(value * 1000) : null; - const dateStr = d ? d.toISOString().slice(0, 10) : ''; - const timeStr = d ? d.toTimeString().slice(0, 5) : '00:00'; + const dateStr = d ? toGregorianDate(d) : ''; + const timeStr = d ? toTehranClockTime(d) : '00:00'; const emit = (nextDate: string, nextTime: string) => { if (!nextDate) return onChange(null); - const ms = Date.parse(`${nextDate}T${nextTime || '00:00'}:00`); - onChange(Number.isNaN(ms) ? null : Math.floor(ms / 1000)); + const ts = tehranWallClockToUnix(nextDate, nextTime || '00:00'); + onChange(Number.isNaN(ts) ? null : ts); }; return ( diff --git a/assets/admin/components/schedule/ScheduleSection.tsx b/assets/admin/components/schedule/ScheduleSection.tsx index 91efd49d..9793b46f 100644 --- a/assets/admin/components/schedule/ScheduleSection.tsx +++ b/assets/admin/components/schedule/ScheduleSection.tsx @@ -10,7 +10,7 @@ import { import { toast } from 'sonner'; import { api, ApiError } from '../../lib/api'; import type { ApiResponse } from '../../lib/api'; -import { formatNumber, digitsOnly } from '../../lib/utils'; +import { formatNumber, digitsOnly, todayIso, unixToIso } from '../../lib/utils'; import Modal from '../ui/Modal'; import ConfirmDialog from '../ui/ConfirmDialog'; import GlobalSearchableSelect from '../ui/SearchableSelect'; @@ -37,8 +37,10 @@ export interface AddressData { // ── Schedule types ───────────────────────────────────────────────────────── interface SlotConfig { start: string; end: string; duration: number; } -interface DateOverrideData { uuid: string; doctor_uuid: string; date: number; active: boolean; reason: string | null; custom_slots: SessionConfig[]; } -interface HolidayData { uuid: string; doctor_uuid: string; start_date: number; end_date: number; active: boolean; reason: string | null; } +// `*_string` فیلدهای «Y-m-d» سرور هستند؛ برای نمایش همیشه اولویت با آن‌هاست تا +// تبدیل timestamp در مرورگر تاریخ را یک روز جابه‌جا نکند. +interface DateOverrideData { uuid: string; doctor_uuid: string; date: number; date_string?: string; active: boolean; reason: string | null; custom_slots: SessionConfig[]; } +interface HolidayData { uuid: string; doctor_uuid: string; start_date: number; end_date: number; start_date_string?: string; end_date_string?: string; active: boolean; reason: string | null; } interface SessionConfig { active: boolean; @@ -109,7 +111,7 @@ function jFirstDayOfWeek(jy: number, jm: number): number { const dow = new Date(gy, gm - 1, gd).getDay(); // 0=Sunday return (dow + 1) % 7; // 0=Saturday, 1=Sunday, ..., 6=Friday } -function todayGregorian(): string { return new Date().toISOString().slice(0, 10); } +function todayGregorian(): string { return todayIso(); } // ── Persian Date Picker ──────────────────────────────────────────────────── @@ -285,7 +287,11 @@ function calcSlotCount(session: SessionConfig): number { return count; } function tsToDate(ts: number): string { - return new Date(ts * 1000).toISOString().slice(0, 10); + return unixToIso(ts); +} +/** رشتهٔ `Y-m-d` سرور در اولویت است؛ timestamp فقط fallback رکوردهای قدیمی پاسخ است. */ +function dayString(serverString: string | undefined, ts: number): string { + return serverString || tsToDate(ts); } // ── Schedule components ──────────────────────────────────────────────────── @@ -925,12 +931,12 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, clinicUuid, on useEffect(() => { if (open) { if (existing) { - setDateStr(tsToDate(existing.date)); + setDateStr(dayString(existing.date_string, existing.date)); setOverrideType(existing.active ? 'custom' : 'closed'); setReason(existing.reason ?? ''); setSlots((existing.custom_slots ?? []).map(toSession)); } else { - setDateStr(new Date().toISOString().slice(0, 10)); + setDateStr(todayGregorian()); setOverrideType('closed'); setReason(''); setSlots([]); } } @@ -1088,7 +1094,7 @@ function DateOverridesTab({ doctorUuid, clinicUuid, addresses, readOnly = false
- {formatPersianDate(tsToDate(ov.date))} + {formatPersianDate(dayString(ov.date_string, ov.date))} {ov.active ? ( {ov.custom_slots.length > 0 ? `${ov.custom_slots.length} بازه سفارشی` : 'ساعات خاص'} @@ -1141,11 +1147,11 @@ function HolidayModal({ open, onClose, existing, doctorUuid, clinicUuid, onSaved useEffect(() => { if (open) { if (existing) { - setStartDate(tsToDate(existing.start_date)); - setEndDate(tsToDate(existing.end_date)); + setStartDate(dayString(existing.start_date_string, existing.start_date)); + setEndDate(dayString(existing.end_date_string, existing.end_date)); setReason(existing.reason ?? ''); } else { - const today = new Date().toISOString().slice(0, 10); + const today = todayGregorian(); setStartDate(today); setEndDate(today); setReason(''); } } @@ -1254,13 +1260,15 @@ function HolidaysTab({ doctorUuid, clinicUuid, readOnly = false }: { doctorUuid: const now = Math.floor(Date.now() / 1000); const isPast = h.end_date < now; const isCurrent = h.start_date <= now && h.end_date >= now; - const sameDay = tsToDate(h.start_date) === tsToDate(h.end_date); + const startDay = dayString(h.start_date_string, h.start_date); + const endDay = dayString(h.end_date_string, h.end_date); + const sameDay = startDay === endDay; return (
- {sameDay ? formatPersianDate(tsToDate(h.start_date)) : `${formatPersianDate(tsToDate(h.start_date))} تا ${formatPersianDate(tsToDate(h.end_date))}`} + {sameDay ? formatPersianDate(startDay) : `${formatPersianDate(startDay)} تا ${formatPersianDate(endDay)}`} {isCurrent && h.active && ( در جریان diff --git a/assets/admin/components/session/CreateStep.tsx b/assets/admin/components/session/CreateStep.tsx index 4ad07c11..aa8274c4 100644 --- a/assets/admin/components/session/CreateStep.tsx +++ b/assets/admin/components/session/CreateStep.tsx @@ -10,7 +10,7 @@ import SearchableSelect from '../ui/SearchableSelect'; import PersianDateInput from '../ui/PersianDateInput'; import { UserTick, FilesServiceAddCard, ClockP, TrashRed } from '../icons/FilesServiceIcons'; import { useAuthStore } from '../../stores/authStore'; -import { digitsOnly } from '../../lib/utils'; +import { digitsOnly, todayIso } from '../../lib/utils'; import { DEFAULT_SERVICE_CATEGORY, contractPercentFor, patientShareOf, type CoverageRule as Rule, type TenantContract, @@ -29,7 +29,7 @@ const VISIT_SERVICE_CATEGORY = DEFAULT_SERVICE_CATEGORY; // مصرف‌کنندگان قبلی (و تست‌ها) نشکنند. export { patientShareOf }; -const todayISO = () => new Date().toISOString().slice(0, 10); +const todayISO = todayIso; const nowHHMM = () => { const d = new Date(); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; diff --git a/assets/admin/components/session/PaymentStep.tsx b/assets/admin/components/session/PaymentStep.tsx index fa2386da..37748579 100644 --- a/assets/admin/components/session/PaymentStep.tsx +++ b/assets/admin/components/session/PaymentStep.tsx @@ -5,7 +5,7 @@ import { toast } from 'sonner'; import { api } from '../../lib/api'; import type { ApiResponse } from '../../lib/api'; import { PencilIcon, TrashIcon } from '@heroicons/react/24/outline'; -import { formatRial, formatDateTime, tomanToRial, rialToToman } from '../../lib/utils'; +import { formatRial, formatDateTime, tomanToRial, rialToToman, todayIso } from '../../lib/utils'; import type { DiscountSuggestion } from '../../types'; import type { SessionCardData } from '../SessionServiceCard'; import SearchableSelect from '../ui/SearchableSelect'; @@ -26,7 +26,7 @@ export const METHOD_LABELS: Record = { wallet: 'پرداخت از کیف پول', pos: 'پرداخت کارتخوان', cash: 'پرداخت نقدی', card: 'کارت به کارت', }; -const todayISO = () => new Date().toISOString().slice(0, 10); +const todayISO = todayIso; /** YYYY-MM-DD → unix (ظهر همان روز تا با هر timezone یک روز بماند) */ const isoToUnix = (iso: string) => Math.floor(new Date(`${iso}T12:00:00`).getTime() / 1000); diff --git a/assets/admin/components/ui/PersianCalendar.tsx b/assets/admin/components/ui/PersianCalendar.tsx index f1d9f225..fcaab4bc 100644 --- a/assets/admin/components/ui/PersianCalendar.tsx +++ b/assets/admin/components/ui/PersianCalendar.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { ChevronRightIcon, ChevronLeftIcon } from '@heroicons/react/24/outline'; +import { todayIso } from '../../lib/utils'; interface Props { value: string; // YYYY-MM-DD Gregorian @@ -70,7 +71,8 @@ const JALALI_MONTHS = ['فروردین','اردیبهشت','خرداد','تیر' export default function PersianCalendar({ value, onChange, onClose, enableYearPicker = false }: Props) { const ref = useRef(null); - const todayGreg = new Date().toISOString().slice(0, 10); + // «امروز» به وقت ایران، نه UTC: `toISOString` بعد از ۲۰:۳۰ فردا را امروز نشان می‌داد. + const todayGreg = todayIso(); const todayJ = toJalali(new Date(todayGreg + 'T12:00:00')); const valueJ = value ? toJalali(new Date(value + 'T12:00:00')) : todayJ; diff --git a/assets/admin/lib/patientForm.test.ts b/assets/admin/lib/patientForm.test.ts index 3c9346f4..97600730 100644 --- a/assets/admin/lib/patientForm.test.ts +++ b/assets/admin/lib/patientForm.test.ts @@ -9,11 +9,16 @@ import type { PatientProfile } from '@/types'; import type { PatientFormValues } from '@/components/PatientRecordInfoForm'; describe('date helpers', () => { - it('timestamp ↔ YYYY-MM-DD رفت‌وبرگشت', () => { + // مبنا نیمه‌شبِ تهران است (نه UTC)، چون بک‌اند هم تاریخ‌های روزمحور را با + // strtotime در تایم‌زون سرور (Asia/Tehran) ذخیره می‌کند. + it('timestamp ↔ YYYY-MM-DD رفت‌وبرگشت روی نیمه‌شبِ تهران', () => { const ts = dateStrToTs('2025-01-07'); - expect(ts).toBe(Math.floor(Date.parse('2025-01-07T00:00:00Z') / 1000)); + expect(ts).toBe(Math.floor(Date.UTC(2025, 0, 6, 20, 30, 0) / 1000)); expect(tsToDateStr(ts)).toBe('2025-01-07'); }); + it('timestampهای ساخته‌شده توسط سرور یک روز عقب نمی‌روند', () => { + expect(tsToDateStr(Math.floor(Date.UTC(2026, 6, 28, 20, 30, 0) / 1000))).toBe('2026-07-29'); + }); it('خالی/نامعتبر → مقدار تهی', () => { expect(tsToDateStr(null)).toBe(''); expect(tsToDateStr(0)).toBe(''); diff --git a/assets/admin/lib/patientForm.ts b/assets/admin/lib/patientForm.ts index 7a4ef0e7..1abaca9d 100644 --- a/assets/admin/lib/patientForm.ts +++ b/assets/admin/lib/patientForm.ts @@ -1,6 +1,7 @@ import type { SelectOption } from '../components/ui/SearchableSelect'; import type { PatientProfile, PatientProfileUpdate } from '../types'; import type { PatientFormValues } from '../components/PatientRecordInfoForm'; +import { unixToIso, tehranWallClockToUnix } from './utils'; // ── enumهای ثابت فرم «اطلاعات پرونده» (بدون منبع API) ───────────────────────── export const GENDER_OPTS: SelectOption[] = [ @@ -21,14 +22,15 @@ export const REFERRAL_OPTS: SelectOption[] = [ ].map((v) => ({ value: v, label: v })); // ── تبدیل تاریخ: timestamp ثانیه‌ای ↔ رشتهٔ میلادی YYYY-MM-DD ───────────────── +// هر دو سمت به وقت ایران‌اند تا با نیمه‌شبِ محلیِ ذخیره‌شده در بک‌اند هم‌راستا باشند؛ +// تبدیل UTC روز را یک واحد جابه‌جا می‌کرد. export function tsToDateStr(ts?: number | null): string { - if (!ts) return ''; - return new Date(ts * 1000).toISOString().slice(0, 10); + return ts ? unixToIso(ts) : ''; } export function dateStrToTs(s: string): number | null { if (!s) return null; - const t = Date.parse(`${s}T00:00:00Z`); - return Number.isNaN(t) ? null : Math.floor(t / 1000); + const t = tehranWallClockToUnix(s, '00:00'); + return Number.isNaN(t) ? null : t; } /** پروفایل API → مقادیر پیش‌فرض فرم. */ diff --git a/assets/admin/lib/utils.test.ts b/assets/admin/lib/utils.test.ts index 277b93dd..9c6fc3d9 100644 --- a/assets/admin/lib/utils.test.ts +++ b/assets/admin/lib/utils.test.ts @@ -16,6 +16,10 @@ import { formatDate, formatDateTime, toGregorianDate, + toTehranClockTime, + tehranWallClockToUnix, + unixToIso, + todayIso, maskMobile, cn, toEnglishDigits, @@ -107,6 +111,42 @@ describe('toGregorianDate', () => { }); }); +describe('unixToIso / todayIso / toTehranClockTime', () => { + // نیمه‌شبِ ۲۹ ژوئیهٔ ۲۰۲۶ به وقت تهران = ۲۸ ژوئیه ۲۰:۳۰ UTC. تبدیل با + // toISOString همین‌جا یک روز عقب می‌رفت — رگرسیونِ «تاریخ خاص یک روز قبل». + const tehranMidnight = Math.floor(Date.UTC(2026, 6, 28, 20, 30, 0) / 1000); + + it('timestamp نیمه‌شبِ تهران همان روز را می‌دهد، نه روز قبل', () => { + expect(unixToIso(tehranMidnight)).toBe('2026-07-29'); + }); + + it('لحظهٔ آخر شب به وقت تهران هنوز همان روز است', () => { + const lateNight = Math.floor(Date.UTC(2026, 6, 29, 20, 0, 0) / 1000); // ۲۳:۳۰ تهران + expect(unixToIso(lateNight)).toBe('2026-07-29'); + }); + + it('مقدار خالی رشتهٔ تهی می‌دهد', () => { + expect(unixToIso(null)).toBe(''); + expect(unixToIso(undefined)).toBe(''); + }); + + it('todayIso همان تاریخ تهرانِ لحظهٔ جاری است', () => { + expect(todayIso()).toBe(toGregorianDate(new Date())); + expect(todayIso()).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it('ساعت دیواری تهران با رقم لاتین و ۲۴ساعته برمی‌گردد', () => { + expect(toTehranClockTime(new Date(tehranMidnight * 1000))).toBe('00:00'); + expect(toTehranClockTime(new Date(Date.UTC(2026, 6, 29, 10, 0, 0)))).toBe('13:30'); + }); + + it('رفت‌وبرگشتِ tehranWallClockToUnix ↔ unixToIso روز را جابه‌جا نمی‌کند', () => { + const ts = tehranWallClockToUnix('2026-07-29', '00:00'); + expect(ts).toBe(tehranMidnight); + expect(unixToIso(ts)).toBe('2026-07-29'); + }); +}); + describe('maskMobile', () => { it('شماره ۱۱ رقمی را ماسک می‌کند', () => { expect(maskMobile('09123456789')).toBe('0912***789'); diff --git a/assets/admin/lib/utils.ts b/assets/admin/lib/utils.ts index 80159e85..eb4accba 100644 --- a/assets/admin/lib/utils.ts +++ b/assets/admin/lib/utils.ts @@ -87,6 +87,14 @@ export function toGregorianDate(d: Date): string { }).format(d); } +// ساعت دیواریِ ایران به صورت «HH:MM» با رقم لاتین — برای مقداردهی +// (بر خلاف formatTime که رقم فارسی می‌دهد و در input معتبر نیست). +export function toTehranClockTime(d: Date): string { + return new Intl.DateTimeFormat('en-GB', { + timeZone: APP_TZ, hour12: false, hour: '2-digit', minute: '2-digit', + }).format(d); +} + // API stores contract dates as Unix seconds; the date input speaks Y-m-d strings. export function isoToUnix(iso: string): number | null { const d = toDate(iso); @@ -98,6 +106,12 @@ export function unixToIso(ts: number | null | undefined): string { return toGregorianDate(new Date(ts * 1000)); } +// «امروز» به وقت ایران. هرگز از `new Date().toISOString().slice(0,10)` استفاده نکن؛ +// آن تاریخِ UTC است و بعد از ۲۰:۳۰ به‌وقت تهران یک روز جلو می‌افتد. +export function todayIso(): string { + return toGregorianDate(new Date()); +} + // عنوان «دکتر» فقط در نمایش افزوده می‌شود و هرگز در فیلد name دیتابیس ذخیره نمی‌شود // (backend با stripDoctorTitle حذف می‌کند). این helper تنها نقطهٔ افزودن عنوان است تا // در کل پنل یکسان باشد و از «دکتر دکتر …» یا نمایش بدون عنوان جلوگیری شود. diff --git a/assets/admin/pages/AppointmentCreatePage.tsx b/assets/admin/pages/AppointmentCreatePage.tsx index 00946c24..196357ef 100644 --- a/assets/admin/pages/AppointmentCreatePage.tsx +++ b/assets/admin/pages/AppointmentCreatePage.tsx @@ -14,7 +14,7 @@ import { WalletChargeLink } from '../components/AppointmentActions'; import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices'; import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker'; import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils'; -import { digitsOnly } from '../lib/utils'; +import { digitsOnly, todayIso } from '../lib/utils'; /** * افزودن نوبت — صفحهٔ کامل (بازسازی `CreateTurn.jsx` طرح tauri). از همان endpointهای @@ -43,7 +43,7 @@ export default function AppointmentCreatePage() { const dbUuid = useAuthStore(s => s.dbUuid); const isDoctor = primaryRole === 'doctor'; - const today = new Date().toISOString().slice(0, 10); + const today = todayIso(); // ── پزشک const [doctorUuid, setDoctorUuid] = useState(isDoctor && dbUuid ? dbUuid : (params.get('doctor') ?? '')); diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 95e01205..0b5d16b9 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -9,7 +9,7 @@ import { toast } from 'sonner'; import { api } from '../lib/api'; import type { PaginatedResponse, ApiResponse } from '../lib/api'; import type { Appointment } from '../types'; -import { formatDate, toGregorianDate, formatTime, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial } from '../lib/utils'; +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 { useAuthStore } from '../stores/authStore'; @@ -507,7 +507,7 @@ export default function AppointmentsPage() { const canCancelAppt = can('appointments', 'cancel'); const [params] = useSearchParams(); - const today = new Date().toISOString().slice(0, 10); + const today = todayIso(); // پس از ویرایش/ثبت، صفحه با ?date=... باز می‌شود تا همان روز نمایش داده شود. const [selectedDate, setSelectedDate] = useState(params.get('date') || today); const [viewMode, setViewMode] = useState('timeline'); diff --git a/assets/admin/pages/ClaimsPage.tsx b/assets/admin/pages/ClaimsPage.tsx index 6c69938e..abc6b3d4 100644 --- a/assets/admin/pages/ClaimsPage.tsx +++ b/assets/admin/pages/ClaimsPage.tsx @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { ArrowPathIcon } from '@heroicons/react/24/outline'; import { api } from '../lib/api'; -import { formatRial, formatNumber } from '../lib/utils'; +import { formatRial, formatNumber, toGregorianDate, todayIso } from '../lib/utils'; import type { ClaimPatientRow } from '../types'; import type { ApiResponse, PaginatedResponse } from '../lib/api'; import PageHeader from '../components/ui/PageHeader'; @@ -35,9 +35,8 @@ const PAYMENT_OPTIONS = [ const isoNDaysAgo = (days: number): string => { const d = new Date(); d.setDate(d.getDate() - days); - return d.toISOString().slice(0, 10); + return toGregorianDate(d); }; -const todayIso = (): string => new Date().toISOString().slice(0, 10); const toUnix = (iso: string): number | null => { if (!iso) return null; diff --git a/assets/admin/pages/MyPaymentsPage.tsx b/assets/admin/pages/MyPaymentsPage.tsx index b6cbc8ac..d79d5d29 100644 --- a/assets/admin/pages/MyPaymentsPage.tsx +++ b/assets/admin/pages/MyPaymentsPage.tsx @@ -7,7 +7,7 @@ import SearchableSelect from '../components/ui/SearchableSelect'; import StatCard from '../components/ui/StatCard'; import StatusBadge from '../components/ui/StatusBadge'; import DataTable, { type Column } from '../components/ui/DataTable'; -import { formatRial, formatDate, formatNumber, toDate } from '../lib/utils'; +import { formatRial, formatDate, formatNumber, toDate, toGregorianDate, todayIso } from '../lib/utils'; import { usePayments, usePaymentsSummary, @@ -25,9 +25,8 @@ const STATUS_OPTIONS = [ const isoNDaysAgo = (days: number): string => { const d = new Date(); d.setDate(d.getDate() - days); - return d.toISOString().slice(0, 10); + return toGregorianDate(d); }; -const todayIso = (): string => new Date().toISOString().slice(0, 10); /** HH:MM (Persian digits) from a unix timestamp. */ function formatTime(unix: number): string { diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index 449e7602..20a3e4ce 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -13,7 +13,7 @@ import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { PatientRecord } from '../types'; import { useAuthStore } from '../stores/authStore'; -import { formatDate, formatDateTime, formatRial, formatTime, formatNumber } from '../lib/utils'; +import { formatDate, formatDateTime, formatRial, formatTime, formatNumber, unixToIso } from '../lib/utils'; import DataTable, { type Column } from '../components/ui/DataTable'; import type { WalletTxn } from '../hooks/usePatientWallet'; import type { WalletModalSubmit } from '../components/WalletTransactionModal'; @@ -390,7 +390,7 @@ function MedicalRecordsTab({ uuid }: { uuid: string }) { const open = (m?: MedicalItem) => { setTitle(m?.title ?? ''); setBody(m?.body ?? ''); - setDate(m?.recorded_at ? new Date(m.recorded_at * 1000).toISOString().slice(0, 10) : ''); + setDate(unixToIso(m?.recorded_at)); setModal(m ?? 'create'); }; diff --git a/assets/admin/pages/PatientRecordFormPage.tsx b/assets/admin/pages/PatientRecordFormPage.tsx index b186bed0..40c40e0a 100644 --- a/assets/admin/pages/PatientRecordFormPage.tsx +++ b/assets/admin/pages/PatientRecordFormPage.tsx @@ -12,7 +12,7 @@ import type { PatientRecord } from '../types'; import PersianDateInput from '../components/ui/PersianDateInput'; import SearchableSelect from '../components/ui/SearchableSelect'; import { numericField } from '../lib/forms'; -import { iranNationalCodeSchema, iranMobileSchema } from '../lib/utils'; +import { iranNationalCodeSchema, iranMobileSchema, unixToIso } from '../lib/utils'; const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر']; @@ -29,7 +29,7 @@ const schema = z.object({ type Form = z.infer; const toEpoch = (iso?: string) => (iso ? Math.floor(new Date(iso).getTime() / 1000) : null); -const fromEpoch = (ts?: number | null) => (ts ? new Date(ts * 1000).toISOString().slice(0, 10) : ''); +const fromEpoch = (ts?: number | null) => unixToIso(ts); /** تشکیل/ویرایش پرونده — patient record create & edit form (Figma "تشکیل پرونده"). */ export default function PatientRecordFormPage() { diff --git a/assets/admin/pages/PaymentDetailPage.tsx b/assets/admin/pages/PaymentDetailPage.tsx index bc8ccc70..b6e77d38 100644 --- a/assets/admin/pages/PaymentDetailPage.tsx +++ b/assets/admin/pages/PaymentDetailPage.tsx @@ -114,7 +114,7 @@ export default function PaymentDetailPage() { {payment.refunds.map((r, i) => ( {formatRial(r.amount)} diff --git a/docs/api/appointment-settings.md b/docs/api/appointment-settings.md index 14be425f..fb602a2e 100644 --- a/docs/api/appointment-settings.md +++ b/docs/api/appointment-settings.md @@ -312,6 +312,7 @@ Get all date overrides for a doctor. "uuid": "...", "doctor_uuid": "...", "date": 1718476800, + "date_string": "2024-06-15", "active": false, "reason": "تعطیل خاص", "custom_slots": [], @@ -322,7 +323,7 @@ Get all date overrides for a doctor. } ``` -> `date` is a Unix timestamp. `active: false` = entire day blocked. `active: true` with `custom_slots` = custom session schedule. +> `date` is a Unix timestamp at **server-local midnight** (`Asia/Tehran`); `date_string` is the same day as `Y-m-d` and is what clients must render — converting the timestamp in a browser with `toISOString()` (UTC) shifts it one day back. `active: false` = entire day blocked. `active: true` with `custom_slots` = custom session schedule. --- @@ -387,6 +388,7 @@ Create a date override. "uuid": "override-uuid-...", "doctor_uuid": "...", "date": 1718476800, + "date_string": "2024-06-15", "active": false, "reason": "تعطیل رسمی", "custom_slots": [], @@ -490,6 +492,8 @@ Get all holidays for a doctor. "doctor_uuid": "...", "start_date": 1719792000, "end_date": 1720656000, + "start_date_string": "2024-07-01", + "end_date_string": "2024-07-11", "reason": "تعطیلات تابستانی", "active": true, "created_at": 1717000000 @@ -499,7 +503,7 @@ Get all holidays for a doctor. } ``` -> `start_date` and `end_date` are Unix timestamps. Holidays take **highest priority** — they block the day even if a date override exists. +> `start_date` / `end_date` are Unix timestamps at **server-local midnight** (`Asia/Tehran`); `start_date_string` / `end_date_string` carry the same days as `Y-m-d` and are what clients must render (UTC conversion in the browser shifts them one day back). Holidays take **highest priority** — they block the day even if a date override exists. --- @@ -536,6 +540,8 @@ Create a holiday range. "doctor_uuid": "...", "start_date": 1719792000, "end_date": 1720656000, + "start_date_string": "2024-07-01", + "end_date_string": "2024-07-11", "reason": "تعطیلات تابستانی", "active": true, "created_at": 1717000000 diff --git a/src/Appointment/Controller/AppointmentSettingsController.php b/src/Appointment/Controller/AppointmentSettingsController.php index 4b4639da..8645fd54 100644 --- a/src/Appointment/Controller/AppointmentSettingsController.php +++ b/src/Appointment/Controller/AppointmentSettingsController.php @@ -42,6 +42,21 @@ class AppointmentSettingsController extends BaseController private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess, ) {} + /** + * تاریخِ روزمحور (`Y-m-d`) را به نیمه‌شبِ همان روز در تایم‌زون سرور تبدیل می‌کند. + * بدون این نرمال‌سازی، ورودی‌هایی که ساعت دارند (مثلاً ISO با `Z`) روز را جابه‌جا + * می‌کنند. `null` یعنی ورودی نامعتبر. + */ + private function parseDayTimestamp(string $value): ?int + { + $ts = strtotime(trim($value)); + if ($ts === false) { + return null; + } + + return (int) strtotime(date('Y-m-d', $ts) . ' 00:00:00'); + } + /** * نوع نوبت‌دهی پس از اولین ثبت غیرقابل‌تغییر است — اما فقط داخل همان context. * پزشکی که در مطب شخصی نوبت‌دهی اسلاتی دارد، همچنان می‌تواند در کلینیک سرویسی @@ -289,8 +304,8 @@ class AppointmentSettingsController extends BaseController return $err; } - $timestamp = strtotime($dateStr); - if ($timestamp === false || $timestamp === -1) { + $timestamp = $this->parseDayTimestamp($dateStr); + if ($timestamp === null) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است', 422, 'date'); } @@ -320,8 +335,8 @@ class AppointmentSettingsController extends BaseController if (array_key_exists('reason', $data)) $override->setReason($data['reason']); if (array_key_exists('custom_slots', $data)) $override->setSetting($data['custom_slots']); if (!empty($data['date'])) { - $ts = strtotime($data['date']); - if ($ts !== false) $override->setDate($ts); + $ts = $this->parseDayTimestamp($data['date']); + if ($ts !== null) $override->setDate($ts); } $this->overrideRepo->save($override); @@ -430,10 +445,10 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'کلینیک فقط می‌تواند تعطیلی مخصوص خودش را ثبت کند', 403, 'clinic_uuid'); } - $startTs = strtotime($startStr); - $endTs = strtotime($endStr); + $startTs = $this->parseDayTimestamp($startStr); + $endTs = $this->parseDayTimestamp($endStr); - if (!$startTs || !$endTs || $endTs < $startTs) { + if ($startTs === null || $endTs === null || $endTs < $startTs) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'تاریخ نادرست است', 422); } @@ -461,12 +476,12 @@ class AppointmentSettingsController extends BaseController if (array_key_exists('active', $data)) $holiday->setActive((bool) $data['active']); if (array_key_exists('reason', $data)) $holiday->setReason($data['reason']); if (!empty($data['start_date'])) { - $ts = strtotime($data['start_date']); - if ($ts) $holiday->setStartDate($ts); + $ts = $this->parseDayTimestamp($data['start_date']); + if ($ts !== null) $holiday->setStartDate($ts); } if (!empty($data['end_date'])) { - $ts = strtotime($data['end_date']); - if ($ts) $holiday->setEndDate($ts); + $ts = $this->parseDayTimestamp($data['end_date']); + if ($ts !== null) $holiday->setEndDate($ts); } $this->holidayRepo->save($holiday); diff --git a/src/Appointment/Entity/DateOverride.php b/src/Appointment/Entity/DateOverride.php index 1b935f8d..39df5b6e 100644 --- a/src/Appointment/Entity/DateOverride.php +++ b/src/Appointment/Entity/DateOverride.php @@ -91,6 +91,9 @@ class DateOverride 'clinic_uuid' => $this->clinic?->getUuid(), 'context' => $this->clinic === null ? 'personal' : 'clinic', 'date' => $this->date, + // نمایش تاریخ باید از این رشته خوانده شود؛ تبدیل timestamp در مرورگر + // با UTC انجام می‌شود و برای نیمه‌شبِ تهران یک روز عقب می‌افتد. + 'date_string' => date('Y-m-d', $this->date), 'active' => $this->active, 'reason' => $this->reason, 'custom_slots' => $this->setting ?? [], diff --git a/src/Appointment/Entity/Holiday.php b/src/Appointment/Entity/Holiday.php index 9db251b1..080272ea 100644 --- a/src/Appointment/Entity/Holiday.php +++ b/src/Appointment/Entity/Holiday.php @@ -90,6 +90,10 @@ class Holiday 'scope' => $this->clinic === null ? 'global' : 'clinic', 'start_date' => $this->startDate, 'end_date' => $this->endDate, + // نمایش تاریخ باید از این رشته‌ها خوانده شود؛ تبدیل timestamp در مرورگر + // با UTC انجام می‌شود و برای نیمه‌شبِ تهران یک روز عقب می‌افتد. + 'start_date_string' => date('Y-m-d', $this->startDate), + 'end_date_string' => date('Y-m-d', $this->endDate), 'active' => $this->active, 'reason' => $this->reason, 'created_at' => $this->createdAt, diff --git a/tests/Appointment/SpecialDateBoundaryTest.php b/tests/Appointment/SpecialDateBoundaryTest.php new file mode 100644 index 00000000..6f4e9f50 --- /dev/null +++ b/tests/Appointment/SpecialDateBoundaryTest.php @@ -0,0 +1,112 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر تاریخ'); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + public function testOverrideKeepsExactDayAndReturnsDateString(): void + { + $doctor = $this->makeDoctor(); + $date = date('Y-m-d', strtotime('+9 days')); + + $body = $this->authJson('POST', '/api/v1/appointment-settings/date-override', $doctor->getUser(), [ + 'doctor_uuid' => $doctor->getUuid(), + 'date' => $date, + 'active' => false, + ]); + + self::assertSame(201, $this->responseCode()); + self::assertSame($date, $body['data']['data']['date_string']); + self::assertSame((int) strtotime($date . ' 00:00:00'), $body['data']['data']['date']); + self::assertSame($date, date('Y-m-d', $body['data']['data']['date'])); + } + + public function testOverrideNormalizesTimestampWithTimeToLocalMidnight(): void + { + $doctor = $this->makeDoctor(); + $date = date('Y-m-d', strtotime('+10 days')); + + // ورودی ISO با ساعت (مثل چیزی که toISOString مرورگر می‌سازد) نباید روز را جابه‌جا کند. + $body = $this->authJson('POST', '/api/v1/appointment-settings/date-override', $doctor->getUser(), [ + 'doctor_uuid' => $doctor->getUuid(), + 'date' => $date . ' 23:45:00', + 'active' => false, + ]); + + self::assertSame(201, $this->responseCode()); + self::assertSame($date, $body['data']['data']['date_string']); + self::assertSame((int) strtotime($date . ' 00:00:00'), $body['data']['data']['date']); + } + + public function testOverridePatchKeepsExactDay(): void + { + $doctor = $this->makeDoctor(); + $first = date('Y-m-d', strtotime('+11 days')); + $second = date('Y-m-d', strtotime('+12 days')); + + $created = $this->authJson('POST', '/api/v1/appointment-settings/date-override', $doctor->getUser(), [ + 'doctor_uuid' => $doctor->getUuid(), + 'date' => $first, + 'active' => false, + ]); + $uuid = $created['data']['data']['uuid']; + + $patched = $this->authJson('PATCH', "/api/v1/appointment-settings/date-override/{$uuid}", $doctor->getUser(), [ + 'date' => $second, + ]); + + self::assertSame(200, $this->responseCode()); + self::assertSame($second, $patched['data']['data']['date_string']); + } + + public function testHolidayKeepsExactDaysAndReturnsDateStrings(): void + { + $doctor = $this->makeDoctor(); + $start = date('Y-m-d', strtotime('+13 days')); + $end = date('Y-m-d', strtotime('+15 days')); + + $body = $this->authJson('POST', '/api/v1/appointment-settings/holidays', $doctor->getUser(), [ + 'doctor_uuid' => $doctor->getUuid(), + 'start_date' => $start, + 'end_date' => $end, + 'reason' => 'سفر', + ]); + + self::assertSame(201, $this->responseCode()); + self::assertSame($start, $body['data']['data']['start_date_string']); + self::assertSame($end, $body['data']['data']['end_date_string']); + self::assertSame((int) strtotime($start . ' 00:00:00'), $body['data']['data']['start_date']); + self::assertSame((int) strtotime($end . ' 00:00:00'), $body['data']['data']['end_date']); + } + + public function testInvalidDateIsRejected(): void + { + $doctor = $this->makeDoctor(); + + $this->authJson('POST', '/api/v1/appointment-settings/date-override', $doctor->getUser(), [ + 'doctor_uuid' => $doctor->getUuid(), + 'date' => 'not-a-date', + 'active' => false, + ]); + + self::assertSame(422, $this->responseCode()); + } +}