refactor: normalize date handling to Tehran timezone
- Updated date handling in BlogSeoFields and ScheduleSection to use Tehran timezone utilities for consistency. - Introduced `toTehranClockTime`, `tehranWallClockToUnix`, and `todayIso` functions for accurate date representation. - Modified various components to utilize these new utilities, ensuring that date strings are correctly formatted and timestamps are accurately converted. - Enhanced API documentation to clarify the handling of date fields, emphasizing the importance of server-local midnight. - Added tests to verify that date overrides and holidays maintain the correct day without shifting due to timezone discrepancies.
This commit is contained in:
@@ -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<BlogFormData>;
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
<div key={ov.uuid} className="flex items-center gap-3 p-3.5 rounded-xl border border-[var(--border)] bg-[var(--surface)]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-[var(--text)]">{formatPersianDate(tsToDate(ov.date))}</span>
|
||||
<span className="text-sm font-medium text-[var(--text)]">{formatPersianDate(dayString(ov.date_string, ov.date))}</span>
|
||||
{ov.active ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-[var(--warning-bg)] text-[var(--warning)]">
|
||||
{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 (
|
||||
<div key={h.uuid} className={`flex items-center gap-3 p-3.5 rounded-xl border transition-colors ${isCurrent ? 'border-[var(--danger)] bg-[var(--danger-bg)]/40' : isPast ? 'border-[var(--border)] bg-[var(--surface-2)]/40 opacity-70' : 'border-[var(--accent)] bg-[var(--accent-bg)]/30'}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-sm font-medium ${isPast ? 'text-[var(--text-2)]' : 'text-[var(--text)]'}`}>
|
||||
{sameDay ? formatPersianDate(tsToDate(h.start_date)) : `${formatPersianDate(tsToDate(h.start_date))} تا ${formatPersianDate(tsToDate(h.end_date))}`}
|
||||
{sameDay ? formatPersianDate(startDay) : `${formatPersianDate(startDay)} تا ${formatPersianDate(endDay)}`}
|
||||
</span>
|
||||
{isCurrent && h.active && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-[var(--danger-bg)] text-[var(--danger)] animate-pulse">در جریان</span>
|
||||
|
||||
@@ -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')}`;
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement>(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;
|
||||
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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 → مقادیر پیشفرض فرم. */
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -87,6 +87,14 @@ export function toGregorianDate(d: Date): string {
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
// ساعت دیواریِ ایران به صورت «HH:MM» با رقم لاتین — برای مقداردهی <input type="time">
|
||||
// (بر خلاف 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 تنها نقطهٔ افزودن عنوان است تا
|
||||
// در کل پنل یکسان باشد و از «دکتر دکتر …» یا نمایش بدون عنوان جلوگیری شود.
|
||||
|
||||
@@ -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') ?? ''));
|
||||
|
||||
@@ -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<TurnsViewMode>('timeline');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
|
||||
@@ -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<typeof schema>;
|
||||
|
||||
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() {
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function PaymentDetailPage() {
|
||||
{payment.refunds.map((r, i) => (
|
||||
<InfoRow
|
||||
key={i}
|
||||
label={formatDateTime(new Date(r.at * 1000).toISOString())}
|
||||
label={formatDateTime(r.at)}
|
||||
value={
|
||||
<span>
|
||||
{formatRial(r.amount)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* تاریخ خاص و تعطیلات باید دقیقاً همان روزِ ارسالی را نگه دارند: timestamp روی
|
||||
* نیمهشبِ محلی و یک رشتهٔ `Y-m-d` کانونی در پاسخ، تا کلاینت مجبور به تبدیل
|
||||
* timestamp (و جابهجایی یکروزه بهخاطر UTC) نباشد.
|
||||
*/
|
||||
class SpecialDateBoundaryTest extends ApiTestCase
|
||||
{
|
||||
private function makeDoctor(): Doctor
|
||||
{
|
||||
$owner = $this->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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user