Browse Source

feat: integrate dual calendar standard and UniversalDatePicker across tour and user views

master
parent
commit
d49ac20fa7
  1. 7
      .agents/AGENTS.md
  2. 16
      .agents/rules/dual_calendar_standard.md
  3. 3
      .agents/rules/translation_synchronization.md
  4. 7
      AGENTS.md
  5. 6
      src/components/shared/TourFeatureIconPicker.tsx
  6. 4
      src/components/shared/UniversalDatePicker.tsx
  7. 8
      src/features/custom-trip/CustomTripOrderDetailPage.tsx
  8. 8
      src/features/custom-trip/components/CustomTripOrderReviewDialog.tsx
  9. 4
      src/features/dashboard/DashboardHome.tsx
  10. 2
      src/features/dashboard/services/dashboard-api.ts
  11. 5
      src/features/services/ServicesPage.tsx
  12. 5
      src/features/services/components/BlogsServiceView.tsx
  13. 6
      src/features/services/components/SupportServiceView.tsx
  14. 13
      src/features/settings/components/AdminUserTable.tsx
  15. 18
      src/features/tours/components/CreateTourDialog.tsx
  16. 31
      src/features/tours/components/TourDetailDialog.tsx
  17. 79
      src/features/tours/components/TourDetailSheet.tsx
  18. 1
      src/features/tours/services/tour-api.ts
  19. 36
      src/features/tours/types.ts
  20. 17
      src/features/users/components/UserFactorsTab.tsx
  21. 20
      src/features/users/components/UserProfileTab.tsx
  22. 5
      src/features/users/components/UserToursTab.tsx
  23. 108
      src/lib/date-utils.ts
  24. 14
      src/lib/jalali.ts
  25. 8
      src/locales/ar.json
  26. 4
      src/locales/en.json
  27. 6
      src/locales/fa.json

7
.agents/AGENTS.md

@ -44,6 +44,13 @@
- **ممنوعیت مطلق متن هاردکد:** تمام متون جدول‌ها، ستون‌ها، فیلدها، دکمه‌ها، نشان‌ها، پیام‌های مودال و نوتیفیکیشن‌ها باید منحصراً از طریق `useTranslation` فراخوانی شوند. - **ممنوعیت مطلق متن هاردکد:** تمام متون جدول‌ها، ستون‌ها، فیلدها، دکمه‌ها، نشان‌ها، پیام‌های مودال و نوتیفیکیشن‌ها باید منحصراً از طریق `useTranslation` فراخوانی شوند.
- **همگام‌سازی همزمان در هر ۳ زبان:** هر کلید جدید یا اصلاح‌شده در یکی از این ۳ زبان باید الزاماً و همزمان در دو زبان دیگر (`fa.json`, `ar.json`, `en.json`) با ترجمه حرفه‌ای و بومی اعمال شود تا تست اعتبارسنجی `npm run check:i18n` و هوک پیش از کامیت (`pre-commit`) کاملاً پاس شود و هیچ کلید ناقص، خالی یا نامتقارنی باقی نماند. - **همگام‌سازی همزمان در هر ۳ زبان:** هر کلید جدید یا اصلاح‌شده در یکی از این ۳ زبان باید الزاماً و همزمان در دو زبان دیگر (`fa.json`, `ar.json`, `en.json`) با ترجمه حرفه‌ای و بومی اعمال شود تا تست اعتبارسنجی `npm run check:i18n` و هوک پیش از کامیت (`pre-commit`) کاملاً پاس شود و هیچ کلید ناقص، خالی یا نامتقارنی باقی نماند.
- **تقارن کامل تغییرات کلمات (Word Modification Parity):** هر زمان کلمه‌ای در یک متن تغییر کند یا کلماتی حذف شوند، باید همان تغییر مفهومی بلافاصله در هر ۳ فایل اعمال گردد (مطابق قانون اختصاصی `.agents/rules/translation_synchronization.md`). - **تقارن کامل تغییرات کلمات (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` استفاده شود.
- برای کلیه فیلدهای انتخاب تاریخ در فرم‌ها و دیالوگ‌ها، استفاده از `<input type="date">` بومی مرورگر ممنوع بوده و باید منحصراً از کامپوننت `<UniversalDatePicker>` با `allowedModes={['islamic', 'gregorian']}` استفاده گردد.
--- ---

16
.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`):**
- استفاده از `<input type="date">` بومی مرورگر در فرم‌ها و مودال‌ها ممنوع است.
- برای تمام ورودی‌های تاریخ از `<UniversalDatePicker allowedModes={['islamic', 'gregorian']} defaultMode="islamic" />` استفاده شود.

3
.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:** 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`. - 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`. - 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.

7
AGENTS.md

