feat(timezone): implement Tehran timezone handling across the application
This commit is contained in:
@@ -20,7 +20,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import type { ApiResponse } from "../lib/api";
|
||||
import { api } from "../lib/api";
|
||||
import { formatRial } from "../lib/utils";
|
||||
import { formatRial, tehranWallClockToUnix } from "../lib/utils";
|
||||
import type { Appointment } from "../types";
|
||||
import AppointmentStatusDropdown from "./ui/AppointmentStatusDropdown";
|
||||
import Modal from "./ui/Modal";
|
||||
@@ -30,8 +30,7 @@ import PriceInput from "./ui/PriceInput";
|
||||
/** Row actions for the appointments table (Figma عملیات menu). */
|
||||
type ModalKind = null | "info" | "move" | "transfer" | "replace";
|
||||
|
||||
const toEpoch = (isoDate: string, time: string) =>
|
||||
Math.floor(new Date(`${isoDate}T${time || "00:00"}`).getTime() / 1000);
|
||||
const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate, time);
|
||||
|
||||
/**
|
||||
* Resolve the patient-record uuid behind an appointment via the patient list
|
||||
|
||||
@@ -8,12 +8,12 @@ import Modal from './ui/Modal';
|
||||
import PersianDateInput from './ui/PersianDateInput';
|
||||
import PriceInput from './ui/PriceInput';
|
||||
import { WalletChargeLink } from './AppointmentActions';
|
||||
import { tehranWallClockToUnix } from '../lib/utils';
|
||||
|
||||
interface Option { uuid: string; name?: string; full_name?: string }
|
||||
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
|
||||
|
||||
const toEpoch = (isoDate: string, time: string) =>
|
||||
Math.floor(new Date(`${isoDate}T${time || '00:00'}`).getTime() / 1000);
|
||||
const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate, time);
|
||||
const addMinutes = (time: string, min: number) => {
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
const t = h * 60 + m + min;
|
||||
|
||||
@@ -61,7 +61,7 @@ function StatusChip({ status }: { status: string }) {
|
||||
|
||||
function formatTime(ts?: number | null): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' });
|
||||
return new Intl.DateTimeFormat('fa-IR', { timeZone: 'Asia/Tehran', hour: '2-digit', minute: '2-digit' }).format(new Date(ts * 1000));
|
||||
}
|
||||
|
||||
const HEAD = ['ردیف', 'نام بیمار', 'شماره تماس', 'شروع', 'پایان', 'سرویس', 'پرسنل', 'وضعیت', 'عملیات'];
|
||||
|
||||
@@ -14,6 +14,10 @@ export function formatNumber(n: number): string {
|
||||
return new Intl.NumberFormat('fa-IR').format(n);
|
||||
}
|
||||
|
||||
// تایمزون رسمی سراسری برنامه = ایران. همهٔ نمایش/تبدیل تاریخ باید با این tz باشد،
|
||||
// مستقل از تایمزون مرورگرِ کاربر (اجباری).
|
||||
export const APP_TZ = 'Asia/Tehran';
|
||||
|
||||
export function toDate(val: string | number | null | undefined): Date | null {
|
||||
if (val == null || val === '') return null;
|
||||
if (typeof val === 'number') return new Date(val * 1000);
|
||||
@@ -26,6 +30,7 @@ export function formatDate(val: string | number | null | undefined): string {
|
||||
const d = toDate(val);
|
||||
if (!d || isNaN(d.getTime())) return '—';
|
||||
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
timeZone: APP_TZ,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).format(d);
|
||||
}
|
||||
@@ -34,16 +39,47 @@ export function formatDateTime(val: string | number | null | undefined): string
|
||||
const d = toDate(val);
|
||||
if (!d || isNaN(d.getTime())) return '—';
|
||||
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
timeZone: APP_TZ,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
// ساعت (HH:MM) یک timestamp ثانیهای به وقت ایران — برای تایملاین/جدول نوبتها.
|
||||
export function formatTime(tsSeconds: number): string {
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
timeZone: APP_TZ, hour: '2-digit', minute: '2-digit',
|
||||
}).format(new Date(tsSeconds * 1000));
|
||||
}
|
||||
|
||||
// اختلاف دقیقهایِ یک تایمزون با UTC در لحظهٔ مشخص (ایران ثابت +03:30 است ولی این
|
||||
// روش عمومی و درست است).
|
||||
function tzOffsetMinutes(date: Date, tz: string): number {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz, hour12: false,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
}).formatToParts(date).reduce<Record<string, string>>((a, p) => (a[p.type] = p.value, a), {});
|
||||
const asUTC = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute, +parts.second);
|
||||
return Math.round((asUTC - date.getTime()) / 60000);
|
||||
}
|
||||
|
||||
// «ساعت دیواریِ ایران» (تاریخ Y-m-d + HH:MM) → Unix ثانیه. مستقل از tz مرورگر، تا
|
||||
// ثبت نوبت با ساعت واردشده دقیقاً همان لحظه به وقت ایران باشد.
|
||||
export function tehranWallClockToUnix(isoDate: string, time: string): number {
|
||||
const [Y, M, D] = isoDate.split('-').map(Number);
|
||||
const [h, m] = (time || '00:00').split(':').map(Number);
|
||||
const asUtc = Date.UTC(Y, (M || 1) - 1, D || 1, h || 0, m || 0, 0);
|
||||
const offset = tzOffsetMinutes(new Date(asUtc), APP_TZ);
|
||||
return Math.floor((asUtc - offset * 60000) / 1000);
|
||||
}
|
||||
|
||||
// تاریخ تقویمیِ میلادی (Y-m-d) یک لحظه، به وقت ایران.
|
||||
export function toGregorianDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
// en-CA → قالب YYYY-MM-DD
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: APP_TZ, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
// API stores contract dates as Unix seconds; the date input speaks Y-m-d strings.
|
||||
|
||||
@@ -12,6 +12,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { WalletChargeLink } from '../components/AppointmentActions';
|
||||
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
||||
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
||||
import { tehranWallClockToUnix } from '../lib/utils';
|
||||
|
||||
/**
|
||||
* افزودن نوبت — صفحهٔ کامل (بازسازی `CreateTurn.jsx` طرح tauri). از همان endpointهای
|
||||
@@ -22,8 +23,7 @@ import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
||||
interface Option { uuid: string; name?: string; full_name?: string }
|
||||
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
|
||||
|
||||
const toEpoch = (isoDate: string, time: string) =>
|
||||
Math.floor(new Date(`${isoDate}T${time || '00:00'}`).getTime() / 1000);
|
||||
const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate, time);
|
||||
const addMinutes = (time: string, min: number) => {
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
const t = h * 60 + m + min;
|
||||
|
||||
@@ -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 } from '../lib/utils';
|
||||
import { formatDate, toGregorianDate, formatTime } from '../lib/utils';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal';
|
||||
@@ -432,7 +432,7 @@ export default function AppointmentsPage() {
|
||||
// حالت سرویسی: اسلات ثابت وجود ندارد — برای هر شیفتِ کاری، نوبتهای رزروشده
|
||||
// نمایش داده میشوند و باقیِ زمان بهصورت بازه(های) خالیِ قابلرزرو بین آنها.
|
||||
if (serviceMode) {
|
||||
const fmt = (ts: number) => new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' });
|
||||
const fmt = (ts: number) => formatTime(ts);
|
||||
const parseHM = (t: string) => { const [h, m] = (t ?? '00:00').split(':').map(Number); return (h * 3600) + (m * 60); };
|
||||
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
|
||||
const booked = [...activeByStart.values()].sort((a, b) => Number(a.slot_start) - Number(b.slot_start));
|
||||
@@ -479,8 +479,8 @@ export default function AppointmentsPage() {
|
||||
out.push({
|
||||
start: slotStart,
|
||||
end: slotEnd,
|
||||
start_time: s.start_time ?? new Date(slotStart * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
||||
end_time: s.end_time ?? new Date(slotEnd * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
||||
start_time: s.start_time ?? formatTime(slotStart),
|
||||
end_time: s.end_time ?? formatTime(slotEnd),
|
||||
is_available: s.is_available as boolean,
|
||||
appointment: activeByStart.get(slotStart) ?? null,
|
||||
cancelled_appointment: cancelledByStart.get(slotStart) ?? null,
|
||||
|
||||
+4
-1
@@ -54,7 +54,10 @@
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "src/"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"config/bootstrap_tz.php"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* تایمزون سراسری اپلیکیشن = ایران (Asia/Tehran). اجباری و مستقل از php.ini سرور.
|
||||
* از طریق composer autoload.files روی همهٔ entrypointها (HTTP، console، worker،
|
||||
* تست) پیش از بوتِ Kernel اجرا میشود تا تمام توابع تاریخ/ساعت (strtotime، date،
|
||||
* DateTime، محاسبهٔ نوبتدهی سرویسی) با ساعت رسمی ایران کار کنند.
|
||||
*/
|
||||
date_default_timezone_set('Asia/Tehran');
|
||||
@@ -1,4 +1,5 @@
|
||||
file_uploads = On
|
||||
date.timezone = Asia/Tehran
|
||||
memory_limit = 1024M
|
||||
upload_max_filesize = 16M
|
||||
post_max_size = 24M
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Shared;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* تایمزون سراسری اپلیکیشن باید ایران باشد (اجباری، مستقل از php.ini سرور).
|
||||
* توسط config/bootstrap_tz.php از طریق composer autoload.files تضمین میشود.
|
||||
*/
|
||||
class TimezoneTest extends TestCase
|
||||
{
|
||||
public function testDefaultTimezoneIsTehran(): void
|
||||
{
|
||||
$this->assertSame('Asia/Tehran', date_default_timezone_get());
|
||||
}
|
||||
|
||||
public function testDateFunctionsUseTehran(): void
|
||||
{
|
||||
// یک لحظهٔ مشخص UTC → ساعت ایران (+03:30).
|
||||
$ts = 1750000000; // 2025-06-15 15:06:40 UTC
|
||||
$this->assertSame('+0330', date('O', $ts));
|
||||
$this->assertSame('18:36', date('H:i', $ts));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user