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 { toast } from "sonner";
|
||||||
import type { ApiResponse } from "../lib/api";
|
import type { ApiResponse } from "../lib/api";
|
||||||
import { api } 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 type { Appointment } from "../types";
|
||||||
import AppointmentStatusDropdown from "./ui/AppointmentStatusDropdown";
|
import AppointmentStatusDropdown from "./ui/AppointmentStatusDropdown";
|
||||||
import Modal from "./ui/Modal";
|
import Modal from "./ui/Modal";
|
||||||
@@ -30,8 +30,7 @@ import PriceInput from "./ui/PriceInput";
|
|||||||
/** Row actions for the appointments table (Figma عملیات menu). */
|
/** Row actions for the appointments table (Figma عملیات menu). */
|
||||||
type ModalKind = null | "info" | "move" | "transfer" | "replace";
|
type ModalKind = null | "info" | "move" | "transfer" | "replace";
|
||||||
|
|
||||||
const toEpoch = (isoDate: string, time: string) =>
|
const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate, time);
|
||||||
Math.floor(new Date(`${isoDate}T${time || "00:00"}`).getTime() / 1000);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the patient-record uuid behind an appointment via the patient list
|
* 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 PersianDateInput from './ui/PersianDateInput';
|
||||||
import PriceInput from './ui/PriceInput';
|
import PriceInput from './ui/PriceInput';
|
||||||
import { WalletChargeLink } from './AppointmentActions';
|
import { WalletChargeLink } from './AppointmentActions';
|
||||||
|
import { tehranWallClockToUnix } from '../lib/utils';
|
||||||
|
|
||||||
interface Option { uuid: string; name?: string; full_name?: string }
|
interface Option { uuid: string; name?: string; full_name?: string }
|
||||||
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
|
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
|
||||||
|
|
||||||
const toEpoch = (isoDate: string, time: string) =>
|
const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate, time);
|
||||||
Math.floor(new Date(`${isoDate}T${time || '00:00'}`).getTime() / 1000);
|
|
||||||
const addMinutes = (time: string, min: number) => {
|
const addMinutes = (time: string, min: number) => {
|
||||||
const [h, m] = time.split(':').map(Number);
|
const [h, m] = time.split(':').map(Number);
|
||||||
const t = h * 60 + m + min;
|
const t = h * 60 + m + min;
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function StatusChip({ status }: { status: string }) {
|
|||||||
|
|
||||||
function formatTime(ts?: number | null): string {
|
function formatTime(ts?: number | null): string {
|
||||||
if (!ts) return '—';
|
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 = ['ردیف', 'نام بیمار', 'شماره تماس', 'شروع', 'پایان', 'سرویس', 'پرسنل', 'وضعیت', 'عملیات'];
|
const HEAD = ['ردیف', 'نام بیمار', 'شماره تماس', 'شروع', 'پایان', 'سرویس', 'پرسنل', 'وضعیت', 'عملیات'];
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export function formatNumber(n: number): string {
|
|||||||
return new Intl.NumberFormat('fa-IR').format(n);
|
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 {
|
export function toDate(val: string | number | null | undefined): Date | null {
|
||||||
if (val == null || val === '') return null;
|
if (val == null || val === '') return null;
|
||||||
if (typeof val === 'number') return new Date(val * 1000);
|
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);
|
const d = toDate(val);
|
||||||
if (!d || isNaN(d.getTime())) return '—';
|
if (!d || isNaN(d.getTime())) return '—';
|
||||||
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||||
|
timeZone: APP_TZ,
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
}).format(d);
|
}).format(d);
|
||||||
}
|
}
|
||||||
@@ -34,16 +39,47 @@ export function formatDateTime(val: string | number | null | undefined): string
|
|||||||
const d = toDate(val);
|
const d = toDate(val);
|
||||||
if (!d || isNaN(d.getTime())) return '—';
|
if (!d || isNaN(d.getTime())) return '—';
|
||||||
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||||
|
timeZone: APP_TZ,
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
hour: '2-digit', minute: '2-digit',
|
hour: '2-digit', minute: '2-digit',
|
||||||
}).format(d);
|
}).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 {
|
export function toGregorianDate(d: Date): string {
|
||||||
const y = d.getFullYear();
|
// en-CA → قالب YYYY-MM-DD
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
return new Intl.DateTimeFormat('en-CA', {
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
timeZone: APP_TZ, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
return `${y}-${m}-${day}`;
|
}).format(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
// API stores contract dates as Unix seconds; the date input speaks Y-m-d strings.
|
// 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 { WalletChargeLink } from '../components/AppointmentActions';
|
||||||
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
||||||
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
||||||
|
import { tehranWallClockToUnix } from '../lib/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* افزودن نوبت — صفحهٔ کامل (بازسازی `CreateTurn.jsx` طرح tauri). از همان endpointهای
|
* افزودن نوبت — صفحهٔ کامل (بازسازی `CreateTurn.jsx` طرح tauri). از همان endpointهای
|
||||||
@@ -22,8 +23,7 @@ import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
|||||||
interface Option { uuid: string; name?: string; full_name?: string }
|
interface Option { uuid: string; name?: string; full_name?: string }
|
||||||
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
|
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
|
||||||
|
|
||||||
const toEpoch = (isoDate: string, time: string) =>
|
const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate, time);
|
||||||
Math.floor(new Date(`${isoDate}T${time || '00:00'}`).getTime() / 1000);
|
|
||||||
const addMinutes = (time: string, min: number) => {
|
const addMinutes = (time: string, min: number) => {
|
||||||
const [h, m] = time.split(':').map(Number);
|
const [h, m] = time.split(':').map(Number);
|
||||||
const t = h * 60 + m + min;
|
const t = h * 60 + m + min;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { toast } from 'sonner';
|
|||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import type { PaginatedResponse, ApiResponse } from '../lib/api';
|
import type { PaginatedResponse, ApiResponse } from '../lib/api';
|
||||||
import type { Appointment } from '../types';
|
import type { Appointment } from '../types';
|
||||||
import { formatDate, toGregorianDate } from '../lib/utils';
|
import { formatDate, toGregorianDate, formatTime } from '../lib/utils';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal';
|
import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal';
|
||||||
@@ -432,7 +432,7 @@ export default function AppointmentsPage() {
|
|||||||
// حالت سرویسی: اسلات ثابت وجود ندارد — برای هر شیفتِ کاری، نوبتهای رزروشده
|
// حالت سرویسی: اسلات ثابت وجود ندارد — برای هر شیفتِ کاری، نوبتهای رزروشده
|
||||||
// نمایش داده میشوند و باقیِ زمان بهصورت بازه(های) خالیِ قابلرزرو بین آنها.
|
// نمایش داده میشوند و باقیِ زمان بهصورت بازه(های) خالیِ قابلرزرو بین آنها.
|
||||||
if (serviceMode) {
|
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 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 rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
|
||||||
const booked = [...activeByStart.values()].sort((a, b) => Number(a.slot_start) - Number(b.slot_start));
|
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({
|
out.push({
|
||||||
start: slotStart,
|
start: slotStart,
|
||||||
end: slotEnd,
|
end: slotEnd,
|
||||||
start_time: s.start_time ?? new Date(slotStart * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
start_time: s.start_time ?? formatTime(slotStart),
|
||||||
end_time: s.end_time ?? new Date(slotEnd * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
end_time: s.end_time ?? formatTime(slotEnd),
|
||||||
is_available: s.is_available as boolean,
|
is_available: s.is_available as boolean,
|
||||||
appointment: activeByStart.get(slotStart) ?? null,
|
appointment: activeByStart.get(slotStart) ?? null,
|
||||||
cancelled_appointment: cancelledByStart.get(slotStart) ?? null,
|
cancelled_appointment: cancelledByStart.get(slotStart) ?? null,
|
||||||
|
|||||||
+4
-1
@@ -54,7 +54,10 @@
|
|||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"App\\": "src/"
|
"App\\": "src/"
|
||||||
}
|
},
|
||||||
|
"files": [
|
||||||
|
"config/bootstrap_tz.php"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"autoload-dev": {
|
"autoload-dev": {
|
||||||
"psr-4": {
|
"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
|
file_uploads = On
|
||||||
|
date.timezone = Asia/Tehran
|
||||||
memory_limit = 1024M
|
memory_limit = 1024M
|
||||||
upload_max_filesize = 16M
|
upload_max_filesize = 16M
|
||||||
post_max_size = 24M
|
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