Browse Source

fixing bugs

master
parent
commit
fbb68b9592
  1. 21
      .agents/AGENTS.md
  2. 4
      index.html
  3. 7
      src/app/router.tsx
  4. 6
      src/components/layout/ServerStatusDialog.tsx
  5. 8
      src/components/shared/FilterBar.tsx
  6. 455
      src/components/shared/UniversalDatePicker.tsx
  7. 2
      src/components/ui/alert-dialog.tsx
  8. 2
      src/components/ui/command.tsx
  9. 2
      src/components/ui/dialog.tsx
  10. 6
      src/components/ui/table.tsx
  11. 480
      src/components/utils/dev-click-to-component.tsx
  12. 27
      src/config/navigation.ts
  13. 1131
      src/features/billing/BillingDetailPage.tsx
  14. 629
      src/features/billing/BillingPage.tsx
  15. 153
      src/features/billing/components/BillingDetailModal.tsx
  16. 10
      src/features/billing/components/ReceiptLightboxDialog.tsx
  17. 3
      src/features/billing/components/RejectFactorDialog.tsx
  18. 12
      src/features/billing/services/billing-api.ts
  19. 45
      src/features/billing/types.ts
  20. 37
      src/features/custom-trip/CustomTripDestinationDetailPage.tsx
  21. 1897
      src/features/custom-trip/CustomTripOrderDetailPage.tsx
  22. 164
      src/features/custom-trip/CustomTripPage.tsx
  23. 5
      src/features/custom-trip/components/CustomTripDestinationsTab.tsx
  24. 23
      src/features/custom-trip/components/CustomTripHotelsTab.tsx
  25. 1613
      src/features/custom-trip/components/CustomTripOrderReviewDialog.tsx
  26. 236
      src/features/custom-trip/components/CustomTripOrdersTab.tsx
  27. 2
      src/features/custom-trip/components/CustomTripOriginsTab.tsx
  28. 12
      src/features/custom-trip/components/CustomTripTransportsTab.tsx
  29. 235
      src/features/custom-trip/services/custom-trip-api.ts
  30. 19
      src/features/custom-trip/types.ts
  31. 31
      src/features/services/components/CategoriesServiceView.tsx
  32. 2
      src/features/services/components/CreateLocationDialog.tsx
  33. 24
      src/features/services/components/DuasServiceView.tsx
  34. 8
      src/features/services/components/FaqServiceView.tsx
  35. 2
      src/features/services/components/LocationDetailSheet.tsx
  36. 8
      src/features/services/components/SimcardFormDialog.tsx
  37. 14
      src/features/services/components/StoreServiceView.tsx
  38. 2
      src/features/services/components/SupportServiceView.tsx
  39. 109
      src/features/settings/SettingsPage.tsx
  40. 16
      src/features/settings/components/AdminManagementTab.tsx
  41. 4
      src/features/settings/components/CreateAdminModal.tsx
  42. 20
      src/features/settings/components/SetPasswordModal.tsx
  43. 8
      src/features/settings/components/SocialMediaSettingsTab.tsx
  44. 64
      src/features/tours/components/CreateTourDialog.tsx
  45. 46
      src/features/tours/components/TourDetailDialog.tsx
  46. 12
      src/features/tours/components/TourDetailSheet.tsx
  47. 32
      src/features/tours/components/TourTranslationsDialog.tsx
  48. 12
      src/features/users/UserDetailPage.tsx
  49. 4
      src/icons.tsx
  50. 134
      src/lib/date-utils.ts
  51. 117
      src/locales/ar.json
  52. 117
      src/locales/en.json
  53. 117
      src/locales/fa.json
  54. 180
      vite.config.ts

21
.agents/AGENTS.md

@ -0,0 +1,21 @@
# Admin Panel Agent Rules & Guidelines - Aqila (پنل مدیریت عقیله)
این سند شامل قوانین، استانداردها و اصول مهندسی الزامی برای توسعه بخش پنل مدیریت ادمین (`aqila_panel`) است.
---
## ۱. استانداردهای توسعه رابط کاربری و معماری
- **فناوری و ساختار:** پروژه بر پایه Vite + React + TypeScript توسعه یافته است.
- **تایپوگرافی و جهت متن (RTL/LTR):** رعایت فونت‌های `Vazirmatn` و `Plus Jakarta Sans`، استفاده از `rtlUtils.ts` جهت تشخیص خودکار جهت متن ورودی کاربران و حفظ ثبات کرسر.
- **چندزبانه بودن کامل پنل مدیریت (۳ زبان):** پنل مدیریت منحصراً از ۳ زبان (`fa` فارسی، `ar` عربی، `en` انگلیسی) در مسیر `src/locales/` استفاده می‌کند. تمام متون جدول‌ها، فیلدها، دکمه‌ها و نشان‌ها باید در هر ۳ فایل دیکشنری زبان (`fa.json`, `ar.json`, `en.json`) تعریف و همگام شوند.
- **قانون هماهنگی ترجمه:** هیچ تغییری نباید تک‌زبانه باشد؛ هر کلید جدید یا اصلاح‌شده در یکی از این ۳ زبان باید همزمان در دو زبان دیگر اعمال شود تا تست `locales.test.ts` کاملاً پاس شود و هیچ کلید ناقص یا نامتعادلی وجود نداشته باشد.
---
## ۲. بررسی الزامی سلامت و ران بودن پروژه پس از هر تغییر کد (Mandatory Runtime Check)
در انتهای هر پاسخ، تسک یا پیامی که در آن کدی ایجاد یا تغییر داده می‌شود:
- **بررسی سریع و بلادرنگ ران بودن پنل مدیریت (Immediate Runtime & Health Check):**
- بلافاصله بررسی شود که تغییرات در کامپوننت‌ها، هوک‌ها، تایپ‌ها یا استایل‌ها باعث خطای کامپایل TypeScript، خطای بیلد Vite یا کرش داشبورد نشده باشد.
- سلامت ران بودن با بررسی تایپ‌ها (`npm run typecheck` / `tsc --noEmit`) یا وضعیت سرور Vite تضمین شود.
- **رفع فوری مشکل تا بازگشت به وضعیت ران (Self-Healing & Auto-Fix):**
- در صورتی که تغییر کد باعث بروز هرگونه خطا، شکست کامپایل یا توقف ران بودن پنل شود، بلافاصله باید منشأ ارور شناسایی و اصلاح گردد تا پنل مدیریت مجدداً در حالت ران، سالم و پایدار قرار گیرد.

4
index.html

@ -8,6 +8,10 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=aqila2" />
<link rel="shortcut icon" href="/favicon.ico?v=aqila2" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="darkreader-lock" content="darkreader-lock" />
<meta name="darkreader" content="NO-DARK-READER" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<title>پنل مدیریت عقیله</title>
<!-- اطمینان از اعمال و رفرش آنی فاوآیکون در کش مرورگر -->
<script>

7
src/app/router.tsx