@ -44,6 +44,13 @@
- **ممنوعیت مطلق متن هاردکد:** تمام متون جدول‌ها، ستون‌ها، فیلدها، دکمه‌ها، نشان‌ها، پیام‌های مودال و نوتیفیکیشن‌ها باید منحصراً از طریق `useTranslation` فراخوانی شوند. - **ممنوعیت مطلق متن هاردکد:** تمام متون جدول‌ها، ستون‌ها، فیلدها، دکمه‌ها، نشان‌ها، پیام‌های مودال و نوتیفیکیشن‌ها باید منحصراً از طریق `useTranslation` فراخوانی شوند.
- **همگام‌سازی همزمان در هر ۳ زبان:** هر کلید جدید یا اصلاح‌شده در یکی از این ۳ زبان باید الزاماً و همزمان در دو زبان دیگر (`fa.json`, `ar.json`, `en.json`) با ترجمه حرفه‌ای و بومی اعمال شود تا تست اعتبارسنجی `npm run check:i18n` و هوک پیش از کامیت (`pre-commit`) کاملاً پاس شود و هیچ کلید ناقص، خالی یا نامتقارنی باقی نماند. - **همگام‌سازی همزمان در هر ۳ زبان:** هر کلید جدید یا اصلاح‌شده در یکی از این ۳ زبان باید الزاماً و همزمان در دو زبان دیگر (`fa.json`, `ar.json`, `en.json`) با ترجمه حرفه‌ای و بومی اعمال شود تا تست اعتبارسنجی `npm run check:i18n` و هوک پیش از کامیت (`pre-commit`) کاملاً پاس شود و هیچ کلید ناقص، خالی یا نامتقارنی باقی نماند.
- **تقارن کامل تغییرات کلمات (Word Modification Parity):** هر زمان کلمه‌ای در یک متن تغییر کند یا کلماتی حذف شوند، باید همان تغییر مفهومی بلافاصله در هر ۳ فایل اعمال گردد (مطابق قانون اختصاصی `.agents/rules/translation_synchronization.md`). - **تقارن کامل تغییرات کلمات (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` استفاده شود.
- برای کلیه فیلدهای انتخاب تاریخ در فرم‌ها و دیالوگ‌ها، استفاده از `<input type="date">` بومی مرورگر ممنوع بوده و باید منحصراً از کامپوننت `<UniversalDatePicker>` با `allowedModes={['islamic', 'gregorian']}` استفاده گردد.
--- ---

6
src/components/shared/TourFeatureIconPicker.tsx

@ -55,10 +55,10 @@ export const TOUR_FEATURE_ICONS: FeatureIconDef[] = [
{ name: 'holyTour', labelFa: 'زیارت دوره جامع', category: 'ziyarat', keywords: ['زیارت دوره', 'اماکن مقدسه', 'قبور شهدا'] }, { 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: 'translator', labelFa: 'مترجم بومی مسلط', category: 'staff', keywords: ['مترجم', 'عربی', 'انگلیسی', 'زبان'] },
{ name: 'doctor', labelFa: 'پزشک همراه کاروان', category: 'staff', keywords: ['پزشک', 'دکتر', 'درمان', 'کادر پزشکی'] },
{ name: 'doctor', labelFa: 'پزشک همراه تور', category: 'staff', keywords: ['پزشک', 'دکتر', 'درمان', 'کادر پزشکی'] },
{ name: 'medical', labelFa: 'خدمات امدادی و دارویی', category: 'staff', keywords: ['دارو', 'پزشکی', 'اورژانس'] }, { name: 'medical', labelFa: 'خدمات امدادی و دارویی', category: 'staff', keywords: ['دارو', 'پزشکی', 'اورژانس'] },
{ name: 'camera', labelFa: 'عکاس و مستندساز', category: 'staff', keywords: ['عکاس', 'فیلمبردار', 'مستند'] }, { name: 'camera', labelFa: 'عکاس و مستندساز', category: 'staff', keywords: ['عکاس', 'فیلمبردار', 'مستند'] },
{ name: 'support24', labelFa: 'پشتیبانی ۲۴ ساعته', category: 'staff', keywords: ['پشتیبانی', 'شبانه روزی', '۲۴ ساعته'] }, { name: 'support24', labelFa: 'پشتیبانی ۲۴ ساعته', category: 'staff', keywords: ['پشتیبانی', 'شبانه روزی', '۲۴ ساعته'] },

4
src/components/shared/UniversalDatePicker.tsx

@ -227,7 +227,7 @@ export function UniversalDatePicker({
{label} {required && <span className="text-rose-500">*</span>} {label} {required && <span className="text-rose-500">*</span>}
</span> </span>
<span className="text-[10px] text-primary font-normal"> <span className="text-[10px] text-primary font-normal">
{language === 'fa' ? 'نمایش دوگانه میلادی و قمری' : 'میلادی و هجري'}
{language === 'fa' ? 'نمایش دوگانه میلادی و قمری' : language === 'ar' ? 'ميلادي وهجري' : 'Dual Gregorian & Hijri'}
</span> </span>
</label> </label>
)} )}
@ -262,7 +262,7 @@ export function UniversalDatePicker({
type="button" type="button"
onClick={handleClear} onClick={handleClear}
className="size-5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted flex items-center justify-center transition" 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'}
> >
<Ic name="x" className="size-3" /> <Ic name="x" className="size-3" />
</button> </button>

8
src/features/custom-trip/CustomTripOrderDetailPage.tsx

@ -1574,15 +1574,15 @@ export default function CustomTripOrderDetailPage() {
<div className="flex items-center gap-2.5 text-xs font-mono select-none flex-wrap"> <div className="flex items-center gap-2.5 text-xs font-mono select-none flex-wrap">
<div className="flex items-center gap-1.5 bg-background px-3 py-1.5 rounded-lg border border-border/70 shadow-2xs"> <div className="flex items-center gap-1.5 bg-background px-3 py-1.5 rounded-lg border border-border/70 shadow-2xs">
<span className="text-[11px] text-muted-foreground font-sans font-medium">{t('customTrip.checkInLabel') || 'ورود'}:</span> <span className="text-[11px] text-muted-foreground font-sans font-medium">{t('customTrip.checkInLabel') || 'ورود'}:</span>
<span className="font-bold text-foreground text-xs dir-ltr">
{leg.checkIn ? leg.checkIn.slice(0, 10) : '—'}
<span className="font-bold text-foreground text-xs" style={{ unicodeBidi: 'plaintext' }}>
{leg.checkIn ? formatDualDate(leg.checkIn, language) : '—'}
</span> </span>
</div> </div>
<span className="text-primary font-bold shrink-0">←</span> <span className="text-primary font-bold shrink-0">←</span>
<div className="flex items-center gap-1.5 bg-background px-3 py-1.5 rounded-lg border border-border/70 shadow-2xs"> <div className="flex items-center gap-1.5 bg-background px-3 py-1.5 rounded-lg border border-border/70 shadow-2xs">
<span className="text-[11px] text-muted-foreground font-sans font-medium">{t('customTrip.checkOutLabel') || 'خروج'}:</span> <span className="text-[11px] text-muted-foreground font-sans font-medium">{t('customTrip.checkOutLabel') || 'خروج'}:</span>
<span className="font-bold text-primary text-xs dir-ltr">
{leg.checkOut ? leg.checkOut.slice(0, 10) : '—'}
<span className="font-bold text-primary text-xs" style={{ unicodeBidi: 'plaintext' }}>
{leg.checkOut ? formatDualDate(leg.checkOut, language) : '—'}
</span> </span>
</div> </div>
</div> </div>

8
src/features/custom-trip/components/CustomTripOrderReviewDialog.tsx

@ -1448,15 +1448,15 @@ export function CustomTripOrderReviewDialog({
<div className="flex items-center gap-2.5 text-xs font-mono select-none flex-wrap"> <div className="flex items-center gap-2.5 text-xs font-mono select-none flex-wrap">
<div className="flex items-center gap-1.5 bg-background px-3 py-1.5 rounded-lg border border-border/70 shadow-2xs"> <div className="flex items-center gap-1.5 bg-background px-3 py-1.5 rounded-lg border border-border/70 shadow-2xs">
<span className="text-[11px] text-muted-foreground font-sans font-medium">{t('customTrip.checkInLabel') || 'ورود'}:</span> <span className="text-[11px] text-muted-foreground font-sans font-medium">{t('customTrip.checkInLabel') || 'ورود'}:</span>
<span className="font-bold text-foreground text-xs dir-ltr">
{leg.checkIn ? leg.checkIn.slice(0, 10) : '—'}
<span className="font-bold text-foreground text-xs" style={{ unicodeBidi: 'plaintext' }}>
{leg.checkIn ? formatDualDate(leg.checkIn, language) : '—'}
</span> </span>
</div> </div>
<span className="text-primary font-bold shrink-0">←</span> <span className="text-primary font-bold shrink-0">←</span>
<div className="flex items-center gap-1.5 bg-background px-3 py-1.5 rounded-lg border border-border/70 shadow-2xs"> <div className="flex items-center gap-1.5 bg-background px-3 py-1.5 rounded-lg border border-border/70 shadow-2xs">
<span className="text-[11px] text-muted-foreground font-sans font-medium">{t('customTrip.checkOutLabel') || 'خروج'}:</span> <span className="text-[11px] text-muted-foreground font-sans font-medium">{t('customTrip.checkOutLabel') || 'خروج'}:</span>
<span className="font-bold text-primary text-xs dir-ltr">
{leg.checkOut ? leg.checkOut.slice(0, 10) : '—'}
<span className="font-bold text-primary text-xs" style={{ unicodeBidi: 'plaintext' }}>
{leg.checkOut ? formatDualDate(leg.checkOut, language) : '—'}
</span> </span>
</div> </div>
</div> </div>

4
src/features/dashboard/DashboardHome.tsx

@ -241,7 +241,7 @@ export default function DashboardHome() {
{toFa(d.customTrips.totalCount)} سفر اختصاصی {toFa(d.customTrips.totalCount)} سفر اختصاصی
</span> </span>
<span className="text-primary font-bold bg-primary/10 px-2 py-0.5 rounded-lg"> <span className="text-primary font-bold bg-primary/10 px-2 py-0.5 rounded-lg">
{toFa(d.tours.totalCount)} کاروان تور
{toFa(d.tours.totalCount)} تور
</span> </span>
</div> </div>
<div className="absolute bottom-0 inset-x-0 h-1 bg-gradient-to-r from-primary/40 via-primary to-primary/40 opacity-0 group-hover:opacity-100 transition-opacity" /> <div className="absolute bottom-0 inset-x-0 h-1 bg-gradient-to-r from-primary/40 via-primary to-primary/40 opacity-0 group-hover:opacity-100 transition-opacity" />
@ -408,7 +408,7 @@ export default function DashboardHome() {
{t('dashboard.toursSectionTitle')} و {t('dashboard.customTripSectionTitle')} {t('dashboard.toursSectionTitle')} و {t('dashboard.customTripSectionTitle')}
</h2> </h2>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
پایش وضعیت کاروان‌های گروهی و درخواست‌های سفر اختصاصی زائران
پایش وضعیت تورهای گروهی و درخواست‌های سفر اختصاصی زائران
</p> </p>
</div> </div>
</div> </div>

2
src/features/dashboard/services/dashboard-api.ts

@ -189,7 +189,7 @@ export async function fetchLiveDashboardMetrics(range: DashboardTimeRange = 'all
service: 'tour', service: 'tour',
detail_service: null, detail_service: null,
transaction_receipt: null, transaction_receipt: null,
description: 'رزرو کاروان عتبات عالیات',
description: 'رزرو تور عتبات عالیات',
amount: '980.00', amount: '980.00',
card_number: '5892-****-****-8819', card_number: '5892-****-****-8819',
object_id: 3, object_id: 3,

5
src/features/services/ServicesPage.tsx

@ -7,6 +7,8 @@ import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Shimmer } from '@/components/shared/shimmer_frontend' import { Shimmer } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils' import { toFa } from '@/lib/utils'
import { formatDualDate } from '@/lib/date-utils'
import { useTranslation } from '@/i18n'
import { SimcardServiceView } from './components/SimcardServiceView' import { SimcardServiceView } from './components/SimcardServiceView'
import { ZayerGuideServiceView } from './components/ZayerGuideServiceView' import { ZayerGuideServiceView } from './components/ZayerGuideServiceView'
import { DuasServiceView } from './components/DuasServiceView' import { DuasServiceView } from './components/DuasServiceView'
@ -347,6 +349,7 @@ function ServiceDetailRenderer({
// ۳. صرافی آنلاین و تشریفات (Online Exchange) // ۳. صرافی آنلاین و تشریفات (Online Exchange)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
function ExchangeServiceView({ service }: { service: ServiceMeta }) { function ExchangeServiceView({ service }: { service: ServiceMeta }) {
const { language } = useTranslation()
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState<'ALL' | 'CONFIRMED' | 'DONE' | 'PENDING'>('ALL') const [statusFilter, setStatusFilter] = useState<'ALL' | 'CONFIRMED' | 'DONE' | 'PENDING'>('ALL')
@ -584,7 +587,7 @@ function ExchangeServiceView({ service }: { service: ServiceMeta }) {
<span>{ord.deliverType}</span> <span>{ord.deliverType}</span>
</div> </div>
</td> </td>
<td className="p-3.5 text-grey-3 font-mono text-[11px]">{ord.date}</td>
<td className="p-3.5 text-grey-3 font-mono text-[11px]">{formatDualDate(ord.date, language as any)}</td>
<td className="p-3.5"> <td className="p-3.5">
<Badge <Badge
className={`text-[10px] rounded-full border ${ className={`text-[10px] rounded-full border ${

5
src/features/services/components/BlogsServiceView.tsx

@ -18,6 +18,8 @@ import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { toast } from '@/stores/toast' import { toast } from '@/stores/toast'
import { toFa } from '@/lib/utils' import { toFa } from '@/lib/utils'
import { formatDualDate } from '@/lib/date-utils'
import { useTranslation } from '@/i18n'
import { import {
fetchAdminBlogs, fetchAdminBlogs,
saveAdminArticle, saveAdminArticle,
@ -50,6 +52,7 @@ interface FormTranslationState {
} }
export function BlogsServiceView() { export function BlogsServiceView() {
const { language } = useTranslation()
const qc = useQueryClient() const qc = useQueryClient()
const [selectedFilterLang, setSelectedFilterLang] = useState<string>('ALL') const [selectedFilterLang, setSelectedFilterLang] = useState<string>('ALL')
const [statusFilter, setStatusFilter] = useState<string>('ALL') const [statusFilter, setStatusFilter] = useState<string>('ALL')
@ -547,7 +550,7 @@ export function BlogsServiceView() {
{/* تاریخ ثبت */} {/* تاریخ ثبت */}
<td className="py-3 px-4 text-center font-mono text-[11px] text-grey-3"> <td className="py-3 px-4 text-center font-mono text-[11px] text-grey-3">
{article.created_at ? new Date(article.created_at).toLocaleDateString('fa-IR') : '-'}
{article.created_at ? formatDualDate(article.created_at, language as any) : '—'}
</td> </td>
{/* عملیات */} {/* عملیات */}

6
src/features/services/components/SupportServiceView.tsx

@ -84,11 +84,11 @@ const INITIAL_SUPPORT_CHANNELS: SupportChannel[] = [
id: 'Jadwal Perjalanan', id: 'Jadwal Perjalanan',
}, },
descriptions: { descriptions: {
ar: 'تنظيم مواعيد الرحلات، برامج الزيارات الميدانية ومرافقة القوافل',
fa: 'هماهنگی جدول زمانی کاروان‌ها، گشت‌های زیارتی و هماهنگی تورلیدر',
ar: 'تنظيم مواعيد الرحلات، برامج الزيارات الميدانية ومرافقة المجموعات',
fa: 'هماهنگی جدول زمانی تورها، گشت‌های زیارتی و هماهنگی تورلیدر',
en: 'Travel schedules, group coordination, ziyarah guides and itineraries', en: 'Travel schedules, group coordination, ziyarah guides and itineraries',
ru: 'Расписание поездок, координация групп и экскурсионных программ', ru: 'Расписание поездок, координация групп и экскурсионных программ',
id: 'Jadwal perjalanan, koordinasi rombongan dan panduan ziarah',
id: 'Jadwal perjalanan, koordinasi tur dan panduan ziarah',
}, },
}, },
{ {

13
src/features/settings/components/AdminUserTable.tsx

@ -1,6 +1,7 @@
import * as React from 'react' import * as React from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { formatDateTimeByLanguage } from '@/lib/date-utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import { ShimmerBox } from '@/components/ui/shimmer' import { ShimmerBox } from '@/components/ui/shimmer'
@ -21,7 +22,7 @@ export function AdminUserTable({
onSetPassword, onSetPassword,
onAdminUpdated, onAdminUpdated,
}: AdminUserTableProps) { }: AdminUserTableProps) {
const { t } = useTranslation()
const { t, language } = useTranslation()
const [togglingId, setTogglingId] = React.useState<number | null>(null) const [togglingId, setTogglingId] = React.useState<number | null>(null)
const [copiedId, setCopiedId] = React.useState<number | null>(null) const [copiedId, setCopiedId] = React.useState<number | null>(null)
const [revealedIds, setRevealedIds] = React.useState<Record<number, boolean>>({}) const [revealedIds, setRevealedIds] = React.useState<Record<number, boolean>>({})
@ -61,15 +62,7 @@ export function AdminUserTable({
const formatDate = (isoString?: string | null) => { const formatDate = (isoString?: string | null) => {
if (!isoString) return '—' 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 ( return (

18
src/features/tours/components/CreateTourDialog.tsx

@ -17,6 +17,7 @@ import {
type ServiceLanguageCode, type ServiceLanguageCode,
} from '@/components/shared/ServiceTranslation' } from '@/components/shared/ServiceTranslation'
import { TourFeatureIconPicker } from '@/components/shared/TourFeatureIconPicker' import { TourFeatureIconPicker } from '@/components/shared/TourFeatureIconPicker'
import { UniversalDatePicker } from '@/components/shared/UniversalDatePicker'
import type { TourTranslationData } from '../types' import type { TourTranslationData } from '../types'
interface CreateTourDialogProps { interface CreateTourDialogProps {
@ -692,11 +693,11 @@ export function CreateTourDialog({
<label className="block text-xs font-bold text-foreground mb-1.5"> <label className="block text-xs font-bold text-foreground mb-1.5">
تاریخ شروع تور <span className="text-rose-500">*</span>: تاریخ شروع تور <span className="text-rose-500">*</span>:
</label> </label>
<Input
type="date"
<UniversalDatePicker
value={startedAt} value={startedAt}
onChange={(e) => setStartedAt(e.target.value)}
className="bg-surface-2 border-border/50 text-sm font-mono"
onChange={setStartedAt}
allowedModes={['islamic', 'gregorian']}
defaultMode="islamic"
required required
/> />
</div> </div>
@ -705,11 +706,12 @@ export function CreateTourDialog({
<label className="block text-xs font-bold text-foreground mb-1.5"> <label className="block text-xs font-bold text-foreground mb-1.5">
تاریخ پایان تور <span className="text-rose-500">*</span>: تاریخ پایان تور <span className="text-rose-500">*</span>:
</label> </label>
<Input
type="date"
<UniversalDatePicker
value={endedAt} value={endedAt}
onChange={(e) => 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 required
/> />
</div> </div>

31
src/features/tours/components/TourDetailDialog.tsx

@ -51,6 +51,9 @@ import type {
import { TourStatusBadge } from './TourStatusBadge' import { TourStatusBadge } from './TourStatusBadge'
import DeleteConfirmDialog from '@/components/shared/DeleteConfirmDialog' import DeleteConfirmDialog from '@/components/shared/DeleteConfirmDialog'
import { TourFeatureIconPicker } from '@/components/shared/TourFeatureIconPicker' import { TourFeatureIconPicker } from '@/components/shared/TourFeatureIconPicker'
import { UniversalDatePicker } from '@/components/shared/UniversalDatePicker'
import { formatDualDate } from '@/lib/date-utils'
import { useTranslation } from '@/i18n'
interface TourDetailDialogProps { interface TourDetailDialogProps {
tourId: number | null tourId: number | null
@ -142,6 +145,7 @@ export function TourDetailDialog({
onClose, onClose,
onUpdated, onUpdated,
}: TourDetailDialogProps) { }: TourDetailDialogProps) {
const { language } = useTranslation()
const [activeTab, setActiveTab] = useState<'overview' | 'itinerary' | 'features' | 'passengers' | 'comments'>('overview') const [activeTab, setActiveTab] = useState<'overview' | 'itinerary' | 'features' | 'passengers' | 'comments'>('overview')
const [selectedLang, setSelectedLang] = useState<LangCode>('ar') const [selectedLang, setSelectedLang] = useState<LangCode>('ar')
@ -1314,26 +1318,29 @@ export function TourDetailDialog({
<span>مشخصات عمومی و زمان‌بندی تور</span> <span>مشخصات عمومی و زمان‌بندی تور</span>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3.5">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5">
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-bold text-grey-2">تاریخ شروع:</label> <label className="text-xs font-bold text-grey-2">تاریخ شروع:</label>
<Input
type="date"
<UniversalDatePicker
value={coreFields.started_at} value={coreFields.started_at}
disabled={!isEditMode || isSaving} disabled={!isEditMode || isSaving}
onChange={(e) => 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"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-bold text-grey-2">تاریخ پایان:</label> <label className="text-xs font-bold text-grey-2">تاریخ پایان:</label>
<Input
type="date"
<UniversalDatePicker
value={coreFields.ended_at} value={coreFields.ended_at}
minDate={coreFields.started_at || undefined}
disabled={!isEditMode || isSaving} disabled={!isEditMode || isSaving}
onChange={(e) => 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"
/> />
</div> </div>
@ -1788,7 +1795,7 @@ export function TourDetailDialog({
<span className="text-xs font-bold text-grey-3"> <span className="text-xs font-bold text-grey-3">
مرحله #{itId} &bull;{' '} مرحله #{itId} &bull;{' '}
{(item as any).started_at {(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}`} : `روز ${idx + 1}`}
</span> </span>
</div> </div>
@ -2137,9 +2144,9 @@ export function TourDetailDialog({
className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1 text-xs" className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1 text-xs"
> >
<div className="font-bold text-foreground">{p.fullname}</div> <div className="font-bold text-foreground">{p.fullname}</div>
<div className="flex justify-between text-grey-3 font-mono text-[11px]" dir="ltr">
<div className="flex flex-wrap justify-between gap-2 text-grey-3 font-mono text-[11px]" dir="ltr">
<span>Passport: {p.passport_number || '-'}</span> <span>Passport: {p.passport_number || '-'}</span>
<span>DOB: {p.birthdate || '-'}</span>
<span>DOB: {p.birthdate ? formatDualDate(p.birthdate, language as any) : '-'}</span>
</div> </div>
{p.phone_number && ( {p.phone_number && (
<div className="flex justify-between items-center text-grey-3 font-mono text-[11px] pt-1 border-t border-border-soft/60" dir="ltr"> <div className="flex justify-between items-center text-grey-3 font-mono text-[11px] pt-1 border-t border-border-soft/60" dir="ltr">

79
src/features/tours/components/TourDetailSheet.tsx

@ -401,9 +401,9 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
<span>گذرنامه:</span> <span>گذرنامه:</span>
<span className="font-mono font-medium text-foreground">{p.passport_number}</span> <span className="font-mono font-medium text-foreground">{p.passport_number}</span>
</div> </div>
<div className="text-[11px] text-grey-3 flex justify-between">
<div className="text-[11px] text-grey-3 flex justify-between gap-2">
<span>تولد:</span> <span>تولد:</span>
<span className="font-mono">{p.birthdate}</span>
<span className="font-mono">{p.birthdate ? formatDateByLanguage(p.birthdate, language as any) : '—'}</span>
</div> </div>
<div className="text-[11px] text-grey-3 flex justify-between"> <div className="text-[11px] text-grey-3 flex justify-between">
<span>واتساپ:</span> <span>واتساپ:</span>
@ -466,6 +466,81 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
<p className="text-xs text-grey-3">توصیه خاصی ثبت نشده است.</p> <p className="text-xs text-grey-3">توصیه خاصی ثبت نشده است.</p>
)} )}
</div> </div>
{/* هتل‌ها و اقامتگاه‌ها */}
{tour.tour_meta?.accommodations && tour.tour_meta.accommodations.length > 0 && (
<div className="space-y-3">
<h4 className="text-xs font-extrabold text-foreground">اقامتگاه‌ها و هتل‌های پکیج</h4>
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
{tour.tour_meta.accommodations.map((acc, aIdx) => (
<div key={aIdx} className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-foreground">{acc.hotel_name}</span>
<span className="text-[11px] text-amber-500 font-bold">{'★'.repeat(acc.stars || 5)}</span>
</div>
<div className="text-[11px] text-grey-3 flex items-center justify-between">
<span>شهر: {acc.city}</span>
{acc.nights ? <span>{acc.nights} شب اقامت</span> : null}
</div>
<p className="text-[10px] text-primary">{acc.distance_to_shrine}</p>
</div>
))}
</div>
</div>
)}
{/* حمل‌ونقل */}
{tour.tour_meta?.transport_info && (
<div className="space-y-3">
<h4 className="text-xs font-extrabold text-foreground">اطلاعات حمل‌ونقل و پرواز</h4>
<div className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-grey-3">وسیله نقلیه:</span>
<span className="font-bold text-foreground">{tour.tour_meta.transport_info.vehicle_type}</span>
</div>
<div className="flex justify-between">
<span className="text-grey-3">مسیر و ناوگان:</span>
<span className="text-foreground">{tour.tour_meta.transport_info.carrier_name}</span>
</div>
<div className="flex justify-between">
<span className="text-grey-3">بار مجاز:</span>
<span className="text-foreground">{tour.tour_meta.transport_info.baggage_allowance}</span>
</div>
</div>
</div>
)}
{/* خدمات شامل و غیرشامل */}
{(tour.tour_meta?.included_services?.length || tour.tour_meta?.excluded_services?.length) && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{tour.tour_meta?.included_services && tour.tour_meta.included_services.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-bold text-emerald-600 dark:text-emerald-400">خدمات شامل پکیج</h4>
<div className="space-y-1">
{tour.tour_meta.included_services.map((inc, iIdx) => (
<div key={iIdx} className="flex items-start gap-1.5 text-[11px] text-foreground">
<Ic name="check" className="size-3.5 text-emerald-500 shrink-0 mt-0.5" />
<span>{inc}</span>
</div>
))}
</div>
</div>
)}
{tour.tour_meta?.excluded_services && tour.tour_meta.excluded_services.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-bold text-rose-500">هزینه‌های غیرشامل</h4>
<div className="space-y-1">
{tour.tour_meta.excluded_services.map((exc, eIdx) => (
<div key={eIdx} className="flex items-start gap-1.5 text-[11px] text-grey-2">
<Ic name="close" className="size-3.5 text-rose-500 shrink-0 mt-0.5" />
<span>{exc}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div> </div>
)} )}

1
src/features/tours/services/tour-api.ts

@ -211,6 +211,7 @@ export interface CreateTourPayload {
trip_status?: string trip_status?: string
tour_feature?: { title: string; icon?: string }[] tour_feature?: { title: string; icon?: string }[]
travel_tips?: Record<string, string> travel_tips?: Record<string, string>
tour_meta?: import('../types').TourMeta
visibility?: boolean visibility?: boolean
translations?: Record<string, import('../types').TourTranslationData> translations?: Record<string, import('../types').TourTranslationData>
image_id?: number image_id?: number

36
src/features/tours/types.ts

@ -54,6 +54,38 @@ export interface TravelTipItem {
description: string 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 { export interface TourDetailItem {
id: number id: number
title: string title: string
@ -66,6 +98,7 @@ export interface TourDetailItem {
number_sold: number number_sold: number
tour_features: TourFeatureItem[] tour_features: TourFeatureItem[]
travel_tips: TravelTipItem[] travel_tips: TravelTipItem[]
tour_meta?: TourMeta
status: TourSaleStatus status: TourSaleStatus
price: string price: string
price_child: string price_child: string
@ -75,6 +108,7 @@ export interface TourDetailItem {
images: TourImageItem[] images: TourImageItem[]
trip_status: TourTripStatus trip_status: TourTripStatus
destination_country?: string | null destination_country?: string | null
origin_country?: string | null
is_access: boolean is_access: boolean
visibility?: boolean visibility?: boolean
} }
@ -162,6 +196,7 @@ export interface TourTranslationData {
description?: string description?: string
tour_feature?: { title: string; icon?: string }[] tour_feature?: { title: string; icon?: string }[]
travel_tips?: Record<string, string> travel_tips?: Record<string, string>
tour_meta?: TourMeta
} }
export interface TourTranslationsResponse { export interface TourTranslationsResponse {
@ -171,6 +206,7 @@ export interface TourTranslationsResponse {
description: string description: string
tour_feature: { title: string; icon?: string }[] tour_feature: { title: string; icon?: string }[]
travel_tips: Record<string, string> travel_tips: Record<string, string>
tour_meta?: TourMeta
} }
translations: Record<string, TourTranslationData> translations: Record<string, TourTranslationData>
} }

17
src/features/users/components/UserFactorsTab.tsx

@ -1,5 +1,6 @@
import * as React from 'react' import * as React from 'react'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { formatDateTimeByLanguage } from '@/lib/date-utils'
import type { UserFactorItem } from '../types' import type { UserFactorItem } from '../types'
import { fetchUserFactors } from '../services/users-api' import { fetchUserFactors } from '../services/users-api'
import { Ic } from '@/icons' import { Ic } from '@/icons'
@ -11,7 +12,7 @@ interface UserFactorsTabProps {
type ServiceFilter = 'all' | 'simcard' | 'tasrif' | 'shop' | 'tour' | 'custom_trip' type ServiceFilter = 'all' | 'simcard' | 'tasrif' | 'shop' | 'tour' | 'custom_trip'
export function UserFactorsTab({ userId }: UserFactorsTabProps) { export function UserFactorsTab({ userId }: UserFactorsTabProps) {
const { t } = useTranslation()
const { t, language } = useTranslation()
const [service, setService] = React.useState<ServiceFilter>('all') const [service, setService] = React.useState<ServiceFilter>('all')
const [loading, setLoading] = React.useState(true) const [loading, setLoading] = React.useState(true)
const [factors, setFactors] = React.useState<UserFactorItem[]>([]) const [factors, setFactors] = React.useState<UserFactorItem[]>([])
@ -35,18 +36,8 @@ export function UserFactorsTab({ userId }: UserFactorsTabProps) {
}, [loadFactors]) }, [loadFactors])
const formatDate = (isoString: string) => { 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) => { const getServiceLabel = (srv: string) => {

20
src/features/users/components/UserProfileTab.tsx

@ -1,5 +1,6 @@
import * as React from 'react' import * as React from 'react'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { formatDateTimeByLanguage, formatDualDate } from '@/lib/date-utils'
import type { UserDetail } from '../types' import type { UserDetail } from '../types'
import { Ic } from '@/icons' import { Ic } from '@/icons'
@ -8,22 +9,11 @@ interface UserProfileTabProps {
} }
export function UserProfileTab({ user }: UserProfileTabProps) { export function UserProfileTab({ user }: UserProfileTabProps) {
const { t } = useTranslation()
const { t, language } = useTranslation()
const formatDate = (isoString: string | null) => { const formatDate = (isoString: string | null) => {
if (!isoString) return t('users.noData') 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 ( return (
@ -197,7 +187,9 @@ export function UserProfileTab({ user }: UserProfileTabProps) {
<td className="py-3 px-3 font-mono text-muted-foreground">{idx + 1}</td> <td className="py-3 px-3 font-mono text-muted-foreground">{idx + 1}</td>
<td className="py-3 px-3 font-semibold text-foreground">{p.fullname || t('users.noData')}</td> <td className="py-3 px-3 font-semibold text-foreground">{p.fullname || t('users.noData')}</td>
<td className="py-3 px-3 font-mono text-foreground">{p.passport_number || '—'}</td> <td className="py-3 px-3 font-mono text-foreground">{p.passport_number || '—'}</td>
<td className="py-3 px-3 font-mono text-muted-foreground">{p.birthdate || '—'}</td>
<td className="py-3 px-3 font-mono text-muted-foreground">
{p.birthdate ? formatDualDate(p.birthdate, language as any) : '—'}
</td>
<td className="py-3 px-3 font-mono dir-ltr text-end">{p.phone_number || '—'}</td> <td className="py-3 px-3 font-mono dir-ltr text-end">{p.phone_number || '—'}</td>
<td className="py-3 px-3"> <td className="py-3 px-3">
<span className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-muted text-foreground"> <span className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-muted text-foreground">

5
src/features/users/components/UserToursTab.tsx

@ -1,5 +1,6 @@
import * as React from 'react' import * as React from 'react'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { formatDualDate } from '@/lib/date-utils'
import type { UserTourItem } from '../types' import type { UserTourItem } from '../types'
import { fetchUserTours } from '../services/users-api' import { fetchUserTours } from '../services/users-api'
import { Ic } from '@/icons' import { Ic } from '@/icons'
@ -11,7 +12,7 @@ interface UserToursTabProps {
type TimelineFilter = 'all' | 'active' | 'upcoming' | 'past' type TimelineFilter = 'all' | 'active' | 'upcoming' | 'past'
export function UserToursTab({ userId }: UserToursTabProps) { export function UserToursTab({ userId }: UserToursTabProps) {
const { t } = useTranslation()
const { t, language } = useTranslation()
const [timeline, setTimeline] = React.useState<TimelineFilter>('all') const [timeline, setTimeline] = React.useState<TimelineFilter>('all')
const [loading, setLoading] = React.useState(true) const [loading, setLoading] = React.useState(true)
const [tours, setTours] = React.useState<UserTourItem[]>([]) const [tours, setTours] = React.useState<UserTourItem[]>([])
@ -35,7 +36,7 @@ export function UserToursTab({ userId }: UserToursTabProps) {
const formatDate = (isoDate: string | null | undefined) => { const formatDate = (isoDate: string | null | undefined) => {
if (!isoDate) return '—' if (!isoDate) return '—'
return isoDate
return formatDualDate(isoDate, language as any)
} }
const getOrderStatusBadge = (status: string) => { const getOrderStatusBadge = (status: string) => {

108
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 ذخیره می‌شوند. * داده‌های پایه بک‌اند و مقادیر فرم‌ها همواره در استاندارد ISO YYYY-MM-DD ذخیره می‌شوند.
* ────────────────────────────────────────────────────────────────────────── */ * ────────────────────────────────────────────────────────────────────────── */
@ -19,6 +21,7 @@ export interface FormatDateOptions {
withTime?: boolean withTime?: boolean
withSeconds?: boolean withSeconds?: boolean
showEra?: 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})$/) const matchOnlyDate = str.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/)
if (matchOnlyDate) { if (matchOnlyDate) {
const y = parseInt(matchOnlyDate[1], 10) 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) 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) const parsed = new Date(str)
@ -46,69 +54,20 @@ export function parseSafeDate(input: string | number | Date | null | undefined):
} }
/** /**
* قالب‌بندی یک تاریخ مشخص بر اساس زبان انتخابی (یا پیش‌فرض سامانه)
* قالب‌بندی یک تاریخ مشخص به صورت دوگانه میلادی و هجری قمری با ذکر نام ماه بر اساس زبان انتخابی
*/ */
export function formatDateByLanguage( export function formatDateByLanguage(
dateInput: string | number | Date | null | undefined, dateInput: string | number | Date | null | undefined,
lang: SupportedLanguage = 'fa', lang: SupportedLanguage = 'fa',
options: FormatDateOptions = {} options: FormatDateOptions = {}
): string { ): 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( export function formatDateRangeByLanguage(
startDateInput: string | number | Date | null | undefined, startDateInput: string | number | Date | null | undefined,
@ -120,11 +79,11 @@ export function formatDateRangeByLanguage(
const e = parseSafeDate(endDateInput) const e = parseSafeDate(endDateInput)
if (!s && !e) return '—' 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') { if (lang === 'fa') {
return `${startFormatted} تا ${endFormatted}` return `${startFormatted} تا ${endFormatted}`
@ -136,7 +95,7 @@ export function formatDateRangeByLanguage(
} }
/** /**
* قالب‌بندی تاریخ و ساعت به صورت همزمان
* قالب‌بندی تاریخ (دوگانه میلادی و قمری با نام ماه) و ساعت به صورت همزمان
*/ */
export function formatDateTimeByLanguage( export function formatDateTimeByLanguage(
dateInput: string | number | Date | null | undefined, dateInput: string | number | Date | null | undefined,
@ -146,19 +105,22 @@ export function formatDateTimeByLanguage(
const d = parseSafeDate(dateInput) const d = parseSafeDate(dateInput)
if (!d) return '—' 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 hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0') const minutes = String(d.getMinutes()).padStart(2, '0')
const timeStr = `${hours}:${minutes}` 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,
})
} }
/* ────────────────────────────────────────────────────────────────────────── /* ──────────────────────────────────────────────────────────────────────────

14
src/lib/jalali.ts

@ -3,23 +3,27 @@
* این ماژول جهت حفظ سازگاری کدهای قبلی حفظ شده و از موتور اصلی date-utils ارث‌بری می‌کند. * این ماژول جهت حفظ سازگاری کدهای قبلی حفظ شده و از موتور اصلی date-utils ارث‌بری می‌کند.
* ────────────────────────────────────────────────────────────────────────── */ * ────────────────────────────────────────────────────────────────────────── */
import { formatDateByLanguage, formatDateTimeByLanguage } from './date-utils'
import { useLanguageStore } from '@/i18n'
import { formatDateByLanguage, formatDateTimeByLanguage, formatDualDate } from './date-utils'
export { export {
formatDateByLanguage, formatDateByLanguage,
formatDateRangeByLanguage, formatDateRangeByLanguage,
formatDateTimeByLanguage, formatDateTimeByLanguage,
formatDualDate,
useFormattedDate, useFormattedDate,
useFormattedDateRange, useFormattedDateRange,
useFormattedDateTime, useFormattedDateTime,
} from './date-utils' } from './date-utils'
/** تاریخ ISO → «۱۴۰۳/۰۱/۲۵» (جلالی در فارسی، هجری در عربی، میلادی در انگلیسی). */
/** نمایش تاریخ به صورت دوگانه میلادی و هجری قمری با ذکر نام ماه متناسب با زبان فعال پنل */
export function toJalali(iso: string | null | undefined): string { 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 { export function toJalaliDateTime(iso: string | null | undefined): string {
return formatDateTimeByLanguage(iso, 'fa')
const currentLang = useLanguageStore.getState().language
return formatDateTimeByLanguage(iso, currentLang)
} }

8
src/locales/ar.json

@ -98,12 +98,12 @@
"rejectedInvoices": "ملغاة أو منتهية", "rejectedInvoices": "ملغاة أو منتهية",
"collectionRate": "نسبة التحصيل", "collectionRate": "نسبة التحصيل",
"viewAllBilling": "عرض كل الفواتير", "viewAllBilling": "عرض كل الفواتير",
"toursSectionTitle": "رحلات القوافل الجماعية",
"toursSectionDesc": "حالة الطاقة الاستيعابية والقوافل النشطة والمتاحة للحجز",
"toursSectionTitle": "الرحلات الجماعية",
"toursSectionDesc": "حالة الطاقة الاستيعابية والرحلات النشطة والمتاحة للحجز",
"activeTours": "رحلة نشطة ومتاحة", "activeTours": "رحلة نشطة ومتاحة",
"soldOutTours": "مكتملة العدد أو متوقفة", "soldOutTours": "مكتملة العدد أو متوقفة",
"travelingTours": "قوافل في الطريق حالياً",
"occupancyRate": "نسبة إشغال القوافل",
"travelingTours": "رحلات في الطريق حالياً",
"occupancyRate": "نسبة إشغال الرحلات",
"viewAllTours": "إدارة الرحلات", "viewAllTours": "إدارة الرحلات",
"customTripSectionTitle": "الرحلات الخاصة (تصميم الزائر)", "customTripSectionTitle": "الرحلات الخاصة (تصميم الزائر)",
"customTripSectionDesc": "طلبات تخطيط الرحلات المخصصة ومراجعة الفنادق ووسائل النقل", "customTripSectionDesc": "طلبات تخطيط الرحلات المخصصة ومراجعة الفنادق ووسائل النقل",

4
src/locales/en.json

@ -99,11 +99,11 @@
"collectionRate": "Collection Rate", "collectionRate": "Collection Rate",
"viewAllBilling": "View All Invoices", "viewAllBilling": "View All Invoices",
"toursSectionTitle": "Group Pilgrimage Tours", "toursSectionTitle": "Group Pilgrimage Tours",
"toursSectionDesc": "Capacity, active caravans and departing packages",
"toursSectionDesc": "Capacity, active tours and departing packages",
"activeTours": "Active Available Tours", "activeTours": "Active Available Tours",
"soldOutTours": "Sold Out / Closed", "soldOutTours": "Sold Out / Closed",
"travelingTours": "Currently Traveling", "travelingTours": "Currently Traveling",
"occupancyRate": "Caravan Occupancy Rate",
"occupancyRate": "Tour Occupancy Rate",
"viewAllTours": "Manage Tours", "viewAllTours": "Manage Tours",
"customTripSectionTitle": "Custom Trips (Pilgrim Design)", "customTripSectionTitle": "Custom Trips (Pilgrim Design)",
"customTripSectionDesc": "Custom itinerary requests, hotel & transport confirmations", "customTripSectionDesc": "Custom itinerary requests, hotel & transport confirmations",

6
src/locales/fa.json

@ -99,11 +99,11 @@
"collectionRate": "نرخ وصول مطالبات", "collectionRate": "نرخ وصول مطالبات",
"viewAllBilling": "مشاهده همه صورت‌حساب‌ها", "viewAllBilling": "مشاهده همه صورت‌حساب‌ها",
"toursSectionTitle": "تورهای گروهی زیارتی", "toursSectionTitle": "تورهای گروهی زیارتی",
"toursSectionDesc": "وضعیت ظرفیت، کاروان‌های فعال و در حال پذیرش زائر",
"toursSectionDesc": "وضعیت ظرفیت، تورهای فعال و در حال پذیرش زائر",
"activeTours": "تور فعال و در حال پذیرش", "activeTours": "تور فعال و در حال پذیرش",
"soldOutTours": "تکمیل‌ظرفیت یا متوقف", "soldOutTours": "تکمیل‌ظرفیت یا متوقف",
"travelingTours": "کاروان در حال سفر",
"occupancyRate": "نرخ تکمیل ظرفیت کاروان‌ها",
"travelingTours": "تور در حال سفر",
"occupancyRate": "نرخ تکمیل ظرفیت تورها",
"viewAllTours": "مدیریت تورها", "viewAllTours": "مدیریت تورها",
"customTripSectionTitle": "سفرهای اختصاصی (طراحی زائر)", "customTripSectionTitle": "سفرهای اختصاصی (طراحی زائر)",
"customTripSectionDesc": "درخواست‌های برنامه‌ریزی سفر اختصاصی، بررسی هتل و ترنسپورت", "customTripSectionDesc": "درخواست‌های برنامه‌ریزی سفر اختصاصی، بررسی هتل و ترنسپورت",

Loading…
Cancel
Save