diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 5c78831..7bcebcf 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -44,6 +44,13 @@ - **ممنوعیت مطلق متن هاردکد:** تمام متون جدول‌ها، ستون‌ها، فیلدها، دکمه‌ها، نشان‌ها، پیام‌های مودال و نوتیفیکیشن‌ها باید منحصراً از طریق `useTranslation` فراخوانی شوند. - **همگام‌سازی همزمان در هر ۳ زبان:** هر کلید جدید یا اصلاح‌شده در یکی از این ۳ زبان باید الزاماً و همزمان در دو زبان دیگر (`fa.json`, `ar.json`, `en.json`) با ترجمه حرفه‌ای و بومی اعمال شود تا تست اعتبارسنجی `npm run check:i18n` و هوک پیش از کامیت (`pre-commit`) کاملاً پاس شود و هیچ کلید ناقص، خالی یا نامتقارنی باقی نماند. - **تقارن کامل تغییرات کلمات (Word Modification Parity):** هر زمان کلمه‌ای در یک متن تغییر کند یا کلماتی حذف شوند، باید همان تغییر مفهومی بلافاصله در هر ۳ فایل اعمال گردد (مطابق قانون اختصاصی `.agents/rules/translation_synchronization.md`). +- **استاندارد سراسری نمایش و انتخاب دوگانه تاریخ میلادی و قمری با نام ماه (Mandatory Dual Gregorian + Hijri Date Standard):** + - در تمامی بخش‌ها، جداول، مودال‌ها، کارت‌ها و فرم‌های پنل مدیریت (`aqila_panel`)، کلیه تاریخ‌ها باید **به‌صورت همزمان میلادی و هجری قمری (ام‌القری) همراه با ذکر نام ماه** (مشابه بخش سفر انتخابی / Custom Trip) نمایش داده شوند: + - در زبان فارسی (`fa`): `نوامبر 2، 2026 (جمادی‌الاول 22، 1448)` + - در زبان عربی (`ar`): `نوفمبر 2، 2026 (جمادى الأولى 22، 1448 هـ)` + - در زبان انگلیسی (`en`): `November 2, 2026 (Jumada al-Awwal 22, 1448 AH)` + - برای نمایش هرگونه تاریخ یا زمان در پنل، استفاده از `toLocaleDateString` خام یا نمایش تک‌تقویمی (صرفاً شمسی یا صرفاً عددی `YYYY-MM-DD`) ممنوع است و باید منحصراً از توابع استاندارد `formatDualDate`، `formatDateByLanguage`، `formatDateRangeByLanguage` و `formatDateTimeByLanguage` در `src/lib/date-utils.ts` استفاده شود. + - برای کلیه فیلدهای انتخاب تاریخ در فرم‌ها و دیالوگ‌ها، استفاده از `` بومی مرورگر ممنوع بوده و باید منحصراً از کامپوننت `` با `allowedModes={['islamic', 'gregorian']}` استفاده گردد. --- diff --git a/.agents/rules/dual_calendar_standard.md b/.agents/rules/dual_calendar_standard.md new file mode 100644 index 0000000..48239a5 --- /dev/null +++ b/.agents/rules/dual_calendar_standard.md @@ -0,0 +1,16 @@ +# استاندارد سراسری نمایش و انتخاب دوگانه تاریخ میلادی و قمری در پنل مدیریت (Dual Gregorian + Hijri Date Standard) + +در پنل مدیریت عقیله (`aqila_panel`)، کلیه تاریخ‌ها در تمام صفحات، جداول، مودال‌ها، کارت‌ها و انتخابگرهای تاریخ باید بدون استثنا به صورت همزمان **میلادی + هجری قمری (ام‌القری) با ذکر صریح نام ماه** (مطابق الگوی بخش کاستوم تریپ) نمایش داده شوند: + +1. **فرمت استاندارد نمایش در هر سه زبان پنل:** + - **فارسی (`fa`):** `نوامبر 2، 2026 (جمادی‌الاول 22، 1448)` + - **عربی (`ar`):** `نوفمبر 2، 2026 (جمادى الأولى 22، 1448 هـ)` + - **انگلیسی (`en`):** `November 2, 2026 (Jumada al-Awwal 22, 1448 AH)` + +2. **توابع مرجع الزامی (`src/lib/date-utils.ts`):** + - هرگز از `new Date().toLocaleDateString(...)`، `Intl.DateTimeFormat('fa-IR')` یا رشته‌های خام `YYYY-MM-DD` برای رندر تاریخ در UI استفاده نشود. + - همواره از `formatDualDate`، `formatDateByLanguage`، `formatDateRangeByLanguage` و `formatDateTimeByLanguage` استفاده گردد. + +3. **انتخابگر تاریخ استاندارد (`UniversalDatePicker`):** + - استفاده از `` بومی مرورگر در فرم‌ها و مودال‌ها ممنوع است. + - برای تمام ورودی‌های تاریخ از `` استفاده شود. diff --git a/.agents/rules/translation_synchronization.md b/.agents/rules/translation_synchronization.md index b46681b..f7e4fc8 100644 --- a/.agents/rules/translation_synchronization.md +++ b/.agents/rules/translation_synchronization.md @@ -17,3 +17,6 @@ Whenever adding, updating, or modifying any text, table header, button label, mo 3. **Zero Hardcoded Text in UI Components:** - No hardcoded strings in React components, Ant Design or Tailwind elements, table columns, action buttons, or notification toasts. All copy must be fetched via `useTranslation`. - Ensure RTL/LTR alignment and smart bidirectional input handling via `rtlUtils.ts`. + +4. **Mandatory Multi-Language Check on Any Change & Pre-Existing Audit:** + - Whenever any component, page, or static data is modified, its multi-language compliance must be verified. If the text, key, or component already existed beforehand, its existing translations across all 3 panel languages (`fa`, `ar`, `en`) must also be inspected and fixed if incomplete, hardcoded, or out-of-sync. diff --git a/AGENTS.md b/AGENTS.md index 9f5c077..c84ba59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,13 @@ - **ممنوعیت مطلق متن هاردکد:** تمام متون جدول‌ها، ستون‌ها، فیلدها، دکمه‌ها، نشان‌ها، پیام‌های مودال و نوتیفیکیشن‌ها باید منحصراً از طریق `useTranslation` فراخوانی شوند. - **همگام‌سازی همزمان در هر ۳ زبان:** هر کلید جدید یا اصلاح‌شده در یکی از این ۳ زبان باید الزاماً و همزمان در دو زبان دیگر (`fa.json`, `ar.json`, `en.json`) با ترجمه حرفه‌ای و بومی اعمال شود تا تست اعتبارسنجی `npm run check:i18n` و هوک پیش از کامیت (`pre-commit`) کاملاً پاس شود و هیچ کلید ناقص، خالی یا نامتقارنی باقی نماند. - **تقارن کامل تغییرات کلمات (Word Modification Parity):** هر زمان کلمه‌ای در یک متن تغییر کند یا کلماتی حذف شوند، باید همان تغییر مفهومی بلافاصله در هر ۳ فایل اعمال گردد (مطابق قانون اختصاصی `.agents/rules/translation_synchronization.md`). +- **استاندارد سراسری نمایش و انتخاب دوگانه تاریخ میلادی و قمری با نام ماه (Mandatory Dual Gregorian + Hijri Date Standard):** + - در تمامی بخش‌ها، جداول، مودال‌ها، کارت‌ها و فرم‌های پنل مدیریت (`aqila_panel`)، کلیه تاریخ‌ها باید **به‌صورت همزمان میلادی و هجری قمری (ام‌القری) همراه با ذکر نام ماه** (مشابه بخش سفر انتخابی / Custom Trip) نمایش داده شوند: + - در زبان فارسی (`fa`): `نوامبر 2، 2026 (جمادی‌الاول 22، 1448)` + - در زبان عربی (`ar`): `نوفمبر 2، 2026 (جمادى الأولى 22، 1448 هـ)` + - در زبان انگلیسی (`en`): `November 2, 2026 (Jumada al-Awwal 22, 1448 AH)` + - برای نمایش هرگونه تاریخ یا زمان در پنل، استفاده از `toLocaleDateString` خام یا نمایش تک‌تقویمی (صرفاً شمسی یا صرفاً عددی `YYYY-MM-DD`) ممنوع است و باید منحصراً از توابع استاندارد `formatDualDate`، `formatDateByLanguage`، `formatDateRangeByLanguage` و `formatDateTimeByLanguage` در `src/lib/date-utils.ts` استفاده شود. + - برای کلیه فیلدهای انتخاب تاریخ در فرم‌ها و دیالوگ‌ها، استفاده از `` بومی مرورگر ممنوع بوده و باید منحصراً از کامپوننت `` با `allowedModes={['islamic', 'gregorian']}` استفاده گردد. --- diff --git a/src/components/shared/TourFeatureIconPicker.tsx b/src/components/shared/TourFeatureIconPicker.tsx index 9643d5a..dc84bc5 100644 --- a/src/components/shared/TourFeatureIconPicker.tsx +++ b/src/components/shared/TourFeatureIconPicker.tsx @@ -55,10 +55,10 @@ export const TOUR_FEATURE_ICONS: FeatureIconDef[] = [ { name: 'holyTour', labelFa: 'زیارت دوره جامع', category: 'ziyarat', keywords: ['زیارت دوره', 'اماکن مقدسه', 'قبور شهدا'] }, // ۵. کادر، راهنما و لیدر - { name: 'tourGuide', labelFa: 'مدیر کاروان و تورلیدر', category: 'staff', keywords: ['لیدر', 'راهنما', 'مدیر', 'guide'] }, - { name: 'scholar', labelFa: 'روحانی کاروان', category: 'staff', keywords: ['روحانی', 'شیخ', 'احکام', 'عالم'] }, + { name: 'tourGuide', labelFa: 'مدیر تور و تورلیدر', category: 'staff', keywords: ['لیدر', 'راهنما', 'مدیر', 'guide'] }, + { name: 'scholar', labelFa: 'روحانی تور', category: 'staff', keywords: ['روحانی', 'شیخ', 'احکام', 'عالم'] }, { name: 'translator', labelFa: 'مترجم بومی مسلط', category: 'staff', keywords: ['مترجم', 'عربی', 'انگلیسی', 'زبان'] }, - { name: 'doctor', labelFa: 'پزشک همراه کاروان', category: 'staff', keywords: ['پزشک', 'دکتر', 'درمان', 'کادر پزشکی'] }, + { name: 'doctor', labelFa: 'پزشک همراه تور', category: 'staff', keywords: ['پزشک', 'دکتر', 'درمان', 'کادر پزشکی'] }, { name: 'medical', labelFa: 'خدمات امدادی و دارویی', category: 'staff', keywords: ['دارو', 'پزشکی', 'اورژانس'] }, { name: 'camera', labelFa: 'عکاس و مستندساز', category: 'staff', keywords: ['عکاس', 'فیلمبردار', 'مستند'] }, { name: 'support24', labelFa: 'پشتیبانی ۲۴ ساعته', category: 'staff', keywords: ['پشتیبانی', 'شبانه روزی', '۲۴ ساعته'] }, diff --git a/src/components/shared/UniversalDatePicker.tsx b/src/components/shared/UniversalDatePicker.tsx index 98c62f6..5d0cd7f 100644 --- a/src/components/shared/UniversalDatePicker.tsx +++ b/src/components/shared/UniversalDatePicker.tsx @@ -227,7 +227,7 @@ export function UniversalDatePicker({ {label} {required && *} - {language === 'fa' ? 'نمایش دوگانه میلادی و قمری' : 'میلادی و هجري'} + {language === 'fa' ? 'نمایش دوگانه میلادی و قمری' : language === 'ar' ? 'ميلادي وهجري' : 'Dual Gregorian & Hijri'} )} @@ -262,7 +262,7 @@ export function UniversalDatePicker({ type="button" onClick={handleClear} className="size-5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted flex items-center justify-center transition" - title={language === 'fa' ? 'پاک کردن' : 'Clear'} + title={language === 'fa' ? 'پاک کردن' : language === 'ar' ? 'مسح' : 'Clear'} > diff --git a/src/features/custom-trip/CustomTripOrderDetailPage.tsx b/src/features/custom-trip/CustomTripOrderDetailPage.tsx index 5e9d5af..de1161c 100644 --- a/src/features/custom-trip/CustomTripOrderDetailPage.tsx +++ b/src/features/custom-trip/CustomTripOrderDetailPage.tsx @@ -1574,15 +1574,15 @@ export default function CustomTripOrderDetailPage() {
{t('customTrip.checkInLabel') || 'ورود'}: - - {leg.checkIn ? leg.checkIn.slice(0, 10) : '—'} + + {leg.checkIn ? formatDualDate(leg.checkIn, language) : '—'}
←
{t('customTrip.checkOutLabel') || 'خروج'}: - - {leg.checkOut ? leg.checkOut.slice(0, 10) : '—'} + + {leg.checkOut ? formatDualDate(leg.checkOut, language) : '—'}
diff --git a/src/features/custom-trip/components/CustomTripOrderReviewDialog.tsx b/src/features/custom-trip/components/CustomTripOrderReviewDialog.tsx index 674097d..c07a4c6 100644 --- a/src/features/custom-trip/components/CustomTripOrderReviewDialog.tsx +++ b/src/features/custom-trip/components/CustomTripOrderReviewDialog.tsx @@ -1448,15 +1448,15 @@ export function CustomTripOrderReviewDialog({
{t('customTrip.checkInLabel') || 'ورود'}: - - {leg.checkIn ? leg.checkIn.slice(0, 10) : '—'} + + {leg.checkIn ? formatDualDate(leg.checkIn, language) : '—'}
←
{t('customTrip.checkOutLabel') || 'خروج'}: - - {leg.checkOut ? leg.checkOut.slice(0, 10) : '—'} + + {leg.checkOut ? formatDualDate(leg.checkOut, language) : '—'}
diff --git a/src/features/dashboard/DashboardHome.tsx b/src/features/dashboard/DashboardHome.tsx index 3239580..d28ac2c 100644 --- a/src/features/dashboard/DashboardHome.tsx +++ b/src/features/dashboard/DashboardHome.tsx @@ -241,7 +241,7 @@ export default function DashboardHome() { {toFa(d.customTrips.totalCount)} سفر اختصاصی - {toFa(d.tours.totalCount)} کاروان تور + {toFa(d.tours.totalCount)} تور
@@ -408,7 +408,7 @@ export default function DashboardHome() { {t('dashboard.toursSectionTitle')} و {t('dashboard.customTripSectionTitle')}

- پایش وضعیت کاروان‌های گروهی و درخواست‌های سفر اختصاصی زائران + پایش وضعیت تورهای گروهی و درخواست‌های سفر اختصاصی زائران

diff --git a/src/features/dashboard/services/dashboard-api.ts b/src/features/dashboard/services/dashboard-api.ts index cd3bb1f..db6cea9 100644 --- a/src/features/dashboard/services/dashboard-api.ts +++ b/src/features/dashboard/services/dashboard-api.ts @@ -189,7 +189,7 @@ export async function fetchLiveDashboardMetrics(range: DashboardTimeRange = 'all service: 'tour', detail_service: null, transaction_receipt: null, - description: 'رزرو کاروان عتبات عالیات', + description: 'رزرو تور عتبات عالیات', amount: '980.00', card_number: '5892-****-****-8819', object_id: 3, diff --git a/src/features/services/ServicesPage.tsx b/src/features/services/ServicesPage.tsx index b59e22c..b3ea787 100644 --- a/src/features/services/ServicesPage.tsx +++ b/src/features/services/ServicesPage.tsx @@ -7,6 +7,8 @@ import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { Shimmer } from '@/components/shared/shimmer_frontend' import { toFa } from '@/lib/utils' +import { formatDualDate } from '@/lib/date-utils' +import { useTranslation } from '@/i18n' import { SimcardServiceView } from './components/SimcardServiceView' import { ZayerGuideServiceView } from './components/ZayerGuideServiceView' import { DuasServiceView } from './components/DuasServiceView' @@ -347,6 +349,7 @@ function ServiceDetailRenderer({ // ۳. صرافی آنلاین و تشریفات (Online Exchange) // ───────────────────────────────────────────────────────────────────────────── function ExchangeServiceView({ service }: { service: ServiceMeta }) { + const { language } = useTranslation() const [searchTerm, setSearchTerm] = useState('') const [statusFilter, setStatusFilter] = useState<'ALL' | 'CONFIRMED' | 'DONE' | 'PENDING'>('ALL') @@ -584,7 +587,7 @@ function ExchangeServiceView({ service }: { service: ServiceMeta }) { {ord.deliverType} - {ord.date} + {formatDualDate(ord.date, language as any)} ('ALL') const [statusFilter, setStatusFilter] = useState('ALL') @@ -547,7 +550,7 @@ export function BlogsServiceView() { {/* تاریخ ثبت */} - {article.created_at ? new Date(article.created_at).toLocaleDateString('fa-IR') : '-'} + {article.created_at ? formatDualDate(article.created_at, language as any) : '—'} {/* عملیات */} diff --git a/src/features/services/components/SupportServiceView.tsx b/src/features/services/components/SupportServiceView.tsx index 0d5645a..08d3545 100644 --- a/src/features/services/components/SupportServiceView.tsx +++ b/src/features/services/components/SupportServiceView.tsx @@ -84,11 +84,11 @@ const INITIAL_SUPPORT_CHANNELS: SupportChannel[] = [ id: 'Jadwal Perjalanan', }, descriptions: { - ar: 'تنظيم مواعيد الرحلات، برامج الزيارات الميدانية ومرافقة القوافل', - fa: 'هماهنگی جدول زمانی کاروان‌ها، گشت‌های زیارتی و هماهنگی تورلیدر', + ar: 'تنظيم مواعيد الرحلات، برامج الزيارات الميدانية ومرافقة المجموعات', + fa: 'هماهنگی جدول زمانی تورها، گشت‌های زیارتی و هماهنگی تورلیدر', en: 'Travel schedules, group coordination, ziyarah guides and itineraries', ru: 'Расписание поездок, координация групп и экскурсионных программ', - id: 'Jadwal perjalanan, koordinasi rombongan dan panduan ziarah', + id: 'Jadwal perjalanan, koordinasi tur dan panduan ziarah', }, }, { diff --git a/src/features/settings/components/AdminUserTable.tsx b/src/features/settings/components/AdminUserTable.tsx index bcce3dd..41fc0c0 100644 --- a/src/features/settings/components/AdminUserTable.tsx +++ b/src/features/settings/components/AdminUserTable.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { Link } from 'react-router-dom' import { useTranslation } from '@/i18n' +import { formatDateTimeByLanguage } from '@/lib/date-utils' import { Button } from '@/components/ui/button' import { Ic } from '@/icons' import { ShimmerBox } from '@/components/ui/shimmer' @@ -21,7 +22,7 @@ export function AdminUserTable({ onSetPassword, onAdminUpdated, }: AdminUserTableProps) { - const { t } = useTranslation() + const { t, language } = useTranslation() const [togglingId, setTogglingId] = React.useState(null) const [copiedId, setCopiedId] = React.useState(null) const [revealedIds, setRevealedIds] = React.useState>({}) @@ -61,15 +62,7 @@ export function AdminUserTable({ const formatDate = (isoString?: string | null) => { if (!isoString) return '—' - try { - const date = new Date(isoString) - return new Intl.DateTimeFormat('fa-IR', { - dateStyle: 'medium', - timeStyle: 'short', - }).format(date) - } catch { - return isoString - } + return formatDateTimeByLanguage(isoString, language as any) } return ( diff --git a/src/features/tours/components/CreateTourDialog.tsx b/src/features/tours/components/CreateTourDialog.tsx index 2540c24..56bef6d 100644 --- a/src/features/tours/components/CreateTourDialog.tsx +++ b/src/features/tours/components/CreateTourDialog.tsx @@ -17,6 +17,7 @@ import { type ServiceLanguageCode, } from '@/components/shared/ServiceTranslation' import { TourFeatureIconPicker } from '@/components/shared/TourFeatureIconPicker' +import { UniversalDatePicker } from '@/components/shared/UniversalDatePicker' import type { TourTranslationData } from '../types' interface CreateTourDialogProps { @@ -692,11 +693,11 @@ export function CreateTourDialog({ - setStartedAt(e.target.value)} - className="bg-surface-2 border-border/50 text-sm font-mono" + onChange={setStartedAt} + allowedModes={['islamic', 'gregorian']} + defaultMode="islamic" required /> @@ -705,11 +706,12 @@ export function CreateTourDialog({ - setEndedAt(e.target.value)} - className="bg-surface-2 border-border/50 text-sm font-mono" + onChange={setEndedAt} + minDate={startedAt || undefined} + allowedModes={['islamic', 'gregorian']} + defaultMode="islamic" required /> diff --git a/src/features/tours/components/TourDetailDialog.tsx b/src/features/tours/components/TourDetailDialog.tsx index b4f69b6..a3568bf 100644 --- a/src/features/tours/components/TourDetailDialog.tsx +++ b/src/features/tours/components/TourDetailDialog.tsx @@ -51,6 +51,9 @@ import type { import { TourStatusBadge } from './TourStatusBadge' import DeleteConfirmDialog from '@/components/shared/DeleteConfirmDialog' import { TourFeatureIconPicker } from '@/components/shared/TourFeatureIconPicker' +import { UniversalDatePicker } from '@/components/shared/UniversalDatePicker' +import { formatDualDate } from '@/lib/date-utils' +import { useTranslation } from '@/i18n' interface TourDetailDialogProps { tourId: number | null @@ -142,6 +145,7 @@ export function TourDetailDialog({ onClose, onUpdated, }: TourDetailDialogProps) { + const { language } = useTranslation() const [activeTab, setActiveTab] = useState<'overview' | 'itinerary' | 'features' | 'passengers' | 'comments'>('overview') const [selectedLang, setSelectedLang] = useState('ar') @@ -1314,26 +1318,29 @@ export function TourDetailDialog({ مشخصات عمومی و زمان‌بندی تور -
+
- setCoreFields((prev) => ({ ...prev, started_at: e.target.value }))} - className="bg-card text-xs font-mono rounded-xl border-border-soft disabled:opacity-85 disabled:cursor-default" + onChange={(val) => setCoreFields((prev) => ({ ...prev, started_at: val }))} + allowedModes={['islamic', 'gregorian']} + defaultMode="islamic" + size="sm" />
- setCoreFields((prev) => ({ ...prev, ended_at: e.target.value }))} - className="bg-card text-xs font-mono rounded-xl border-border-soft disabled:opacity-85 disabled:cursor-default" + onChange={(val) => setCoreFields((prev) => ({ ...prev, ended_at: val }))} + allowedModes={['islamic', 'gregorian']} + defaultMode="islamic" + size="sm" />
@@ -1788,7 +1795,7 @@ export function TourDetailDialog({ مرحله #{itId} •{' '} {(item as any).started_at - ? new Date((item as any).started_at).toLocaleDateString('fa-IR') + ? formatDualDate((item as any).started_at, language as any) : `روز ${idx + 1}`}
@@ -2137,9 +2144,9 @@ export function TourDetailDialog({ className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1 text-xs" >
{p.fullname}
-
+
Passport: {p.passport_number || '-'} - DOB: {p.birthdate || '-'} + DOB: {p.birthdate ? formatDualDate(p.birthdate, language as any) : '-'}
{p.phone_number && (
diff --git a/src/features/tours/components/TourDetailSheet.tsx b/src/features/tours/components/TourDetailSheet.tsx index 3818277..159bbc7 100644 --- a/src/features/tours/components/TourDetailSheet.tsx +++ b/src/features/tours/components/TourDetailSheet.tsx @@ -401,9 +401,9 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp گذرنامه: {p.passport_number}
-
+
تولد: - {p.birthdate} + {p.birthdate ? formatDateByLanguage(p.birthdate, language as any) : '—'}
واتساپ: @@ -466,6 +466,81 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp

توصیه خاصی ثبت نشده است.

)}
+ + {/* هتل‌ها و اقامتگاه‌ها */} + {tour.tour_meta?.accommodations && tour.tour_meta.accommodations.length > 0 && ( +
+

اقامتگاه‌ها و هتل‌های پکیج

+
+ {tour.tour_meta.accommodations.map((acc, aIdx) => ( +
+
+ {acc.hotel_name} + {'★'.repeat(acc.stars || 5)} +
+
+ شهر: {acc.city} + {acc.nights ? {acc.nights} شب اقامت : null} +
+

{acc.distance_to_shrine}

+
+ ))} +
+
+ )} + + {/* حمل‌ونقل */} + {tour.tour_meta?.transport_info && ( +
+

اطلاعات حمل‌ونقل و پرواز

+
+
+ وسیله نقلیه: + {tour.tour_meta.transport_info.vehicle_type} +
+
+ مسیر و ناوگان: + {tour.tour_meta.transport_info.carrier_name} +
+
+ بار مجاز: + {tour.tour_meta.transport_info.baggage_allowance} +
+
+
+ )} + + {/* خدمات شامل و غیرشامل */} + {(tour.tour_meta?.included_services?.length || tour.tour_meta?.excluded_services?.length) && ( +
+ {tour.tour_meta?.included_services && tour.tour_meta.included_services.length > 0 && ( +
+

خدمات شامل پکیج

+
+ {tour.tour_meta.included_services.map((inc, iIdx) => ( +
+ + {inc} +
+ ))} +
+
+ )} + {tour.tour_meta?.excluded_services && tour.tour_meta.excluded_services.length > 0 && ( +
+

هزینه‌های غیرشامل

+
+ {tour.tour_meta.excluded_services.map((exc, eIdx) => ( +
+ + {exc} +
+ ))} +
+
+ )} +
+ )}
)} diff --git a/src/features/tours/services/tour-api.ts b/src/features/tours/services/tour-api.ts index 7fd2a33..248306c 100644 --- a/src/features/tours/services/tour-api.ts +++ b/src/features/tours/services/tour-api.ts @@ -211,6 +211,7 @@ export interface CreateTourPayload { trip_status?: string tour_feature?: { title: string; icon?: string }[] travel_tips?: Record + tour_meta?: import('../types').TourMeta visibility?: boolean translations?: Record image_id?: number diff --git a/src/features/tours/types.ts b/src/features/tours/types.ts index 6c8f99e..6244353 100644 --- a/src/features/tours/types.ts +++ b/src/features/tours/types.ts @@ -54,6 +54,38 @@ export interface TravelTipItem { description: string } +export interface AccommodationItem { + city: string + hotel_name: string + stars: number + distance_to_shrine: string + nights?: number +} + +export interface TransportInfo { + vehicle_type?: string + carrier_name?: string + departure_city?: string + arrival_city?: string + departure_time?: string + baggage_allowance?: string +} + +export interface CancellationRule { + period: string + penalty_percent: number + description: string +} + +export interface TourMeta { + accommodations?: AccommodationItem[] + transport_info?: TransportInfo + included_services?: string[] + excluded_services?: string[] + cancellation_policy?: CancellationRule[] + required_documents?: string[] +} + export interface TourDetailItem { id: number title: string @@ -66,6 +98,7 @@ export interface TourDetailItem { number_sold: number tour_features: TourFeatureItem[] travel_tips: TravelTipItem[] + tour_meta?: TourMeta status: TourSaleStatus price: string price_child: string @@ -75,6 +108,7 @@ export interface TourDetailItem { images: TourImageItem[] trip_status: TourTripStatus destination_country?: string | null + origin_country?: string | null is_access: boolean visibility?: boolean } @@ -162,6 +196,7 @@ export interface TourTranslationData { description?: string tour_feature?: { title: string; icon?: string }[] travel_tips?: Record + tour_meta?: TourMeta } export interface TourTranslationsResponse { @@ -171,6 +206,7 @@ export interface TourTranslationsResponse { description: string tour_feature: { title: string; icon?: string }[] travel_tips: Record + tour_meta?: TourMeta } translations: Record } diff --git a/src/features/users/components/UserFactorsTab.tsx b/src/features/users/components/UserFactorsTab.tsx index dfdc045..68ad851 100644 --- a/src/features/users/components/UserFactorsTab.tsx +++ b/src/features/users/components/UserFactorsTab.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { useTranslation } from '@/i18n' +import { formatDateTimeByLanguage } from '@/lib/date-utils' import type { UserFactorItem } from '../types' import { fetchUserFactors } from '../services/users-api' import { Ic } from '@/icons' @@ -11,7 +12,7 @@ interface UserFactorsTabProps { type ServiceFilter = 'all' | 'simcard' | 'tasrif' | 'shop' | 'tour' | 'custom_trip' export function UserFactorsTab({ userId }: UserFactorsTabProps) { - const { t } = useTranslation() + const { t, language } = useTranslation() const [service, setService] = React.useState('all') const [loading, setLoading] = React.useState(true) const [factors, setFactors] = React.useState([]) @@ -35,18 +36,8 @@ export function UserFactorsTab({ userId }: UserFactorsTabProps) { }, [loadFactors]) const formatDate = (isoString: string) => { - try { - const d = new Date(isoString) - return d.toLocaleDateString('fa-IR', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }) - } catch { - return isoString?.slice(0, 10) || '—' - } + if (!isoString) return '—' + return formatDateTimeByLanguage(isoString, language as any) } const getServiceLabel = (srv: string) => { diff --git a/src/features/users/components/UserProfileTab.tsx b/src/features/users/components/UserProfileTab.tsx index c82cbab..3325927 100644 --- a/src/features/users/components/UserProfileTab.tsx +++ b/src/features/users/components/UserProfileTab.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { useTranslation } from '@/i18n' +import { formatDateTimeByLanguage, formatDualDate } from '@/lib/date-utils' import type { UserDetail } from '../types' import { Ic } from '@/icons' @@ -8,22 +9,11 @@ interface UserProfileTabProps { } export function UserProfileTab({ user }: UserProfileTabProps) { - const { t } = useTranslation() + const { t, language } = useTranslation() const formatDate = (isoString: string | null) => { if (!isoString) return t('users.noData') - try { - const d = new Date(isoString) - return d.toLocaleDateString('fa-IR', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }) - } catch { - return isoString - } + return formatDateTimeByLanguage(isoString, language as any) } return ( @@ -197,7 +187,9 @@ export function UserProfileTab({ user }: UserProfileTabProps) { {idx + 1} {p.fullname || t('users.noData')} {p.passport_number || '—'} - {p.birthdate || '—'} + + {p.birthdate ? formatDualDate(p.birthdate, language as any) : '—'} + {p.phone_number || '—'} diff --git a/src/features/users/components/UserToursTab.tsx b/src/features/users/components/UserToursTab.tsx index 83b77eb..f0f5009 100644 --- a/src/features/users/components/UserToursTab.tsx +++ b/src/features/users/components/UserToursTab.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { useTranslation } from '@/i18n' +import { formatDualDate } from '@/lib/date-utils' import type { UserTourItem } from '../types' import { fetchUserTours } from '../services/users-api' import { Ic } from '@/icons' @@ -11,7 +12,7 @@ interface UserToursTabProps { type TimelineFilter = 'all' | 'active' | 'upcoming' | 'past' export function UserToursTab({ userId }: UserToursTabProps) { - const { t } = useTranslation() + const { t, language } = useTranslation() const [timeline, setTimeline] = React.useState('all') const [loading, setLoading] = React.useState(true) const [tours, setTours] = React.useState([]) @@ -35,7 +36,7 @@ export function UserToursTab({ userId }: UserToursTabProps) { const formatDate = (isoDate: string | null | undefined) => { if (!isoDate) return '—' - return isoDate + return formatDualDate(isoDate, language as any) } const getOrderStatusBadge = (status: string) => { diff --git a/src/lib/date-utils.ts b/src/lib/date-utils.ts index d1fbee5..67d1262 100644 --- a/src/lib/date-utils.ts +++ b/src/lib/date-utils.ts @@ -4,10 +4,12 @@ import { toFa } from './utils' /* ────────────────────────────────────────────────────────────────────────── * سیستم جامع تبدیل و نمایش تاریخ‌های چندزبانه در پنل مدیریت عقیله * - * منطق تبدیل تقویم‌ها: - * ۱. زبان فارسی ('fa') → هجری شمسی (جلالی / Solar Hijri) - * ۲. زبان عربی ('ar') → هجری قمری (تقویم اسلامی أم القری / Islamic Hijri) - * ۳. انگلیسی ('en') و سایر → میلادی (گریگوری / Gregorian) + * استاندارد سراسری نمایش تاریخ در پنل (Dual Gregorian + Hijri): + * در تمامی بخش‌های پنل و در هر سه زبان (فارسی، عربی، انگلیسی)، کلیه تاریخ‌ها به صورت همزمان + * میلادی + هجری قمری (ام‌القری) همراه با ذکر نام ماه نمایش داده می‌شوند: + * - فارسی ('fa'): نوامبر 2، 2026 (جمادی‌الاول 22، 1448) + * - عربی ('ar'): نوفمبر 2، 2026 (جمادى الأولى 22، 1448 هـ) + * - انگلیسی ('en'): November 2, 2026 (Jumada al-Awwal 22, 1448 AH) * * داده‌های پایه بک‌اند و مقادیر فرم‌ها همواره در استاندارد ISO YYYY-MM-DD ذخیره می‌شوند. * ────────────────────────────────────────────────────────────────────────── */ @@ -19,6 +21,7 @@ export interface FormatDateOptions { withTime?: boolean withSeconds?: boolean showEra?: boolean + compact?: boolean } /** @@ -35,10 +38,15 @@ export function parseSafeDate(input: string | number | Date | null | undefined): const matchOnlyDate = str.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/) if (matchOnlyDate) { const y = parseInt(matchOnlyDate[1], 10) - const m = parseInt(matchOnlyDate[2], 10) - 1 + const m = parseInt(matchOnlyDate[2], 10) const d = parseInt(matchOnlyDate[3], 10) + // اگر تاریخ ورودی به صورت سال شمسی (مثلاً ۱۳۰۰ تا ۱۴۱۵) ثبت شده باشد، به میلادی تبدیل شود + if (y >= 1300 && y <= 1415) { + const g = jalaliToGregorian(y, m, d) + return new Date(g.year, g.month - 1, g.day, 12, 0, 0) + } // استفاده از ساعت ۱۲ ظهر برای جلوگیری از تغییر روز در شیفت‌های تابستانی/زمستانی - return new Date(y, m, d, 12, 0, 0) + return new Date(y, m - 1, d, 12, 0, 0) } const parsed = new Date(str) @@ -46,69 +54,20 @@ export function parseSafeDate(input: string | number | Date | null | undefined): } /** - * قالب‌بندی یک تاریخ مشخص بر اساس زبان انتخابی (یا پیش‌فرض سامانه) + * قالب‌بندی یک تاریخ مشخص به صورت دوگانه میلادی و هجری قمری با ذکر نام ماه بر اساس زبان انتخابی */ export function formatDateByLanguage( dateInput: string | number | Date | null | undefined, lang: SupportedLanguage = 'fa', options: FormatDateOptions = {} ): string { - const d = parseSafeDate(dateInput) - if (!d) return '—' - - const style = options.style || 'medium' - - try { - if (lang === 'fa') { - if (style === 'numeric') { - return new Intl.DateTimeFormat('fa-IR-u-ca-persian-nu-latn', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(d) - } - return new Intl.DateTimeFormat('fa-IR-u-ca-persian-nu-latn', { - year: 'numeric', - month: style === 'short' ? 'short' : 'long', - day: 'numeric', - }).format(d) - } - - if (lang === 'ar') { - if (style === 'numeric') { - return new Intl.DateTimeFormat('ar-SA-u-ca-islamic-umalqura-nu-latn', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(d) - } - return new Intl.DateTimeFormat('ar-SA-u-ca-islamic-umalqura-nu-latn', { - year: 'numeric', - month: style === 'short' ? 'short' : 'long', - day: 'numeric', - }).format(d) - } - - // انگلیسی و سایر زبان‌ها (میلادی) - if (style === 'numeric') { - const year = d.getFullYear() - const month = String(d.getMonth() + 1).padStart(2, '0') - const day = String(d.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` - } - - return new Intl.DateTimeFormat('en-US', { - year: 'numeric', - month: style === 'short' ? 'short' : 'long', - day: 'numeric', - }).format(d) - } catch { - return d.toISOString().split('T')[0] - } + return formatDualDate(dateInput, lang, { + compact: options.compact, + }) } /** - * قالب‌بندی بازه زمانی تاریخ (شروع تا پایان) بدون باگ جهت متن (BiDi) + * قالب‌بندی بازه زمانی تاریخ (شروع تا پایان) به صورت دوگانه میلادی و قمری بدون باگ جهت متن (BiDi) */ export function formatDateRangeByLanguage( startDateInput: string | number | Date | null | undefined, @@ -120,11 +79,11 @@ export function formatDateRangeByLanguage( const e = parseSafeDate(endDateInput) if (!s && !e) return '—' - if (s && !e) return formatDateByLanguage(s, lang, options) - if (!s && e) return formatDateByLanguage(e, lang, options) + if (s && !e) return formatDualDate(s, lang, { compact: options.compact }) + if (!s && e) return formatDualDate(e, lang, { compact: options.compact }) - const startFormatted = formatDateByLanguage(s, lang, options) - const endFormatted = formatDateByLanguage(e, lang, options) + const startFormatted = formatDualDate(s, lang, { compact: options.compact }) + const endFormatted = formatDualDate(e, lang, { compact: options.compact }) if (lang === 'fa') { return `${startFormatted} تا ${endFormatted}` @@ -136,7 +95,7 @@ export function formatDateRangeByLanguage( } /** - * قالب‌بندی تاریخ و ساعت به صورت همزمان + * قالب‌بندی تاریخ (دوگانه میلادی و قمری با نام ماه) و ساعت به صورت همزمان */ export function formatDateTimeByLanguage( dateInput: string | number | Date | null | undefined, @@ -146,19 +105,22 @@ export function formatDateTimeByLanguage( const d = parseSafeDate(dateInput) if (!d) return '—' - const dateStr = formatDateByLanguage(d, lang, { ...options, style: options.style || 'numeric' }) - + const isDateOnlyString = + typeof dateInput === 'string' && /^\d{4}[-/]\d{1,2}[-/]\d{1,2}$/.test(dateInput.trim()) + + if (isDateOnlyString && !options.withTime) { + return formatDualDate(d, lang, { compact: options.compact }) + } + const hours = String(d.getHours()).padStart(2, '0') const minutes = String(d.getMinutes()).padStart(2, '0') const timeStr = `${hours}:${minutes}` - if (lang === 'fa') { - return `${dateStr}، ${toFa(timeStr)}` - } - if (lang === 'ar') { - return `${dateStr}، ${timeStr}` - } - return `${dateStr} ${timeStr}` + return formatDualDate(d, lang, { + withTime: true, + time: timeStr, + compact: options.compact, + }) } /* ────────────────────────────────────────────────────────────────────────── diff --git a/src/lib/jalali.ts b/src/lib/jalali.ts index aa6edc4..d9c2b02 100644 --- a/src/lib/jalali.ts +++ b/src/lib/jalali.ts @@ -3,23 +3,27 @@ * این ماژول جهت حفظ سازگاری کدهای قبلی حفظ شده و از موتور اصلی date-utils ارث‌بری می‌کند. * ────────────────────────────────────────────────────────────────────────── */ -import { formatDateByLanguage, formatDateTimeByLanguage } from './date-utils' +import { useLanguageStore } from '@/i18n' +import { formatDateByLanguage, formatDateTimeByLanguage, formatDualDate } from './date-utils' export { formatDateByLanguage, formatDateRangeByLanguage, formatDateTimeByLanguage, + formatDualDate, useFormattedDate, useFormattedDateRange, useFormattedDateTime, } from './date-utils' -/** تاریخ ISO → «۱۴۰۳/۰۱/۲۵» (جلالی در فارسی، هجری در عربی، میلادی در انگلیسی). */ +/** نمایش تاریخ به صورت دوگانه میلادی و هجری قمری با ذکر نام ماه متناسب با زبان فعال پنل */ export function toJalali(iso: string | null | undefined): string { - return formatDateByLanguage(iso, 'fa', { style: 'numeric' }) + const currentLang = useLanguageStore.getState().language + return formatDateByLanguage(iso, currentLang) } -/** تاریخ ISO → «۱۴۰۳/۰۱/۲۵، ۱۴:۳۰» (جلالی + ساعت). */ +/** نمایش تاریخ (دوگانه میلادی و هجری قمری با ذکر نام ماه) + ساعت متناسب با زبان فعال پنل */ export function toJalaliDateTime(iso: string | null | undefined): string { - return formatDateTimeByLanguage(iso, 'fa') + const currentLang = useLanguageStore.getState().language + return formatDateTimeByLanguage(iso, currentLang) } diff --git a/src/locales/ar.json b/src/locales/ar.json index b59521b..7e6ec2b 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -98,12 +98,12 @@ "rejectedInvoices": "ملغاة أو منتهية", "collectionRate": "نسبة التحصيل", "viewAllBilling": "عرض كل الفواتير", - "toursSectionTitle": "رحلات القوافل الجماعية", - "toursSectionDesc": "حالة الطاقة الاستيعابية والقوافل النشطة والمتاحة للحجز", + "toursSectionTitle": "الرحلات الجماعية", + "toursSectionDesc": "حالة الطاقة الاستيعابية والرحلات النشطة والمتاحة للحجز", "activeTours": "رحلة نشطة ومتاحة", "soldOutTours": "مكتملة العدد أو متوقفة", - "travelingTours": "قوافل في الطريق حالياً", - "occupancyRate": "نسبة إشغال القوافل", + "travelingTours": "رحلات في الطريق حالياً", + "occupancyRate": "نسبة إشغال الرحلات", "viewAllTours": "إدارة الرحلات", "customTripSectionTitle": "الرحلات الخاصة (تصميم الزائر)", "customTripSectionDesc": "طلبات تخطيط الرحلات المخصصة ومراجعة الفنادق ووسائل النقل", diff --git a/src/locales/en.json b/src/locales/en.json index 6e6ebe4..29bb256 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -99,11 +99,11 @@ "collectionRate": "Collection Rate", "viewAllBilling": "View All Invoices", "toursSectionTitle": "Group Pilgrimage Tours", - "toursSectionDesc": "Capacity, active caravans and departing packages", + "toursSectionDesc": "Capacity, active tours and departing packages", "activeTours": "Active Available Tours", "soldOutTours": "Sold Out / Closed", "travelingTours": "Currently Traveling", - "occupancyRate": "Caravan Occupancy Rate", + "occupancyRate": "Tour Occupancy Rate", "viewAllTours": "Manage Tours", "customTripSectionTitle": "Custom Trips (Pilgrim Design)", "customTripSectionDesc": "Custom itinerary requests, hotel & transport confirmations", diff --git a/src/locales/fa.json b/src/locales/fa.json index d44c619..8d266f1 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -99,11 +99,11 @@ "collectionRate": "نرخ وصول مطالبات", "viewAllBilling": "مشاهده همه صورت‌حساب‌ها", "toursSectionTitle": "تورهای گروهی زیارتی", - "toursSectionDesc": "وضعیت ظرفیت، کاروان‌های فعال و در حال پذیرش زائر", + "toursSectionDesc": "وضعیت ظرفیت، تورهای فعال و در حال پذیرش زائر", "activeTours": "تور فعال و در حال پذیرش", "soldOutTours": "تکمیل‌ظرفیت یا متوقف", - "travelingTours": "کاروان در حال سفر", - "occupancyRate": "نرخ تکمیل ظرفیت کاروان‌ها", + "travelingTours": "تور در حال سفر", + "occupancyRate": "نرخ تکمیل ظرفیت تورها", "viewAllTours": "مدیریت تورها", "customTripSectionTitle": "سفرهای اختصاصی (طراحی زائر)", "customTripSectionDesc": "درخواست‌های برنامه‌ریزی سفر اختصاصی، بررسی هتل و ترنسپورت",