@ -12,9 +12,11 @@ const ToursPage = lazy(() => import('@/features/tours/ToursPage'))
const OngoingToursPage = lazy(() => import('@/features/tours/OngoingToursPage'))
const ServicesPage = lazy(() => import('@/features/services/ServicesPage'))
const BillingPage = lazy(() => import('@/features/billing/BillingPage'))
const BillingDetailPage = lazy(() => import('@/features/billing/BillingDetailPage'))
const SettingsPage = lazy(() => import('@/features/settings/SettingsPage'))
const CustomTripPage = lazy(() => import('@/features/custom-trip/CustomTripPage'))
const CustomTripDestinationDetailPage = lazy(() => import('@/features/custom-trip/CustomTripDestinationDetailPage'))
const CustomTripOrderDetailPage = lazy(() => import('@/features/custom-trip/CustomTripOrderDetailPage'))
export default function AppRouter() {
return (
@ -37,6 +39,7 @@ export default function AppRouter() {
{/* ماژول مستقل سفرهای اختصاصی (Custom Trip) */}
<Route path="/custom-trip" element={<Navigate to="/custom-trip/orders" replace />} />
<Route path="/custom-trip/orders/:id" element={<PageReady><CustomTripOrderDetailPage /></PageReady>} />
<Route path="/custom-trip/destinations/:id" element={<PageReady><CustomTripDestinationDetailPage /></PageReady>} />
<Route path="/custom-trip/:tab" element={<PageReady><CustomTripPage /></PageReady>} />
@ -44,7 +47,9 @@ export default function AppRouter() {
<Route path="/services/:serviceKey" element={<PageReady><ServicesPage /></PageReady>} />
<Route path="/services/:serviceKey/:subKey" element={<PageReady><ServicesPage /></PageReady>} />
<Route path="/billing" element={<PageReady><BillingPage /></PageReady>} />
<Route path="/settings" element={<PageReady><SettingsPage /></PageReady>} />
<Route path="/billing/:id" element={<PageReady><BillingDetailPage /></PageReady>} />
<Route path="/settings" element={<Navigate to="/settings/admins" replace />} />
<Route path="/settings/:section" element={<PageReady><SettingsPage /></PageReady>} />
{/* هدایت مسیرهای ناشناخته به صفحه اصلی */}
<Route path="*" element={<Navigate to="/" replace />} />

6
src/components/layout/ServerStatusDialog.tsx

@ -35,8 +35,8 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[540px] p-6 space-y-5">
<DialogHeader className="pb-3 border-b border-border-soft">
<DialogContent className="max-w-[540px] max-h-[90vh] overflow-y-auto p-4 sm:p-6 space-y-5">
<DialogHeader className="pb-3 pe-10 border-b border-border-soft">
<div className="flex items-center gap-3">
<div
className={`flex size-10 shrink-0 items-center justify-center rounded-xl ${
@ -176,7 +176,7 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
</div>
</div>
<DialogFooter className="flex-row items-center justify-between pt-4 border-t border-border-soft">
<DialogFooter className="flex-row flex-wrap items-center justify-between gap-3 pt-4 border-t border-border-soft">
<div className="text-xs text-grey-3 flex items-center gap-1.5">
<span
className={`size-2 rounded-full ${

8
src/components/shared/FilterBar.tsx

@ -37,22 +37,22 @@ export default function FilterBar({
{/* Search Input and Primary Filters */}
<div className="flex flex-1 flex-wrap items-center gap-2.5">
{onSearchChange !== undefined && (
<div className="relative w-full max-w-xs shrink-0">
<div className="relative w-full sm:max-w-xs shrink-0">
<Ic
name="search"
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none"
className="absolute start-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none"
/>
<Input
value={searchQuery ?? ''}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder}
className="h-9 pr-9 pl-8 text-xs bg-card/70 border-border/70 focus-visible:border-primary focus-visible:ring-1 focus-visible:ring-primary rounded-xl"
className="h-9 ps-9 pe-8 text-xs bg-card/70 border-border/70 focus-visible:border-primary focus-visible:ring-1 focus-visible:ring-primary rounded-xl"
/>
{Boolean(searchQuery) && (
<button
type="button"
onClick={() => onSearchChange('')}
className="absolute left-2.5 top-1/2 -translate-y-1/2 flex size-4 items-center justify-center rounded-full text-muted-foreground hover:text-foreground transition-colors"
className="absolute end-2.5 top-1/2 -translate-y-1/2 flex size-4 items-center justify-center rounded-full text-muted-foreground hover:text-foreground transition-colors"
title="پاک‌کردن جستجو"
>
<Ic name="x" className="size-3" />

455
src/components/shared/UniversalDatePicker.tsx

@ -1,24 +1,28 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import React, { useState, useEffect, useMemo } from 'react'
import { Ic } from '@/icons'
import { Button } from '@/components/ui/button'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { useTranslation } from '@/i18n'
import {
parseSafeDate,
formatDateByLanguage,
formatDualDate,
gregorianToJalali,
jalaliToGregorian,
gregorianToHijri,
hijriToGregorian,
getCalendarMonthData,
JALALI_MONTH_NAMES,
HIJRI_MONTH_NAMES,
GREGORIAN_MONTH_NAMES,
} from '@/lib/date-utils'
import { cn } from '@/lib/utils'
export interface UniversalDatePickerProps {
value?: string | null
onChange: (isoDate: string) => void
withTime?: boolean
timeValue?: string | null
onTimeChange?: (time: string) => void
defaultMode?: 'gregorian' | 'islamic' | 'persian'
allowedModes?: ('islamic' | 'gregorian' | 'persian')[]
closeOnSelect?: boolean
placeholder?: string
disabled?: boolean
label?: string
@ -28,11 +32,18 @@ export interface UniversalDatePickerProps {
required?: boolean
size?: 'sm' | 'default' | 'lg'
clearable?: boolean
compact?: boolean
}
export function UniversalDatePicker({
value,
onChange,
withTime = false,
timeValue,
onTimeChange,
defaultMode = 'islamic',
allowedModes = ['islamic', 'gregorian'],
closeOnSelect = true,
placeholder,
disabled = false,
label,
@ -42,74 +53,96 @@ export function UniversalDatePicker({
required = false,
size = 'default',
clearable = true,
compact = true,
}: UniversalDatePickerProps) {
const { language, dir } = useTranslation()
const { language, dir, t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
// تعیین نوع تقویم بر اساس زبان فعال
const calendarType: 'persian' | 'islamic' | 'gregorian' =
language === 'fa' ? 'persian' : language === 'ar' ? 'islamic' : 'gregorian'
// سیستم تقویم فعال (پیش‌فرض هجری قمری یا میلادی بر اساس allowedModes)
const [calendarType, setCalendarType] = useState<'islamic' | 'gregorian' | 'persian'>(() => {
if (defaultMode && allowedModes.includes(defaultMode)) return defaultMode
return allowedModes[0] || 'islamic'
})
// مقدار فعلی به صورت Date
const selectedDate = useMemo(() => parseSafeDate(value), [value])
// وضعیت ماه و سال در حال مشاهده در تقویم
// تابع کمکی برای محاسبه سال و ماه در سیستم تقویم انتخابی
const calculateYearMonth = (targetCal: 'islamic' | 'gregorian' | 'persian', baseDate: Date) => {
if (targetCal === 'islamic') {
const h = gregorianToHijri(baseDate)
return { year: h.year, month: h.month }
}
if (targetCal === 'persian') {
const j = gregorianToJalali(baseDate.getFullYear(), baseDate.getMonth() + 1, baseDate.getDate())
return { year: j.year, month: j.month }
}
return { year: baseDate.getFullYear(), month: baseDate.getMonth() + 1 }
}
// وضعیت سال و ماه در حال مشاهده در تقویم
const [viewYear, setViewYear] = useState<number>(() => {
const d = selectedDate || new Date()
if (calendarType === 'persian') return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()).year
if (calendarType === 'islamic') return gregorianToHijri(d).year
return d.getFullYear()
return calculateYearMonth(calendarType, d).year
})
const [viewMonth, setViewMonth] = useState<number>(() => {
const d = selectedDate || new Date()
if (calendarType === 'persian') return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()).month
if (calendarType === 'islamic') return gregorianToHijri(d).month
return d.getMonth() + 1
return calculateYearMonth(calendarType, d).month
})
// وقتی مقدار تغییر کند یا باز شود، تقویم روی تاریخ انتخابی یا امروز تنظیم شود
// هنگام باز شدن پاپ‌اور یا تغییر مقدار، سال و ماه روی تاریخ جاری/انتخابی تنظیم شود
useEffect(() => {
if (isOpen) {
const d = selectedDate || new Date()
if (calendarType === 'persian') {
const j = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate())
setViewYear(j.year)
setViewMonth(j.month)
} else if (calendarType === 'islamic') {
const h = gregorianToHijri(d)
setViewYear(h.year)
setViewMonth(h.month)
} else {
setViewYear(d.getFullYear())
setViewMonth(d.getMonth() + 1)
let d = selectedDate || new Date()
if (minDate) {
const minD = parseSafeDate(minDate)
if (minD && d < minD) {
d = minD
}
}
const { year, month } = calculateYearMonth(calendarType, d)
setViewYear(year)
setViewMonth(month)
}
}, [isOpen, selectedDate, calendarType])
}, [isOpen, selectedDate, minDate])
// بستن پاپ‌اور هنگام کلیک به بیرون یا فشردن Escape
useEffect(() => {
if (!isOpen) return
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen])
// تعویض حالت تقویم (قمری / میلادی / شمسی)
const handleSwitchCalendarMode = (newMode: 'islamic' | 'gregorian' | 'persian') => {
setCalendarType(newMode)
const d = selectedDate || new Date()
const { year, month } = calculateYearMonth(newMode, d)
setViewYear(year)
setViewMonth(month)
}
// ماتریس روزهای ماه بر اساس تقویم فعال
const monthData = useMemo(() => {
return getCalendarMonthData(calendarType, viewYear, viewMonth)
}, [calendarType, viewYear, viewMonth])
// تاریخ امروز به استاندارد ISO
const todayIso = useMemo(() => {
const n = new Date()
return `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`
}, [])
// بررسی عدم امکان بازگشت به ماه‌های گذشته بر اساس minDate
const isPrevMonthDisabled = useMemo(() => {
if (!minDate) return false
return monthData.toIso(1) <= minDate
}, [minDate, monthData])
// بررسی عدم امکان رفتن به ماه‌های آینده بر اساس maxDate
const isNextMonthDisabled = useMemo(() => {
if (!maxDate) return false
return monthData.toIso(monthData.daysInMonth) >= maxDate
}, [maxDate, monthData])
// رفتن به ماه قبل
const handlePrevMonth = (e: React.MouseEvent) => {
e.stopPropagation()
if (isPrevMonthDisabled) return
if (viewMonth === 1) {
setViewMonth(12)
setViewYear((y) => y - 1)
@ -121,6 +154,7 @@ export function UniversalDatePicker({
// رفتن به ماه بعد
const handleNextMonth = (e: React.MouseEvent) => {
e.stopPropagation()
if (isNextMonthDisabled) return
if (viewMonth === 12) {
setViewMonth(1)
setViewYear((y) => y + 1)
@ -129,17 +163,7 @@ export function UniversalDatePicker({
}
}
// داده‌های ماه و ماتریس روزها
const monthData = useMemo(() => {
return getCalendarMonthData(calendarType, viewYear, viewMonth)
}, [calendarType, viewYear, viewMonth])
// بررسی وضعیت امروز و انتخاب
const todayIso = useMemo(() => {
const n = new Date()
return `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`
}, [])
// تاریخ انتخابی به استاندارد ISO
const selectedIso = useMemo(() => {
if (!selectedDate) return ''
return `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, '0')}-${String(selectedDate.getDate()).padStart(2, '0')}`
@ -148,14 +172,30 @@ export function UniversalDatePicker({
// انتخاب یک روز
const handleSelectDay = (dayNumber: number) => {
const isoStr = monthData.toIso(dayNumber)
if (minDate && isoStr < minDate) return
if (maxDate && isoStr > maxDate) return
onChange(isoStr)
setIsOpen(false)
if (closeOnSelect) {
setIsOpen(false)
}
}
// وضعیت غیرفعال بودن دکمه امروز بر اساس بازه مجاز
const isTodayDisabled = Boolean(
(minDate && todayIso < minDate) || (maxDate && todayIso > maxDate)
)
// انتخاب تاریخ امروز
const handleSelectToday = () => {
if (isTodayDisabled) return
onChange(todayIso)
setIsOpen(false)
const n = new Date()
const { year, month } = calculateYearMonth(calendarType, n)
setViewYear(year)
setViewMonth(month)
if (closeOnSelect) {
setIsOpen(false)
}
}
// پاک کردن تاریخ
@ -165,82 +205,172 @@ export function UniversalDatePicker({
setIsOpen(false)
}
const formattedDisplayValue = value ? formatDateByLanguage(value, language, { style: 'medium' }) : ''
// متن نمایش در اینپوت با پشتیبانی همزمان از تاریخ میلادی و قمری
const formattedDisplayValue = value
? formatDualDate(value, language, {
compact,
})
: ''
const defaultPlaceholder =
language === 'fa' ? 'انتخاب تاریخ...' : language === 'ar' ? 'اختر التاريخ...' : 'Select date...'
language === 'fa'
? 'انتخاب تاریخ (میلادی / قمری)...'
: language === 'ar'
? 'اختر التاريخ (ميلادي / هجري)...'
: 'Select date (Gregorian / Hijri)...'
return (
<div className={cn('relative inline-flex flex-col', className)} ref={containerRef}>
<div className={cn('relative inline-flex flex-col w-full', className)}>
{label && (
<label className="mb-1.5 text-xs font-bold text-foreground">
{label} {required && <span className="text-rose-500">*</span>}
<label className="mb-1 text-[11px] font-semibold text-muted-foreground flex items-center justify-between">
<span>
{label} {required && <span className="text-rose-500">*</span>}
</span>
<span className="text-[10px] text-primary font-normal">
{language === 'fa' ? 'نمایش دوگانه میلادی و قمری' : 'میلادی و هجري'}
</span>
</label>
)}
{/* دکمه اینپوت تریگر انتخاب تاریخ */}
<div
onClick={() => !disabled && setIsOpen((prev) => !prev)}
className={cn(
'flex items-center justify-between gap-2 rounded-xl border border-border-soft bg-card/60 px-3 text-xs transition cursor-pointer select-none',
size === 'sm' && 'h-8 text-[11px]',
size === 'default' && 'h-9',
size === 'lg' && 'h-11 text-sm',
disabled && 'opacity-50 cursor-not-allowed pointer-events-none',
isOpen ? 'border-primary ring-1 ring-primary/40' : 'hover:border-primary/40',
!formattedDisplayValue && 'text-muted-foreground'
)}
>
<div className="flex items-center gap-2 truncate min-w-0">
<Ic name="calendar" className="size-4 shrink-0 text-primary" />
<span className="truncate font-medium">
{formattedDisplayValue || placeholder || defaultPlaceholder}
</span>
</div>
{/* دکمه اینپوت تریگر انتخاب تاریخ با Popover Radix */}
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<div
className={cn(
'flex items-center justify-between gap-2 rounded-xl border border-border/80 bg-background px-3 text-xs transition cursor-pointer select-none',
size === 'sm' && 'h-8 text-[11px] px-2.5',
size === 'default' && 'h-10 text-xs',
size === 'lg' && 'h-11 text-sm px-3.5',
disabled && 'opacity-50 cursor-not-allowed pointer-events-none',
isOpen ? 'border-primary ring-1 ring-primary/40' : 'hover:border-primary/50',
!formattedDisplayValue && 'text-muted-foreground'
)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Ic name="calendar" className="size-4 shrink-0 text-primary" />
<span
className="font-semibold text-xs text-foreground truncate select-text"
style={{ unicodeBidi: 'plaintext' }}
>
{formattedDisplayValue || placeholder || defaultPlaceholder}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{clearable && Boolean(value) && !disabled && (
<button
type="button"
onClick={handleClear}
className="size-5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/40 flex items-center justify-center transition"
title={language === 'fa' ? 'پاک کردن' : language === 'ar' ? 'مسح' : 'Clear'}
>
<Ic name="x" className="size-3.5" />
</button>
)}
<Ic name="chevronDown" className={cn('size-3.5 text-muted-foreground transition-transform', isOpen && 'rotate-180')} />
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{clearable && Boolean(value) && !disabled && (
<button
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'}
>
<Ic name="x" className="size-3" />
</button>
)}
<Ic
name="chevronDown"
className={cn('size-3.5 text-muted-foreground transition-transform duration-200', isOpen && 'rotate-180')}
/>
</div>
</div>
</PopoverTrigger>
{/* پاپ‌اور تقویم ماهانه */}
{isOpen && (
<div
{/* محتوای پاپ‌اور تقویم با سیستم دوگانه قمری و میلادی */}
<PopoverContent
align="start"
sideOffset={6}
dir={dir}
className="absolute start-0 top-full z-50 mt-1.5 w-[280px] sm:w-[300px] rounded-2xl border border-border-soft bg-[#1e2128] p-3 shadow-xl backdrop-blur-md anim-pop"
className="w-[316px] sm:w-[336px] p-3 rounded-2xl bg-card border border-border shadow-2xl z-50 text-foreground"
>
{/* هدر کنترل ماه و سال */}
<div className="flex items-center justify-between gap-1 pb-2.5 border-b border-border-soft">
{/* تب‌های انتخاب سیستم تقویم (قمری / میلادی) */}
<div className="flex items-center justify-between p-1 bg-muted/60 rounded-xl mb-2.5 border border-border/60">
{allowedModes.includes('islamic') && (
<button
type="button"
onClick={() => handleSwitchCalendarMode('islamic')}
className={cn(
'flex-1 py-1.5 px-2 rounded-lg text-[11px] font-bold transition flex items-center justify-center gap-1.5 cursor-pointer',
calendarType === 'islamic'
? 'bg-background text-primary shadow-xs border border-border/80'
: 'text-muted-foreground hover:text-foreground'
)}
>
<span>🌙</span>
<span>{t('customTrip.lunarCalendarTab') || 'تقویم قمری'}</span>
</button>
)}
{allowedModes.includes('gregorian') && (
<button
type="button"
onClick={() => handleSwitchCalendarMode('gregorian')}
className={cn(
'flex-1 py-1.5 px-2 rounded-lg text-[11px] font-bold transition flex items-center justify-center gap-1.5 cursor-pointer',
calendarType === 'gregorian'
? 'bg-background text-primary shadow-xs border border-border/80'
: 'text-muted-foreground hover:text-foreground'
)}
>
<span>🌍</span>
<span>{t('customTrip.gregorianCalendarTab') || 'تقویم میلادی'}</span>
</button>
)}
{allowedModes.includes('persian') && (
<button
type="button"
onClick={() => handleSwitchCalendarMode('persian')}
className={cn(
'flex-1 py-1.5 px-2 rounded-lg text-[11px] font-bold transition flex items-center justify-center gap-1.5 cursor-pointer',
calendarType === 'persian'
? 'bg-background text-primary shadow-xs border border-border/80'
: 'text-muted-foreground hover:text-foreground'
)}
>
<span>☀️</span>
<span>{t('customTrip.jalaliCalendarTab') || 'شمسی'}</span>
</button>
)}
</div>
{/* هدر کنترل ماه و سال + نمایش بازه تقویم دوم */}
<div className="flex items-center justify-between gap-1 pb-2 border-b border-border/60">
<Button
type="button"
variant="ghost"
size="sm"
disabled={dir === 'rtl' ? isNextMonthDisabled : isPrevMonthDisabled}
onClick={dir === 'rtl' ? handleNextMonth : handlePrevMonth}
className="size-7 p-0 rounded-lg text-muted-foreground hover:text-foreground hover:bg-card"
className={cn(
'size-7 p-0 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted',
(dir === 'rtl' ? isNextMonthDisabled : isPrevMonthDisabled) &&
'opacity-30 cursor-not-allowed pointer-events-none'
)}
>
<Ic name={dir === 'rtl' ? 'chevronRight' : 'chevronLeft'} className="size-4" />
</Button>
<div className="flex items-center gap-1 font-bold text-xs text-foreground">
<span>{monthData.monthName}</span>
<span className="font-mono">{monthData.yearFormatted}</span>
<div className="flex flex-col items-center">
<div className="flex items-center gap-1 font-bold text-xs text-foreground">
<span>{monthData.monthName}</span>
<span className="font-mono">{monthData.yearFormatted}</span>
</div>
{monthData.secondaryHeader && (
<div className="text-[10px] text-muted-foreground font-medium">
{monthData.secondaryHeader}
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
disabled={dir === 'rtl' ? isPrevMonthDisabled : isNextMonthDisabled}
onClick={dir === 'rtl' ? handlePrevMonth : handleNextMonth}
className="size-7 p-0 rounded-lg text-muted-foreground hover:text-foreground hover:bg-card"
className={cn(
'size-7 p-0 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted',
(dir === 'rtl' ? isPrevMonthDisabled : isNextMonthDisabled) &&
'opacity-30 cursor-not-allowed pointer-events-none'
)}
>
<Ic name={dir === 'rtl' ? 'chevronLeft' : 'chevronRight'} className="size-4" />
</Button>
@ -249,17 +379,17 @@ export function UniversalDatePicker({
{/* ردیف روزهای هفته */}
<div className="grid grid-cols-7 gap-1 pt-2 text-center text-[10px] font-bold text-muted-foreground">
{monthData.weekdays.map((w, idx) => (
<div key={idx} className="py-1">
<div key={idx} className="py-0.5">
{w}
</div>
))}
</div>
{/* ماتریس روزهای ماه */}
<div className="grid grid-cols-7 gap-1 pt-1">
{/* ماتریس روزهای ماه با نمایش دوگانه (عدد اصلی + عدد تقویم معادل) */}
<div className="grid grid-cols-7 gap-1 pt-1.5">
{/* خانه‌های خالی ابتدای ماه */}
{Array.from({ length: monthData.startOffset }).map((_, idx) => (
<div key={`empty-${idx}`} className="size-8" />
<div key={`empty-${idx}`} className="h-10 sm:h-11" />
))}
{/* روزهای ماه */}
@ -268,8 +398,8 @@ export function UniversalDatePicker({
const dayIso = monthData.toIso(dayNum)
const isSelected = selectedIso === dayIso
const isToday = todayIso === dayIso
const secondary = monthData.getSecondaryDay(dayNum)
// بررسی minDate و maxDate
const isDisabled =
(minDate && dayIso < minDate) ||
(maxDate && dayIso > maxDate)
@ -281,27 +411,91 @@ export function UniversalDatePicker({
disabled={Boolean(isDisabled)}
onClick={() => handleSelectDay(dayNum)}
className={cn(
'size-8 rounded-xl text-xs font-bold transition flex items-center justify-center',
'h-10 sm:h-11 rounded-xl transition flex flex-col items-center justify-center p-0.5 relative cursor-pointer',
isSelected
? 'bg-primary text-primary-foreground shadow-sm'
? 'bg-primary text-primary-foreground shadow-sm ring-1 ring-primary'
: isToday
? 'border border-primary/60 text-primary hover:bg-primary/10'
: 'text-foreground hover:bg-card/70',
? 'border border-primary/60 text-primary hover:bg-primary/10'
: 'text-foreground hover:bg-muted/70',
isDisabled && 'opacity-30 cursor-not-allowed pointer-events-none'
)}
title={`${monthData.monthName} ${dayNum} ↔ ${secondary.calendarName}: ${secondary.formatted} ${secondary.monthName}`}
>
{monthData.formatDayNumber(dayNum)}
<span className="text-xs font-bold font-mono leading-none">
{monthData.formatDayNumber(dayNum)}
</span>
<span
className={cn(
'text-[9px] font-mono leading-none mt-1 opacity-70',
isSelected ? 'text-primary-foreground/90 font-bold' : 'text-muted-foreground'
)}
>
{secondary.formatted}
</span>
</button>
)
})}
</div>
{/* انتخاب ساعت حرکت/پرواز در صورت فعال بودن withTime */}
{withTime && (
<div className="mt-2.5 pt-2.5 border-t border-border/70 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-xs font-semibold text-foreground">
<Ic name="clock" className="size-3.5 text-primary" />
<span>{t('customTrip.ticketDepartureTime') || 'ساعت حرکت'}:</span>
</div>
<div className="flex items-center gap-1.5">
<input
type="time"
value={timeValue || '09:00'}
onChange={(e) => onTimeChange?.(e.target.value)}
className="h-7.5 w-24 px-2 text-xs font-mono text-center rounded-lg bg-background border border-border focus:ring-1 focus:ring-primary focus:outline-none dir-ltr font-bold text-foreground"
/>
<div className="flex items-center gap-0.5">
{['09:00', '18:00'].map((preset) => (
<button
key={preset}
type="button"
onClick={() => onTimeChange?.(preset)}
className={cn(
'px-1.5 py-0.5 rounded text-[10px] font-mono transition cursor-pointer',
timeValue === preset
? 'bg-primary text-primary-foreground font-bold'
: 'bg-muted text-muted-foreground hover:text-foreground'
)}
>
{preset}
</button>
))}
</div>
</div>
</div>
)}
{/* خلاصه تاریخ انتخابی به صورت دوگانه */}
{value && (
<div className="mt-2.5 p-1.5 rounded-lg bg-muted/40 border border-border/60 text-[10px] text-muted-foreground font-mono flex items-center justify-between gap-1">
<span className="truncate">
{formatDualDate(value, language, { compact: false })}
</span>
{withTime && timeValue && (
<span className="font-bold text-primary shrink-0">⏱ {timeValue}</span>
)}
</div>
)}
{/* فوتر تقویم با دکمه‌های سریع */}
<div className="mt-3 pt-2.5 border-t border-border-soft flex items-center justify-between gap-1 text-[11px]">
<div className="mt-2.5 pt-2 border-t border-border/70 flex items-center justify-between gap-1 text-[11px]">
<button
type="button"
disabled={isTodayDisabled}
onClick={handleSelectToday}
className="text-primary hover:underline font-bold px-2 py-0.5 rounded"
className={cn(
'text-primary hover:underline font-bold px-2 py-0.5 rounded',
isTodayDisabled
? 'opacity-30 cursor-not-allowed pointer-events-none'
: 'cursor-pointer'
)}
>
{language === 'fa' ? 'امروز' : language === 'ar' ? 'اليوم' : 'Today'}
</button>
@ -310,22 +504,23 @@ export function UniversalDatePicker({
<button
type="button"
onClick={handleClear}
className="text-muted-foreground hover:text-rose-400 px-2 py-0.5 rounded"
className="text-muted-foreground hover:text-rose-500 px-2 py-0.5 rounded cursor-pointer"
>
{language === 'fa' ? 'پاک کردن' : language === 'ar' ? 'مسح' : 'Clear'}
</button>
)}
<button
<Button
type="button"
size="sm"
onClick={() => setIsOpen(false)}
className="text-muted-foreground hover:text-foreground px-2 py-0.5 rounded"
className="h-7 px-3 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 cursor-pointer"
>
{language === 'fa' ? 'بستن' : language === 'ar' ? 'إغلاق' : 'Close'}
</button>
{language === 'fa' ? 'تأیید' : language === 'ar' ? 'تأكيد' : 'Done'}
</Button>
</div>
</div>
)}
</PopoverContent>
</Popover>
</div>
)
}

2
src/components/ui/alert-dialog.tsx

@ -23,7 +23,7 @@ function AlertDialogContent({ className, children, ...props }: React.ComponentPr
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-50 w-full max-w-[420px] -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border bg-card text-card-foreground shadow-2xl anim-pop outline-none p-6',
'fixed left-1/2 top-1/2 z-50 w-[calc(100vw-1.5rem)] sm:w-full max-w-[420px] -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border bg-card text-card-foreground shadow-2xl anim-pop outline-none p-6',
className,
)}
{...props}

2
src/components/ui/command.tsx

@ -72,7 +72,7 @@ function CommandInput({
trailing?: React.ReactNode
}) {
return (
<div className="flex items-center gap-2.5 border-b border-border-soft px-4 [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-grey-3">
<div className="flex items-center gap-2.5 border-b border-border-soft px-4 pe-12 [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-grey-3">
<Ic name="search" />
<CommandPrimitive.Input
className={cn(

2
src/components/ui/dialog.tsx

@ -36,7 +36,7 @@ function DialogContent({
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-50 w-full max-w-[640px] max-h-[88vh] -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border-soft bg-card text-card-foreground shadow-2xl anim-pop outline-none flex flex-col p-0 overflow-hidden',
'fixed left-1/2 top-1/2 z-50 w-[calc(100vw-1.5rem)] sm:w-full max-w-[640px] max-h-[88vh] -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border-soft bg-card text-card-foreground shadow-2xl anim-pop outline-none flex flex-col p-0 overflow-hidden',
className,
)}
onPointerDownOutside={(e) => {

6
src/components/ui/table.tsx

@ -2,7 +2,11 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return <table className={cn('w-full caption-bottom text-[13px]', className)} {...props} />
return (
<div className="relative w-full overflow-x-auto">
<table className={cn('w-full caption-bottom text-[13px]', className)} {...props} />
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return <thead className={cn('[&_tr]:border-b [&_tr]:border-border-soft', className)} {...props} />

480
src/components/utils/dev-click-to-component.tsx

@ -6,27 +6,27 @@ import { useEffect, useState, useRef } from "react";
const IDE_SCHEMES = [
{
matches: ["antigravity"],
createUrl: (locator: string) => `antigravity://file/${locator}`,
createUrl: (uri: string) => `antigravity-ide://file${uri.startsWith('/') ? uri : `/${uri}`}`,
},
{
matches: ["cursor"],
createUrl: (locator: string) => `cursor://file/${locator}`,
createUrl: (uri: string) => `cursor://file${uri.startsWith('/') ? uri : `/${uri}`}`,
},
{
matches: ["vscode", "code"],
createUrl: (locator: string) => `vscode://file/${locator}`,
createUrl: (uri: string) => `vscode://file${uri.startsWith('/') ? uri : `/${uri}`}`,
},
{
matches: ["webstorm", "intellij"],
createUrl: (locator: string) => `webstorm://open?file=${locator}`,
createUrl: (uri: string) => `webstorm://open?file=${uri.startsWith('/') ? uri : `/${uri}`}`,
},
{
matches: ["sublime"],
createUrl: (locator: string) => `subl://open?url=file://${locator}`,
createUrl: (uri: string) => `subl://open?url=file://${uri.startsWith('/') ? uri : `/${uri}`}`,
},
{
matches: ["atom", "nova"],
createUrl: (locator: string) => `atom://open?url=file://${locator}`,
createUrl: (uri: string) => `atom://open?url=file://${uri.startsWith('/') ? uri : `/${uri}`}`,
},
] as const;
@ -51,6 +51,12 @@ export function DevClickToComponent() {
const [isMobile, setIsMobile] = useState(false);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [hoverInfo, setHoverInfo] = useState<{
locator: string;
label: string;
rect: { top: number; left: number; width: number; height: number };
} | null>(null);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null);
const dragStart = useRef({ x: 0, y: 0, buttonX: 0, buttonY: 0, hasMoved: false });
@ -67,9 +73,10 @@ export function DevClickToComponent() {
// Initialize button position
useEffect(() => {
if (typeof window !== "undefined") {
const isRtl = document.documentElement.dir === 'rtl' || document.documentElement.classList.contains('lang-rtl');
setPosition({
x: window.innerWidth - 66, // 50px width + 16px margin
y: window.innerHeight - 66, // 50px height + 16px margin
x: isRtl ? 16 : window.innerWidth - 66,
y: window.innerHeight - 66,
});
}
}, [isMobile]);
@ -90,124 +97,194 @@ export function DevClickToComponent() {
return () => window.removeEventListener("resize", handleResize);
}, [isMobile]);
// Handle keyboard shortcuts (Alt key for hover highlight, Escape to cancel inspect)
useEffect(() => {
const userAgent = navigator.userAgent.toLowerCase();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsInspecting(false);
setHoverInfo(null);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "Alt" && !isInspecting) {
setHoverInfo(null);
}
};
// Desktop: Alt + Click logic
const handleDesktopClick = (event: MouseEvent) => {
if (!event.altKey) {
const handleBlur = () => {
if (!isInspecting) {
setHoverInfo(null);
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
};
}, [isInspecting]);
// Live hover highlight when Alt is held or Inspect Mode is active
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
if (!event.altKey && !isInspecting) {
if (hoverInfo) setHoverInfo(null);
return;
}
const target = event.target;
if (!(target instanceof HTMLElement)) {
if (!(target instanceof Element)) {
if (hoverInfo) setHoverInfo(null);
return;
}
const locator = target
.closest<HTMLElement>("[data-locator]")
?.getAttribute("data-locator");
if (target.closest(".dev-floating-panel") || target.closest(".dev-inspect-overlay")) {
if (hoverInfo) setHoverInfo(null);
return;
}
const locatorEl = target.closest<HTMLElement>("[data-locator]");
if (!locatorEl) {
if (hoverInfo) setHoverInfo(null);
return;
}
const locator = locatorEl.getAttribute("data-locator");
if (!locator) {
if (hoverInfo) setHoverInfo(null);
return;
}
event.preventDefault();
event.stopPropagation();
const rect = locatorEl.getBoundingClientRect();
const { filePath, line } = parseLocator(locator);
const shortName = filePath.split("/").slice(-2).join("/");
const label = `<${shortName}${line ? `:${line}` : ""}>`;
setHoverInfo({
locator,
label,
rect: {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
},
});
};
const { filePath, line, column } = parseLocator(locator);
const positionSuffix =
line === null ? "" : `:${line}${column === null ? "" : `:${column}`}`;
window.addEventListener("mousemove", handleMouseMove, true);
return () => {
window.removeEventListener("mousemove", handleMouseMove, true);
};
}, [isInspecting, hoverInfo]);
// Helper to open locator in IDE with visual feedback
const openElementInIde = async (locator: string, targetEl?: HTMLElement | null) => {
const userAgent = navigator.userAgent.toLowerCase();
const { filePath, line, column } = parseLocator(locator);
const positionSuffix = line === null ? "" : `:${line}${column === null ? "" : `:${column}`}`;
const locatorString = `${filePath}${positionSuffix}`;
const fileName = filePath.split("/").pop() || filePath;
if (targetEl) {
const originalOutline = targetEl.style.outline;
const originalOffset = targetEl.style.outlineOffset;
targetEl.style.outline = "3px solid #10b981";
targetEl.style.outlineOffset = "-2px";
setTimeout(() => {
targetEl.style.outline = originalOutline;
targetEl.style.outlineOffset = originalOffset;
}, 500);
}
const locatorString = `${filePath}${positionSuffix}`;
setToast({ message: `در حال باز کردن ${fileName}${positionSuffix} در ادیتور...`, type: "info" });
// Use the API route to execute the shell command which reuses the existing window
fetch("/api/open-in-ide", {
const cleanPath = filePath.replace(/^[/\\]+/, "");
const uriPath = filePath.startsWith("/") ? filePath : `/${filePath}`;
const targetUri = `${uriPath}${positionSuffix}`;
const antigravityIdeUrl = `antigravity-ide://file${targetUri}`;
try {
const res = await fetch("/api/open-in-ide", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locator: locatorString, userAgent }),
}).catch((err) => {
console.error("[DevClickToComponent] Error sending API request:", err);
});
// Fallback to URL scheme
const ideUrl =
IDE_SCHEMES.find(({ matches }) =>
matches.some((match) => userAgent.includes(match)),
)?.createUrl(locatorString) ??
`antigravity://file/${locatorString}`;
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || data.error || `HTTP ${res.status}`);
}
try {
window.location.href = ideUrl;
} catch {
window.open(`file://${filePath}`, "_blank", "noopener,noreferrer");
}
});
};
setToast({ message: `✓ فایل ${fileName}${positionSuffix} باز شد`, type: "success" });
setTimeout(() => setToast(null), 2500);
} catch (err: any) {
console.error("[DevClickToComponent] Error sending API request:", err);
document.addEventListener("click", handleDesktopClick, true);
return () => {
document.removeEventListener("click", handleDesktopClick, true);
};
}, []);
const ideUrl =
IDE_SCHEMES.find(({ matches }) =>
matches.some((match) => userAgent.includes(match)),
)?.createUrl(targetUri) ?? antigravityIdeUrl;
// Mobile: Inspect Mode click interception
useEffect(() => {
if (!isMobile || !isInspecting) return;
try {
window.location.assign(ideUrl);
setToast({ message: `✓ باز کردن از طریق پروتکل: ${fileName}`, type: "success" });
setTimeout(() => setToast(null), 2500);
} catch {
window.open(`file://${filePath}`, "_blank", "noopener,noreferrer");
setToast({ message: `خطا در باز کردن ادیتور: ${err?.message || "نامشخص"}`, type: "error" });
setTimeout(() => setToast(null), 4000);
}
}
};
const userAgent = navigator.userAgent.toLowerCase();
// Click interception (Alt + Click on desktop, or tap in Inspect Mode)
useEffect(() => {
const handleDocumentClick = (event: MouseEvent) => {
if (!event.altKey && !isInspecting) {
return;
}
const handleInspectClick = async (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (!(target instanceof Element)) {
return;
}
// Ignore clicks on the inspect button itself
if (target.closest(".dev-inspect-btn")) {
if (target.closest(".dev-floating-panel") || target.closest(".dev-inspect-overlay")) {
return;
}
const locatorEl = target.closest<HTMLElement>("[data-locator]");
if (!locatorEl) {
return;
}
const locator = locatorEl.getAttribute("data-locator");
if (!locator) {
return;
}
event.preventDefault();
event.stopPropagation();
const locator = target
.closest("[data-locator]")
?.getAttribute("data-locator");
if (locator) {
console.log(`[DevClickToComponent] Inspect Mode matched element. Opening in IDE:`, locator);
// Flash target element outline briefly as visual feedback
const element = target.closest<HTMLElement>("[data-locator]");
if (element) {
const originalOutline = element.style.outline;
element.style.outline = "3px solid #3b82f6";
setTimeout(() => {
element.style.outline = originalOutline;
}, 400);
}
openElementInIde(locator, locatorEl);
try {
await fetch("/api/open-in-ide", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ locator, userAgent }),
});
} catch (err) {
console.error("[DevClickToComponent] Error sending API request:", err);
}
if (isInspecting) {
setIsInspecting(false);
}
setIsInspecting(false);
setHoverInfo(null);
};
// Use capturing phase to intercept clicks/taps before they trigger other actions
document.addEventListener("click", handleInspectClick, true);
document.addEventListener("click", handleDocumentClick, true);
return () => {
document.removeEventListener("click", handleInspectClick, true);
document.removeEventListener("click", handleDocumentClick, true);
};
}, [isMobile, isInspecting]);
}, [isInspecting]);
// Dev mode: Intercept and forward console logs globally
useEffect(() => {
@ -522,64 +599,181 @@ export function DevClickToComponent() {
};
return (
<div
style={{
position: "fixed",
left: position ? `${position.x}px` : "auto",
top: position ? `${position.y}px` : "auto",
bottom: position ? "auto" : "16px",
right: position ? "auto" : "16px",
display: "flex",
flexDirection: "column",
gap: "10px",
zIndex: 99998,
touchAction: "none",
}}
>
{/* Send LocalStorage Button */}
<button
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onClick={handleSendStorageClick}
<>
{/* Hover outline overlay */}
{hoverInfo && (
<div
className="dev-inspect-overlay"
style={{
position: "fixed",
top: `${hoverInfo.rect.top}px`,
left: `${hoverInfo.rect.left}px`,
width: `${hoverInfo.rect.width}px`,
height: `${hoverInfo.rect.height}px`,
border: "2px dashed #6366f1",
backgroundColor: "rgba(99, 102, 241, 0.09)",
zIndex: 99997,
pointerEvents: "none",
boxSizing: "border-box",
borderRadius: "4px",
transition: "top 0.04s ease, left 0.04s ease, width 0.04s ease, height 0.04s ease",
}}
>
<div
style={{
position: "absolute",
top: hoverInfo.rect.top < 26 ? "100%" : "-24px",
left: 0,
backgroundColor: "#4f46e5",
color: "#ffffff",
fontSize: "11px",
fontWeight: 600,
padding: "2px 8px",
borderRadius: "4px",
whiteSpace: "nowrap",
boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
pointerEvents: "none",
fontFamily: "monospace",
direction: "ltr",
}}
>
{hoverInfo.label}
</div>
</div>
)}
{/* Inspect Mode Top Notification */}
{isInspecting && (
<div
className="dev-inspect-overlay"
style={{
position: "fixed",
top: "16px",
left: "50%",
transform: "translateX(-50%)",
backgroundColor: "#0f172a",
color: "#f8fafc",
border: "1px solid #3b82f6",
padding: "8px 18px",
borderRadius: "9999px",
fontSize: "12px",
fontWeight: 500,
zIndex: 99998,
boxShadow: "0 10px 25px -5px rgba(0, 0, 0, 0.5), 0 0 15px rgba(59, 130, 246, 0.4)",
display: "flex",
alignItems: "center",
gap: "10px",
direction: "rtl",
}}
>
<span style={{ width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#3b82f6" }} />
<span>حالت انتخاب کامپوننت فعال است (روی هر بخش کلیک کنید تا در ادیتور باز شود)</span>
<button
onClick={() => {
setIsInspecting(false);
setHoverInfo(null);
}}
style={{
background: "rgba(255,255,255,0.15)",
border: "none",
color: "#fff",
borderRadius: "4px",
padding: "2px 8px",
fontSize: "11px",
cursor: "pointer",
}}
>
انصراف (Esc)
</button>
</div>
)}
{/* Toast Feedback */}
{toast && (
<div
className="dev-inspect-overlay"
style={{
position: "fixed",
bottom: "80px",
left: "50%",
transform: "translateX(-50%)",
backgroundColor: toast.type === "error" ? "#dc2626" : toast.type === "success" ? "#059669" : "#2563eb",
color: "#ffffff",
padding: "8px 18px",
borderRadius: "8px",
fontSize: "13px",
fontWeight: 500,
zIndex: 99999,
boxShadow: "0 4px 14px rgba(0,0,0,0.3)",
pointerEvents: "none",
direction: "rtl",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
{toast.message}
</div>
)}
<div
className="dev-floating-panel"
style={{
width: "50px",
height: "50px",
borderRadius: "25px",
backgroundColor: sendSuccess ? "#10b981" : "#a855f7",
color: "#ffffff",
border: "none",
boxShadow: sendSuccess
? "0 0 15px rgba(16, 185, 129, 0.6)"
: "0 4px 12px rgba(0,0,0,0.35)",
position: "fixed",
left: position ? `${position.x}px` : "auto",
top: position ? `${position.y}px` : "auto",
bottom: position ? "auto" : "16px",
right: position ? "auto" : "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: isDragging ? "grabbing" : "pointer",
transition: isDragging ? "none" : "background-color 0.25s, transform 0.25s, box-shadow 0.25s",
outline: "none",
transform: isSendingStorage ? "scale(0.95)" : "scale(1)",
flexDirection: "column",
gap: "10px",
zIndex: 99998,
touchAction: "none",
}}
title="Send LocalStorage to Terminal API"
>
{isSendingStorage ? (
<svg className="animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" style={{ width: "22px", height: "22px", pointerEvents: "none" }}>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : sendSuccess ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" style={{ width: "22px", height: "22px", pointerEvents: "none" }}>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" style={{ width: "22px", height: "22px", pointerEvents: "none" }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 16.5V9.75m0 0 3 3m-3-3-3 3M6.75 19.5a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" />
</svg>
)}
</button>
{/* Floating Action Draggable Button (Inspect Mode) */}
{isMobile && (
{/* Send LocalStorage Button */}
<button
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onClick={handleSendStorageClick}
style={{
width: "50px",
height: "50px",
borderRadius: "25px",
backgroundColor: sendSuccess ? "#10b981" : "#a855f7",
color: "#ffffff",
border: "none",
boxShadow: sendSuccess
? "0 0 15px rgba(16, 185, 129, 0.6)"
: "0 4px 12px rgba(0,0,0,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: isDragging ? "grabbing" : "pointer",
transition: isDragging ? "none" : "background-color 0.25s, transform 0.25s, box-shadow 0.25s",
outline: "none",
transform: isSendingStorage ? "scale(0.95)" : "scale(1)",
}}
title="Send LocalStorage to Terminal API"
>
{isSendingStorage ? (
<svg className="animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" style={{ width: "22px", height: "22px", pointerEvents: "none" }}>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : sendSuccess ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" style={{ width: "22px", height: "22px", pointerEvents: "none" }}>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" style={{ width: "22px", height: "22px", pointerEvents: "none" }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 16.5V9.75m0 0 3 3m-3-3-3 3M6.75 19.5a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" />
</svg>
)}
</button>
{/* Floating Action Draggable Button (Inspect Mode) */}
<button
className="dev-inspect-btn"
onPointerDown={handlePointerDown}
@ -604,7 +798,7 @@ export function DevClickToComponent() {
outline: "none",
transform: isInspecting ? "scale(1.1)" : "scale(1)",
}}
title="Toggle Dev Inspect Mode (Drag to move)"
title={isInspecting ? "خروج از حالت انتخاب (Esc)" : "حالت انتخاب کامپوننت برای باز کردن در ادیتور (یا کلید Alt + کلیک)"}
>
{isInspecting ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" style={{ width: "22px", height: "22px", pointerEvents: "none" }}>
@ -616,8 +810,8 @@ export function DevClickToComponent() {
</svg>
)}
</button>
)}
</div>
</div>
</>
);
}

27
src/config/navigation.ts

@ -129,8 +129,31 @@ export function getNavigationConfig(t: (key: string) => string): NavGroupConfig[
},
{ title: t('navigation.costEstimate'), titleKey: 'navigation.costEstimate', to: '/services/cost-estimate', icon: 'calculator' },
{ title: t('navigation.support'), titleKey: 'navigation.support', to: '/services/consultant', icon: 'headset' },
{ title: t('navigation.faq'), titleKey: 'navigation.faq', to: '/services/faq', icon: 'helpCircle' },
{ title: t('navigation.settings'), titleKey: 'navigation.settings', to: '/settings', icon: 'settings' },
{
title: t('navigation.settings'),
titleKey: 'navigation.settings',
icon: 'settings',
children: [
{
title: t('navigation.settingsAdmins'),
titleKey: 'navigation.settingsAdmins',
to: '/settings/admins',
icon: 'users',
},
{
title: t('navigation.settingsSocial'),
titleKey: 'navigation.settingsSocial',
to: '/settings/social',
icon: 'share',
},
{
title: t('navigation.settingsNotifications'),
titleKey: 'navigation.settingsNotifications',
to: '/settings/notifications',
icon: 'bell',
},
],
},
],
},
]

1131
src/features/billing/BillingDetailPage.tsx
File diff suppressed because it is too large
View File

629
src/features/billing/BillingPage.tsx

@ -17,7 +17,7 @@ import {
} from '@/components/ui/table'
import { Ic, IconName } from '@/icons'
import { Shimmer, ShimmerBox, ShimmerCircle, ShimmerLine } from '@/components/shared/shimmer_frontend'
import { fetchFactors, reviewFactor, clearFactorReceipt } from './services/billing-api'
import { fetchFactors, fetchFactorDetail, reviewFactor, clearFactorReceipt } from './services/billing-api'
import type { FactorItem, FactorStatus, FactorService, BillingStats } from './types'
import { BillingStatusBadge } from './components/BillingStatusBadge'
import { BillingServiceBadge } from './components/BillingServiceBadge'
@ -111,6 +111,15 @@ export default function BillingPage() {
setSelectedStatuses((prev) => (prev.join(',') !== urlStatuses.join(',') ? urlStatuses : prev))
setSearchQuery((prev) => (prev !== urlSearch ? urlSearch : prev))
setCurrentPage((prev) => (prev !== urlPage ? urlPage : prev))
const directFactorId = searchParams.get('factorId') || searchParams.get('id')
if (directFactorId) {
fetchFactorDetail(directFactorId)
.then((f) => {
if (f && f.id) setSelectedFactorForDetail(f)
})
.catch((e) => console.warn('Could not auto-open factor from URL:', e))
}
}, [searchParams])
// Load factors from API with race condition protection
@ -334,13 +343,13 @@ export default function BillingPage() {
)}
{/* سربرگ اصلی صفحه صورت‌حساب‌ها */}
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-6 shadow-xs sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs">
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-4 sm:p-6 shadow-xs sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs">
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<div className="flex items-center gap-2.5 flex-wrap">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0">
<Ic name="receipt" className="size-5" />
</div>
<h1 className="text-xl font-black text-foreground">
<h1 className="text-lg sm:text-xl font-black text-foreground">
{t('billing.title')}
</h1>
<Badge
@ -359,7 +368,7 @@ export default function BillingPage() {
</p>
</div>
<div className="flex items-center gap-2.5">
<div className="flex items-center gap-2.5 self-start sm:self-auto shrink-0">
<Button
variant="outline"
size="sm"
@ -375,19 +384,19 @@ export default function BillingPage() {
{/* کارت‌های KPI شاخص‌های کلیدی مالی */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3.5">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-5 gap-3 sm:gap-3.5">
{/* کل فاکتورها */}
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-grey-3 font-medium">{t('billing.totalInvoices')}</p>
<CardContent className="p-3.5 sm:p-4 flex items-center justify-between gap-2.5">
<div className="min-w-0 flex-1">
<p className="text-[11px] text-grey-3 font-medium truncate">{t('billing.totalInvoices')}</p>
{isLoading ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono text-foreground">{stats.total}</p>
<p className="text-lg sm:text-xl font-black mt-1 font-mono text-foreground tabular-nums">{stats.total}</p>
)}
</div>
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground shrink-0">
<Ic name="receipt" className="size-4" />
</div>
</CardContent>
@ -395,20 +404,20 @@ export default function BillingPage() {
{/* در انتظار بررسی فیش */}
<Card className="border-blue-500/20 bg-blue-500/5 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-blue-600 dark:text-blue-400 font-medium">
<CardContent className="p-3.5 sm:p-4 flex items-center justify-between gap-2.5">
<div className="min-w-0 flex-1">
<p className="text-[11px] text-blue-600 dark:text-blue-400 font-medium truncate">
{t('billing.pendingReview')}
</p>
{isLoading ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono text-blue-600 dark:text-blue-400">
<p className="text-lg sm:text-xl font-black mt-1 font-mono text-blue-600 dark:text-blue-400 tabular-nums">
{stats.pendingReview}
</p>
)}
</div>
<div className="size-9 rounded-xl bg-blue-500/15 text-blue-600 dark:text-blue-400 flex items-center justify-center">
<div className="size-9 rounded-xl bg-blue-500/15 text-blue-600 dark:text-blue-400 flex items-center justify-center shrink-0">
<Ic name="clock" className="size-4" />
</div>
</CardContent>
@ -416,20 +425,20 @@ export default function BillingPage() {
{/* در انتظار پرداخت */}
<Card className="border-amber-500/20 bg-amber-500/5 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-amber-600 dark:text-amber-400 font-medium">
<CardContent className="p-3.5 sm:p-4 flex items-center justify-between gap-2.5">
<div className="min-w-0 flex-1">
<p className="text-[11px] text-amber-600 dark:text-amber-400 font-medium truncate">
{t('billing.awaitingPayment')}
</p>
{isLoading ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono text-amber-600 dark:text-amber-400">
<p className="text-lg sm:text-xl font-black mt-1 font-mono text-amber-600 dark:text-amber-400 tabular-nums">
{stats.awaitingPayment}
</p>
)}
</div>
<div className="size-9 rounded-xl bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center justify-center">
<div className="size-9 rounded-xl bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center justify-center shrink-0">
<Ic name="creditCard" className="size-4" />
</div>
</CardContent>
@ -437,41 +446,41 @@ export default function BillingPage() {
{/* تایید شده */}
<Card className="border-emerald-500/20 bg-emerald-500/5 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">
<CardContent className="p-3.5 sm:p-4 flex items-center justify-between gap-2.5">
<div className="min-w-0 flex-1">
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium truncate">
{t('billing.approvedPayments')}
</p>
{isLoading ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400">
<p className="text-lg sm:text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400 tabular-nums">
{stats.approved}
</p>
)}
</div>
<div className="size-9 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
<div className="size-9 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center shrink-0">
<Ic name="checkCircle" className="size-4" />
</div>
</CardContent>
</Card>
{/* رد شده یا منقضی */}
<Card className="border-rose-500/20 bg-rose-500/5 shadow-xs col-span-2 sm:col-span-1">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-rose-600 dark:text-rose-400 font-medium">
<Card className="border-rose-500/20 bg-rose-500/5 shadow-xs col-span-2 sm:col-span-1 lg:col-span-2 xl:col-span-1">
<CardContent className="p-3.5 sm:p-4 flex items-center justify-between gap-2.5">
<div className="min-w-0 flex-1">
<p className="text-[11px] text-rose-600 dark:text-rose-400 font-medium truncate">
{t('billing.rejectedOrExpired')}
</p>
{isLoading ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono text-rose-600 dark:text-rose-400">
<p className="text-lg sm:text-xl font-black mt-1 font-mono text-rose-600 dark:text-rose-400 tabular-nums">
{stats.rejectedOrExpired}
</p>
)}
</div>
<div className="size-9 rounded-xl bg-rose-500/15 text-rose-600 dark:text-rose-400 flex items-center justify-center">
<div className="size-9 rounded-xl bg-rose-500/15 text-rose-600 dark:text-rose-400 flex items-center justify-center shrink-0">
<Ic name="xCircle" className="size-4" />
</div>
</CardContent>
@ -480,8 +489,8 @@ export default function BillingPage() {
{/* نوار جستجو و فیلترهای دوگانه: ۱. نوع سرویس ۲. وضعیت صورت‌حساب */}
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-3">
<CardContent className="p-3.5 sm:p-4">
<div className="flex flex-col xl:flex-row items-stretch xl:items-center justify-between gap-3">
{/* جستجوی متنی */}
<div className="relative flex-1 min-w-[200px]">
<Ic
@ -505,9 +514,9 @@ export default function BillingPage() {
</div>
{/* فیلترهای دوگانه چندانتخابی مجزا: نوع سرویس و وضعیت صورت‌حساب */}
<div className="flex flex-wrap sm:flex-nowrap items-center gap-2.5 w-full lg:w-auto">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2.5 w-full xl:w-auto">
{/* فیلتر چندگزینه‌ای نوع سرویس */}
<div className="w-full sm:w-52">
<div className="w-full sm:w-48 lg:w-52">
<MultiFilterSelect
values={selectedServices}
onChange={handleSelectServices}
@ -516,12 +525,12 @@ export default function BillingPage() {
placeholder={t('billing.filterService')}
selectedCountLabel={(count) => t('billing.selectedServicesCount', { count })}
clearLabel={t('billing.clearFilters')}
triggerClassName="h-9 text-xs rounded-xl bg-surface-2 border-border-soft"
triggerClassName="h-9 text-xs rounded-xl bg-surface-2 border-border-soft w-full"
/>
</div>
{/* فیلتر چندگزینه‌ای وضعیت صورت‌حساب */}
<div className="w-full sm:w-52">
<div className="w-full sm:w-48 lg:w-52">
<MultiFilterSelect
values={selectedStatuses}
onChange={handleSelectStatuses}
@ -530,7 +539,7 @@ export default function BillingPage() {
placeholder={t('billing.filterStatus')}
selectedCountLabel={(count) => t('billing.selectedStatusesCount', { count })}
clearLabel={t('billing.clearFilters')}
triggerClassName="h-9 text-xs rounded-xl bg-surface-2 border-border-soft"
triggerClassName="h-9 text-xs rounded-xl bg-surface-2 border-border-soft w-full"
/>
</div>
@ -542,7 +551,7 @@ export default function BillingPage() {
variant="ghost"
size="sm"
onClick={handleResetFilters}
className="h-9 px-3 text-xs text-grey-3 hover:text-foreground shrink-0 rounded-xl hover:bg-surface-2"
className="h-9 px-3 text-xs text-grey-3 hover:text-foreground shrink-0 rounded-xl hover:bg-surface-2 justify-center"
title={t('billing.clearFilters')}
>
<Ic name="x" className="size-3.5 me-1" />
@ -556,8 +565,8 @@ export default function BillingPage() {
{/* جدول داده‌ای جامع صورت‌حساب‌ها و سفارشات */}
<Card className="border-border-soft bg-card/40 shadow-xs overflow-hidden">
<CardHeader className="py-3 px-4 border-b border-border-soft bg-card/60 flex flex-row items-center justify-between">
<CardTitle className="text-xs font-bold flex items-center gap-2 text-foreground">
<CardHeader className="py-3 px-4 border-b border-border-soft bg-card/60 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<CardTitle className="text-xs sm:text-sm font-bold flex items-center gap-2 text-foreground">
<Ic name="list" className="size-4 text-primary" />
<span>{t('billing.invoiceList')}</span>
{selectedServices.length > 0 && !selectedServices.includes('all') && (
@ -611,239 +620,363 @@ export default function BillingPage() {
})()}
</div>
) : (
<div className="overflow-x-auto relative">
{/* خط انیمیشن لودینگ در بالای جدول هنگام بارگذاری مجدد */}
{isLoading && (
<div className="absolute top-0 inset-x-0 h-0.5 bg-primary/20 overflow-hidden z-10">
<div className="h-full bg-primary animate-indeterminate origin-right" />
</div>
)}
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent text-xs border-b border-border-soft bg-secondary/15 text-grey-2">
<TableHead className="w-14 text-center">{t('billing.id')}</TableHead>
<TableHead>{t('billing.customer')}</TableHead>
<TableHead className="w-28 text-center">{t('billing.factorNumber')}</TableHead>
<TableHead>{t('billing.service')}</TableHead>
<TableHead>{t('billing.amount')}</TableHead>
<TableHead>{t('billing.liveStatus')}</TableHead>
<TableHead className="text-center">{t('billing.receipt')}</TableHead>
<TableHead>{t('billing.date')}</TableHead>
<TableHead className="text-end pl-4">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody className="text-xs divide-y divide-border-soft">
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => (
<TableRow key={i} className="hover:bg-transparent">
{/* ۱. شناسه */}
<TableCell className="text-center">
<ShimmerBox width={28} height={14} rounded="md" className="mx-auto" />
</TableCell>
{/* ۲. خریدار */}
<TableCell>
<div className="flex items-center gap-2.5">
<ShimmerCircle size={32} />
<div className="space-y-1.5 flex-1 min-w-0">
<ShimmerLine width={96} height={14} rounded="sm" />
<ShimmerLine width={64} height={10} rounded="sm" />
<>
{/* ۱. نمای جدول برای تبلت و دسکتاپ (md به بالا با حداقل عرض مشخص) */}
<div className="overflow-x-auto relative hidden md:block">
{/* خط انیمیشن لودینگ در بالای جدول هنگام بارگذاری مجدد */}
{isLoading && (
<div className="absolute top-0 inset-x-0 h-0.5 bg-primary/20 overflow-hidden z-10">
<div className="h-full bg-primary animate-indeterminate origin-right" />
</div>
)}
<Table className="w-full min-w-[1080px]">
<TableHeader>
<TableRow className="hover:bg-transparent text-xs border-b border-border-soft bg-secondary/15 text-grey-2">
<TableHead className="w-16 min-w-[64px] text-center">{t('billing.id')}</TableHead>
<TableHead className="min-w-[210px]">{t('billing.customer')}</TableHead>
<TableHead className="w-28 min-w-[110px] text-center">{t('billing.factorNumber')}</TableHead>
<TableHead className="w-28 min-w-[110px]">{t('billing.service')}</TableHead>
<TableHead className="w-24 min-w-[95px]">{t('billing.amount')}</TableHead>
<TableHead className="w-36 min-w-[135px]">{t('billing.liveStatus')}</TableHead>
<TableHead className="w-24 min-w-[90px] text-center">{t('billing.receipt')}</TableHead>
<TableHead className="w-36 min-w-[140px]">{t('billing.date')}</TableHead>
<TableHead className="w-32 min-w-[125px] text-end pe-4">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody className="text-xs divide-y divide-border-soft">
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => (
<TableRow key={i} className="hover:bg-transparent">
{/* ۱. شناسه */}
<TableCell className="text-center">
<ShimmerBox width={28} height={14} rounded="md" className="mx-auto" />
</TableCell>
{/* ۲. خریدار */}
<TableCell>
<div className="flex items-center gap-2.5">
<ShimmerCircle size={32} />
<div className="space-y-1.5 flex-1 min-w-0">
<ShimmerLine width={96} height={14} rounded="sm" />
<ShimmerLine width={64} height={10} rounded="sm" />
</div>
</div>
</div>
</TableCell>
{/* ۳. شماره فاکتور */}
<TableCell className="text-center">
<ShimmerBox width={56} height={18} rounded="md" className="mx-auto" />
</TableCell>
{/* ۴. سرویس */}
<TableCell>
<ShimmerBox width={80} height={24} rounded="full" />
</TableCell>
{/* ۵. مبلغ */}
<TableCell>
<ShimmerLine width={64} height={16} rounded="sm" />
</TableCell>
{/* ۶. وضعیت */}
<TableCell>
<ShimmerBox width={96} height={24} rounded="full" />
</TableCell>
{/* ۷. تصویر فیش */}
<TableCell className="text-center">
<ShimmerBox width={36} height={36} rounded="lg" className="mx-auto" />
</TableCell>
{/* ۸. تاریخ و ساعت */}
<TableCell>
<ShimmerLine width={100} height={14} rounded="sm" />
</TableCell>
{/* ۹. عملیات */}
<TableCell className="text-end pl-4">
<div className="flex items-center justify-end">
<ShimmerBox width={105} height={32} rounded="xl" />
</div>
</TableCell>
</TableRow>
))
) : (
factors.map((factor) => {
</TableCell>
{/* ۳. شماره فاکتور */}
<TableCell className="text-center">
<ShimmerBox width={56} height={18} rounded="md" className="mx-auto" />
</TableCell>
{/* ۴. سرویس */}
<TableCell>
<ShimmerBox width={80} height={24} rounded="full" />
</TableCell>
{/* ۵. مبلغ */}
<TableCell>
<ShimmerLine width={64} height={16} rounded="sm" />
</TableCell>
{/* ۶. وضعیت */}
<TableCell>
<ShimmerBox width={96} height={24} rounded="full" />
</TableCell>
{/* ۷. تصویر فیش */}
<TableCell className="text-center">
<ShimmerBox width={36} height={36} rounded="lg" className="mx-auto" />
</TableCell>
{/* ۸. تاریخ و ساعت */}
<TableCell>
<ShimmerLine width={100} height={14} rounded="sm" />
</TableCell>
{/* ۹. عملیات */}
<TableCell className="text-end pe-4">
<div className="flex items-center justify-end">
<ShimmerBox width={105} height={32} rounded="xl" />
</div>
</TableCell>
</TableRow>
))
) : (
factors.map((factor) => {
const isPending = factor.status === 'pending'
const hasReceipt = Boolean(factor.transaction_receipt)
const user = factor.user_info
return (
<TableRow
key={factor.id}
className={`transition-colors hover:bg-surface-2/50 ${
isPending ? 'bg-blue-500/[0.03] hover:bg-blue-500/[0.07]' : ''
}`}
>
{/* ۱. شناسه دیتابیس */}
<TableCell className="text-center font-mono font-medium text-[11px] text-grey-3/80">
{factor.id}
</TableCell>
{/* ۲. مشخصات مشتری و خریدار */}
<TableCell>
<div className="flex items-center gap-2.5">
<Avatar className="size-8.5 shrink-0" />
<div className="min-w-0 space-y-0.5">
<p className="font-bold text-xs sm:text-[13px] text-foreground truncate max-w-[140px] sm:max-w-[190px]">
{user?.fullname || '—'}
</p>
<p className="text-[11px] font-bold text-foreground/80 truncate max-w-[140px] sm:max-w-[190px] font-mono dir-ltr text-start tracking-tight">
{user?.phone_number || user?.email || '—'}
</p>
</div>
</div>
</TableCell>
{/* ۳. شماره فاکتور (کلیک جهت کپی) */}
<TableCell className="text-center">
{factor.factor_number ? (
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(String(factor.factor_number))
toast.success(t('billing.factorNumberCopied'))
}}
className="font-mono font-black text-sm text-foreground hover:text-primary transition-colors cursor-pointer select-all tracking-wider py-1 px-2 rounded-lg hover:bg-surface-3 active:scale-95"
title={t('billing.factorNumberCopied')}
>
{factor.factor_number}
</button>
) : (
<span className="text-grey-3 font-mono text-xs">—</span>
)}
</TableCell>
{/* ۴. سرویس */}
<TableCell>
<BillingServiceBadge service={factor.service} />
</TableCell>
{/* ۵. مبلغ */}
<TableCell>
<span className="font-bold font-mono text-sm text-foreground">
${Number(factor.amount).toLocaleString()}
</span>
</TableCell>
{/* ۶. وضعیت لحظه‌ای */}
<TableCell>
<BillingStatusBadge status={factor.status} />
</TableCell>
{/* ۷. پیش‌نمایش تصویر فیش */}
<TableCell className="text-center">
{hasReceipt ? (
<button
type="button"
onClick={() => setSelectedFactorForLightbox(factor)}
className="relative group size-8 rounded-lg overflow-hidden border border-primary/20 bg-primary/10 mx-auto flex items-center justify-center hover:border-primary/50 hover:scale-105 transition-all shadow-2xs cursor-pointer"
title={t('billing.viewReceipt')}
>
<img
src={resolveMediaUrl(factor.transaction_receipt)}
alt=""
className="size-full object-cover"
onError={(e) => {
const el = e.currentTarget
if (!el.dataset.fallbackApplied && factor.transaction_receipt) {
el.dataset.fallbackApplied = 'true'
const fallback = getMediaFallbackUrl(factor.transaction_receipt)
if (fallback && el.src !== fallback) {
el.src = fallback
return
}
}
el.style.display = 'none'
}}
/>
<Ic
name="image"
className="size-3.5 text-primary pointer-events-none group-hover:scale-110 transition-transform"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center text-white transition-opacity">
<Ic name="eye" className="size-3" />
</div>
</button>
) : (
<span
className="inline-block text-[10.5px] font-medium text-muted-foreground/70 bg-muted/40 px-2 py-0.5 rounded-md border border-border-soft select-none"
title={t('billing.noReceiptUploaded')}
>
{t('billing.noReceipt')}
</span>
)}
</TableCell>
{/* ۸. تاریخ و ساعت صدور (با ساختار خوانا و مجزا) */}
<TableCell className="whitespace-nowrap">
<div className="flex flex-col text-[11px] leading-tight" dir="rtl">
<span className="font-semibold text-foreground/90">
{formatDateByLanguage(factor.created_at, language as any, { style: 'medium' })}
</span>
<span className="text-[10px] text-muted-foreground font-mono mt-0.5" dir="ltr">
{new Date(factor.created_at).toLocaleTimeString(
language === 'fa' ? 'fa-IR' : language === 'ar' ? 'ar-SA' : 'en-US',
{ hour: '2-digit', minute: '2-digit' }
)}
</span>
</div>
</TableCell>
{/* دکمه‌های عملیات */}
<TableCell className="text-end pe-4">
<div className="flex items-center justify-end">
<Button
variant="default"
size="sm"
className="h-8 px-3 text-xs gap-1.5 rounded-lg font-bold shadow-2xs hover:brightness-105 active:scale-[0.98] transition-all shrink-0"
onClick={() => setSelectedFactorForDetail(factor)}
title={t('billing.viewDetails')}
>
<Ic name="eye" className="size-3.5" />
<span>{t('billing.viewDetails')}</span>
</Button>
</div>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
{/* ۲. نمای کارت‌های واکنش‌گرا برای گوشی‌های موبایل (زیر md) */}
<div className="flex flex-col gap-3.5 p-3.5 sm:p-4 md:hidden">
{isLoading && factors.length === 0 ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-44 rounded-2xl bg-muted/40 border border-border-soft animate-pulse p-4 space-y-3">
<div className="flex justify-between items-center">
<ShimmerBox width={90} height={22} rounded="full" />
<ShimmerBox width={80} height={20} rounded="full" />
</div>
<div className="flex items-center gap-2.5 pt-2">
<ShimmerCircle size={36} />
<div className="space-y-1.5 flex-1">
<ShimmerLine width={110} height={14} rounded="sm" />
<ShimmerLine width={80} height={10} rounded="sm" />
</div>
</div>
<div className="pt-2 border-t border-border-soft flex justify-between items-center">
<ShimmerLine width={70} height={14} rounded="sm" />
<ShimmerBox width={90} height={28} rounded="xl" />
</div>
</div>
))
) : (
factors.map((factor) => {
const isPending = factor.status === 'pending'
const hasReceipt = Boolean(factor.transaction_receipt)
const user = factor.user_info
return (
<TableRow
<div
key={factor.id}
className={`transition-colors hover:bg-surface-2/50 ${
isPending ? 'bg-blue-500/[0.03] hover:bg-blue-500/[0.07]' : ''
className={`bg-card/80 border border-border-soft rounded-2xl p-4 shadow-xs space-y-3 transition-colors ${
isPending ? 'bg-blue-500/[0.04] border-blue-500/30' : ''
}`}
>
{/* ۱. شناسه دیتابیس (بدون هشتگ و فونت ریزتر) */}
<TableCell className="text-center font-mono font-medium text-[11px] text-grey-3/80">
{factor.id}
</TableCell>
{/* ۲. مشخصات مشتری و خریدار (نام و اطلاعات تماس بولدتر) */}
<TableCell>
<div className="flex items-center gap-2.5">
<Avatar className="size-8.5 shrink-0" />
{/* سطر ۱: سرویس، شماره فاکتور، و وضعیت */}
<div className="flex items-center justify-between gap-2 border-b border-border-soft pb-2.5">
<div className="flex items-center gap-2 flex-wrap">
<BillingServiceBadge service={factor.service} />
<span className="font-mono text-xs font-bold text-foreground">
#{factor.factor_number || factor.id}
</span>
</div>
<BillingStatusBadge status={factor.status} />
</div>
{/* سطر ۲: مشتری و مبلغ */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5 min-w-0">
<Avatar className="size-9 shrink-0" />
<div className="min-w-0 space-y-0.5">
<p className="font-black text-xs sm:text-[13px] text-foreground truncate max-w-[140px] sm:max-w-[190px]">
<p className="font-bold text-xs text-foreground truncate">
{user?.fullname || '—'}
</p>
<p className="text-[11px] font-bold text-foreground/80 truncate max-w-[140px] sm:max-w-[190px] font-mono dir-ltr text-start tracking-tight">
<p className="text-[11px] text-muted-foreground truncate font-mono" dir="ltr">
{user?.phone_number || user?.email || '—'}
</p>
</div>
</div>
</TableCell>
{/* ۳. شماره فاکتور (بین مشتری و سرویس، فونت بزرگتر، کلیک جهت کپی) */}
<TableCell className="text-center">
{factor.factor_number ? (
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(String(factor.factor_number))
toast.success(t('billing.factorNumberCopied'))
}}
className="font-mono font-black text-sm text-foreground hover:text-primary transition-colors cursor-pointer select-all tracking-wider py-1 px-2 rounded-lg hover:bg-surface-3 active:scale-95"
title={t('billing.factorNumberCopied')}
>
{factor.factor_number}
</button>
) : (
<span className="text-grey-3 font-mono text-xs">—</span>
)}
</TableCell>
{/* ۴. سرویس */}
<TableCell>
<BillingServiceBadge service={factor.service} />
</TableCell>
{/* ۵. مبلغ */}
<TableCell>
<span className="font-bold font-mono text-sm text-foreground">
${Number(factor.amount).toLocaleString()}
</span>
</TableCell>
{/* ۶. وضعیت لحظه‌ای */}
<TableCell>
<BillingStatusBadge status={factor.status} />
</TableCell>
{/* ۷. پیش‌نمایش تصویر فیش */}
<TableCell className="text-center">
{hasReceipt ? (
<button
type="button"
onClick={() => setSelectedFactorForLightbox(factor)}
className="relative group size-8 rounded-lg overflow-hidden border border-primary/20 bg-primary/10 mx-auto flex items-center justify-center hover:border-primary/50 hover:scale-105 transition-all shadow-2xs cursor-pointer"
title={t('billing.viewReceipt')}
>
<img
src={resolveMediaUrl(factor.transaction_receipt)}
alt=""
className="size-full object-cover"
onError={(e) => {
const el = e.currentTarget
if (!el.dataset.fallbackApplied && factor.transaction_receipt) {
el.dataset.fallbackApplied = 'true'
const fallback = getMediaFallbackUrl(factor.transaction_receipt)
if (fallback && el.src !== fallback) {
el.src = fallback
return
}
}
el.style.display = 'none'
}}
/>
<Ic
name="image"
className="size-3.5 text-primary pointer-events-none group-hover:scale-110 transition-transform"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center text-white transition-opacity">
<Ic name="eye" className="size-3" />
</div>
</button>
) : (
<span
className="inline-block text-[10.5px] font-medium text-muted-foreground/70 bg-muted/40 px-2 py-0.5 rounded-md border border-border-soft select-none"
title={t('billing.noReceiptUploaded')}
>
{t('billing.noReceipt')}
<div className="text-end shrink-0">
<span className="text-[10px] text-muted-foreground block">{t('billing.amount')}</span>
<span className="font-black font-mono text-base text-foreground">
${Number(factor.amount).toLocaleString()}
</span>
)}
</TableCell>
{/* ۸. تاریخ و ساعت صدور (با فونت ریزتر و همراه با ساعت) */}
<TableCell className="text-muted-foreground whitespace-nowrap text-[11px] font-mono font-medium">
{formatDateTimeByLanguage(factor.created_at, language as any, { style: 'medium' })}
</TableCell>
{/* دکمه‌های عملیات */}
<TableCell className="text-end pl-4">
<div className="flex items-center justify-end">
{/* دکمه مشاهده جزئیات کامل خریدار و پرداخت */}
<Button
variant="default"
size="sm"
className="h-8 px-3 text-xs gap-1.5 rounded-lg font-bold shadow-2xs hover:brightness-105 active:scale-[0.98] transition-all shrink-0"
onClick={() => setSelectedFactorForDetail(factor)}
title={t('billing.viewDetails')}
>
<Ic name="eye" className="size-3.5" />
<span>{t('billing.viewDetails')}</span>
</Button>
</div>
</TableCell>
</TableRow>
</div>
{/* سطر ۳: فیش پرداخت و تاریخ */}
<div className="pt-2 border-t border-border-soft flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-[11px]">{t('billing.receipt')}:</span>
{hasReceipt ? (
<button
type="button"
onClick={() => setSelectedFactorForLightbox(factor)}
className="flex items-center gap-1 text-[11px] text-primary font-bold hover:underline"
>
<Ic name="image" className="size-3.5" />
<span>{t('billing.viewReceipt')}</span>
</button>
) : (
<span className="text-[10.5px] text-muted-foreground bg-muted/40 px-2 py-0.5 rounded-md border border-border-soft">
{t('billing.noReceipt')}
</span>
)}
</div>
<span className="text-[11px] text-muted-foreground font-mono" dir="rtl">
{formatDateByLanguage(factor.created_at, language as any, { style: 'short' })}
</span>
</div>
{/* سطر ۴: دکمه مشاهده جزئیات */}
<div className="pt-2 border-t border-border-soft flex items-center justify-end">
<Button
variant="default"
size="sm"
className="w-full sm:w-auto h-8 px-4 text-xs gap-1.5 rounded-xl font-bold shadow-2xs hover:brightness-105 active:scale-[0.98] transition-all"
onClick={() => setSelectedFactorForDetail(factor)}
>
<Ic name="eye" className="size-3.5" />
<span>{t('billing.viewDetails')}</span>
</Button>
</div>
</div>
)
}))}
</TableBody>
</Table>
</div>
})
)}
</div>
</>
)}
{/* صفحه‌بندی */}
{/* صفحه‌بندی واکنش‌گرا */}
{totalPages > 1 && (
<div className="p-3 border-t border-border-soft flex items-center justify-between text-xs bg-card/40">
<span className="text-grey-3">
<div className="p-3.5 sm:p-4 border-t border-border-soft flex flex-col sm:flex-row items-center justify-between gap-3 text-xs bg-card/40">
<span className="text-grey-3 text-center sm:text-start order-2 sm:order-1">
{t('common.showing')} {(currentPage - 1) * PAGE_SIZE + 1} {t('common.to')}{' '}
{Math.min(currentPage * PAGE_SIZE, totalCount)} {t('common.of')} {totalCount} {t('billing.invoices')}
</span>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 order-1 sm:order-2">
<Button
variant="outline"
size="sm"
className="h-7 text-xs rounded-xl"
className="h-8 text-xs rounded-xl"
onClick={() => {
const p = Math.max(1, currentPage - 1)
setCurrentPage(p)
@ -859,7 +992,7 @@ export default function BillingPage() {
<Button
variant="outline"
size="sm"
className="h-7 text-xs rounded-xl"
className="h-8 text-xs rounded-xl"
onClick={() => {
const p = Math.min(totalPages, currentPage + 1)
setCurrentPage(p)

153
src/features/billing/components/BillingDetailModal.tsx

@ -26,7 +26,7 @@ import { ShimmerLine, ShimmerBox } from '@/components/shared/shimmer_frontend'
import { toast } from '@/stores/toast'
import { useTranslation } from '@/i18n'
import { formatDateByLanguage, formatDateTimeByLanguage } from '@/lib/date-utils'
import { formatDateByLanguage, formatDateTimeByLanguage, formatDualDate } from '@/lib/date-utils'
import { cn, resolveMediaUrl, getMediaFallbackUrl } from '@/lib/utils'
interface Props {
@ -235,7 +235,11 @@ export function BillingDetailModal({
setHasChanges(false)
}
onFactorUpdated?.(updated)
toast.success(t('billing.ticketUploadSuccess'))
toast.success(
updated.status !== 'approved'
? t('billing.ticketUploadSuccessPendingSave')
: t('billing.ticketUploadSuccess')
)
} catch (err: any) {
toast.error(err?.message || 'خطا در بارگذاری فایل بلیت.')
} finally {
@ -265,6 +269,10 @@ export function BillingDetailModal({
try {
const updated = await deleteFactorTicket(activeFactor.id)
setCurrentFactor(updated)
if (selectedStatus === 'approved') {
setSelectedStatus(updated.status)
setHasChanges(false)
}
onFactorUpdated?.(updated)
toast.success(t('billing.ticketDeleteSuccess'))
} catch (err: any) {
@ -278,14 +286,14 @@ export function BillingDetailModal({
<Dialog open={open} onOpenChange={(val) => !val && onClose()}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col border border-border/60 bg-card p-0 overflow-hidden shadow-2xl rounded-2xl">
{/* هدر مودال ثابت با پس‌زمینه سالید بدون تداخل اسکرول */}
<DialogHeader className="p-4 px-5 border-b border-border/40 bg-card shrink-0 z-10">
<DialogHeader className="p-4 px-5 pe-12 border-b border-border/40 bg-card shrink-0 z-10">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="size-10 rounded-xl bg-primary/15 text-primary flex items-center justify-center font-bold shrink-0">
<Ic name="receipt" className="size-5" />
</div>
<div>
<DialogTitle className="text-lg font-bold flex items-center gap-2">
<DialogTitle className="text-lg font-bold flex flex-wrap items-center gap-2">
<span>صورت‌حساب شماره #{activeFactor.factor_number || activeFactor.id}</span>
{activeFactor.factor_number && (
<span className="text-xs font-mono font-normal text-muted-foreground">
@ -299,7 +307,7 @@ export function BillingDetailModal({
</div>
</div>
<div className="flex items-center gap-2.5 me-8">
<div className="flex items-center gap-2.5">
<BillingServiceBadge service={activeFactor.service} />
</div>
</div>
@ -748,21 +756,124 @@ export function BillingDetailModal({
{/* ۵. سفر اختصاصی (Custom Trip) */}
{activeFactor.service === 'custom_trip' && (
<div className="space-y-3">
{detail?.destinations && detail.destinations.length > 0 && (
<div>
<span className="text-muted-foreground block mb-1">مسیر سفر:</span>
<div className="flex flex-wrap items-center justify-between gap-2 pb-2 border-b border-border/40">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">مسیر نهایی سفر:</span>
{detail?.origin_city && (
<span className="bg-muted px-2 py-0.5 rounded-md font-bold text-foreground">
{detail.origin_city}
</span>
)}
{detail?.destinations &&
detail.destinations.map((d, i) => (
<React.Fragment key={i}>
<span className="text-muted-foreground">←</span>
<span className="bg-primary/10 text-primary px-2.5 py-0.5 rounded-md font-semibold">
{d}
</span>
</React.Fragment>
))}
</div>
{detail?.tracking_code && (
<span className="font-mono text-[11px] bg-muted px-2 py-0.5 rounded-md text-muted-foreground">
کد رهگیری: {detail.tracking_code}
</span>
)}
</div>
{detail?.legs && detail.legs.length > 0 && (
<div className="space-y-2">
<span className="text-muted-foreground font-bold block">
آیتم‌های تأییدشده پرواز، حمل‌ونقل و هتل‌ها:
</span>
<div className="space-y-2">
{detail.legs.map((leg, idx) => {
const isReturnLeg = Boolean(leg.is_return)
const stay = isReturnLeg
? null
: detail.stays?.[idx] ||
detail.stays?.find((s) => s.city === leg.to_city)
return (
<div
key={idx}
className={`p-2.5 rounded-xl border flex flex-col sm:flex-row sm:items-center justify-between gap-2 ${
isReturnLeg
? 'bg-primary/5 border-primary/30'
: 'bg-muted/30 border-border/50'
}`}
>
<div className="flex flex-wrap items-center gap-2">
<span className="size-5 rounded-full bg-primary/15 text-primary font-bold flex items-center justify-center text-[11px]">
{idx + 1}
</span>
<span className="font-bold text-foreground">{leg.from_city}</span>
<span className="text-muted-foreground">←</span>
<span className="font-bold text-primary">{leg.to_city}</span>
{isReturnLeg ? (
<span className="text-primary font-semibold text-[11px] bg-primary/10 px-2 py-0.5 rounded-md">
(بازگشت به شهر مبدأ — بدون هتل)
</span>
) : (
stay?.nights && (
<span className="text-muted-foreground text-[11px]">
({stay.nights} شب اقامت)
</span>
)
)}
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px]">
<span className="bg-background px-2 py-0.5 rounded-md border border-border/60 font-medium">
✈️ {leg.transport_method_display}
</span>
{leg.departure_date && (
<span className="bg-background px-2 py-0.5 rounded-md border border-border/60 text-muted-foreground">
📅 {formatDualDate(leg.departure_date, (language as any) || 'fa', { withTime: true, time: leg.departure_time })}
</span>
)}
{!isReturnLeg && stay?.hotel_name && (
<span className="bg-background px-2 py-0.5 rounded-md border border-border/60 font-semibold text-foreground">
🏨 {stay.hotel_name}
</span>
)}
</div>
</div>
)
})}
</div>
</div>
)}
{detail?.passengers && detail.passengers.length > 0 && (
<div className="space-y-2">
<span className="text-muted-foreground font-bold block">
مشخصات مسافران ({detail.passengers.length} نفر):
</span>
<div className="flex flex-wrap gap-2">
{detail.destinations.map((d, i) => (
<span
key={i}
className="bg-primary/10 text-primary px-2.5 py-1 rounded-md text-xs font-semibold"
{detail.passengers.map((pax, idx) => (
<div
key={idx}
className="bg-muted/40 px-3 py-1.5 rounded-xl border border-border-soft flex items-center gap-2 text-xs"
>
{d}
</span>
<Ic name="users" className="size-3.5 text-primary shrink-0" />
<span className="font-bold text-foreground">{pax.fullname || '—'}</span>
<span className="text-[11px] text-muted-foreground">
({pax.passenger_type === 'CHILD' ? 'کودک' : pax.passenger_type === 'INFANT' ? 'نوزاد' : 'بزرگسال'})
</span>
</div>
))}
</div>
</div>
)}
{detail?.admin_notes && (
<div className="bg-emerald-500/10 p-2.5 rounded-xl border border-emerald-500/25">
<span className="font-bold text-emerald-700 dark:text-emerald-300 block mb-0.5">
توضیحات تأیید نهایی سفارش:
</span>
<p className="text-xs text-foreground">{detail.admin_notes}</p>
</div>
)}
{detail?.detail && (
<div className="bg-muted/30 p-3 rounded-lg border border-border/40">
<span className="text-muted-foreground block mb-1">خلاصه برنامه سفر:</span>
@ -1040,7 +1151,7 @@ export function BillingDetailModal({
</div>
{/* فوتر مودال و دکمه‌های عملیاتی: ثابت و بدون تداخل با اسکرول */}
<DialogFooter className="p-4 px-5 border-t border-border-soft bg-card shrink-0 flex flex-row items-center justify-between w-full z-10">
<DialogFooter className="p-4 px-5 border-t border-border-soft bg-card shrink-0 flex flex-row flex-wrap items-center justify-between gap-2 w-full z-10">
<Button
variant="outline"
size="sm"
@ -1050,7 +1161,7 @@ export function BillingDetailModal({
{t('common.close')}
</Button>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{/* دکمه لغو فاکتور: منحصراً در وضعیت در انتظار پرداخت و پیش از بارگذاری هرگونه فیش یا پرداخت */}
{activeFactor.status === 'awaiting_payment' && !activeFactor.transaction_receipt && (
<Button
@ -1093,7 +1204,15 @@ export function BillingDetailModal({
onClick={handleSaveStatus}
>
<Ic name="check" className="size-4" />
<span>{hasChanges ? 'ذخیره تغییرات' : 'ثبت وضعیت'}</span>
<span>
{hasChanges
? selectedStatus === 'approved'
? t('billing.confirmAndSave')
: t('billing.saveStatusChanges')
: activeFactor.status === 'approved'
? t('billing.reSubmitStatus')
: t('billing.submitStatus')}
</span>
</Button>
</div>
</DialogFooter>

10
src/features/billing/components/ReceiptLightboxDialog.tsx

@ -65,7 +65,7 @@ export function ReceiptLightboxDialog({
<Dialog open={open} onOpenChange={(val) => !val && onClose()}>
<DialogContent className="max-w-3xl sm:max-w-4xl max-h-[92vh] overflow-hidden p-0 border border-border/60 bg-background/95 backdrop-blur-md flex flex-col">
{/* هدر مدال پیش‌نمایش فیش */}
<DialogHeader className="p-4 px-5 border-b border-border/40 flex flex-row items-center justify-between shrink-0 bg-background/95">
<DialogHeader className="p-4 px-5 pe-12 border-b border-border/40 flex flex-row flex-wrap items-center justify-between gap-2 shrink-0 bg-background/95">
<div className="flex items-center gap-2.5">
<div className="size-9 rounded-xl bg-primary/15 text-primary flex items-center justify-center font-bold shrink-0">
<Ic name="receipt" className="size-4.5" />
@ -83,15 +83,15 @@ export function ReceiptLightboxDialog({
</div>
</div>
<div className="flex items-center gap-2 pl-6">
<div className="flex items-center gap-2">
<BillingStatusBadge status={factor.status} />
</div>
</DialogHeader>
{/* نوار ابزار بزرگنمایی و کنترل تصویر */}
{hasReceipt && !imageError && (
<div className="px-5 py-2 border-b border-border-soft bg-muted/40 flex items-center justify-between text-xs shrink-0">
<div className="flex items-center gap-1.5">
<div className="px-3 sm:px-5 py-2 border-b border-border-soft bg-muted/40 flex flex-wrap items-center justify-between gap-2 text-xs shrink-0">
<div className="flex flex-wrap items-center gap-1.5">
<Button
variant="outline"
size="sm"
@ -159,7 +159,7 @@ export function ReceiptLightboxDialog({
)}
{/* کادر نمایش تصویر فیش */}
<div className="relative flex-1 min-h-[380px] max-h-[62vh] overflow-auto p-4 flex items-center justify-center bg-slate-950/20 dark:bg-black/40">
<div className="relative flex-1 min-h-[240px] sm:min-h-[380px] max-h-[62vh] overflow-auto p-4 flex items-center justify-center bg-slate-950/20 dark:bg-black/40">
{hasReceipt && !imageError ? (
<div className="relative flex items-center justify-center min-w-full min-h-full">
{imageLoading && (

3
src/features/billing/components/RejectFactorDialog.tsx

@ -48,6 +48,7 @@ export function RejectFactorDialog({
return (
<Dialog open={open} onOpenChange={(val) => !val && onClose()}>
<DialogContent
showCloseButton={false}
className="max-w-md p-0 overflow-hidden rounded-2xl border border-border/60 bg-card shadow-2xl"
dir={dir}
>
@ -62,7 +63,7 @@ export function RejectFactorDialog({
<span>{t('billing.rejectTitle')}</span>
<span dir="ltr" className="font-mono">#{factor.id}</span>
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5 flex items-center gap-2">
<DialogDescription className="text-xs text-muted-foreground mt-0.5 flex flex-wrap items-center gap-2">
<span>
{t('billing.customer')}:{' '}
<strong className="font-medium text-foreground">

12
src/features/billing/services/billing-api.ts

@ -39,7 +39,7 @@ export async function fetchFactors(
/**
* دریافت جزئیات جامع یک فاکتور از طریق شناسه
*/
export async function fetchFactorDetail(factorId: number): Promise<FactorItem> {
export async function fetchFactorDetail(factorId: number | string): Promise<FactorItem> {
return apiFetch<FactorItem>(`factors/${factorId}/`)
}
@ -47,7 +47,7 @@ export async function fetchFactorDetail(factorId: number): Promise<FactorItem> {
* بررسی، تایید یا رد فاکتور توسط ادمین و همگام‌سازی وضعیت سفارش مرتبط
*/
export async function reviewFactor(
factorId: number,
factorId: number | string,
data: {
status: FactorStatus
rejected_description?: string
@ -80,7 +80,7 @@ export async function fetchBillingCounts(): Promise<BillingCounts> {
* پاکسازی تصویر فیش ثبت‌شده برای فاکتور
*/
export async function clearFactorReceipt(
factorId: number
factorId: number | string
): Promise<{ detail: string }> {
return apiFetch<{ detail: string }>(`factors/clear-receipt/${factorId}/`, {
method: 'POST',
@ -90,7 +90,7 @@ export async function clearFactorReceipt(
/**
* کنسل کردن فاکتور در وضعیت در انتظار پرداخت
*/
export async function cancelFactor(factorId: number): Promise<FactorItem> {
export async function cancelFactor(factorId: number | string): Promise<FactorItem> {
return apiFetch<FactorItem>(`factors/${factorId}/cancel/`, {
method: 'POST',
})
@ -100,7 +100,7 @@ export async function cancelFactor(factorId: number): Promise<FactorItem> {
* بارگذاری فایل بلیت برای خریدهای تور و سفر اختصاصی تأییدشده
*/
export async function uploadFactorTicket(
factorId: number,
factorId: number | string,
file: File
): Promise<FactorItem> {
const formData = new FormData()
@ -115,7 +115,7 @@ export async function uploadFactorTicket(
* حذف فایل بلیت ثبت‌شده برای فاکتور
*/
export async function deleteFactorTicket(
factorId: number
factorId: number | string
): Promise<FactorItem> {
return apiFetch<FactorItem>(`factors/${factorId}/ticket/`, {
method: 'DELETE',

45
src/features/billing/types.ts

@ -52,9 +52,54 @@ export interface DetailService {
amount?: string | number
total_price?: string | number
// Simcard / Tasrif / Shop
title?: string
count?: number
exchange_type?: string
product_title?: string
// Custom Trip
order_id?: number
tracking_code?: string
origin_city?: string
price_estimate?: number
final_price?: number | null
admin_notes?: string
detail?: string
destinations?: string[]
legs?: {
sequence: number
from_city: string
to_city: string
transport_method: number
transport_method_display: string
departure_date?: string | null
departure_time?: string | null
is_return?: boolean
adult_price?: number
}[]
stays?: {
sequence: number
city: string
hotel_id?: number | null
hotel_name: string
nights: number
meal_plan: string
check_in?: string | null
check_out?: string | null
adult_night_price?: number
}[]
passengers?: {
fullname: string
passenger_type: string
nationality?: string
}[]
passenger_count?: number
services?: {
service_type: string
quantity: number
total_price: number
}[]
}
export interface UserInfo {

37
src/features/custom-trip/CustomTripDestinationDetailPage.tsx

@ -1,6 +1,7 @@
import React, { useState, useEffect, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
@ -1216,7 +1217,7 @@ export default function CustomTripDestinationDetailPage() {
<label className="text-xs font-bold text-foreground block">
توضیحات و راهنمای شهر ({currentLang.name}):
</label>
<textarea
<Textarea
value={descriptionsMap[activeLangTab] || ''}
onChange={(e) =>
setDescriptionsMap((prev) => ({ ...prev, [activeLangTab]: e.target.value }))
@ -1224,7 +1225,7 @@ export default function CustomTripDestinationDetailPage() {
dir={currentLang.dir}
rows={3}
placeholder={`شرح کوتاه اماکن مقدسه، شرایط سفر و ویژگی‌های شهر به زبان ${currentLang.name}...`}
className="w-full p-3 text-xs rounded-xl bg-card border border-border/70 text-foreground placeholder:text-muted-foreground leading-relaxed focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none resize-y shadow-2xs"
className="text-xs rounded-xl bg-card border-border/70 text-foreground placeholder:text-muted-foreground leading-relaxed focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none resize-y shadow-2xs"
/>
</div>
</div>
@ -1288,13 +1289,13 @@ export default function CustomTripDestinationDetailPage() {
</div>
{/* Stars Filter */}
<div className="flex items-center gap-1 p-1 bg-muted/60 rounded-xl border border-border/50 text-xs">
<div className="flex items-center gap-1 p-1 bg-muted/60 rounded-xl border border-border/50 text-xs overflow-x-auto no-scrollbar max-w-full">
{(['all', '5', '4', '3'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setHotelStarsFilter(s)}
className={`px-3 py-1.5 rounded-lg font-medium transition-all cursor-pointer ${
className={`px-3 py-1.5 rounded-lg font-medium transition-all cursor-pointer whitespace-nowrap shrink-0 ${
hotelStarsFilter === s
? 'bg-card text-foreground shadow-2xs font-bold'
: 'text-muted-foreground hover:text-foreground'
@ -1580,7 +1581,7 @@ export default function CustomTripDestinationDetailPage() {
</div>
{/* Method Filter */}
<div className="flex items-center gap-1 p-1 bg-muted/60 rounded-xl border border-border/50 text-xs">
<div className="flex items-center gap-1 p-1 bg-muted/60 rounded-xl border border-border/50 text-xs overflow-x-auto no-scrollbar max-w-full">
{(
[
{ id: 'all', label: 'همه روش‌ها' },
@ -1593,7 +1594,7 @@ export default function CustomTripDestinationDetailPage() {
key={m.id}
type="button"
onClick={() => setTransportMethodFilter(m.id)}
className={`px-3 py-1.5 rounded-lg font-medium transition-all cursor-pointer ${
className={`px-3 py-1.5 rounded-lg font-medium transition-all cursor-pointer whitespace-nowrap shrink-0 ${
transportMethodFilter === m.id
? 'bg-card text-foreground shadow-2xs font-bold'
: 'text-muted-foreground hover:text-foreground'
@ -1821,7 +1822,7 @@ export default function CustomTripDestinationDetailPage() {
<label className="text-xs font-bold text-foreground block">
توضیحات و مشخصات هتل ({currentHotelLang.name}):
</label>
<textarea
<Textarea
value={hotelDescriptions[hotelLangTab] || ''}
onChange={(e) =>
setHotelDescriptions((prev) => ({ ...prev, [hotelLangTab]: e.target.value }))
@ -1829,7 +1830,7 @@ export default function CustomTripDestinationDetailPage() {
dir={currentHotelLang.dir}
rows={2}
placeholder={`شرح امکانات و شرایط هتل به زبان ${currentHotelLang.name}...`}
className="w-full p-2.5 text-xs rounded-xl bg-background border border-border/70 text-foreground leading-relaxed focus:ring-1 focus:ring-primary focus:outline-none"
className="text-xs rounded-xl bg-background border-border/70 text-foreground leading-relaxed focus:ring-1 focus:ring-primary focus:outline-none"
/>
</div>
@ -1838,7 +1839,7 @@ export default function CustomTripDestinationDetailPage() {
<label className="text-xs font-bold text-foreground block">
راهنما و نکات تجربی اقامت ({currentHotelLang.name}):
</label>
<textarea
<Textarea
value={hotelGuideNotes[hotelLangTab] || ''}
onChange={(e) =>
setHotelGuideNotes((prev) => ({ ...prev, [hotelLangTab]: e.target.value }))
@ -1846,14 +1847,14 @@ export default function CustomTripDestinationDetailPage() {
dir={currentHotelLang.dir}
rows={2}
placeholder={`نکات راهنما برای مسافران به زبان ${currentHotelLang.name}...`}
className="w-full p-2.5 text-xs rounded-xl bg-background border border-border/70 text-foreground leading-relaxed focus:ring-1 focus:ring-primary focus:outline-none"
className="text-xs rounded-xl bg-background border-border/70 text-foreground leading-relaxed focus:ring-1 focus:ring-primary focus:outline-none"
/>
</div>
</div>
</div>
{/* Stars & Score */}
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-foreground block mb-1">
درجه هتل (تعداد ستاره):
@ -1891,7 +1892,7 @@ export default function CustomTripDestinationDetailPage() {
</div>
{/* Daily Pricing */}
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-foreground block mb-1">
نرخ روزانه بزرگسال (دلار $):
@ -2181,7 +2182,7 @@ export default function CustomTripDestinationDetailPage() {
{currentTransportLang.dir.toUpperCase()}
</span>
</div>
<textarea
<Textarea
value={transportAirplaneDetailsMap[transportLangTab] || ''}
onChange={(e) =>
setTransportAirplaneDetailsMap((prev) => ({
@ -2192,7 +2193,7 @@ export default function CustomTripDestinationDetailPage() {
dir={currentTransportLang.dir}
rows={2}
placeholder={`توضیحات اختیاری خطوط هوایی و پروازها به زبان ${currentTransportLang.name}...`}
className="w-full p-2.5 text-xs rounded-xl bg-background border border-border/70 text-foreground leading-relaxed focus:ring-1 focus:ring-primary focus:outline-none"
className="text-xs rounded-xl bg-background border-border/70 text-foreground leading-relaxed focus:ring-1 focus:ring-primary focus:outline-none"
/>
</div>
@ -2213,7 +2214,7 @@ export default function CustomTripDestinationDetailPage() {
{currentTransportLang.dir.toUpperCase()}
</span>
</div>
<textarea
<Textarea
value={transportTrainDetailsMap[transportLangTab] || ''}
onChange={(e) =>
setTransportTrainDetailsMap((prev) => ({
@ -2224,7 +2225,7 @@ export default function CustomTripDestinationDetailPage() {
dir={currentTransportLang.dir}
rows={2}
placeholder={`توضیحات اختیاری قطار ریلی به زبان ${currentTransportLang.name}...`}
className="w-full p-2.5 text-xs rounded-xl bg-background border border-border/70 text-foreground leading-relaxed focus:ring-1 focus:ring-primary focus:outline-none"
className="text-xs rounded-xl bg-background border-border/70 text-foreground leading-relaxed focus:ring-1 focus:ring-primary focus:outline-none"
/>
</div>
@ -2245,7 +2246,7 @@ export default function CustomTripDestinationDetailPage() {
{currentTransportLang.dir.toUpperCase()}
</span>
</div>
<textarea
<Textarea
value={transportRoadDetailsMap[transportLangTab] || ''}
onChange={(e) =>
setTransportRoadDetailsMap((prev) => ({
@ -2256,7 +2257,7 @@ export default function CustomTripDestinationDetailPage() {
dir={currentTransportLang.dir}
rows={2}
placeholder={`توضیحات اختیاری ترابری اتوبوس و ون به زبان ${currentTransportLang.name}...`}
className="w-full p-2.5 text-xs rounded-xl bg-background border border-border/70 text-foreground leading-relaxed focus:ring-1 focus:ring-primary focus:outline-none"
className="text-xs rounded-xl bg-background border-border/70 text-foreground leading-relaxed focus:ring-1 focus:ring-primary focus:outline-none"
/>
</div>
</div>

1897
src/features/custom-trip/CustomTripOrderDetailPage.tsx
File diff suppressed because it is too large
View File

164
src/features/custom-trip/CustomTripPage.tsx

@ -55,7 +55,9 @@ export default function CustomTripPage() {
const activeTab = activeTabParam && TABS.some((tab) => tab.id === activeTabParam) ? activeTabParam : 'orders'
const [isLoading, setIsLoading] = useState(true)
const [isOrdersLoading, setIsOrdersLoading] = useState(true)
const [isOriginsLoading, setIsOriginsLoading] = useState(true)
const [isDestinationsLoading, setIsDestinationsLoading] = useState(true)
const [orders, setOrders] = useState<CustomTripOrderItem[]>([])
const [origins, setOrigins] = useState<CustomTripOriginItem[]>([])
const [destinations, setDestinations] = useState<CustomTripDestinationItem[]>([])
@ -64,32 +66,56 @@ export default function CustomTripPage() {
useEffect(() => {
let isMounted = true
async function loadData() {
setIsLoading(true)
try {
const [oData, origData, destData, relData, hotelData] = await Promise.all([
fetchCustomTripOrders(),
fetchCustomTripOrigins(),
fetchCustomTripDestinations(),
fetchCustomTripTransportRelations(),
fetchCustomTripHotels(),
// Smooth visible loading state per user UX requirement
new Promise((resolve) => setTimeout(resolve, 450)),
])
setIsOrdersLoading(true)
setIsOriginsLoading(true)
setIsDestinationsLoading(true)
fetchCustomTripOrders()
.then((data) => {
if (isMounted) {
setOrders(oData)
setOrigins(origData)
setDestinations(destData)
setRelations(relData)
setHotels(hotelData)
setOrders(data)
setIsOrdersLoading(false)
}
} catch {
// Handled gracefully with fallback mock data
} finally {
if (isMounted) setIsLoading(false)
}
}
loadData()
})
.catch(() => {
if (isMounted) setIsOrdersLoading(false)
})
fetchCustomTripOrigins()
.then((data) => {
if (isMounted) {
setOrigins(data)
setIsOriginsLoading(false)
}
})
.catch(() => {
if (isMounted) setIsOriginsLoading(false)
})
fetchCustomTripDestinations()
.then((data) => {
if (isMounted) {
setDestinations(data)
setIsDestinationsLoading(false)
}
})
.catch(() => {
if (isMounted) setIsDestinationsLoading(false)
})
fetchCustomTripTransportRelations()
.then((data) => {
if (isMounted) setRelations(data)
})
.catch(() => {})
fetchCustomTripHotels()
.then((data) => {
if (isMounted) setHotels(data)
})
.catch(() => {})
return () => {
isMounted = false
}
@ -263,42 +289,58 @@ export default function CustomTripPage() {
</div>
{/* Main Tab Content */}
{isLoading ? (
<div className="space-y-4">
<Shimmer className="h-11 w-full rounded-xl" />
<Shimmer rows={6} />
</div>
) : (
<div>
{activeTab === 'orders' && (
<CustomTripOrdersTab
orders={orders}
onOrderUpdated={handleOrderUpdated}
/>
)}
{activeTab === 'origins' && (
<CustomTripOriginsTab
origins={origins}
onAddOrigin={handleAddOrigin}
onUpdateOrigin={handleUpdateOrigin}
onDeleteOrigin={handleDeleteOrigin}
/>
)}
{activeTab === 'destinations' && (
<CustomTripDestinationsTab
destinations={destinations}
relations={relations}
onAddDestination={handleAddDestination}
onUpdateDestination={handleUpdateDestination}
onDeleteDestination={handleDeleteDestination}
onNavigateToTransports={handleNavigateToTransports}
onNavigateToHotels={handleNavigateToHotels}
/>
)}
</div>
)}
{(() => {
const isCurrentTabLoading =
activeTab === 'orders'
? isOrdersLoading
: activeTab === 'origins'
? isOriginsLoading
: isDestinationsLoading
if (isCurrentTabLoading) {
return (
<div className="space-y-4">
<Shimmer className="h-11 w-full rounded-xl" />
<Shimmer rows={6} />
</div>
)
}
return (
<div>
{activeTab === 'orders' && (
<CustomTripOrdersTab
orders={orders}
onOrderUpdated={handleOrderUpdated}
origins={origins}
destinations={destinations}
hotels={hotels}
/>
)}
{activeTab === 'origins' && (
<CustomTripOriginsTab
origins={origins}
onAddOrigin={handleAddOrigin}
onUpdateOrigin={handleUpdateOrigin}
onDeleteOrigin={handleDeleteOrigin}
/>
)}
{activeTab === 'destinations' && (
<CustomTripDestinationsTab
destinations={destinations}
relations={relations}
onAddDestination={handleAddDestination}
onUpdateDestination={handleUpdateDestination}
onDeleteDestination={handleDeleteDestination}
onNavigateToTransports={handleNavigateToTransports}
onNavigateToHotels={handleNavigateToHotels}
/>
)}
</div>
)
})()}
</div>
)
}

5
src/features/custom-trip/components/CustomTripDestinationsTab.tsx

@ -1,6 +1,7 @@
import React, { useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
@ -922,7 +923,7 @@ export function CustomTripDestinationsTab({
<label className="text-xs font-bold text-foreground">
توضیحات و راهنمای مقصد ({currentLang.name})
</label>
<textarea
<Textarea
value={descriptionsMap[activeLangTab] || ''}
onChange={(e) =>
setDescriptionsMap((prev) => ({ ...prev, [activeLangTab]: e.target.value }))
@ -930,7 +931,7 @@ export function CustomTripDestinationsTab({
dir={currentLang.dir}
rows={3}
placeholder="توضیحاتی درباره موقعیت شهر، شرایط ورود و آداب زیارت..."
className="w-full rounded-xl border border-border/70 bg-card p-3 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none resize-none shadow-2xs"
className="rounded-xl border border-border/70 bg-card text-xs text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none resize-none shadow-2xs"
/>
</div>
</div>

23
src/features/custom-trip/components/CustomTripHotelsTab.tsx

@ -1,5 +1,6 @@
import React, { useState, useMemo } from 'react'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
@ -348,10 +349,10 @@ export function CustomTripHotelsTab({
<div className="flex items-center gap-2 flex-wrap">
{/* Stars Filter */}
<div className="flex items-center gap-1 bg-muted/60 p-1 rounded-xl border border-border/50">
<div className="flex items-center gap-1 bg-muted/60 p-1 rounded-xl border border-border/50 overflow-x-auto no-scrollbar max-w-full">
<button
onClick={() => setStarsFilter('all')}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors whitespace-nowrap shrink-0 ${
starsFilter === 'all' ? 'bg-card text-foreground shadow-2xs font-bold' : 'text-muted-foreground'
}`}
>
@ -359,7 +360,7 @@ export function CustomTripHotelsTab({
</button>
<button
onClick={() => setStarsFilter('5')}
className={`px-2 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 ${
className={`px-2 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 whitespace-nowrap shrink-0 ${
starsFilter === '5' ? 'bg-card text-foreground shadow-2xs font-bold' : 'text-muted-foreground'
}`}
>
@ -368,7 +369,7 @@ export function CustomTripHotelsTab({
</button>
<button
onClick={() => setStarsFilter('4')}
className={`px-2 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 ${
className={`px-2 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 whitespace-nowrap shrink-0 ${
starsFilter === '4' ? 'bg-card text-foreground shadow-2xs font-bold' : 'text-muted-foreground'
}`}
>
@ -377,7 +378,7 @@ export function CustomTripHotelsTab({
</button>
<button
onClick={() => setStarsFilter('3')}
className={`px-2 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 ${
className={`px-2 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 whitespace-nowrap shrink-0 ${
starsFilter === '3' ? 'bg-card text-foreground shadow-2xs font-bold' : 'text-muted-foreground'
}`}
>
@ -386,7 +387,7 @@ export function CustomTripHotelsTab({
</button>
<button
onClick={() => setStarsFilter('2')}
className={`px-2 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 ${
className={`px-2 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 whitespace-nowrap shrink-0 ${
starsFilter === '2' ? 'bg-card text-foreground shadow-2xs font-bold' : 'text-muted-foreground'
}`}
>
@ -599,7 +600,7 @@ export function CustomTripHotelsTab({
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
<DialogContent className="sm:max-w-xl md:max-w-2xl w-full rounded-2xl bg-card border border-border-soft p-0 max-h-[85vh] flex flex-col overflow-hidden shadow-2xl">
{/* هدر ثابت مدال */}
<DialogHeader className="p-6 pb-4 border-b border-border-soft shrink-0 text-start">
<DialogHeader className="p-5 sm:p-6 pb-4 pe-12 border-b border-border-soft shrink-0 text-start">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Ic name="bed" className="size-5" />
@ -617,7 +618,7 @@ export function CustomTripHotelsTab({
{/* فرم: بدنه اسکرول‌شونده و فوتر فیکس‌شده */}
<form onSubmit={handleSaveForm} className="flex flex-col flex-1 overflow-hidden min-h-0">
<div className="flex-1 overflow-y-auto p-6 space-y-5">
<div className="flex-1 overflow-y-auto p-5 sm:p-6 space-y-5">
{validationError && (
<div className="p-3.5 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-500 text-xs flex items-center gap-2 font-medium">
<Ic name="alert" className="size-4 shrink-0" />
@ -718,7 +719,7 @@ export function CustomTripHotelsTab({
<label className="text-xs font-bold text-foreground">
توضیحات و خدمات هتل ({currentLang.name})
</label>
<textarea
<Textarea
value={descriptions[activeLangTab] || ''}
onChange={(e) =>
setDescriptions((prev) => ({ ...prev, [activeLangTab]: e.target.value }))
@ -726,7 +727,7 @@ export function CustomTripHotelsTab({
dir={currentLang.dir}
rows={3}
placeholder="توضیحاتی پیرامون کیفیت اتاق‌ها، رستوران، بوفه، نحوه دسترسی و چشم‌انداز..."
className="w-full rounded-xl border border-border-soft bg-surface-base p-3 text-xs text-foreground focus:border-primary focus:outline-none resize-none"
className="rounded-xl border border-border-soft bg-surface-base text-xs text-foreground focus:border-primary focus:outline-none resize-none"
/>
</div>
</div>
@ -750,7 +751,7 @@ export function CustomTripHotelsTab({
<label className="text-xs font-bold text-foreground block">
امکانات و خدمات هتل
</label>
<div className="grid grid-cols-2 gap-2 p-3.5 rounded-xl bg-surface-base/50 border border-border-soft">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 p-3.5 rounded-xl bg-surface-base/50 border border-border-soft">
{COMMON_AMENITIES.map((am) => {
const isChecked = formAmenities.includes(am.name)
return (

1613
src/features/custom-trip/components/CustomTripOrderReviewDialog.tsx
File diff suppressed because it is too large
View File

236
src/features/custom-trip/components/CustomTripOrdersTab.tsx

@ -1,78 +1,103 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from '@/components/ui/table'
import { Ic } from '@/icons'
import { toFa } from '@/lib/utils'
import { useTranslation } from '@/i18n'
import { formatDateByLanguage } from '@/lib/date-utils'
import type { CustomTripOrderItem, CustomTripOrderStatus } from '../types'
import { CustomTripOrderReviewDialog } from './CustomTripOrderReviewDialog'
import type {
CustomTripOrderItem,
CustomTripOrderStatus,
CustomTripOriginItem,
CustomTripDestinationItem,
CustomTripHotelItem,
} from '../types'
interface CustomTripOrdersTabProps {
orders: CustomTripOrderItem[]
onOrderUpdated: (updated: CustomTripOrderItem) => void
origins?: CustomTripOriginItem[]
destinations?: CustomTripDestinationItem[]
hotels?: CustomTripHotelItem[]
}
export function CustomTripOrdersTab({ orders, onOrderUpdated }: CustomTripOrdersTabProps) {
export function CustomTripOrdersTab({
orders,
onOrderUpdated,
origins = [],
destinations = [],
hotels = [],
}: CustomTripOrdersTabProps) {
const navigate = useNavigate()
const { t, language } = useTranslation()
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [selectedOrder, setSelectedOrder] = useState<CustomTripOrderItem | null>(null)
const [toastMessage, setToastMessage] = useState<string | null>(null)
const handleOpenReview = (order: CustomTripOrderItem) => {
setSelectedOrder(order)
navigate(`/custom-trip/orders/${order.id}`)
}
const getStatusBadge = (status: CustomTripOrderStatus) => {
switch (status) {
case 'APPROVED':
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Badge variant="success" className="gap-1.5 py-1 text-xs font-semibold">
<Ic name="checkCircle" className="size-3.5" />
{t('customTrip.statusApproved')}
</span>
</Badge>
)
case 'AWAITING_PAYMENT':
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
<Badge className="gap-1.5 py-1 text-xs font-semibold bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
<Ic name="calculator" className="size-3.5" />
{t('customTrip.statusAwaitingPayment')}
</span>
</Badge>
)
case 'PENDING':
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
<Badge className="gap-1.5 py-1 text-xs font-semibold bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
<Ic name="history" className="size-3.5" />
{t('customTrip.statusPending')}
</span>
</Badge>
)
case 'REJECTED':
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-rose-500/10 text-rose-600 dark:text-rose-400 border border-rose-500/20">
<Badge variant="destructive" className="gap-1.5 py-1 text-xs font-semibold">
<Ic name="xCircle" className="size-3.5" />
{t('customTrip.statusRejected')}
</span>
</Badge>
)
case 'EXPIRED':
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-slate-500/10 text-slate-500 dark:text-slate-400 border border-slate-500/20">
<Badge variant="secondary" className="gap-1.5 py-1 text-xs font-semibold">
<Ic name="history" className="size-3.5" />
{t('customTrip.statusExpired')}
</span>
</Badge>
)
case 'CANCELLED':
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-rose-500/10 text-rose-600 dark:text-rose-400 border border-rose-500/20">
<Badge variant="destructive" className="gap-1.5 py-1 text-xs font-semibold">
<Ic name="xCircle" className="size-3.5" />
{t('customTrip.statusCancelled')}
</span>
</Badge>
)
default:
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-muted text-muted-foreground border border-border">
<Badge variant="secondary" className="gap-1.5 py-1 text-xs font-semibold">
{status}
</span>
</Badge>
)
}
}
@ -101,7 +126,7 @@ export function CustomTripOrdersTab({ orders, onOrderUpdated }: CustomTripOrders
{/* KPI Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-3.5 rounded-2xl bg-card border border-border/70 shadow-xs">
<Card className="p-3.5 border-border/70 shadow-xs">
<span className="text-[11px] text-muted-foreground font-medium block">
{t('customTrip.allOrdersCount')}
</span>
@ -109,8 +134,8 @@ export function CustomTripOrdersTab({ orders, onOrderUpdated }: CustomTripOrders
<span className="text-xl font-black font-mono text-foreground">{toFa(orders.length)}</span>
<span className="text-xs text-muted-foreground">{t('customTrip.ordersUnit')}</span>
</div>
</div>
<div className="p-3.5 rounded-2xl bg-card border border-border/70 shadow-xs">
</Card>
<Card className="p-3.5 border-border/70 shadow-xs">
<span className="text-[11px] text-muted-foreground font-medium block">
{t('customTrip.pendingOrdersCount')}
</span>
@ -120,8 +145,8 @@ export function CustomTripOrdersTab({ orders, onOrderUpdated }: CustomTripOrders
</span>
<span className="text-xs text-muted-foreground">{t('customTrip.ordersUnit')}</span>
</div>
</div>
<div className="p-3.5 rounded-2xl bg-card border border-border/70 shadow-xs">
</Card>
<Card className="p-3.5 border-border/70 shadow-xs">
<span className="text-[11px] text-muted-foreground font-medium block">
{t('customTrip.approvedOrdersCount')}
</span>
@ -131,8 +156,8 @@ export function CustomTripOrdersTab({ orders, onOrderUpdated }: CustomTripOrders
</span>
<span className="text-xs text-muted-foreground">{t('customTrip.ordersUnit')}</span>
</div>
</div>
<div className="p-3.5 rounded-2xl bg-card border border-border/70 shadow-xs">
</Card>
<Card className="p-3.5 border-border/70 shadow-xs">
<span className="text-[11px] text-muted-foreground font-medium block">
{t('customTrip.awaitingPaymentOrdersCount')}
</span>
@ -142,11 +167,11 @@ export function CustomTripOrdersTab({ orders, onOrderUpdated }: CustomTripOrders
</span>
<span className="text-xs text-muted-foreground">{t('customTrip.ordersUnit')}</span>
</div>
</div>
</Card>
</div>
{/* Filter and Action Bar */}
<div className="flex flex-col gap-3 bg-card p-3 rounded-2xl border border-border/70 shadow-xs">
<Card className="flex flex-col gap-3 p-3 border-border/70 shadow-xs">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="relative flex-1">
<Ic name="search" className="absolute top-1/2 -translate-y-1/2 start-3 size-4 text-muted-foreground" />
@ -230,57 +255,71 @@ export function CustomTripOrdersTab({ orders, onOrderUpdated }: CustomTripOrders
({toFa(orders.filter((o) => o.status === 'REJECTED').length)})
</span>
</button>
<button
onClick={() => setStatusFilter('CANCELLED')}
className={`flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${
statusFilter === 'CANCELLED'
? 'bg-primary text-white shadow-xs'
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<span className="size-2 rounded-full bg-slate-500" />
<span>{t('customTrip.statusCancelled')}</span>
<span className="opacity-75 font-mono text-[10px]">
({toFa(orders.filter((o) => o.status === 'CANCELLED').length)})
</span>
</button>
</div>
</div>
</Card>
{/* Orders Table */}
<div className="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-xs">
<Card className="overflow-hidden border-border/70 shadow-xs">
<div className="overflow-x-auto">
<table className="w-full text-xs sm:text-sm text-start">
<thead className="bg-muted/40 text-muted-foreground border-b border-border/60 font-medium">
<tr>
<th className="px-4 py-3.5 text-start font-medium">{t('customTrip.rowNumber')}</th>
<th className="px-4 py-3.5 text-start font-medium">{t('customTrip.orderId')}</th>
<th className="px-4 py-3.5 text-start font-medium">{t('customTrip.customer')}</th>
<th className="px-4 py-3.5 text-start font-medium">{t('customTrip.travelRoute')}</th>
<th className="px-4 py-3.5 text-center font-medium">{t('customTrip.passengers')}</th>
<th className="px-4 py-3.5 text-center font-medium">{t('customTrip.priceEstimate')}</th>
<th className="px-4 py-3.5 text-center font-medium">{t('customTrip.status')}</th>
<th className="px-4 py-3.5 text-center font-medium">{t('customTrip.createdDate')}</th>
<th className="px-4 py-3.5 text-center font-medium">{t('customTrip.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
<Table className="w-full text-xs sm:text-sm text-start">
<TableHeader className="bg-muted/40 text-muted-foreground border-b border-border/60 font-medium">
<TableRow className="hover:bg-transparent">
<TableHead className="px-4 py-3.5 text-start font-medium">{t('customTrip.rowNumber')}</TableHead>
<TableHead className="px-4 py-3.5 text-start font-medium">{t('customTrip.orderId')}</TableHead>
<TableHead className="px-4 py-3.5 text-start font-medium">{t('customTrip.customer')}</TableHead>
<TableHead className="px-4 py-3.5 text-start font-medium">{t('customTrip.travelRoute')}</TableHead>
<TableHead className="px-4 py-3.5 text-center font-medium">{t('customTrip.passengers')}</TableHead>
<TableHead className="px-4 py-3.5 text-center font-medium">{t('customTrip.invoiceAmount')}</TableHead>
<TableHead className="px-4 py-3.5 text-center font-medium">{t('customTrip.status')}</TableHead>
<TableHead className="px-4 py-3.5 text-center font-medium">{t('customTrip.createdDate')}</TableHead>
<TableHead className="px-4 py-3.5 text-center font-medium">{t('customTrip.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border/50">
{filteredOrders.length === 0 ? (
<tr>
<td colSpan={9} className="py-12 text-center text-muted-foreground">
<TableRow>
<TableCell colSpan={9} className="py-12 text-center text-muted-foreground">
<Ic name="inbox" className="size-8 mx-auto mb-2 text-muted-foreground/60" />
{t('customTrip.noOrdersFound')}
</td>
</tr>
</TableCell>
</TableRow>
) : (
filteredOrders.map((order, idx) => (
<tr
<TableRow
key={order.id}
onClick={() => handleOpenReview(order)}
className="hover:bg-muted/40 transition-colors cursor-pointer group"
title={t('customTrip.reviewOrder')}
>
<td className="px-4 py-3.5 font-mono text-muted-foreground">
<TableCell className="px-4 py-3.5 font-mono text-muted-foreground">
{toFa(idx + 1)}
</td>
<td className="px-4 py-3.5 font-mono font-bold text-foreground group-hover:text-primary transition-colors">
</TableCell>
<TableCell className="px-4 py-3.5 font-mono font-bold text-foreground group-hover:text-primary transition-colors">
#{toFa(order.id)}
</td>
<td className="px-4 py-3.5">
</TableCell>
<TableCell className="px-4 py-3.5">
<div className="font-semibold text-foreground group-hover:text-primary transition-colors">
{order.user.fullName || order.user.username}
</div>
<div className="font-mono text-xs text-muted-foreground mt-0.5 dir-ltr text-start">
{order.user.phoneNumber || order.user.email || '—'}
</div>
</td>
<td className="px-4 py-3.5">
</TableCell>
<TableCell className="px-4 py-3.5">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-medium text-foreground px-2.5 py-1 rounded-lg bg-muted/70 text-xs">
{order.originCity}
@ -293,14 +332,25 @@ export function CustomTripOrdersTab({ orders, onOrderUpdated }: CustomTripOrders
</span>
</React.Fragment>
))}
{order.originCity && (
<>
<span className="text-muted-foreground/60 text-xs">←</span>
<span className="font-medium text-muted-foreground px-2 py-0.5 rounded-lg bg-muted/50 border border-border/60 text-[11px]">
{order.originCity}
</span>
</>
)}
</div>
{order.legs && order.legs.length > 0 && (
<div className="text-[11px] text-muted-foreground mt-1 truncate max-w-xs">
{order.legs.map((l) => `${l.toCity}: ${l.hotelName ?? ''}`).join(' | ')}
{order.legs
.filter((l) => !l.isReturn)
.map((l) => `${l.toCity}: ${l.hotelName || '—'} (${toFa(l.stayNights || 1)} شب)`)
.join(' | ')}
</div>
)}
</td>
<td className="px-4 py-3.5 text-center">
</TableCell>
<TableCell className="px-4 py-3.5 text-center">
<span className="inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-xl bg-card border border-border/70 text-foreground">
<Ic name="users" className="size-3 text-muted-foreground" />
<span className="font-mono">{toFa(order.adultsCount)}</span>
@ -320,17 +370,37 @@ export function CustomTripOrdersTab({ orders, onOrderUpdated }: CustomTripOrders
</>
)}
</span>
</td>
<td className="px-4 py-3.5 text-center font-mono font-bold text-foreground">
{order.priceEstimate ? `${toFa(order.priceEstimate.toLocaleString())} $` : '—'}
</td>
<td className="px-4 py-3.5 text-center">
{getStatusBadge(order.status)}
</td>
<td className="px-4 py-3.5 text-center font-mono text-xs text-muted-foreground">
</TableCell>
<TableCell className="px-4 py-3.5 text-center">
{order.finalPrice && Number(order.finalPrice) > 0 ? (
<span className="font-mono font-bold text-foreground">
${toFa(Number(order.finalPrice).toLocaleString())}
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 whitespace-nowrap">
{t('customTrip.pendingInvoiceBadge')}
</span>
)}
</TableCell>
<TableCell className="px-4 py-3.5 text-center">
<div className="flex flex-col items-center gap-1">
{getStatusBadge(order.status)}
{order.factorId && order.status !== 'PENDING' ? (
<span className="inline-flex items-center gap-1 text-[10px] font-mono font-bold text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-md">
<Ic name="receipt" className="size-3" />
<span>فاکتور #{toFa(order.factorNumber || order.factorId)}</span>
</span>
) : (
<span className="text-[10px] text-muted-foreground">
{t('customTrip.notInvoicedYetBadge')}
</span>
)}
</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-center font-mono text-xs text-muted-foreground">
{formatDateByLanguage(order.created, language)}
</td>
<td className="px-4 py-3.5 text-center" onClick={(e) => e.stopPropagation()}>
</TableCell>
<TableCell className="px-4 py-3.5 text-center" onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="outline"
@ -340,30 +410,14 @@ export function CustomTripOrdersTab({ orders, onOrderUpdated }: CustomTripOrders
<Ic name="edit" className="size-3.5" />
<span>{t('customTrip.reviewOrder')}</span>
</Button>
</td>
</tr>
</TableCell>
</TableRow>
))
)}
</tbody>
</table>
</TableBody>
</Table>
</div>
</div>
{/* Review & Feedback Modal Component */}
<CustomTripOrderReviewDialog
order={selectedOrder}
open={!!selectedOrder}
onOpenChange={(open) => {
if (!open) setSelectedOrder(null)
}}
onOrderUpdated={(updated) => {
onOrderUpdated(updated)
setSelectedOrder(updated)
setToastMessage(t('customTrip.successFeedbackSaved'))
setTimeout(() => setToastMessage(null), 4000)
}}
getStatusBadge={getStatusBadge}
/>
</Card>
</div>
)
}

2
src/features/custom-trip/components/CustomTripOriginsTab.tsx

@ -530,7 +530,7 @@ export function CustomTripOriginsTab({
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
<DialogContent className="sm:max-w-xl md:max-w-2xl w-full rounded-2xl bg-card border border-border-soft p-0 max-h-[85vh] flex flex-col overflow-hidden shadow-2xl">
{/* هدر ثابت مدال */}
<DialogHeader className="p-6 pb-4 border-b border-border-soft shrink-0 text-start">
<DialogHeader className="p-5 sm:p-6 pb-4 pe-12 border-b border-border-soft shrink-0 text-start">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Ic name="mapPin" className="size-5" />

12
src/features/custom-trip/components/CustomTripTransportsTab.tsx

@ -266,10 +266,10 @@ export function CustomTripTransportsTab({
<div className="flex items-center gap-2 flex-wrap">
{/* Filter by Method */}
<div className="flex items-center gap-1 bg-muted/60 p-1 rounded-xl border border-border/50">
<div className="flex items-center gap-1 bg-muted/60 p-1 rounded-xl border border-border/50 overflow-x-auto no-scrollbar max-w-full">
<button
onClick={() => setMethodFilter('all')}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors whitespace-nowrap shrink-0 ${
methodFilter === 'all' ? 'bg-card text-foreground shadow-2xs font-bold' : 'text-muted-foreground'
}`}
>
@ -277,7 +277,7 @@ export function CustomTripTransportsTab({
</button>
<button
onClick={() => setMethodFilter('flight')}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 ${
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 whitespace-nowrap shrink-0 ${
methodFilter === 'flight' ? 'bg-card text-foreground shadow-2xs font-bold' : 'text-muted-foreground'
}`}
>
@ -286,7 +286,7 @@ export function CustomTripTransportsTab({
</button>
<button
onClick={() => setMethodFilter('train')}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 ${
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 whitespace-nowrap shrink-0 ${
methodFilter === 'train' ? 'bg-card text-foreground shadow-2xs font-bold' : 'text-muted-foreground'
}`}
>
@ -295,7 +295,7 @@ export function CustomTripTransportsTab({
</button>
<button
onClick={() => setMethodFilter('road')}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 ${
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 whitespace-nowrap shrink-0 ${
methodFilter === 'road' ? 'bg-card text-foreground shadow-2xs font-bold' : 'text-muted-foreground'
}`}
>
@ -587,7 +587,7 @@ export function CustomTripTransportsTab({
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
<DialogContent className="sm:max-w-xl md:max-w-2xl w-full rounded-2xl bg-card border border-border-soft p-0 max-h-[85vh] flex flex-col overflow-hidden shadow-2xl">
{/* هدر ثابت مدال */}
<DialogHeader className="p-6 pb-4 border-b border-border-soft shrink-0 text-start">
<DialogHeader className="p-5 sm:p-6 pb-4 pe-12 border-b border-border-soft shrink-0 text-start">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Ic name="route" className="size-5" />

235
src/features/custom-trip/services/custom-trip-api.ts

@ -36,23 +36,150 @@ function cleanLegacyDetail(rawDetail: any): string {
return s
}
function addDaysIso(dateStr: string, days: number): string {
const d = new Date(`${dateStr.slice(0, 10)}T00:00:00Z`)
if (Number.isNaN(d.getTime())) {
const fallback = new Date(Date.now() + 7 * 86400000)
fallback.setUTCDate(fallback.getUTCDate() + days)
return fallback.toISOString().slice(0, 10)
}
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
function getDefaultStartDateIso(createdStr?: string): string {
const d = createdStr ? new Date(createdStr) : new Date()
if (Number.isNaN(d.getTime())) {
return new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10)
}
d.setUTCDate(d.getUTCDate() + 7)
return d.toISOString().slice(0, 10)
}
function mapBackendOrderToPanel(raw: any): CustomTripOrderItem {
const stays = Array.isArray(raw.stays) ? raw.stays : []
const rawLegs = Array.isArray(raw.legs) ? raw.legs : []
const passengers = Array.isArray(raw.passengers) ? raw.passengers : []
const rawServices = Array.isArray(raw.services) ? raw.services : []
const originCity = raw.origin_city || raw.originCity || ''
const destinations: string[] = Array.isArray(raw.destinations) ? raw.destinations : []
const legs = rawLegs.map((leg: any, idx: number) => {
const matchingStay = stays.find((s: any) => s.city === leg.to_city) || stays[idx]
let legs = rawLegs.map((leg: any, idx: number) => {
const isReturn = Boolean(
leg.is_return ||
leg.isReturn ||
(idx === rawLegs.length - 1 &&
rawLegs.length > stays.length &&
stays.length > 0 &&
leg.to_city === originCity)
)
const matchingStay = isReturn
? null
: stays[idx] || stays.find((s: any) => s.city === leg.to_city)
const methodCode = (Number(leg.transport_method) || 1) as 1 | 2 | 3
return {
fromCity: leg.from_city || '',
toCity: leg.to_city || '',
transportMethod: leg.transport_method_display || leg.transport_name_snapshot || leg.transport_method || '',
hotelName: matchingStay?.hotel_name_snapshot || '',
stayNights: matchingStay?.nights || 0,
mealPlan: matchingStay?.meal_plan || '',
toCity: isReturn ? leg.to_city || originCity || '' : leg.to_city || '',
transportMethod:
leg.transport_method_display ||
leg.transport_name_snapshot ||
(methodCode === 2 ? 'قطار (ریلی)' : methodCode === 3 ? 'زمینی (اتوبوس / ون VIP)' : 'پرواز (هوایی)'),
transportMethodCode: methodCode,
transportId: leg.transport_id ?? null,
hotelId: isReturn ? null : matchingStay?.hotel_id ?? null,
hotelName: isReturn ? '' : matchingStay?.hotel_name_snapshot || '',
stayNights: isReturn ? 0 : matchingStay?.nights || 3,
mealPlan: isReturn ? '' : matchingStay?.meal_plan || 'FULL_BOARD',
departureDate: leg.departure_date || matchingStay?.check_in || null,
departureTime: leg.departure_time || (isReturn ? '18:00' : '09:00'),
checkIn: isReturn ? null : matchingStay?.check_in || null,
checkOut: isReturn ? null : matchingStay?.check_out || null,
isReturn,
}
})
// If legacy order had destinations/stays but no legs, synthesize editable legs
if (legs.length === 0 && destinations.length > 0) {
legs = destinations.map((destCity, idx) => {
const matchingStay = stays.find((s: any) => s.city === destCity) || stays[idx]
return {
fromCity: idx === 0 ? originCity : destinations[idx - 1],
toCity: destCity,
transportMethod: 'پرواز (هوایی)',
transportMethodCode: 1 as const,
transportId: null,
hotelId: matchingStay?.hotel_id ?? null,
hotelName: matchingStay?.hotel_name_snapshot || '',
stayNights: matchingStay?.nights || 3,
mealPlan: matchingStay?.meal_plan || 'FULL_BOARD',
departureDate: matchingStay?.check_in || null,
departureTime: '09:00',
checkIn: matchingStay?.check_in || null,
checkOut: matchingStay?.check_out || null,
isReturn: false,
}
})
}
// Ensure last leg is Return to Origin City (has transport, no hotel) & dates are sequential
if (legs.length > 0 && originCity) {
const hasReturn = legs.some((l) => l.isReturn)
if (!hasReturn) {
const lastDest = legs[legs.length - 1].toCity || originCity
legs.push({
fromCity: lastDest,
toCity: originCity,
transportMethod: 'پرواز (هوایی)',
transportMethodCode: 1 as const,
transportId: null,
hotelId: null,
hotelName: '',
stayNights: 0,
mealPlan: '',
departureDate: null,
departureTime: '18:00',
checkIn: null,
checkOut: null,
isReturn: true,
})
}
let curDate = legs[0].departureDate || getDefaultStartDateIso(raw.created)
legs = legs.map((l, idx) => {
const fromCity = idx === 0 ? originCity || l.fromCity : legs[idx - 1].toCity || l.fromCity
if (l.isReturn) {
return {
...l,
fromCity,
toCity: originCity,
hotelId: null,
hotelName: '',
stayNights: 0,
mealPlan: '',
departureDate: curDate,
departureTime: l.departureTime || '18:00',
checkIn: null,
checkOut: null,
isReturn: true,
}
}
const nights = Math.max(1, Number(l.stayNights) || 3)
const depDate = idx === 0 && l.departureDate ? l.departureDate : curDate
const checkOut = addDaysIso(depDate, nights)
curDate = checkOut
return {
...l,
fromCity,
stayNights: nights,
departureDate: depDate,
departureTime: l.departureTime || '09:00',
checkIn: depDate,
checkOut,
isReturn: false,
}
})
}
const adultsCount = passengers.filter((p: any) => p.passenger_type === 'ADULT').length || raw.passenger_count || 1
const childrenCount = passengers.filter((p: any) => p.passenger_type === 'CHILD').length || 0
const infantsCount = passengers.filter((p: any) => p.passenger_type === 'INFANT').length || 0
@ -67,8 +194,15 @@ function mapBackendOrderToPanel(raw: any): CustomTripOrderItem {
passportImage: p.passport_image || p.passportImage || null,
}))
const mappedServices = rawServices.map((s: any) => ({
serviceType: s.service_type || s.serviceType || '',
quantity: Number(s.quantity) || 1,
totalPrice: Number(s.total_price ?? s.totalPrice ?? 0),
}))
return {
id: Number(raw.id),
trackingCode: raw.tracking_code || raw.trackingCode || '',
user: {
id: raw.user?.id || raw.id,
username: raw.customer_name || raw.user?.username || `user_${raw.id}`,
@ -76,14 +210,18 @@ function mapBackendOrderToPanel(raw: any): CustomTripOrderItem {
phoneNumber: raw.contact_phone || raw.whatsapp_number || raw.user?.phoneNumber || '',
email: raw.user?.email || '',
},
originCity: raw.origin_city || raw.originCity || '',
destinations: Array.isArray(raw.destinations) ? raw.destinations : [],
originCity,
destinations,
legs,
passengers: mappedPassengers,
services: mappedServices,
adultsCount,
childrenCount,
infantsCount,
priceEstimate: Number(raw.final_price ?? raw.price_estimate ?? raw.priceEstimate ?? 0),
finalPrice: raw.final_price !== null && raw.final_price !== undefined ? Number(raw.final_price) : null,
factorId: raw.factor_id ?? null,
factorNumber: raw.factor_number ?? null,
status: (raw.status || 'PENDING') as CustomTripOrderStatus,
created: raw.created || new Date().toISOString(),
detail: cleanLegacyDetail(raw.detail),
@ -102,24 +240,95 @@ export async function fetchCustomTripOrders(): Promise<CustomTripOrderItem[]> {
}
}
export async function fetchCustomTripOrderDetail(orderId: number): Promise<CustomTripOrderItem | null> {
try {
const res = await apiFetch<any>(`trip/custom/v2/admin/orders/${orderId}/`)
if (res && res.id) {
return mapBackendOrderToPanel(res)
}
return null
} catch (err) {
console.warn(`[CustomTrip API] Could not fetch order ${orderId} detail:`, err)
// Fallback: search in orders list
try {
const orders = await fetchCustomTripOrders()
return orders.find((o) => o.id === orderId) || null
} catch {
return null
}
}
}
export async function updateCustomTripOrder(
orderId: number,
data: {
status?: CustomTripOrderStatus
priceEstimate?: number
adminNotes?: string
originCity?: string
legs?: {
fromCity: string
toCity: string
transportMethodCode?: 1 | 2 | 3
transportMethod?: string
hotelId?: number | string | null
hotelName?: string
stayNights?: number
mealPlan?: string
departureDate?: string | null
departureTime?: string | null
checkIn?: string | null
checkOut?: string | null
isReturn?: boolean
}[]
transferToBilling?: boolean
}
): Promise<CustomTripOrderItem> {
if (data.status === 'AWAITING_PAYMENT' && data.priceEstimate !== undefined && data.priceEstimate > 0) {
const itineraryPayload: Record<string, any> = {}
if (data.originCity) {
itineraryPayload.origin_city = data.originCity
}
if (Array.isArray(data.legs) && data.legs.length > 0) {
itineraryPayload.legs = data.legs.map((leg, idx) => ({
sequence: idx + 1,
from_city: leg.fromCity || (idx === 0 && data.originCity ? data.originCity : ''),
to_city: leg.toCity || (leg.isReturn && data.originCity ? data.originCity : leg.toCity),
transport_method: leg.transportMethodCode || 1,
departure_date: leg.departureDate || null,
departure_time: leg.departureTime || (leg.isReturn ? '18:00' : '09:00'),
is_return: Boolean(leg.isReturn),
}))
itineraryPayload.stays = data.legs
.filter((leg) => !leg.isReturn)
.map((leg, idx) => ({
sequence: idx + 1,
city: leg.toCity,
hotel_id: leg.hotelId ?? null,
hotel_name: leg.hotelName || '',
nights: Number(leg.stayNights) || 3,
meal_plan: leg.mealPlan || 'FULL_BOARD',
check_in: leg.checkIn || leg.departureDate || null,
check_out: leg.checkOut || null,
}))
}
// Stage 2: Finalize order and transfer to Financial Invoices (Billing)
if (
(data.transferToBilling || data.status === 'AWAITING_PAYMENT') &&
data.priceEstimate !== undefined &&
data.priceEstimate > 0
) {
const res = await apiFetch<any>(`trip/custom/v2/admin/orders/${orderId}/quote/`, {
method: 'POST',
body: {
final_price: data.priceEstimate,
admin_notes: data.adminNotes || '',
...itineraryPayload,
},
})
return mapBackendOrderToPanel(res)
}
if (data.status === 'REJECTED') {
const noteText = data.adminNotes || 'رد شده توسط مدیریت'
const res = await apiFetch<any>(`trip/custom/v2/admin/orders/${orderId}/reject/`, {
@ -131,12 +340,16 @@ export async function updateCustomTripOrder(
})
return mapBackendOrderToPanel(res)
}
// Stage 1: Save changes/edits while keeping order in review (or updating status/items)
const res = await apiFetch<any>(`trip/custom/v2/admin/orders/${orderId}/`, {
method: 'PATCH',
body: {
status: data.status,
final_price: data.priceEstimate,
price_estimate: data.priceEstimate,
final_price: data.status === 'PENDING' ? undefined : data.priceEstimate,
admin_notes: data.adminNotes,
...itineraryPayload,
},
})
return mapBackendOrderToPanel(res)

19
src/features/custom-trip/types.ts

@ -18,9 +18,17 @@ export interface CustomTripLeg {
fromCity: string
toCity: string
transportMethod?: string
transportMethodCode?: 1 | 2 | 3
transportId?: number | string | null
hotelId?: number | string | null
hotelName?: string
stayNights?: number
mealPlan?: string
departureDate?: string | null
departureTime?: string | null
checkIn?: string | null
checkOut?: string | null
isReturn?: boolean
}
export interface CustomTripPassenger {
@ -33,17 +41,28 @@ export interface CustomTripPassenger {
passportImage?: string | null
}
export interface CustomTripOrderServiceItem {
serviceType: string
quantity: number
totalPrice: number
}
export interface CustomTripOrderItem {
id: number
trackingCode?: string
user: CustomTripUser
originCity: string
destinations: string[]
legs: CustomTripLeg[]
passengers?: CustomTripPassenger[]
services?: CustomTripOrderServiceItem[]
adultsCount: number
childrenCount: number
infantsCount: number
priceEstimate: number
finalPrice?: number | null
factorId?: number | null
factorNumber?: number | null
status: CustomTripOrderStatus
created: string
detail?: string

31
src/features/services/components/CategoriesServiceView.tsx

@ -498,23 +498,6 @@ export function CategoriesServiceView() {
<Ic name="list" className="size-4" />
</button>
</div>
{/* دکمه پاک کردن فیلترها */}
{(searchTerm || selectedCityId !== 'ALL') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchTerm('')
setSelectedCityId('ALL')
}}
className="text-xs text-destructive hover:bg-destructive/10 h-9.5 px-2.5 rounded-xl shrink-0"
title="پاک کردن فیلترها"
>
<Ic name="trash" className="size-3.5 me-1" />
<span>ریست</span>
</Button>
)}
</div>
{/* دکمه‌های عملیاتی سمت چپ */}
@ -561,7 +544,7 @@ export function CategoriesServiceView() {
<ShimmerBox width={110} height={22} rounded="md" />
</div>
</div>
<div className="pt-3 border-t border-border-soft flex items-center gap-2">
<div className="pt-3 border-t border-border-soft flex items-center justify-between gap-2">
<ShimmerBox height={32} rounded="xl" className="flex-1" />
<ShimmerBox width={32} height={32} rounded="xl" />
<ShimmerBox width={32} height={32} rounded="xl" />
@ -677,6 +660,7 @@ export function CategoriesServiceView() {
) : (
/* نمای جدولی (Table) */
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<div className="overflow-x-auto">
<table className="w-full text-start text-xs">
<thead className="border-b border-border-soft bg-card/60 text-grey-3">
<tr>
@ -754,13 +738,14 @@ export function CategoriesServiceView() {
))}
</tbody>
</table>
</div>
</div>
)}
{/* ۴. دیالوگ ایجاد و ویرایش دسته‌بندی */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="w-full max-w-full sm:max-w-xl md:max-w-2xl max-h-[90vh] flex flex-col p-0 rounded-2xl bg-card border-border-soft overflow-hidden" dir="rtl">
<DialogHeader className="p-5 pb-4 border-b border-border-soft shrink-0">
<DialogHeader className="p-5 pb-4 pe-12 border-b border-border-soft shrink-0">
<DialogTitle className="text-base font-bold text-foreground">
{editingCategory ? 'ویرایش دسته‌بندی ادعیه' : 'افزودن دسته‌بندی جدید برای شهر'}
</DialogTitle>
@ -771,7 +756,7 @@ export function CategoriesServiceView() {
<div className="flex-1 overflow-y-auto p-5 sm:p-6 space-y-4">
{/* انتخاب شهرهای مربوطه */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="block text-xs font-bold text-foreground">
شهر(های) مربوطه
</label>
@ -788,7 +773,7 @@ export function CategoriesServiceView() {
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 rounded-xl border border-border-soft bg-card/40">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 p-3 rounded-xl border border-border-soft bg-card/40">
{PILGRIMAGE_CITIES.map((city) => {
const isSelected = formSelectedCityIds.includes(city.id)
return (
@ -874,7 +859,7 @@ export function CategoriesServiceView() {
className="space-y-2 rounded-2xl bg-surface-base/80 p-4 border border-border-soft"
dir={currentMeta.dir}
>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="block text-xs font-semibold text-foreground">
عنوان دسته‌بندی به زبان {currentMeta.name} ({currentMeta.nativeName}){' '}
{formActiveLang === 'ar' && <span className="text-destructive">*</span>}
@ -982,7 +967,7 @@ export function CategoriesServiceView() {
{/* ۵. دیالوگ تخصیص سریع ادعیه به دسته‌بندی */}
<Dialog open={Boolean(assigningCategory)} onOpenChange={(open) => !open && setAssigningCategory(null)}>
<DialogContent className="w-full max-w-full sm:max-w-md max-h-[90vh] flex flex-col p-0 rounded-2xl bg-card border-border-soft overflow-hidden" dir="rtl">
<DialogHeader className="p-5 pb-4 border-b border-border-soft shrink-0">
<DialogHeader className="p-5 pb-4 pe-12 border-b border-border-soft shrink-0">
<DialogTitle className="text-base font-bold text-foreground">
تخصیص ادعیه به «{assigningCategory?.name_display}»{assigningCategory?.city_info?.name ? ` (${assigningCategory.city_info.name})` : ''}
</DialogTitle>

2
src/features/services/components/CreateLocationDialog.tsx

@ -336,7 +336,7 @@ export function CreateLocationDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-full sm:max-w-xl md:max-w-2xl max-h-[92vh] flex flex-col p-0 rounded-t-2xl sm:rounded-2xl border-border-soft bg-card overflow-hidden">
<DialogHeader className="px-6 pt-5 pb-4 border-b border-border-soft bg-card/60">
<DialogHeader className="px-6 pt-5 pb-4 pe-12 border-b border-border-soft bg-card/60">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Ic name={editingLocation ? 'pencil' : 'plus'} className="size-5" />

24
src/features/services/components/DuasServiceView.tsx

@ -849,8 +849,8 @@ export function DuasServiceView() {
</div>
{/* فیلتر بر اساس شهر */}
<div className="flex items-center gap-2">
<div className="relative min-w-[140px] sm:min-w-[170px]">
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 sm:flex-none min-w-[140px] sm:min-w-[170px]">
<select
value={selectedCityFilter}
onChange={(e) => setSelectedCityFilter(e.target.value)}
@ -868,7 +868,7 @@ export function DuasServiceView() {
</div>
{/* فیلتر بر اساس دسته‌بندی */}
<div className="relative min-w-[150px] sm:min-w-[190px]">
<div className="relative flex-1 sm:flex-none min-w-[150px] sm:min-w-[190px]">
<select
value={selectedCategoryFilter}
onChange={(e) => setSelectedCategoryFilter(e.target.value)}
@ -1164,7 +1164,7 @@ export function DuasServiceView() {
{/* نوار صفحه‌بندی جدول */}
{!isLoading && filteredDuas.length > 0 && (
<div className="p-3.5 border-t border-border-soft flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-grey-3 bg-card/20">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center justify-center gap-2">
<span>
نمایش{' '}
<strong className="font-mono text-foreground">
@ -1195,7 +1195,7 @@ export function DuasServiceView() {
{/* کلیدهای صفحه‌بندی */}
{totalPages > 1 && (
<div className="flex items-center gap-1">
<div className="flex flex-wrap items-center justify-center gap-1">
<Button
variant="outline"
size="sm"
@ -1206,7 +1206,7 @@ export function DuasServiceView() {
قبلی
</Button>
<div className="flex items-center gap-1">
<div className="flex flex-wrap items-center justify-center gap-1">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pg) => (
<button
key={pg}
@ -1242,7 +1242,7 @@ export function DuasServiceView() {
<DialogContent className="w-full max-w-full sm:max-w-4xl lg:max-w-5xl h-[92vh] max-h-[900px] p-0 flex flex-col bg-card border-border-soft overflow-hidden" dir="rtl">
{/* هدر مدال با تب‌های شکیل */}
<DialogHeader className="p-4 sm:p-5 border-b border-border-soft shrink-0 space-y-3 bg-card/80">
<div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pe-10">
<div className="flex items-center gap-2.5">
<div className="size-9 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary shrink-0">
<Ic name="book" className="size-5" />
@ -1260,11 +1260,11 @@ export function DuasServiceView() {
</div>
{/* سوییچر تب‌ها در هدر */}
<div className="flex items-center gap-1 p-1 bg-card border border-border-soft rounded-xl me-8 sm:me-10">
<div className="flex items-center gap-1 p-1 bg-card border border-border-soft rounded-xl w-full sm:w-auto">
<button
type="button"
onClick={() => setModalActiveTab('info')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition cursor-pointer ${
className={`flex-1 sm:flex-initial justify-center flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition cursor-pointer ${
modalActiveTab === 'info'
? 'bg-primary text-primary-foreground shadow-xs'
: 'text-grey-3 hover:text-foreground'
@ -1276,7 +1276,7 @@ export function DuasServiceView() {
<button
type="button"
onClick={() => setModalActiveTab('verses')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition cursor-pointer ${
className={`flex-1 sm:flex-initial justify-center flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition cursor-pointer ${
modalActiveTab === 'verses'
? 'bg-primary text-primary-foreground shadow-xs'
: 'text-grey-3 hover:text-foreground'
@ -1529,7 +1529,7 @@ export function DuasServiceView() {
{/* ردیف هدر شهر (انتخاب‌گونه: اول باید شهر را انتخاب کند و نشان دهد چند دسته‌بندی دارد) */}
<div
onClick={() => handleToggleCitySelect(city)}
className={`p-3 sm:p-3.5 flex items-center justify-between gap-3 ${
className={`p-3 sm:p-3.5 flex flex-wrap items-center justify-between gap-2.5 ${
!hasCats ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-muted/30 transition-colors'
}`}
>
@ -1620,7 +1620,7 @@ export function DuasServiceView() {
? 'border-red-500/30 bg-red-500/[0.03]'
: 'border-border-soft/40 bg-muted/[0.08]'
}`}>
<div className="flex items-center justify-between text-[11px]">
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px]">
<span className={`font-medium flex items-center gap-1.5 ${
showRedError ? 'text-red-600 dark:text-red-400 font-bold' : 'text-muted-foreground'
}`}>

8
src/features/services/components/FaqServiceView.tsx

@ -1235,7 +1235,7 @@ export function FaqServiceView() {
دیالوگ ایجاد و ویرایش دسته‌بندی (با تب‌های ۵ زبانه)
═══════════════════════════════════════════════════════════════════════ */}
<Dialog open={isCatDialogOpen} onOpenChange={setIsCatDialogOpen}>
<DialogContent className="sm:max-w-2xl p-0 overflow-hidden rounded-2xl max-h-[90vh] flex flex-col bg-card border-border-soft shadow-2xl">
<DialogContent showCloseButton={false} className="sm:max-w-2xl p-0 overflow-hidden rounded-2xl max-h-[90vh] flex flex-col bg-card border-border-soft shadow-2xl">
{/* هدر مدال */}
<div className="px-6 py-4 border-b border-border-soft bg-muted/20 flex items-center justify-between">
<div className="flex items-center gap-3">
@ -1324,7 +1324,7 @@ export function FaqServiceView() {
{/* بخش ترجمه‌های ۵ زبانه */}
<div className="space-y-2.5 pt-2">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div className="size-6 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
<Ic name="globe" className="size-3.5" />
@ -1441,7 +1441,7 @@ export function FaqServiceView() {
دیالوگ ایجاد و ویرایش سوال و پاسخ (با تب‌های ۵ زبانه)
═══════════════════════════════════════════════════════════════════════ */}
<Dialog open={isItemDialogOpen} onOpenChange={setIsItemDialogOpen}>
<DialogContent className="sm:max-w-2xl p-0 overflow-hidden rounded-2xl max-h-[90vh] flex flex-col bg-card border-border-soft shadow-2xl">
<DialogContent showCloseButton={false} className="sm:max-w-2xl p-0 overflow-hidden rounded-2xl max-h-[90vh] flex flex-col bg-card border-border-soft shadow-2xl">
{/* هدر مدال */}
<div className="px-6 py-4 border-b border-border-soft bg-muted/20 flex items-center justify-between">
<div className="flex items-center gap-3">
@ -1519,7 +1519,7 @@ export function FaqServiceView() {
{/* بخش ترجمه‌های ۵ زبانه سوال و پاسخ */}
<div className="space-y-2.5 pt-2">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div className="size-6 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
<Ic name="globe" className="size-3.5" />

2
src/features/services/components/LocationDetailSheet.tsx

@ -182,7 +182,7 @@ export function LocationDetailSheet({
{/* انتخاب زبان نمایش شرح و آدرس در صورت وجود ترجمه */}
{Object.keys(trans).length > 0 && (
<div className="flex items-center justify-between border-b border-border-soft pb-2">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border-soft pb-2">
<span className="text-xs font-bold text-foreground">مشاهده زبان ترجمه</span>
<div className="flex items-center gap-1 rounded-xl bg-surface-base p-1 border border-border-soft text-xs">
{([

8
src/features/services/components/SimcardFormDialog.tsx

@ -329,7 +329,7 @@ export function SimcardFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl lg:max-w-3xl w-full rounded-2xl bg-card border border-border-soft p-0 max-h-[85vh] flex flex-col overflow-hidden shadow-2xl">
{/* هدر ثابت در بالای مدال */}
<DialogHeader className="p-6 pb-4 border-b border-border-soft shrink-0 text-start">
<DialogHeader className="p-5 sm:p-6 pb-4 pe-12 border-b border-border-soft shrink-0 text-start">
<DialogTitle className="text-lg font-bold text-foreground">
{isEditing ? 'ویرایش بسته سیم‌کارت' : 'افزودن سیم‌کارت جدید'}
</DialogTitle>
@ -343,7 +343,7 @@ export function SimcardFormDialog({
{/* فرم: بدنه اسکرول‌شونده و فوتر فیکس‌شده */}
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden min-h-0">
{/* بدنه محتوا (تنها بخشی که اسکرول می‌شود) */}
<div className="flex-1 overflow-y-auto p-6 space-y-5">
<div className="flex-1 overflow-y-auto p-5 sm:p-6 space-y-5">
{errorMsg && (
<div className="p-3.5 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-500 text-xs flex items-center gap-2">
<Ic name="alert" className="size-4 shrink-0" />
@ -356,7 +356,7 @@ export function SimcardFormDialog({
<label className="text-xs font-bold text-foreground block">
کشور مقصد <span className="text-rose-500">*</span>
</label>
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
{COUNTRIES.map((c) => {
const isSelected = countryId === c.id
return (
@ -454,7 +454,7 @@ export function SimcardFormDialog({
{/* ۵. ویژگی‌ها و جزئیات بسته به زبان فعال */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-1.5">
<label className="text-xs font-bold text-foreground flex items-center gap-1.5">
<span>ویژگی‌ها و جزئیات بسته به زبان {currentLang.name}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded font-mono font-bold bg-primary/10 text-primary border border-primary/20">

14
src/features/services/components/StoreServiceView.tsx

@ -1038,6 +1038,7 @@ export function StoreServiceView({
{/* جدول دسته‌بندی‌ها */}
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<div className="overflow-x-auto">
<table className="w-full text-start text-xs">
<thead className="border-b border-border-soft bg-card/60 text-muted-foreground">
<tr>
@ -1192,6 +1193,7 @@ export function StoreServiceView({
)}
</tbody>
</table>
</div>
</div>
</div>
)}
@ -1266,6 +1268,7 @@ export function StoreServiceView({
{/* جدول محصولات */}
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<div className="overflow-x-auto">
<table className="w-full text-start text-xs">
<thead className="border-b border-border-soft bg-card/60 text-muted-foreground">
<tr>
@ -1537,6 +1540,7 @@ export function StoreServiceView({
)}
</tbody>
</table>
</div>
</div>
</div>
)}
@ -1574,10 +1578,10 @@ export function StoreServiceView({
{/* بدنه فرم با تب‌های ۵ زبانه */}
<form onSubmit={handleSaveCategory}>
<div className="px-6 py-5 space-y-4 text-xs">
<div className="px-6 py-5 space-y-4 text-xs max-h-[75vh] overflow-y-auto">
{/* بخش ترجمه‌های ۵ زبانه */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div className="size-6 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
<Ic name="globe" className="size-3.5" />
@ -1887,7 +1891,7 @@ export function StoreServiceView({
{/* بخش مدیریت تصاویر محصول */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-1.5">
<label className="block text-xs font-semibold text-foreground">
تصاویر محصول (گالری)
<span className="text-[11px] font-normal text-muted-foreground mr-1.5">
@ -2004,7 +2008,7 @@ export function StoreServiceView({
{/* اختصاص و انتخاب چندگانه کشورها */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="block text-xs font-semibold text-foreground">
کشورهای در دسترس (تورهای مجاز) <span className="text-rose-500">*</span>
<span className="text-[11px] font-normal text-muted-foreground mr-1.5">
@ -2066,7 +2070,7 @@ export function StoreServiceView({
</div>
{/* قیمت و موجودی */}
<div className="grid grid-cols-2 gap-3.5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3.5">
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-foreground">قیمت واحد</label>
<Input

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

@ -570,7 +570,7 @@ export function SupportServiceView() {
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-lg sm:max-w-xl max-h-[88vh] flex flex-col p-0 overflow-hidden rounded-2xl bg-card border border-border-soft shadow-2xl">
{/* هدر استاندارد با تفکیک و دکمه بستن داخلی */}
<DialogHeader className="p-5 sm:p-6 pb-4 border-b border-border-soft shrink-0 text-start">
<DialogHeader className="p-5 sm:p-6 pb-4 pe-12 border-b border-border-soft shrink-0 text-start">
<DialogTitle className="text-base font-bold text-foreground">
{editingChannel ? 'ویرایش کانال پشتیبانی' : 'افزودن کانال پشتیبانی'}
</DialogTitle>

109
src/features/settings/SettingsPage.tsx

@ -1,91 +1,58 @@
import * as React from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTranslation } from '@/i18n'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Ic, IconName } from '@/icons'
import { Ic } from '@/icons'
import { AdminManagementTab } from './components/AdminManagementTab'
import { NotificationsTab } from './components/NotificationsTab'
import { SocialMediaSettingsTab } from './components/SocialMediaSettingsTab'
interface SettingsTabConfig {
id: string
labelKey: string
icon: IconName
isCore?: boolean
}
const SETTINGS_TABS: SettingsTabConfig[] = [
{ id: 'admins', labelKey: 'settings.tabAdmins', icon: 'users', isCore: true },
{ id: 'social', labelKey: 'settings.tabSocial', icon: 'share' },
{ id: 'notifications', labelKey: 'settings.tabNotifications', icon: 'bell' },
]
export default function SettingsPage() {
const { t } = useTranslation()
const [activeTab, setActiveTab] = React.useState<string>('admins')
const { section } = useParams<{ section?: string }>()
const navigate = useNavigate()
React.useEffect(() => {
if (!section || !['admins', 'social', 'notifications'].includes(section)) {
navigate('/settings/admins', { replace: true })
}
}, [section, navigate])
const activeSection = section && ['admins', 'social', 'notifications'].includes(section)
? section
: 'admins'
return (
<div className="space-y-6 pt-2 pb-14">
{/* ۱. سربرگ اصلی صفحه تنظیمات (مطابق الگوی طراحی جامع عقیله - کالیبره دارک) */}
<div className="flex flex-col gap-4 rounded-2xl border border-white/[0.08] bg-[#1e2129] p-6 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1.5">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/15 text-primary border border-primary/20">
<Ic name="settings" className="size-5" />
{/* سربرگ اختصاصی بخش مدیریت مدیران و دسترسی‌ها */}
{activeSection === 'admins' && (
<div className="flex flex-col gap-4 rounded-2xl border border-white/[0.08] bg-[#1e2129] p-4 sm:p-6 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/15 text-primary border border-primary/20">
<Ic name="users" className="size-5" />
</div>
<h1 className="text-xl font-black text-white">
{t('settings.tabAdmins')}
</h1>
<Badge
variant="outline"
className="border-primary/30 bg-primary/10 text-primary font-bold text-xs px-2.5 py-0.5 justify-center"
>
{t('settings.badgeRoleControl')}
</Badge>
</div>
<h1 className="text-xl font-black text-white">
{t('settings.pageTitle')}
</h1>
<Badge
variant="outline"
className="border-primary/30 bg-primary/10 text-primary font-bold text-xs px-2.5 py-0.5 justify-center"
>
{t('settings.badgeRoleControl')}
</Badge>
<p className="text-xs text-slate-400">
{t('settings.pageDescription')}
</p>
</div>
<p className="text-xs text-slate-400">
{t('settings.pageSubtitle')}
</p>
</div>
</div>
{/* ۲. نوار تب‌های تفکیک بخش‌های تنظیمات */}
<div className="flex items-center gap-2 overflow-x-auto pb-2 sm:pb-0 scrollbar-none">
{SETTINGS_TABS.map((tab) => {
const isActive = activeTab === tab.id
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-bold transition-all whitespace-nowrap border shrink-0 ${
isActive
? 'bg-gradient-to-r from-[#d09460] to-[#b37745] text-white border-primary shadow-md shadow-[#d09460]/20'
: 'bg-[#1e2129] text-slate-300 border-white/[0.08] hover:bg-[#252833] hover:text-white'
}`}
>
<Ic name={tab.icon} className={`size-4 ${isActive ? 'text-white' : 'text-primary'}`} />
<span>{t(tab.labelKey as any)}</span>
{tab.isCore && (
<span
className={`text-[10px] px-1.5 py-0.2 rounded-md font-bold ${
isActive
? 'bg-white/20 text-white'
: 'bg-primary/20 text-primary'
}`}
>
{t('billing.coreTourBadge')}
</span>
)}
</button>
)
})}
</div>
)}
{/* ۳. محتوای تب فعال */}
{activeTab === 'admins' ? (
{/* محتوای بخش فعال بر اساس مسیر سایدبار (تب‌های درون‌صفحه‌ای حذف شدند) */}
{activeSection === 'admins' ? (
<AdminManagementTab />
) : activeTab === 'social' ? (
) : activeSection === 'social' ? (
<SocialMediaSettingsTab />
) : (
<NotificationsTab />

16
src/features/settings/components/AdminManagementTab.tsx

@ -112,10 +112,10 @@ export function AdminManagementTab() {
<AdminStatsCards stats={stats} loading={loadingStats} />
{/* نوار جستجو، فیلترها و دکمه افزودن مدیر جدید */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 bg-card p-3 rounded-2xl border border-border-soft">
<div className="flex flex-1 items-center gap-2">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-3 bg-card p-3 rounded-2xl border border-border-soft">
<div className="flex flex-col sm:flex-row flex-1 items-stretch sm:items-center gap-2">
{/* فیلد جستجوی زنده */}
<div className="relative flex-1 max-w-md">
<div className="relative flex-1 sm:max-w-md">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
@ -138,14 +138,14 @@ export function AdminManagementTab() {
</div>
{/* فیلتر وضعیت */}
<div className="flex items-center gap-1 bg-muted/60 p-0.5 rounded-xl border border-border-soft text-xs">
<div className="flex items-center gap-1 bg-muted/60 p-0.5 rounded-xl border border-border-soft text-xs overflow-x-auto no-scrollbar">
<button
type="button"
onClick={() => {
setSelectedStatus('all')
setOffset(0)
}}
className={`px-3 py-1.5 rounded-lg font-bold transition-colors cursor-pointer ${
className={`px-3 py-1.5 rounded-lg font-bold transition-colors cursor-pointer whitespace-nowrap flex-1 sm:flex-initial ${
selectedStatus === 'all'
? 'bg-card text-foreground shadow-xs'
: 'text-grey-3 hover:text-foreground'
@ -159,7 +159,7 @@ export function AdminManagementTab() {
setSelectedStatus('active')
setOffset(0)
}}
className={`px-3 py-1.5 rounded-lg font-bold transition-colors cursor-pointer ${
className={`px-3 py-1.5 rounded-lg font-bold transition-colors cursor-pointer whitespace-nowrap flex-1 sm:flex-initial ${
selectedStatus === 'active'
? 'bg-card text-emerald-600 dark:text-emerald-400 shadow-xs'
: 'text-grey-3 hover:text-foreground'
@ -173,7 +173,7 @@ export function AdminManagementTab() {
setSelectedStatus('inactive')
setOffset(0)
}}
className={`px-3 py-1.5 rounded-lg font-bold transition-colors cursor-pointer ${
className={`px-3 py-1.5 rounded-lg font-bold transition-colors cursor-pointer whitespace-nowrap flex-1 sm:flex-initial ${
selectedStatus === 'inactive'
? 'bg-card text-rose-500 shadow-xs'
: 'text-grey-3 hover:text-foreground'
@ -224,7 +224,7 @@ export function AdminManagementTab() {
{/* پیجینیشن صفحه‌بندی */}
{totalCount > limit && (
<div className="flex items-center justify-between pt-2 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center justify-between gap-2 pt-2 text-xs text-muted-foreground">
<p>
{t('users.page')} {Math.floor(offset / limit) + 1} {t('users.of')}{' '}
{Math.ceil(totalCount / limit)} ({totalCount} {t('settings.adminsCountLabel')})

4
src/features/settings/components/CreateAdminModal.tsx

@ -111,8 +111,8 @@ export function CreateAdminModal({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[520px] p-0 overflow-hidden">
<DialogHeader className="p-5 pb-4 border-b border-border-soft bg-card/60">
<DialogContent className="max-w-[520px] max-h-[90vh] overflow-y-auto p-0">
<DialogHeader className="p-5 pb-4 pe-12 border-b border-border-soft bg-card/60">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Ic name="users" className="size-5" />

20
src/features/settings/components/SetPasswordModal.tsx

@ -113,8 +113,8 @@ export function SetPasswordModal({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[480px] p-0 overflow-hidden">
<DialogHeader className="p-5 pb-4 border-b border-border-soft bg-card/60">
<DialogContent className="max-w-[480px] max-h-[90vh] overflow-y-auto p-0">
<DialogHeader className="p-5 pb-4 pe-12 border-b border-border-soft bg-card/60">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Ic name="key" className="size-5" />
@ -132,22 +132,22 @@ export function SetPasswordModal({
<form onSubmit={handleSubmit} className="p-5 space-y-4">
{/* مشخصات ادمین هدف */}
<div className="p-3.5 rounded-xl bg-muted/40 border border-border-soft flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="size-9 rounded-lg bg-primary/15 text-primary flex items-center justify-center font-bold text-sm">
<div className="p-3.5 rounded-xl bg-muted/40 border border-border-soft flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div className="size-9 rounded-lg bg-primary/15 text-primary flex items-center justify-center font-bold text-sm shrink-0">
{(admin.fullname || admin.email || 'A').charAt(0).toUpperCase()}
</div>
<div>
<p className="text-xs font-bold text-foreground">
<div className="min-w-0">
<p className="text-xs font-bold text-foreground truncate">
{admin.fullname || t('users.unnamedUser')}
</p>
<p className="text-[11px] text-muted-foreground font-mono mt-0.5" dir="ltr">
<p className="text-[11px] text-muted-foreground font-mono mt-0.5 truncate" dir="ltr">
{admin.email || admin.phone_number || `#${admin.id}`}
</p>
</div>
</div>
<span
className={`text-[10px] px-2 py-0.5 rounded-md font-bold ${
className={`text-[10px] px-2 py-0.5 rounded-md font-bold shrink-0 ${
admin.is_superuser
? 'bg-primary/15 text-primary border border-primary/20'
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border border-blue-500/20'
@ -159,7 +159,7 @@ export function SetPasswordModal({
{/* اگر رمز فعلی ذخیره‌شده دارد، نمایش و دکمه کپی */}
{admin.available_password && (
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-between">
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Ic name="lock" className="size-4 text-amber-600 dark:text-amber-400 shrink-0" />
<span className="text-xs text-amber-700 dark:text-amber-300 font-medium">

8
src/features/settings/components/SocialMediaSettingsTab.tsx

@ -271,7 +271,7 @@ export function SocialMediaSettingsTab() {
<div className="space-y-6">
{/* ۱. کارت سربرگ و راهنمای تنظیمات شبکه‌های اجتماعی (کالیبره حالت دارک) */}
<Card className="border border-white/[0.08] bg-[#1e2129] shadow-md">
<CardContent className="p-6">
<CardContent className="p-4 sm:p-6">
<div className="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary/15 text-primary border border-primary/25 shadow-inner">
@ -532,7 +532,7 @@ export function SocialMediaSettingsTab() {
{/* ۳. کارت اطلاعات تماس تکمیلی (Contact Us - کالیبره در تم تیره) */}
<Card className="border border-white/[0.08] bg-[#1e2129] shadow-md">
<CardContent className="p-6 space-y-5">
<CardContent className="p-4 sm:p-6 space-y-5">
<div className="flex items-center gap-3.5">
<div className="flex size-11 items-center justify-center rounded-2xl bg-amber-500/15 text-amber-400 border border-amber-500/25 shadow-inner">
<Ic name="phone" className="size-5.5" />
@ -616,8 +616,8 @@ export function SocialMediaSettingsTab() {
{/* مدال افزودن پلتفرم جدید (کالیبره تم تیره) */}
<Dialog open={addModalOpen} onOpenChange={setAddModalOpen}>
<DialogContent className="max-w-md bg-[#1e2129] border border-white/[0.12] text-white">
<DialogHeader className="p-5 pb-0">
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto p-0 bg-[#1e2129] border border-white/[0.12] text-white">
<DialogHeader className="p-5 pb-0 pe-12">
<DialogTitle className="text-sm font-black text-white flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-lg bg-primary/15 text-primary">
<Ic name="share" className="size-4" />

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

@ -360,34 +360,48 @@ export function CreateTourDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
className="max-w-5xl xl:max-w-6xl w-[95vw] p-0 overflow-hidden h-[90vh] min-h-[600px] max-h-[92vh] flex flex-col border border-border-soft bg-background rounded-2xl shadow-2xl"
className="max-w-5xl xl:max-w-6xl w-[95vw] p-0 overflow-hidden h-[90vh] max-h-[92vh] flex flex-col border border-border-soft bg-background rounded-2xl shadow-2xl"
>
{/* Header */}
<DialogHeader className="px-6 pt-5 pb-4 border-b border-border/50 bg-secondary/15">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0">
<Ic name="plus" className="size-5" />
</div>
<div>
<DialogTitle className="text-base font-extrabold text-foreground">
افزودن تور زیارتی و گردشگری جدید
</DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5">
ثبت مشخصات پایه، هزینه‌ها، ظرفیت، تصویر شاخص و ورودی‌های ترجمه 5 زبانه
</DialogDescription>
<DialogHeader className="px-4 sm:px-6 pt-4 sm:pt-5 pb-3.5 sm:pb-4 border-b border-border/50 bg-secondary/15 shrink-0">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
<div className="flex items-start justify-between gap-2.5">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0">
<Ic name="plus" className="size-5" />
</div>
<div>
<DialogTitle className="text-sm sm:text-base font-extrabold text-foreground">
افزودن تور زیارتی و گردشگری جدید
</DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5">
ثبت مشخصات پایه، هزینه‌ها، ظرفیت، تصویر شاخص و ورودی‌های ترجمه 5 زبانه
</DialogDescription>
</div>
</div>
{/* دکمه علامت ضربدر بالای مودال در موبایل و تبلت */}
<button
type="button"
onClick={() => onOpenChange(false)}
aria-label="بستن پنجره"
title="بستن"
className="flex lg:hidden size-8 items-center justify-center rounded-lg text-grey-3 hover:text-foreground hover:bg-white/10 transition-colors shrink-0 cursor-pointer"
>
<Ic name="x" className="size-4.5" />
</button>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center justify-between lg:justify-end gap-2 w-full lg:w-auto">
{/* Step/Tab buttons */}
<div className="flex items-center gap-1 rounded-xl bg-surface-2 p-1 border border-border/40">
<div className="flex items-center gap-1 rounded-xl bg-surface-2 p-1 border border-border/40 overflow-x-auto no-scrollbar w-full lg:w-auto">
<button
type="button"
onClick={() => setActiveTab('basic')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all cursor-pointer ${
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all cursor-pointer whitespace-nowrap shrink-0 flex-1 lg:flex-initial ${
activeTab === 'basic'
? 'bg-primary text-white shadow-xs'
: 'text-grey-3 hover:text-foreground'
@ -398,7 +412,7 @@ export function CreateTourDialog({
<button
type="button"
onClick={() => setActiveTab('features_tips')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all cursor-pointer ${
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all cursor-pointer whitespace-nowrap shrink-0 flex-1 lg:flex-initial ${
activeTab === 'features_tips'
? 'bg-primary text-white shadow-xs'
: 'text-grey-3 hover:text-foreground'
@ -409,24 +423,24 @@ export function CreateTourDialog({
<button
type="button"
onClick={() => setActiveTab('translations')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all flex items-center gap-1.5 cursor-pointer ${
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all flex items-center justify-center gap-1.5 cursor-pointer whitespace-nowrap shrink-0 flex-1 lg:flex-initial ${
activeTab === 'translations'
? 'bg-primary text-white shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
<Ic name="globe" className="size-3.5" />
<Ic name="globe" className="size-3.5 shrink-0" />
<span>3. ترجمه‌های 5 زبانه</span>
</button>
</div>
{/* دکمه علامت ضربدر بالای مودال */}
{/* دکمه علامت ضربدر بالای مودال در دسکتاپ */}
<button
type="button"
onClick={() => onOpenChange(false)}
aria-label="بستن پنجره"
title="بستن"
className="flex size-8 items-center justify-center rounded-lg text-grey-3 hover:text-foreground hover:bg-white/10 transition-colors shrink-0 cursor-pointer"
className="hidden lg:flex size-8 items-center justify-center rounded-lg text-grey-3 hover:text-foreground hover:bg-white/10 transition-colors shrink-0 cursor-pointer"
>
<Ic name="x" className="size-4.5" />
</button>
@ -435,7 +449,7 @@ export function CreateTourDialog({
</DialogHeader>
{/* Form Body */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto px-6 py-5 space-y-5 flex flex-col justify-between">
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto px-4 sm:px-6 py-5 space-y-5 flex flex-col justify-between">
<div className="space-y-4">
{errorMessage && (
<div className="flex items-center gap-2 rounded-xl bg-rose-500/10 border border-rose-500/20 px-4 py-3 text-xs text-rose-400">
@ -1037,7 +1051,7 @@ export function CreateTourDialog({
</div>
{/* Footer */}
<DialogFooter className="px-6 py-4 bg-secondary/10 border-t border-border/50 flex items-center justify-between -mx-6 -mb-5 mt-6">
<DialogFooter className="px-4 sm:px-6 py-4 bg-secondary/10 border-t border-border/50 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 -mx-4 sm:-mx-6 -mb-5 mt-6">
<div className="flex items-center gap-2">
<span className="text-xs text-grey-3">
{activeTab === 'basic' && 'مرحله 1 از 3: اطلاعات پایه و قیمت'}
@ -1046,7 +1060,7 @@ export function CreateTourDialog({
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center justify-end gap-2">
<DialogClose asChild>
<Button
type="button"

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

@ -958,17 +958,17 @@ export function TourDetailDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={(open) => !open && handleRequestClose()}>
<DialogContent className="max-w-6xl xl:max-w-7xl w-[96vw] p-0 overflow-hidden h-[92vh] min-h-[640px] max-h-[92vh] flex flex-col border border-border-soft bg-background rounded-2xl shadow-2xl">
<DialogContent className="max-w-6xl xl:max-w-7xl w-[96vw] p-0 overflow-hidden h-[92vh] max-h-[92vh] flex flex-col border border-border-soft bg-background rounded-2xl shadow-2xl">
{/* Header Modal */}
<DialogHeader className="px-6 py-4 border-b border-border-soft bg-card/40 backdrop-blur-md shrink-0 space-y-3">
<div className="flex items-center justify-between gap-4">
<DialogHeader className="px-4 sm:px-6 py-4 border-b border-border-soft bg-card/40 backdrop-blur-md shrink-0 space-y-3">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 pe-10">
{/* بخش راست: آیکون، عنوان تور، وضعیت سفر و تاگل روشن/خاموش */}
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2.5 min-w-0 flex-1">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0">
<Ic name="map" className="size-5" />
</div>
<DialogTitle className="text-base font-extrabold text-foreground truncate max-w-sm shrink-0">
<DialogTitle className="text-sm sm:text-base font-extrabold text-foreground truncate max-w-full sm:max-w-sm">
{isLoading && !tour ? (
<ShimmerLine width={160} height={20} rounded="sm" />
) : (
@ -977,14 +977,14 @@ export function TourDetailDialog({
</DialogTitle>
{tour && (
<div className="flex items-center gap-2.5 shrink-0">
<div className="flex flex-wrap items-center gap-2 shrink-0">
{/* بج وضعیت برگزاری سفر */}
<TourStatusBadge status={coreFields.trip_status || tour.trip_status} type="trip" />
{/* تاگل تعاملی روشن/خاموش وضعیت فعال/غیرفعال بودن تور */}
<div
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-2 rounded-xl border border-border-soft bg-card/60 px-3 py-1.5 shadow-2xs transition"
className="flex items-center gap-2 rounded-xl border border-border-soft bg-card/60 px-2.5 sm:px-3 py-1.5 shadow-2xs transition"
>
<span className="text-[11px] font-bold text-grey-2">نمایش در اپ و وب:</span>
<Switch
@ -1008,8 +1008,8 @@ export function TourDetailDialog({
)}
</div>
{/* دکمه‌های عملیاتی ثابت و بدون شکستن خط (Action Buttons) */}
<div className="flex items-center gap-2 shrink-0 pe-8">
{/* دکمه‌های عملیاتی (Action Buttons) */}
<div className="flex flex-wrap items-center gap-2 shrink-0">
{isLoading && !tour ? (
<div className="flex items-center gap-1.5">
<ShimmerBox width={80} height={32} rounded="lg" />
@ -1101,25 +1101,25 @@ export function TourDetailDialog({
onValueChange={(val: any) => setActiveTab(val)}
className="mt-3 w-full"
>
<TabsList className="grid w-full grid-cols-5 bg-surface-base border border-border-soft p-1">
<TabsTrigger value="overview" className="text-xs gap-1.5">
<Ic name="file" className="size-3.5" />
<TabsList className="flex w-full overflow-x-auto no-scrollbar sm:grid sm:grid-cols-5 bg-surface-base border border-border-soft p-1">
<TabsTrigger value="overview" className="text-xs gap-1.5 shrink-0">
<Ic name="file" className="size-3.5 shrink-0" />
<span>مشخصات و متون</span>
</TabsTrigger>
<TabsTrigger value="itinerary" className="text-xs gap-1.5">
<Ic name="calendar" className="size-3.5" />
<TabsTrigger value="itinerary" className="text-xs gap-1.5 shrink-0">
<Ic name="calendar" className="size-3.5 shrink-0" />
<span>برنامه سفر</span>
</TabsTrigger>
<TabsTrigger value="features" className="text-xs gap-1.5">
<Ic name="star" className="size-3.5" />
<TabsTrigger value="features" className="text-xs gap-1.5 shrink-0">
<Ic name="star" className="size-3.5 shrink-0" />
<span>امکانات و نکات</span>
</TabsTrigger>
<TabsTrigger value="passengers" className="text-xs gap-1.5">
<Ic name="users" className="size-3.5" />
<TabsTrigger value="passengers" className="text-xs gap-1.5 shrink-0">
<Ic name="users" className="size-3.5 shrink-0" />
<span>مسافران</span>
</TabsTrigger>
<TabsTrigger value="comments" className="text-xs gap-1.5">
<Ic name="message" className="size-3.5" />
<TabsTrigger value="comments" className="text-xs gap-1.5 shrink-0">
<Ic name="message" className="size-3.5 shrink-0" />
<span>نظرات</span>
</TabsTrigger>
</TabsList>
@ -1127,7 +1127,7 @@ export function TourDetailDialog({
</DialogHeader>
{/* Modal Scrollable Body */}
<div className="flex-1 overflow-y-auto p-6 space-y-6 min-h-0">
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6 min-h-0">
{successMessage && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-3 text-xs font-bold text-emerald-600 dark:text-emerald-400 flex items-center justify-between">
<div className="flex items-center gap-2">
@ -1167,7 +1167,7 @@ export function TourDetailDialog({
<div className="space-y-6">
{/* بخش ترجمه پویا برای عنوان و توضیحات تور */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs font-extrabold text-foreground">
<span>{activeLangConfig.flag}</span>
<span>
@ -1218,7 +1218,7 @@ export function TourDetailDialog({
<span>مشخصات عمومی و زمان‌بندی تور</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3.5">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3.5">
<div className="space-y-1">
<label className="text-xs font-bold text-grey-2">تاریخ شروع:</label>
<Input

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

@ -166,12 +166,12 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
onValueChange={(val: any) => setActiveTab(val)}
className="mt-4 w-full"
>
<TabsList className="grid w-full grid-cols-5 bg-surface-base border border-border-soft p-1">
<TabsTrigger value="overview" className="text-xs">مشخصات</TabsTrigger>
<TabsTrigger value="itinerary" className="text-xs">برنامه سفر</TabsTrigger>
<TabsTrigger value="passengers" className="text-xs">مسافران</TabsTrigger>
<TabsTrigger value="features" className="text-xs">امکانات</TabsTrigger>
<TabsTrigger value="comments" className="text-xs">نظرات</TabsTrigger>
<TabsList className="flex w-full overflow-x-auto no-scrollbar sm:grid sm:grid-cols-5 bg-surface-base border border-border-soft p-1">
<TabsTrigger value="overview" className="text-xs shrink-0">مشخصات</TabsTrigger>
<TabsTrigger value="itinerary" className="text-xs shrink-0">برنامه سفر</TabsTrigger>
<TabsTrigger value="passengers" className="text-xs shrink-0">مسافران</TabsTrigger>
<TabsTrigger value="features" className="text-xs shrink-0">امکانات</TabsTrigger>
<TabsTrigger value="comments" className="text-xs shrink-0">نظرات</TabsTrigger>
</TabsList>
</Tabs>
</SheetHeader>

32
src/features/tours/components/TourTranslationsDialog.tsx

@ -264,25 +264,25 @@ export function TourTranslationsDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[760px] p-0 overflow-hidden max-h-[90vh] flex flex-col">
<DialogContent showCloseButton={false} className="max-w-[760px] p-0 overflow-hidden max-h-[90vh] flex flex-col">
{/* Dialog Header */}
<DialogHeader className="px-6 pt-5 pb-4 border-b border-border/50 bg-secondary/15">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<DialogHeader className="px-4 sm:px-6 pt-4 sm:pt-5 pb-4 border-b border-border/50 bg-secondary/15">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0">
<Ic name="globe" className="size-5" />
</div>
<div>
<DialogTitle className="text-base font-extrabold text-foreground">
<div className="min-w-0">
<DialogTitle className="text-sm sm:text-base font-extrabold text-foreground">
مدیریت ترجمه‌های 5 زبانه تور
</DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5">
<DialogDescription className="text-xs text-grey-3 mt-0.5 truncate">
{tourTitle || `تور شناسه #${tourId}`} &bull; ذخیره‌سازی پویا در سرور و پایگاه‌داده
</DialogDescription>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 ms-auto">
{/* Primary Language Indicator */}
{transData && (
<div className="flex items-center gap-1.5 rounded-full bg-surface-2 px-3 py-1 text-xs text-grey-2 border border-border/40">
@ -300,7 +300,7 @@ export function TourTranslationsDialog({
onClick={() => onOpenChange(false)}
aria-label="بستن پنجره"
title="بستن"
className="flex size-8 items-center justify-center rounded-lg text-grey-3 hover:text-foreground hover:bg-white/10 transition-colors shrink-0 cursor-pointer mr-1"
className="flex size-8 items-center justify-center rounded-lg text-grey-3 hover:text-foreground hover:bg-white/10 transition-colors shrink-0 cursor-pointer ms-1"
>
<Ic name="x" className="size-4.5" />
</button>
@ -329,7 +329,7 @@ export function TourTranslationsDialog({
</DialogHeader>
{/* Dialog Body */}
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-5 space-y-5">
{isLoading ? (
<TourDetailOverviewShimmer />
) : (
@ -349,11 +349,11 @@ export function TourTranslationsDialog({
)}
{/* Section Tabs */}
<div className="flex items-center gap-2 border-b border-border/40 pb-2">
<div className="flex items-center gap-2 border-b border-border/40 pb-2 overflow-x-auto no-scrollbar">
<button
type="button"
onClick={() => setActiveSection('base')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors ${
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors whitespace-nowrap shrink-0 ${
activeSection === 'base'
? 'bg-primary/15 text-primary'
: 'text-grey-3 hover:text-foreground'
@ -364,7 +364,7 @@ export function TourTranslationsDialog({
<button
type="button"
onClick={() => setActiveSection('features')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors ${
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors whitespace-nowrap shrink-0 ${
activeSection === 'features'
? 'bg-primary/15 text-primary'
: 'text-grey-3 hover:text-foreground'
@ -375,7 +375,7 @@ export function TourTranslationsDialog({
<button
type="button"
onClick={() => setActiveSection('tips')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors ${
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors whitespace-nowrap shrink-0 ${
activeSection === 'tips'
? 'bg-primary/15 text-primary'
: 'text-grey-3 hover:text-foreground'
@ -386,7 +386,7 @@ export function TourTranslationsDialog({
<button
type="button"
onClick={() => setActiveSection('itinerary')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors ${
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors whitespace-nowrap shrink-0 ${
activeSection === 'itinerary'
? 'bg-primary/15 text-primary'
: 'text-grey-3 hover:text-foreground'

12
src/features/users/UserDetailPage.tsx

@ -84,8 +84,8 @@ export default function UserDetailPage() {
</div>
{/* بنر اطلاعات هویت کاربر */}
<div className="bg-card border border-border/60 rounded-2xl p-5 sm:p-6 shadow-xs flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="bg-card border border-border/60 rounded-2xl p-5 sm:p-6 shadow-xs flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div className="flex items-center gap-4 min-w-0">
{user.avatar ? (
<img
src={user.avatar}
@ -98,7 +98,7 @@ export default function UserDetailPage() {
</div>
)}
<div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-lg sm:text-xl font-black text-foreground">
{user.fullname || t('users.noData')}
@ -130,8 +130,8 @@ export default function UserDetailPage() {
</div>
)}
{user.email && (
<div className="flex items-center gap-1 font-mono">
<Ic name="mail" className="w-3.5 h-3.5 text-muted-foreground/70" />
<div className="flex items-center gap-1 font-mono break-all">
<Ic name="mail" className="w-3.5 h-3.5 text-muted-foreground/70 shrink-0" />
<span>{user.email.split(':deleted')[0].trim()}</span>
{user.email.includes(':deleted') && (
<span className="px-1.5 py-0.2 rounded-md bg-rose-500/10 text-rose-600 dark:text-rose-400 text-[10px] font-bold">
@ -145,7 +145,7 @@ export default function UserDetailPage() {
</div>
{/* کارت‌های آماری سریع */}
<div className="grid grid-cols-3 gap-2 sm:gap-3 shrink-0 pt-3 sm:pt-0 border-t sm:border-t-0 border-border/40">
<div className="grid grid-cols-3 gap-2 sm:gap-3 shrink-0 pt-3 lg:pt-0 border-t lg:border-t-0 border-border/40 w-full lg:w-auto">
<div className="p-3 rounded-xl bg-muted/30 border border-border/30 text-center">
<span className="block text-[11px] text-muted-foreground mb-0.5">
{t('users.totalFactorsCount')}

4
src/icons.tsx

@ -38,6 +38,10 @@ const PATHS: Record<string, string> = {
video: '<rect x="2" y="6" width="13" height="12" rx="2"/><path d="m15 11 7-4v10l-7-4Z"/>',
chevDown: '<path d="m6 9 6 6 6-6"/>',
chevUp: '<path d="m6 15 6-6 6 6"/>',
chevronDown: '<path d="m6 9 6 6 6-6"/>',
chevronUp: '<path d="m6 15 6-6 6 6"/>',
gripVertical: '<circle cx="9" cy="12" r="1"/><circle cx="9" cy="5" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="19" r="1"/>',
sliders: '<line x1="4" x2="4" y1="21" y2="14"/><line x1="4" x2="4" y1="10" y2="3"/><line x1="12" x2="12" y1="21" y2="12"/><line x1="12" x2="12" y1="8" y2="3"/><line x1="20" x2="20" y1="21" y2="16"/><line x1="20" x2="20" y1="12" y2="3"/><line x1="2" x2="6" y1="14" y2="14"/><line x1="10" x2="14" y1="8" y2="8"/><line x1="18" x2="22" y1="16" y2="16"/>',
pencil: '<path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
edit: '<path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',

134
src/lib/date-utils.ts

@ -377,6 +377,36 @@ export const HIJRI_MONTH_NAMES = [
'ذو الحجة',
]
export const HIJRI_MONTH_NAMES_FA = [
'محرم',
'صفر',
'ربیع‌الاول',
'ربیع‌الثانی',
'جمادی‌الاول',
'جمادی‌الثانی',
'رجب',
'شعبان',
'رمضان',
'شوال',
'ذی‌القعده',
'ذی‌الحجه',
]
export const HIJRI_MONTH_NAMES_EN = [
'Muharram',
'Safar',
'Rabi I',
'Rabi II',
'Jumada I',
'Jumada II',
'Rajab',
'Shaban',
'Ramadan',
'Shawwal',
'Dhu al-Qadah',
'Dhu al-Hijjah',
]
export const GREGORIAN_MONTH_NAMES = [
'January',
'February',
@ -396,8 +426,59 @@ export const WEEKDAY_NAMES_FA = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج']
export const WEEKDAY_NAMES_AR = ['س', 'ح', 'ن', 'ث', 'ر', 'خ', 'ج']
export const WEEKDAY_NAMES_EN = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
export interface SecondaryDayInfo {
day: number
formatted: string
monthName: string
calendarName: string
}
/**
* نمایش تاریخ به صورت دوگانه (هم میلادی و هم هجری قمری)
* خروجی نمونه: 2026/10/25 (۱۴ جمادی‌الاول ۱۴۴۸)
*/
export function formatDualDate(
dateInput: string | number | Date | null | undefined,
lang: SupportedLanguage = 'fa',
options: { withTime?: boolean; time?: string | null; compact?: boolean } = {}
): string {
const d = parseSafeDate(dateInput)
if (!d) return '—'
const gy = d.getFullYear()
const gm = String(d.getMonth() + 1).padStart(2, '0')
const gd = String(d.getDate()).padStart(2, '0')
const gregDateStr = `${gy}/${gm}/${gd}`
const h = gregorianToHijri(d)
let hijriStr = ''
if (lang === 'fa') {
const mName = HIJRI_MONTH_NAMES_FA[h.month - 1] || HIJRI_MONTH_NAMES[h.month - 1]
hijriStr = options.compact
? `${toFa(h.day)} ${mName}`
: `${toFa(h.day)} ${mName} ${toFa(h.year)}`
} else if (lang === 'ar') {
const mName = HIJRI_MONTH_NAMES[h.month - 1]
hijriStr = options.compact
? `${h.day} ${mName}`
: `${h.day} ${mName} ${h.year} هـ`
} else {
const mName = HIJRI_MONTH_NAMES_EN[h.month - 1] || HIJRI_MONTH_NAMES[h.month - 1]
hijriStr = options.compact
? `${h.day} ${mName}`
: `${h.day} ${mName} ${h.year} AH`
}
let result = `\u200E${gregDateStr}\u200E (${hijriStr})`
if (options.withTime && options.time) {
const timeFormatted = lang === 'fa' ? toFa(options.time) : options.time
result += ` | ${timeFormatted}`
}
return result
}
/**
* دریافت اطلاعات و روزهای ماه جهت ساخت ماتریس گرید تقویم در ادیتور
* دریافت اطلاعات و روزهای ماه جهت ساخت ماتریس گرید تقویم در ادیتور همراه با تقویم ثانویه
*/
export function getCalendarMonthData(
calendar: 'persian' | 'islamic' | 'gregorian',
@ -418,11 +499,23 @@ export function getCalendarMonthData(
monthName: JALALI_MONTH_NAMES[month - 1] || '',
weekdays: WEEKDAY_NAMES_FA,
yearFormatted: toFa(year),
secondaryHeader: '',
toIso: (d: number) => {
const g = jalaliToGregorian(year, month, d)
return `${g.year}-${String(g.month).padStart(2, '0')}-${String(g.day).padStart(2, '0')}`
},
formatDayNumber: (d: number) => toFa(d),
getSecondaryDay: (d: number): SecondaryDayInfo => {
const g = jalaliToGregorian(year, month, d)
const dt = new Date(Date.UTC(g.year, g.month - 1, g.day))
const h = gregorianToHijri(dt)
return {
day: h.day,
formatted: toFa(h.day),
monthName: HIJRI_MONTH_NAMES_FA[h.month - 1] || '',
calendarName: 'قمری',
}
},
}
}
@ -453,24 +546,48 @@ export function getCalendarMonthData(
}
}
const gLast = hijriToGregorian(year, month, daysInMonth)
const secondaryHeader =
gFirst.month === gLast.month
? `${GREGORIAN_MONTH_NAMES[gFirst.month - 1]} ${gFirst.year}`
: `${GREGORIAN_MONTH_NAMES[gFirst.month - 1]?.slice(0, 3)} - ${GREGORIAN_MONTH_NAMES[gLast.month - 1]?.slice(0, 3)} ${gLast.year}`
return {
daysInMonth,
startOffset,
monthName: HIJRI_MONTH_NAMES[month - 1] || '',
weekdays: WEEKDAY_NAMES_AR,
yearFormatted: `${year} هـ`,
secondaryHeader,
toIso: (d: number) => {
const g = hijriToGregorian(year, month, d)
return `${g.year}-${String(g.month).padStart(2, '0')}-${String(g.day).padStart(2, '0')}`
},
formatDayNumber: (d: number) => String(d),
getSecondaryDay: (d: number): SecondaryDayInfo => {
const g = hijriToGregorian(year, month, d)
return {
day: g.day,
formatted: String(g.day),
monthName: GREGORIAN_MONTH_NAMES[g.month - 1] || '',
calendarName: 'میلادی',
}
},
}
}
// میلادی (Gregorian)
const dFirst = new Date(year, month - 1, 1)
const dFirst = new Date(Date.UTC(year, month - 1, 1))
const daysInMonth = new Date(year, month, 0).getDate()
const startOffset = dFirst.getDay() // 0 = Sunday
const startOffset = dFirst.getUTCDay() // 0 = Sunday
const hFirst = gregorianToHijri(dFirst)
const dLast = new Date(Date.UTC(year, month - 1, daysInMonth))
const hLast = gregorianToHijri(dLast)
const secondaryHeader =
hFirst.month === hLast.month
? `${HIJRI_MONTH_NAMES[hFirst.month - 1]} ${hFirst.year} هـ`
: `${HIJRI_MONTH_NAMES[hFirst.month - 1]} - ${HIJRI_MONTH_NAMES[hLast.month - 1]} ${hLast.year} هـ`
return {
daysInMonth,
@ -478,9 +595,20 @@ export function getCalendarMonthData(
monthName: GREGORIAN_MONTH_NAMES[month - 1] || '',
weekdays: WEEKDAY_NAMES_EN,
yearFormatted: String(year),
secondaryHeader,
toIso: (d: number) => {
return `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`
},
formatDayNumber: (d: number) => String(d),
getSecondaryDay: (d: number): SecondaryDayInfo => {
const dt = new Date(Date.UTC(year, month - 1, d))
const h = gregorianToHijri(dt)
return {
day: h.day,
formatted: String(h.day),
monthName: HIJRI_MONTH_NAMES[h.month - 1] || '',
calendarName: 'قمری',
}
},
}
}

117
src/locales/ar.json

@ -35,8 +35,12 @@
"consultant": "الدعم الفني",
"billing": "الفواتير والمالية",
"settings": "الإعدادات",
"settingsAdmins": "إدارة المشرفين والصلاحيات",
"settingsSocial": "وسائل التواصل الاجتماعي",
"settingsNotifications": "نظام الإشعارات",
"logout": "تسجيل الخروج",
"soon": "قريباً",
"comingSoon": "قريباً",
"adminRole": "مدير النظام العام"
},
"topbar": {
@ -343,7 +347,12 @@
"ticketDeleting": "جاري حذف التذكرة...",
"ticketDragDropPrompt": "اسحب ملف التذكرة وأفلته هنا أو اختره من الزر أدناه",
"ticketSupportedFormats": "الصيغ المدعومة: PDF, PNG, JPG (الحد الأقصى ۱۰ ميجابايت)",
"ticketAutoApproveNotice": "ملاحظة: رفع ملف التذكرة إلزامي لتغيير الحالة إلى «مؤكدة / مدفوعة». بعد الرفع، يرجى الضغط على «حفظ الحالة» للاعتماد النهائي.",
"ticketAutoApproveNotice": "ملاحظة: رفع ملف التذكرة إلزامي لتغيير الحالة إلى «مؤكدة / مدفوعة». بعد الرفع، ستتغير الحالة إلى «مؤكدة»؛ ثم اضغط على «تأكيد وحفظ» للاعتماد النهائي.",
"ticketUploadSuccessPendingSave": "تم تحميل ملف التذكرة بنجاح. تم تغيير الحالة إلى «مؤكدة / مدفوعة»؛ يرجى الضغط على «تأكيد وحفظ» للاعتماد.",
"confirmAndSave": "تأكيد وحفظ",
"saveStatusChanges": "حفظ تغييرات الحالة",
"reSubmitStatus": "إعادة تسجيل الحالة",
"submitStatus": "تسجيل الحالة",
"ticketRequiredTitle": "رفع التذكرة إلزامي",
"ticketRequiredForApprovedTitle": "ملف تذكرة السفر إلزامي",
"ticketRequiredForApprovedDesc": "تغيير الحالة إلى «مؤكدة / مدفوعة» يتطلب رفع ملف التذكرة الإلكترونية. يرجى أولاً رفع التذكرة من قسم «إدارة وإصدار تذكرة السفر» أدناه.",
@ -660,14 +669,104 @@
"passengersList": "بيانات المسافرين وصور جوازات السفر",
"passportNumber": "رقم الجواز",
"viewPassportImage": "عرض الجواز",
"noPassportImage": "لا توجد صورة جواز"
},
"common": {
"deleteConfirmTitle": "تأكيد الحذف",
"deleteConfirmDesc": "هل أنت متأكد من حذف هذا العنصر؟ لا يمكن التراجع عن هذا الإجراء.",
"yesDelete": "نعم، حذف",
"cancel": "إلغاء",
"deleting": "جارٍ الحذف..."
"noPassportImage": "لا توجد صورة جواز",
"stage1Title": "المرحلة ١: مراجعة وتعديل تفاصيل الرحلة المخصصة",
"stage1Desc": "راجع وعدّل الطيران والفنادق وترتيب الوجهات والسعر. لن يتم إصدار فاتورة مالية قبل التأكيد النهائي.",
"stage2Title": "المرحلة ٢: التأكيد النهائي والانتقال إلى الفواتير المالية",
"stage2Desc": "تمت مراجعة هذا الطلب واعتماده ونقل فاتورته إلى قسم الفواتير المالية.",
"invoiceTransferredBadge": "تم النقل إلى الفواتير المالية",
"notInvoicedYetBadge": "بانتظار المراجعة وإصدار الفاتورة",
"viewInBilling": "عرض في الفواتير المالية",
"saveStage1Review": "حفظ تعديلات المراجعة (المرحلة ١)",
"approveAndTransferToBilling": "التأكيد النهائي والنقل إلى الفواتير المالية",
"updateInvoiceAndItems": "تحديث العناصر والفاتورة المالية",
"rejectOrderBtn": "رفض الطلب",
"addDestinationLeg": "إضافة وجهة جديدة لبرنامج الرحلة",
"moveLegUp": "تحريك لأعلى (تغيير ترتيب الوجهة)",
"moveLegDown": "تحريك لأسفل (تغيير ترتيب الوجهة)",
"removeLeg": "حذف هذه الوجهة",
"selectHotelPlaceholder": "اختر أو غيّر فندق الإقامة...",
"customHotelPlaceholder": "اسم الفندق المخصص...",
"nightsLabel": "عدد ليالي الإقامة",
"mealPlanLabel": "نظام الوجبات",
"mealFullBoard": "إقامة كاملة (إفطار، غداء، عشاء)",
"mealBreakfast": "مع وجبة الإفطار",
"mealRoomOnly": "غرفة فقط (بدون وجبات)",
"finalInvoicePrice": "المبلغ النهائي للفاتورة",
"requiredBadge": "إلزامي",
"priceRequiredNotice": "تحديد المبلغ النهائي للفاتورة إلزامي (يجب أن يكون أكبر من صفر).",
"priceRequiredAlert": "إدخال المبلغ النهائي للفاتورة إلزامي.",
"priceRequiredTitle": "المبلغ إلزامي",
"requiredFieldsAlert": "يرجى إكمال جميع الحقول الإلزامية (المبلغ، نقطة الانطلاق والوجهات).",
"requiredFieldsTitle": "معلومات غير مكتملة",
"requiredFieldsMissingHint": "إكمال الحقول الإلزامية لتفعيل أزرار الحفظ:",
"missingPriceItem": "تحديد المبلغ النهائي للفاتورة (بالدولار)",
"missingOriginItem": "تحديد مدينة الانطلاق",
"missingLegsItem": "تحديد وجهة واحدة على الأقل وعدد ليالي الإقامة",
"stage1SavedToast": "تم حفظ تعديلات برنامج الرحلة في مرحلة المراجعة (دون إصدار فاتورة مالية).",
"stage2TransferredToast": "تم اعتماد الطلب نهائياً ونقل الفاتورة إلى قسم الفواتير المالية.",
"dragToReorderHint": "اسحب وأفلت لتغيير ترتيب الوجهات (Drag & Drop)",
"returnLegBadge": "المسار الأخير: العودة إلى مدينة المبدأ",
"returnToOriginNoHotel": "العودة إلى مدينة المبدأ (بدون فندق وإقامة)",
"ticketDepartureDate": "تاريخ رحلة التذكرة",
"ticketDepartureTime": "وقت رحلة التذكرة",
"stayDateRange": "فترة إقامة الفندق",
"checkInLabel": "الدخول",
"checkOutLabel": "الخروج",
"invoiceAmount": "مبلغ الفاتورة",
"pendingInvoiceBadge": "بانتظار صدور الفاتورة",
"priceAfterInvoiceNotice": "لا يظهر السعر للمسافر إلا بعد اعتماد الفاتورة الرسمية وإرسالها.",
"flightDepartureDate": "تاريخ الرحلة الجوية (ميلادي / هجري)",
"flightDepartureTime": "وقت الرحلة الجوية",
"flightInfoBadge": "رحلة جوية",
"returnFlightBadge": "رحلة العودة الجوية",
"lunarCalendarTab": "التقويم الهجري",
"gregorianCalendarTab": "التقويم الميلادي",
"jalaliCalendarTab": "الشمسي",
"selectDepartureDatePlaceholder": "اختر التاريخ (ميلادي / هجري)...",
"selectDestinationPlaceholder": "اختر وجهة السفر...",
"returnLegBadgeGeneral": "مسار العودة",
"returnDestinationCityLabel": "اسم مدينة الوجهة في مسار العودة",
"returnNoHotel": "مسار العودة (بدون إقامة فندقية)",
"duplicateDestinationError": "لا يمكن تكرار أي من وجهات السفر.",
"originAsDestinationError": "لا يمكن اختيار مدينة الانطلاق كإحدى الوجهات الوسطى.",
"emptyDestinationError": "يرجى تحديد مدينة الوجهة لجميع المسارات.",
"emptyDepartureDateError": "يرجى تحديد تاريخ المغادرة.",
"pastDateNotAllowedError": "لا يمكن اختيار تاريخ ماضٍ. يرجى اختيار تاريخ من اليوم فصاعداً.",
"duplicateRouteError": "تم تسجيل هذا المسار (من نقطة الانطلاق إلى الوجهة) مسبقاً ولا يمكن تكراره.",
"sameOriginAndDestinationError": "لا يمكن أن تكون نقطة الانطلاق والوجهة متطابقتين في نفس المرحلة.",
"destinationCategoryOrigins": "مدن الانطلاق والترانزيت",
"destinationCategoryPilgrimage": "المدن الدينية والوجهات",
"alreadySelectedLabel": "تم اختياره مسبقاً",
"alreadySelectedAsDest": "تم اختياره كوجهة",
"routeAlreadyExistsLabel": "تم اختيار هذا المسار مسبقاً",
"originCityOptionLabel": "مدينة الانطلاق",
"currentCityOptionLabel": "نقطة انطلاق هذا المسار",
"initialOriginBadge": "مدينة الانطلاق الأساسية",
"pendingDestinationChoice": "بانتظار تحديد الوجهة",
"backToOrders": "العودة إلى الطلبات",
"backToOrdersList": "العودة إلى قائمة الطلبات",
"orderNotFound": "لم يتم العثور على طلب الرحلة المخصصة",
"orderNotFoundDesc": "الطلب بهذا المعرف غير موجود في قاعدة البيانات أو تم حذفه.",
"customerAndContactInfo": "بيانات العميل ومعلومات الاتصال",
"viewFullUserProfile": "عرض الملف الكامل للمستخدم",
"pricingAndBilling": "التسعير والفواتير المالية",
"invoiceIssued": "تم إصدار الفاتورة الأولية",
"orderOperations": "عمليات الطلب",
"cancelAndReturn": "إلغاء وعودة",
"returnTripEnd": "نهاية الرحلة والعودة",
"returnOrigin": "نقطة انطلاق العودة",
"lastDestination": "الوجهة الأخيرة",
"previousStage": "المرحلة السابقة",
"returnNoHotelNotice": "مسار العودة النهائي ولا يتطلب حجز إقامة فندقية.",
"time": "الوقت",
"flightReturnDate": "تاريخ رحلة طيران العودة",
"trainReturnDate": "تاريخ قطار العودة",
"roadReturnDate": "تاريخ حركة حافلة العودة",
"flightDate": "تاريخ الرحلة الجوية",
"trainDate": "تاريخ القطار",
"roadDate": "تاريخ حركة النقل البري",
"originStartAndEnd": "(نقطة انطلاق ونهاية الرحلة)"
},
"languages": {
"fa": "فارسی",

117
src/locales/en.json

@ -35,8 +35,12 @@
"consultant": "Support",
"billing": "Billing & Finance",
"settings": "Settings",
"settingsAdmins": "Admin & Access Management",
"settingsSocial": "Social Media & Networks",
"settingsNotifications": "Notification System",
"logout": "Logout",
"soon": "Soon",
"comingSoon": "Coming Soon",
"adminRole": "System Administrator"
},
"topbar": {
@ -343,7 +347,12 @@
"ticketDeleting": "Deleting ticket...",
"ticketDragDropPrompt": "Drag and drop the ticket file here or choose from the button below",
"ticketSupportedFormats": "Supported formats: PDF, PNG, JPG (up to 10MB)",
"ticketAutoApproveNotice": "Note: Uploading the ticket is required to change status to 'Approved / Paid'. After uploading, click 'Save Status' to finalize.",
"ticketAutoApproveNotice": "Note: Uploading the ticket is required to change status to 'Approved / Paid'. After uploading, the status will switch to 'Approved'; then click 'Confirm & Save' to finalize.",
"ticketUploadSuccessPendingSave": "Ticket file uploaded successfully. Status changed to 'Approved / Paid'; please click 'Confirm & Save' to finalize.",
"confirmAndSave": "Confirm & Save",
"saveStatusChanges": "Save Status Changes",
"reSubmitStatus": "Re-save Status",
"submitStatus": "Save Status",
"ticketRequiredTitle": "Ticket Upload Required",
"ticketRequiredForApprovedTitle": "Travel Ticket File Required",
"ticketRequiredForApprovedDesc": "Changing the status to 'Approved / Paid' requires uploading an e-ticket file. Please first upload the ticket in the 'Travel Ticket Management' section below.",
@ -660,14 +669,104 @@
"passengersList": "Passengers & Passport Images",
"passportNumber": "Passport No.",
"viewPassportImage": "View Passport",
"noPassportImage": "No Passport Image"
},
"common": {
"deleteConfirmTitle": "Confirm Deletion",
"deleteConfirmDesc": "Are you sure you want to delete this item? This action cannot be undone.",
"yesDelete": "Yes, Delete",
"cancel": "Cancel",
"deleting": "Deleting..."
"noPassportImage": "No Passport Image",
"stage1Title": "Stage 1: Review & Modify Custom Trip Order",
"stage1Desc": "Review and adjust flights, hotels, destination order, and pricing. No financial invoice is created until final approval.",
"stage2Title": "Stage 2: Final Approval & Transfer to Financial Invoices",
"stage2Desc": "This order has been finalized and its invoice has been transferred to Financial Invoices.",
"invoiceTransferredBadge": "Transferred to Financial Invoices",
"notInvoicedYetBadge": "Pending Review & Invoice Issuance",
"viewInBilling": "View in Financial Invoices",
"saveStage1Review": "Save Review Changes (Stage 1)",
"approveAndTransferToBilling": "Final Approve & Transfer to Financial Invoices",
"updateInvoiceAndItems": "Update Items & Financial Invoice",
"rejectOrderBtn": "Reject Order",
"addDestinationLeg": "Add New Destination Leg",
"moveLegUp": "Move Up (Reorder Destination)",
"moveLegDown": "Move Down (Reorder Destination)",
"removeLeg": "Remove Destination",
"selectHotelPlaceholder": "Select or change hotel...",
"customHotelPlaceholder": "Custom hotel name...",
"nightsLabel": "Stay Nights",
"mealPlanLabel": "Meal Plan",
"mealFullBoard": "Full Board (Breakfast, Lunch, Dinner)",
"mealBreakfast": "Bed & Breakfast",
"mealRoomOnly": "Room Only",
"finalInvoicePrice": "Final Invoice Amount",
"requiredBadge": "Required",
"priceRequiredNotice": "Final invoice amount is required (must be greater than 0).",
"priceRequiredAlert": "Entering final invoice price is required.",
"priceRequiredTitle": "Amount is Required",
"requiredFieldsAlert": "Please complete all mandatory fields (amount, origin, and destinations).",
"requiredFieldsTitle": "Incomplete Information",
"requiredFieldsMissingHint": "Complete mandatory fields to enable save buttons:",
"missingPriceItem": "Enter final invoice amount (USD)",
"missingOriginItem": "Specify origin city",
"missingLegsItem": "Specify at least one destination and nights of stay",
"stage1SavedToast": "Itinerary changes saved in Stage 1 review (without issuing a financial invoice).",
"stage2TransferredToast": "Order finalized and invoice transferred to Financial Invoices.",
"dragToReorderHint": "Drag & drop destination cards to reorder itinerary",
"returnLegBadge": "Final Leg: Return to Origin City",
"returnToOriginNoHotel": "Return to Origin City (No Hotel Stay)",
"ticketDepartureDate": "Ticket Departure Date",
"ticketDepartureTime": "Ticket Departure Time",
"stayDateRange": "Hotel Stay Period",
"checkInLabel": "Check-in",
"checkOutLabel": "Check-out",
"invoiceAmount": "Invoice Amount",
"pendingInvoiceBadge": "Pending Invoice",
"priceAfterInvoiceNotice": "The price is hidden from the traveler until the invoice is issued and sent.",
"flightDepartureDate": "Flight Date (Gregorian / Hijri)",
"flightDepartureTime": "Flight Time",
"flightInfoBadge": "Flight",
"returnFlightBadge": "Return Flight",
"lunarCalendarTab": "Hijri / Lunar",
"gregorianCalendarTab": "Gregorian",
"jalaliCalendarTab": "Solar",
"selectDepartureDatePlaceholder": "Select date (Gregorian / Hijri)...",
"selectDestinationPlaceholder": "Select destination city...",
"returnLegBadgeGeneral": "Return Route",
"returnDestinationCityLabel": "Return destination city name",
"returnNoHotel": "Return leg (no hotel stay)",
"duplicateDestinationError": "Destination cities cannot be duplicated.",
"originAsDestinationError": "Origin city cannot be selected as an intermediate destination.",
"emptyDestinationError": "Please specify a destination city for all route legs.",
"emptyDepartureDateError": "Please specify the departure date.",
"pastDateNotAllowedError": "Past dates cannot be selected. Please choose today or a future date.",
"duplicateRouteError": "This route (from origin to destination) already exists in the itinerary and cannot be duplicated.",
"sameOriginAndDestinationError": "Origin and destination cannot be identical in the same leg.",
"destinationCategoryOrigins": "Origin & Transit Cities",
"destinationCategoryPilgrimage": "Pilgrimage & Destination Cities",
"alreadySelectedLabel": "Already selected",
"alreadySelectedAsDest": "Selected as destination",
"routeAlreadyExistsLabel": "This route is already selected",
"originCityOptionLabel": "Origin city",
"currentCityOptionLabel": "Origin of this leg",
"initialOriginBadge": "Trip origin",
"pendingDestinationChoice": "Awaiting destination choice",
"backToOrders": "Back to Orders",
"backToOrdersList": "Back to Orders List",
"orderNotFound": "Custom Trip Order Not Found",
"orderNotFoundDesc": "Order with this ID does not exist in the database or has been deleted.",
"customerAndContactInfo": "Customer & Contact Details",
"viewFullUserProfile": "View Full User Dossier",
"pricingAndBilling": "Pricing & Financial Invoicing",
"invoiceIssued": "Proforma Invoice Issued",
"orderOperations": "Order Actions",
"cancelAndReturn": "Cancel & Return",
"returnTripEnd": "Trip End & Return",
"returnOrigin": "Return Departure Origin",
"lastDestination": "Last Destination",
"previousStage": "Previous Leg",
"returnNoHotelNotice": "This is the final return route and does not require hotel booking.",
"time": "Time",
"flightReturnDate": "Return Flight Date",
"trainReturnDate": "Return Train Date",
"roadReturnDate": "Return Road Departure Date",
"flightDate": "Flight Date",
"trainDate": "Train Date",
"roadDate": "Road Departure Date",
"originStartAndEnd": "(Origin & final endpoint of journey)"
},
"languages": {
"fa": "فارسی",

117
src/locales/fa.json

@ -35,8 +35,12 @@
"consultant": "پشتیبانی",
"billing": "صورت‌حساب‌ها و مالی",
"settings": "تنظیمات",
"settingsAdmins": "مدیریت مدیران و دسترسی‌ها",
"settingsSocial": "سوشال مدیا و فضای مجازی",
"settingsNotifications": "سامانه اعلان‌ها",
"logout": "خروج از سیستم",
"soon": "به‌زودی",
"comingSoon": "به‌زودی",
"adminRole": "مدیر کل سیستم"
},
"topbar": {
@ -343,7 +347,12 @@
"ticketDeleting": "در حال حذف بلیت...",
"ticketDragDropPrompt": "فایل بلیت را اینجا بکشید و رها کنید یا از دکمه زیر انتخاب نمایید",
"ticketSupportedFormats": "فرمت‌های مجاز: PDF, PNG, JPG (حداکثر ۱۰ مگابایت)",
"ticketAutoApproveNotice": "نکته: برای تغییر وضعیت به «پرداخت شده»، بارگذاری بلیت الزامی است. پس از آپلود بلیت، دکمه «ثبت وضعیت» را جهت تایید نهایی بزنید.",
"ticketAutoApproveNotice": "نکته: برای تغییر وضعیت به «پرداخت شده»، بارگذاری بلیت الزامی است. پس از آپلود بلیت، وضعیت فاکتور روی «پرداخت شده» تنظیم می‌شود؛ سپس دکمه «تایید و ذخیره» را جهت ثبت نهایی بزنید.",
"ticketUploadSuccessPendingSave": "فایل بلیت با موفقیت بارگذاری شد. وضعیت به «پرداخت شده» تغییر یافت؛ لطفاً دکمه «تایید و ذخیره» را بزنید.",
"confirmAndSave": "تایید و ذخیره",
"saveStatusChanges": "ذخیره تغییرات وضعیت",
"reSubmitStatus": "ثبت مجدد وضعیت",
"submitStatus": "ثبت وضعیت",
"ticketRequiredTitle": "بارگذاری بلیت الزامی است",
"ticketRequiredForApprovedTitle": "بارگذاری فایل بلیت سفر الزامی است",
"ticketRequiredForApprovedDesc": "تغییر وضعیت به «پرداخت شده» منوط به بارگذاری فایل بلیت الکترونیکی است. لطفاً ابتدا از بخش «مدیریت و صدور بلیت سفر» در پایین، فایل بلیت را آپلود نمایید.",
@ -660,14 +669,104 @@
"passengersList": "مشخصات مسافران و تصویر پاسپورت",
"passportNumber": "شماره پاسپورت",
"viewPassportImage": "مشاهده پاسپورت",
"noPassportImage": "بدون تصویر پاسپورت"
},
"common": {
"deleteConfirmTitle": "تأیید حذف",
"deleteConfirmDesc": "آیا از حذف این مورد اطمینان دارید؟ این عمل قابل بازگشت نیست.",
"yesDelete": "بله، حذف شود",
"cancel": "انصراف",
"deleting": "در حال حذف..."
"noPassportImage": "بدون تصویر پاسپورت",
"stage1Title": "مرحله ۱: بررسی و اعمال تغییرات در سفر اختصاصی",
"stage1Desc": "پروازها، هتل‌ها، ترتیب مقاصد و قیمت را بررسی و اصلاح کنید. تا پیش از تأیید نهایی، صورت‌حساب مالی صادر نمی‌شود.",
"stage2Title": "مرحله ۲: تأیید نهایی و انتقال به صورت‌حساب‌های مالی",
"stage2Desc": "این سفارش بررسی و نهایی شده و فاکتور آن به بخش صورت‌حساب‌های مالی منتقل شده است.",
"invoiceTransferredBadge": "منتقل‌شده به صورت‌حساب مالی",
"notInvoicedYetBadge": "در انتظار بررسی و صدور فاکتور",
"viewInBilling": "مشاهده در صورت‌حساب‌های مالی",
"saveStage1Review": "ذخیره تغییرات بررسی (مرحله ۱)",
"approveAndTransferToBilling": "تأیید نهایی و انتقال به صورت‌حساب‌های مالی",
"updateInvoiceAndItems": "به‌روزرسانی آیتم‌ها و فاکتور مالی",
"rejectOrderBtn": "رد سفارش",
"addDestinationLeg": "افزودن مقصد جدید به برنامه سفر",
"moveLegUp": "جابجایی به بالا (تغییر ترتیب مقصد)",
"moveLegDown": "جابجایی به پایین (تغییر ترتیب مقصد)",
"removeLeg": "حذف این مقصد",
"selectHotelPlaceholder": "انتخاب یا تغییر هتل اقامت...",
"customHotelPlaceholder": "نام هتل دلخواه...",
"nightsLabel": "تعداد شب اقامت",
"mealPlanLabel": "برنامه غذایی",
"mealFullBoard": "فول‌برد (صبحانه، ناهار، شام)",
"mealBreakfast": "همراه با صبحانه",
"mealRoomOnly": "فقط اتاق (بدون غذا)",
"finalInvoicePrice": "مبلغ نهایی فاکتور",
"requiredBadge": "اجباری",
"priceRequiredNotice": "تعیین مبلغ نهایی فاکتور اجباری است (باید بیشتر از صفر باشد).",
"priceRequiredAlert": "ثبت مبلغ نهایی فاکتور الزامی است.",
"priceRequiredTitle": "مبلغ اجباری است",
"requiredFieldsAlert": "لطفاً تمامی موارد اجباری (مبلغ، مبدأ و مقاصد) را تکمیل فرمایید.",
"requiredFieldsTitle": "اطلاعات ناقص است",
"requiredFieldsMissingHint": "تکمیل موارد الزامی جهت فعال‌سازی دکمه‌های ذخیره:",
"missingPriceItem": "تعیین مبلغ نهایی فاکتور (دلار)",
"missingOriginItem": "تعیین شهر مبدأ سفر",
"missingLegsItem": "تعیین حداقل یک مقصد و تعداد شب اقامت",
"stage1SavedToast": "تغییرات برنامه سفر در مرحله بررسی ذخیره شد (بدون صدور فاکتور مالی).",
"stage2TransferredToast": "سفارش نهایی شد و فاکتور آن به بخش صورت‌حساب‌های مالی منتقل گردید.",
"dragToReorderHint": "برای تغییر ترتیب، مقاصد را بکشید و رها کنید (Drag & Drop)",
"returnLegBadge": "مسیر پایانی: بازگشت به شهر مبدأ",
"returnToOriginNoHotel": "بازگشت به شهر مبدأ (بدون هتل و اقامت)",
"ticketDepartureDate": "تاریخ حرکت بلیط",
"ticketDepartureTime": "ساعت حرکت بلیط",
"stayDateRange": "بازه اقامت هتل",
"checkInLabel": "ورود",
"checkOutLabel": "خروج",
"invoiceAmount": "مبلغ فاکتور",
"pendingInvoiceBadge": "در انتظار صدور فاکتور",
"priceAfterInvoiceNotice": "قیمت به زائر نمایش داده نمی‌شود و تنها پس از صدور و ارسال فاکتور به وی اعلام خواهد شد.",
"flightDepartureDate": "تاریخ پرواز (میلادی / قمری)",
"flightDepartureTime": "ساعت پرواز",
"flightInfoBadge": "پرواز",
"returnFlightBadge": "پرواز بازگشت",
"lunarCalendarTab": "تقویم قمری",
"gregorianCalendarTab": "تقویم میلادی",
"jalaliCalendarTab": "شمسی",
"selectDepartureDatePlaceholder": "انتخاب تاریخ (میلادی / قمری)...",
"selectDestinationPlaceholder": "انتخاب شهر مقصد...",
"returnLegBadgeGeneral": "مسیر بازگشت",
"returnDestinationCityLabel": "نام شهر مقصد در مسیر بازگشت",
"returnNoHotel": "مسیر بازگشت (بدون هتل اقامتی)",
"duplicateDestinationError": "هیچ یک از شهرهای مقصد نمی‌تواند تکراری باشد.",
"originAsDestinationError": "شهر مبدأ نمی‌تواند به عنوان یکی از مقاصد میانی انتخاب شود.",
"emptyDestinationError": "لطفاً نام شهر مقصد را برای تمامی مسیرها مشخص نمایید.",
"emptyDepartureDateError": "لطفاً تاریخ حرکت را مشخص نمایید.",
"pastDateNotAllowedError": "امکان انتخاب تاریخ گذشته وجود ندارد. لطفاً تاریخی از امروز به بعد را انتخاب نمایید.",
"duplicateRouteError": "این مسیر (مبدأ به مقصد) قبلاً در مراحل سفر ثبت شده است و نمی‌تواند تکرار شود.",
"sameOriginAndDestinationError": "شهر مبدأ و مقصد در یک مرحله نمی‌تواند یکسان باشد.",
"destinationCategoryOrigins": "شهرهای مبدأ و ترانزیت (Origins)",
"destinationCategoryPilgrimage": "شهرهای زیارتی و مقاصد (Destinations)",
"alreadySelectedLabel": "قبلاً انتخاب شده",
"alreadySelectedAsDest": "به عنوان مقصد انتخاب شده",
"routeAlreadyExistsLabel": "این مسیر قبلاً انتخاب شده",
"originCityOptionLabel": "شهر مبدأ",
"currentCityOptionLabel": "مبدأ این مسیر",
"initialOriginBadge": "شهر مبدأ سفر",
"pendingDestinationChoice": "در انتظار تعیین مقصد",
"backToOrders": "بازگشت به سفارش‌ها",
"backToOrdersList": "بازگشت به لیست سفارش‌ها",
"orderNotFound": "سفارش سفر اختصاصی یافت نشد",
"orderNotFoundDesc": "سفارش با این شناسه در پایگاه‌داده موجود نیست یا حذف گردیده است.",
"customerAndContactInfo": "مشخصات کاربر و تماس",
"viewFullUserProfile": "مشاهده پرونده کامل کاربر",
"pricingAndBilling": "قیمت‌گذاری و صورت‌حساب مالی",
"invoiceIssued": "پیش‌فاکتور مالی صادر شده",
"orderOperations": "عملیات سفارش",
"cancelAndReturn": "انصراف و بازگشت",
"returnTripEnd": "پایان سفر و بازگشت",
"returnOrigin": "مبدأ بازگشت",
"lastDestination": "آخرین مقصد",
"previousStage": "مرحله قبل",
"returnNoHotelNotice": "مسیر پایانی بازگشت است و نیازی به رزرو هتل اقامتی ندارد.",
"time": "ساعت",
"flightReturnDate": "تاریخ پرواز بازگشت",
"trainReturnDate": "تاریخ قطار بازگشت",
"roadReturnDate": "تاریخ حرکت بازگشت",
"flightDate": "تاریخ پرواز",
"trainDate": "تاریخ قطار",
"roadDate": "تاریخ حرکت",
"originStartAndEnd": "(مبدأ آغاز و پایان سفر)"
},
"languages": {
"fa": "فارسی",

180
vite.config.ts

@ -1,4 +1,5 @@
/// <reference types="vitest/config" />
import fs from 'node:fs'
import path from 'node:path'
import { exec } from 'node:child_process'
import os from 'node:os'
@ -8,6 +9,175 @@ import tailwindcss from '@tailwindcss/vite'
import babel from '@babel/core'
import addDataLocator from './plugins/add-data-locator.cjs'
function getIdeCandidates(): string[] {
const isWindows = os.platform() === 'win32'
const isMac = os.platform() === 'darwin'
const home = os.homedir()
const candidates: string[] = []
if (isMac) {
const macPaths = [
'/Applications/Antigravity IDE.app/Contents/Resources/app/bin/antigravity-ide',
path.join(home, 'Applications/Antigravity IDE.app/Contents/Resources/app/bin/antigravity-ide'),
path.join(home, '.gemini/antigravity/bin/antigravity-ide'),
'/Applications/Cursor.app/Contents/Resources/app/bin/cursor',
path.join(home, 'Applications/Cursor.app/Contents/Resources/app/bin/cursor'),
'/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
path.join(home, 'Applications/Visual Studio Code.app/Contents/Resources/app/bin/code'),
path.join(home, '.gemini/antigravity/bin/code'),
'/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code-insiders',
path.join(home, 'Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code-insiders'),
'/usr/local/bin/antigravity-ide',
'/usr/local/bin/cursor',
'/usr/local/bin/code',
'/opt/homebrew/bin/antigravity-ide',
'/opt/homebrew/bin/cursor',
'/opt/homebrew/bin/code',
]
for (const p of macPaths) {
if (fs.existsSync(p) && !candidates.includes(p)) {
candidates.push(p)
}
}
candidates.push('antigravity-ide', 'cursor', 'code')
} else if (isWindows) {
const localAppData = process.env.LOCALAPPDATA || ''
const programFiles = process.env.ProgramFiles || 'C:\\Program Files'
const winPaths = [
path.join(localAppData, 'Programs', 'Antigravity IDE', 'bin', 'antigravity-ide.cmd'),
path.join(localAppData, 'Programs', 'cursor', 'resources', 'app', 'bin', 'cursor.cmd'),
path.join(localAppData, 'Programs', 'Microsoft VS Code', 'bin', 'code.cmd'),
path.join(programFiles, 'Microsoft VS Code', 'bin', 'code.cmd'),
'antigravity-ide.cmd',
'cursor.cmd',
'code.cmd',
]
for (const p of winPaths) {
if (p.includes(path.sep)) {
if (fs.existsSync(p) && !candidates.includes(p)) candidates.push(p)
} else if (!candidates.includes(p)) {
candidates.push(p)
}
}
} else {
// Linux
const linuxPaths = [
'/usr/local/bin/antigravity-ide',
'/usr/bin/antigravity-ide',
'/usr/local/bin/cursor',
'/usr/bin/cursor',
'/usr/local/bin/code',
'/usr/bin/code',
'/snap/bin/code',
'antigravity-ide',
'cursor',
'code',
]
for (const p of linuxPaths) {
if (p.startsWith('/') ? fs.existsSync(p) : true) {
if (!candidates.includes(p)) candidates.push(p)
}
}
}
return candidates.length > 0 ? candidates : ['antigravity-ide', 'code']
}
function openViaCli(locator: string, callback: (err: Error | null, bin?: string) => void) {
const isWindows = os.platform() === 'win32'
const home = os.homedir()
const extraPaths = isWindows
? []
: [
path.join(home, '.gemini/antigravity/bin'),
path.join(home, 'Library/Application Support/Antigravity/bin'),
'/usr/local/bin',
'/opt/homebrew/bin',
'/opt/homebrew/sbin',
'/usr/bin',
'/bin',
]
const augmentedPath = [...extraPaths, process.env.PATH || ''].filter(Boolean).join(isWindows ? ';' : ':')
const env = { ...process.env, PATH: augmentedPath }
const candidates = getIdeCandidates()
let index = 0
let lastError: Error | null = null
function tryNext() {
if (index >= candidates.length) {
return callback(lastError || new Error('No IDE command succeeded to open locator'))
}
const bin = candidates[index++]
const quotedBin = bin.includes(' ') ? `"${bin}"` : bin
const cmd = `${quotedBin} -r -g "${locator}"`
exec(cmd, { env }, (error) => {
if (error) {
lastError = error
tryNext()
} else {
callback(null, bin)
}
})
}
tryNext()
}
function openInIde(locator: string, callback: (err: Error | null, bin?: string) => void) {
const isWindows = os.platform() === 'win32'
const isMac = os.platform() === 'darwin'
const cleanPath = locator.replace(/^[/\\]+/, '')
const targetUri = `/${cleanPath}`
if (isMac) {
// Specifically target Antigravity IDE.app to prevent opening regular Antigravity or other apps
const ideAppPath = fs.existsSync('/Applications/Antigravity IDE.app')
? '/Applications/Antigravity IDE.app'
: path.join(os.homedir(), 'Applications/Antigravity IDE.app')
const hasIdeApp = fs.existsSync(ideAppPath)
const cmd = hasIdeApp
? `open -a "${ideAppPath}" "antigravity-ide://file${targetUri}"`
: `open "antigravity-ide://file${targetUri}" || open "cursor://file${targetUri}" || open "vscode://file${targetUri}"`
exec(cmd, (error) => {
// Specifically bring Antigravity IDE to the foreground (never 'Antigravity')
exec(
`osascript -e 'tell application id "com.google.antigravity-ide" to activate' -e 'tell application "Antigravity IDE" to activate' 2>/dev/null`,
() => {}
)
if (!error) {
return callback(null, 'Antigravity IDE')
}
openViaCli(locator, callback)
})
return
}
if (isWindows) {
const cmd = `start "" /MAX "antigravity-ide://file${targetUri}" || start "" /MAX "cursor://file${targetUri}" || start "" /MAX "vscode://file${targetUri}"`
exec(cmd, (error) => {
if (!error) {
return callback(null, 'antigravity-ide://file')
}
openViaCli(locator, callback)
})
return
}
// Linux
const cmd = `xdg-open "antigravity-ide://file${targetUri}" || xdg-open "vscode://file${targetUri}"`
exec(cmd, (error) => {
if (!error) {
return callback(null, 'antigravity-ide://file')
}
openViaCli(locator, callback)
})
}
function openInIdePlugin(): Plugin {
return {
name: 'open-in-ide-plugin',
@ -30,13 +200,9 @@ function openInIdePlugin(): Plugin {
return
}
const isWindows = os.platform() === 'win32'
const bin = isWindows ? 'antigravity-ide.cmd' : 'antigravity-ide'
const cmd = `${bin} -r -g "${locator}"`
exec(cmd, (error) => {
openInIde(locator, (error, bin) => {
if (error) {
console.error(`[open-in-ide] Error: ${error.message}`)
console.error(`[open-in-ide] Error opening "${locator}": ${error.message}`)
res.statusCode = 500
res.setHeader('Content-Type', 'application/json')
res.end(
@ -49,7 +215,7 @@ function openInIdePlugin(): Plugin {
}
res.statusCode = 200
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ success: true }))
res.end(JSON.stringify({ success: true, bin }))
})
} catch (err) {
console.error('[open-in-ide] Error parsing body:', err, 'raw body:', body)

Loading…
Cancel
Save