Browse Source

feat: add tour orders page, billing management, and custom trip configuration features

master
parent
commit
a860b8cf66
  1. 9
      src/app/router.tsx
  2. 58
      src/components/shared/FilterSelect.tsx
  3. 298
      src/components/shared/MultiFilterSelect.tsx
  4. 4
      src/components/ui/select.tsx
  5. 128
      src/components/ui/sidebar.tsx
  6. 21
      src/config/navigation.ts
  7. 348
      src/features/billing/BillingPage.tsx
  8. 285
      src/features/billing/components/BillingDetailModal.tsx
  9. 26
      src/features/billing/services/billing-api.ts
  10. 2
      src/features/billing/types.ts
  11. 1616
      src/features/custom-trip/CustomTripDestinationDetailPage.tsx
  12. 317
      src/features/custom-trip/CustomTripPage.tsx
  13. 914
      src/features/custom-trip/components/CustomTripDestinationsTab.tsx
  14. 833
      src/features/custom-trip/components/CustomTripHotelsTab.tsx
  15. 421
      src/features/custom-trip/components/CustomTripOrdersTab.tsx
  16. 578
      src/features/custom-trip/components/CustomTripOriginsTab.tsx
  17. 749
      src/features/custom-trip/components/CustomTripTransportsTab.tsx
  18. 715
      src/features/custom-trip/services/custom-trip-api.ts
  19. 111
      src/features/custom-trip/types.ts
  20. 25
      src/features/tours/TourOrdersPage.tsx
  21. 1
      src/features/tours/types.ts
  22. 2
      src/features/users/components/UserFactorsTab.tsx
  23. 1
      src/features/users/types.ts
  24. 11
      src/icons.tsx
  25. 154
      src/locales/ar.json
  26. 110
      src/locales/en.json
  27. 158
      src/locales/fa.json

9
src/app/router.tsx

@ -13,6 +13,8 @@ const OngoingToursPage = lazy(() => import('@/features/tours/OngoingToursPage'))
const ServicesPage = lazy(() => import('@/features/services/ServicesPage')) const ServicesPage = lazy(() => import('@/features/services/ServicesPage'))
const BillingPage = lazy(() => import('@/features/billing/BillingPage')) const BillingPage = lazy(() => import('@/features/billing/BillingPage'))
const SettingsPage = lazy(() => import('@/features/settings/SettingsPage')) const SettingsPage = lazy(() => import('@/features/settings/SettingsPage'))
const CustomTripPage = lazy(() => import('@/features/custom-trip/CustomTripPage'))
const CustomTripDestinationDetailPage = lazy(() => import('@/features/custom-trip/CustomTripDestinationDetailPage'))
export default function AppRouter() { export default function AppRouter() {
return ( return (
@ -31,6 +33,13 @@ export default function AppRouter() {
<Route path="/tours" element={<PageReady><ToursPage /></PageReady>} /> <Route path="/tours" element={<PageReady><ToursPage /></PageReady>} />
<Route path="/tours/ongoing" element={<PageReady><OngoingToursPage /></PageReady>} /> <Route path="/tours/ongoing" element={<PageReady><OngoingToursPage /></PageReady>} />
<Route path="/tours/orders" element={<Navigate to="/billing?service=tour" replace />} /> <Route path="/tours/orders" element={<Navigate to="/billing?service=tour" replace />} />
<Route path="/tours/custom-trip" element={<Navigate to="/custom-trip" replace />} />
{/* ماژول مستقل سفرهای اختصاصی (Custom Trip) */}
<Route path="/custom-trip" element={<Navigate to="/custom-trip/orders" replace />} />
<Route path="/custom-trip/destinations/:id" element={<PageReady><CustomTripDestinationDetailPage /></PageReady>} />
<Route path="/custom-trip/:tab" element={<PageReady><CustomTripPage /></PageReady>} />
<Route path="/services" element={<Navigate to="/services/simcard" replace />} /> <Route path="/services" element={<Navigate to="/services/simcard" replace />} />
<Route path="/services/:serviceKey" element={<PageReady><ServicesPage /></PageReady>} /> <Route path="/services/:serviceKey" element={<PageReady><ServicesPage /></PageReady>} />
<Route path="/services/:serviceKey/:subKey" element={<PageReady><ServicesPage /></PageReady>} /> <Route path="/services/:serviceKey/:subKey" element={<PageReady><ServicesPage /></PageReady>} />

58
src/components/shared/FilterSelect.tsx

@ -4,7 +4,6 @@ import {
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Ic, type IconName } from '@/icons' import { Ic, type IconName } from '@/icons'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -58,6 +57,10 @@ export default function FilterSelect<T extends string = string>({
} }
} }
// نمایش تک‌آیکون دقیق و یکتا بدون هیچ‌گونه آیکون تکراری
const activeIcon = selectedOption?.icon || icon
const displayLabel = selectedOption ? selectedOption.label : placeholder
return ( return (
<div className={cn('flex flex-col gap-1', className)}> <div className={cn('flex flex-col gap-1', className)}>
{label && ( {label && (
@ -70,54 +73,71 @@ export default function FilterSelect<T extends string = string>({
<SelectTrigger <SelectTrigger
size={size} size={size}
className={cn( className={cn(
'transition-all duration-150',
'h-9 w-full rounded-xl border border-border-soft bg-surface-2 px-3 text-xs font-medium text-foreground transition-all duration-150',
'hover:border-border hover:bg-surface-3',
'focus-visible:border-primary focus-visible:ring-1 focus-visible:ring-primary',
isFiltered && isFiltered &&
'border-primary/60 bg-primary/5 text-primary font-semibold hover:border-primary/80 hover:bg-primary/10 shadow-xs',
'border-primary/50 bg-primary/5 text-primary font-bold hover:border-primary/70 hover:bg-primary/10 shadow-2xs',
clearable && isFiltered && 'pe-8', clearable && isFiltered && 'pe-8',
triggerClassName, triggerClassName,
)} )}
> >
<div className="flex items-center gap-2 truncate"> <div className="flex items-center gap-2 truncate">
{icon && (
{activeIcon && typeof activeIcon === 'string' ? (
<Ic <Ic
name={icon}
name={activeIcon as IconName}
className={cn( className={cn(
'size-3.5 shrink-0 transition-colors',
'size-4 shrink-0 transition-colors',
isFiltered ? 'text-primary' : 'text-muted-foreground', isFiltered ? 'text-primary' : 'text-muted-foreground',
)} )}
/> />
)}
{selectedOption?.icon && typeof selectedOption.icon === 'string' ? (
<Ic name={selectedOption.icon as IconName} className="size-3.5 shrink-0" />
) : ( ) : (
selectedOption?.icon
activeIcon
)} )}
<SelectValue placeholder={placeholder} />
<span className="truncate">{displayLabel}</span>
</div> </div>
</SelectTrigger> </SelectTrigger>
<SelectContent className={cn('min-w-[10rem]', contentClassName)}>
{options.map((option) => (
<SelectContent className={cn('max-h-none overflow-visible min-w-[13.5rem] p-1.5 rounded-2xl border-border-soft bg-popover/95 backdrop-blur-md shadow-xl', contentClassName)}>
{options.map((option) => {
const isSelected = option.value === value
return (
<SelectItem <SelectItem
key={option.value} key={option.value}
value={option.value} value={option.value}
disabled={option.disabled} disabled={option.disabled}
className={cn(
'rounded-xl px-2.5 py-1.5 text-xs transition-colors my-0.5',
isSelected && 'bg-primary/10 text-primary font-bold',
)}
> >
<div className="flex items-center gap-2 truncate">
{option.icon && typeof option.icon === 'string' ? (
<Ic name={option.icon as IconName} className="size-3.5 shrink-0 text-muted-foreground" />
<div className="flex items-center gap-2.5 min-w-0">
{option.icon && (
<div
className={cn(
'size-6 rounded-lg flex items-center justify-center shrink-0 transition-colors',
isSelected
? 'bg-primary/15 text-primary'
: 'bg-muted/60 text-muted-foreground',
)}
>
{typeof option.icon === 'string' ? (
<Ic name={option.icon as IconName} className="size-3.5" />
) : ( ) : (
option.icon option.icon
)} )}
<span>{option.label}</span>
</div>
)}
<span className="truncate text-start">{option.label}</span>
</div> </div>
{option.badge !== undefined && ( {option.badge !== undefined && (
<span className="ms-2 rounded-full bg-secondary px-1.5 py-0.2 text-[10px] font-mono text-muted-foreground">
<span className="ms-2 rounded-full bg-secondary px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
{option.badge} {option.badge}
</span> </span>
)} )}
</SelectItem> </SelectItem>
))}
)
})}
</SelectContent> </SelectContent>
</Select> </Select>

298
src/components/shared/MultiFilterSelect.tsx

@ -0,0 +1,298 @@
import React, { useState } from 'react'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Ic, type IconName } from '@/icons'
import { cn } from '@/lib/utils'
export interface MultiFilterOption<T extends string = string> {
value: T
label: string
icon?: IconName | React.ReactNode
badge?: string | number
disabled?: boolean
}
export interface MultiFilterSelectProps<T extends string = string> {
values: T[]
onChange: (values: T[]) => void
options: MultiFilterOption<T>[]
placeholder?: string
label?: string
icon?: IconName
allValue?: T
selectedCountLabel?: (count: number) => string
clearLabel?: string
disabled?: boolean
className?: string
triggerClassName?: string
contentClassName?: string
}
export default function MultiFilterSelect<T extends string = string>({
values,
onChange,
options,
placeholder = 'انتخاب کنید...',
label,
icon,
allValue = 'all' as T,
selectedCountLabel,
clearLabel = 'پاک کردن فیلتر',
disabled = false,
className,
triggerClassName,
contentClassName,
}: MultiFilterSelectProps<T>) {
const [open, setOpen] = useState(false)
// آیا حالت "همه" انتخاب شده است؟ (یا لیست خالی است یا شامل allValue است)
const isAllSelected = values.length === 0 || values.includes(allValue)
const isFiltered = !isAllSelected && values.length > 0
// پیدا کردن گزینه All
const allOption = options.find((opt) => opt.value === allValue)
// پیدا کردن گزینه‌های انتخاب‌شده فعلی (غیر از all)
const selectedOptions = options.filter(
(opt) => opt.value !== allValue && values.includes(opt.value)
)
// عنوان و آیکون نمایشی در تریگر
let displayLabel: string = placeholder
let activeIcon: IconName | React.ReactNode | undefined = icon
if (isAllSelected) {
displayLabel = allOption ? allOption.label : placeholder
activeIcon = allOption?.icon || icon
} else if (selectedOptions.length === 1) {
displayLabel = selectedOptions[0].label
activeIcon = selectedOptions[0].icon || icon
} else if (selectedOptions.length > 1) {
displayLabel = selectedCountLabel
? selectedCountLabel(selectedOptions.length)
: `${selectedOptions.length} مورد انتخاب‌شده`
activeIcon = icon || selectedOptions[0].icon
}
// کلیک روی گزینه All
const handleSelectAll = () => {
onChange([allValue])
}
// کلیک روی یکی از گزینه‌های تکی
const handleToggleOption = (val: T) => {
if (isAllSelected) {
// اگر قبلاً همه انتخاب شده بود، فقط همین مورد جدید فعال شود
onChange([val])
return
}
if (values.includes(val)) {
// حذف این مورد
const next = values.filter((v) => v !== val)
if (next.length === 0) {
// اگر هیچ موردی نماند، خودکار به All برمی‌گردیم
onChange([allValue])
} else {
onChange(next)
}
} else {
// اضافه کردن این مورد
onChange([...values.filter((v) => v !== allValue), val])
}
}
// پاک کردن تمام فیلترها و برگشت به All
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation()
onChange([allValue])
}
return (
<div className={cn('flex flex-col gap-1', className)}>
{label && (
<span className="text-[11px] font-semibold text-muted-foreground px-0.5">
{label}
</span>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild disabled={disabled}>
<button
type="button"
className={cn(
'group flex h-9 w-full items-center justify-between gap-2 rounded-xl border border-border-soft bg-surface-2 px-3 text-xs font-medium text-foreground transition-all duration-150',
'hover:border-border hover:bg-surface-3',
'focus-visible:border-primary focus-visible:ring-1 focus-visible:ring-primary focus-visible:outline-none',
isFiltered &&
'border-primary/50 bg-primary/5 text-primary font-bold hover:border-primary/70 hover:bg-primary/10 shadow-2xs',
triggerClassName
)}
>
<div className="flex items-center gap-2 truncate min-w-0">
{activeIcon && typeof activeIcon === 'string' ? (
<Ic
name={activeIcon as IconName}
className={cn(
'size-4 shrink-0 transition-colors',
isFiltered ? 'text-primary' : 'text-muted-foreground'
)}
/>
) : (
activeIcon
)}
<span className="truncate">{displayLabel}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{/* نشانگر عددی در صورت انتخاب بیش از یک مورد */}
{isFiltered && selectedOptions.length > 1 && (
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[10px] font-mono font-bold text-white shadow-xs">
{selectedOptions.length}
</span>
)}
{/* چورون جهت نما */}
<Ic
name="chevronDown"
className={cn(
'size-3.5 text-muted-foreground transition-transform duration-200',
open && 'rotate-180 text-foreground'
)}
/>
</div>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={6}
className={cn(
'w-56 sm:w-60 p-1.5 rounded-2xl border-border-soft bg-popover/95 backdrop-blur-md shadow-xl max-h-none overflow-visible',
contentClassName
)}
>
{/* گزینه اول: انتخاب همه */}
{allOption && (
<div
onClick={handleSelectAll}
className={cn(
'group flex items-center justify-between gap-2.5 rounded-xl px-2.5 py-1.5 text-xs transition-colors cursor-pointer select-none',
isAllSelected
? 'bg-primary/10 text-primary font-bold'
: 'hover:bg-surface-2 text-foreground'
)}
>
<div className="flex items-center gap-2.5 min-w-0">
{allOption.icon && (
<div
className={cn(
'size-6 rounded-lg flex items-center justify-center shrink-0 transition-colors',
isAllSelected
? 'bg-primary/15 text-primary'
: 'bg-muted/60 text-muted-foreground'
)}
>
{typeof allOption.icon === 'string' ? (
<Ic name={allOption.icon as IconName} className="size-3.5" />
) : (
allOption.icon
)}
</div>
)}
<span className="truncate">{allOption.label}</span>
</div>
{/* نشانگر تیک برای گزینه All */}
<div
className={cn(
'flex size-4 items-center justify-center rounded-md border transition-all shrink-0',
isAllSelected
? 'border-primary bg-primary text-white shadow-2xs'
: 'border-border-soft bg-surface-2/60 group-hover:border-grey-3'
)}
>
{isAllSelected && <Ic name="check" className="size-3 stroke-[3]" />}
</div>
</div>
)}
{/* جداکننده ظریف */}
<div className="my-1 border-t border-border-soft/70" />
{/* گزینه‌های تکی چندانتخابی */}
<div className="space-y-0.5">
{options
.filter((opt) => opt.value !== allValue)
.map((option) => {
const isSelected = !isAllSelected && values.includes(option.value)
return (
<div
key={option.value}
onClick={() => !option.disabled && handleToggleOption(option.value)}
className={cn(
'group flex items-center justify-between gap-2.5 rounded-xl px-2.5 py-1.5 text-xs transition-colors cursor-pointer select-none',
isSelected
? 'bg-primary/10 text-primary font-bold'
: 'hover:bg-surface-2 text-foreground',
option.disabled && 'opacity-40 cursor-not-allowed pointer-events-none'
)}
>
<div className="flex items-center gap-2.5 min-w-0">
{option.icon && (
<div
className={cn(
'size-6 rounded-lg flex items-center justify-center shrink-0 transition-colors',
isSelected
? 'bg-primary/15 text-primary'
: 'bg-muted/60 text-muted-foreground'
)}
>
{typeof option.icon === 'string' ? (
<Ic name={option.icon as IconName} className="size-3.5" />
) : (
option.icon
)}
</div>
)}
<span className="truncate">{option.label}</span>
</div>
{/* چک‌باکس تیک‌دار هماهنگ با سیستم دیزاین */}
<div
className={cn(
'flex size-4 items-center justify-center rounded-md border transition-all shrink-0',
isSelected
? 'border-primary bg-primary text-white shadow-2xs'
: 'border-border-soft bg-surface-2/60 group-hover:border-grey-3'
)}
>
{isSelected && <Ic name="check" className="size-3 stroke-[3]" />}
</div>
</div>
)
})}
</div>
{/* فوتر بازنشانی در صورت انتخاب هر یک از گزینه‌ها */}
{isFiltered && (
<div className="mt-1 pt-1 border-t border-border-soft/70">
<button
type="button"
onClick={handleClear}
className="w-full flex items-center justify-center gap-1.5 rounded-lg py-1 px-2 text-[11px] text-grey-3 hover:text-primary hover:bg-surface-2 transition-colors font-medium"
>
<Ic name="x" className="size-3" />
<span>{clearLabel}</span>
</button>
</div>
)}
</PopoverContent>
</Popover>
</div>
)
}

4
src/components/ui/select.tsx

@ -48,7 +48,7 @@ function SelectContent({
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
className={cn( className={cn(
'relative z-50 max-h-72 min-w-[8.5rem] overflow-hidden rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-xl backdrop-blur-md anim-pop',
'relative z-50 max-h-96 min-w-[8.5rem] overflow-hidden rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-xl backdrop-blur-md anim-pop',
position === 'popper' && position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className, className,
@ -60,7 +60,7 @@ function SelectContent({
className={cn( className={cn(
'p-1', 'p-1',
position === 'popper' && position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
'w-full min-w-[var(--radix-select-trigger-width)]',
)} )}
> >
{children} {children}

128
src/components/ui/sidebar.tsx

@ -48,7 +48,7 @@ function useSidebar() {
} }
function SidebarProvider({ function SidebarProvider({
defaultOpen = false,
defaultOpen = true,
open: openProp, open: openProp,
onOpenChange: setOpenProp, onOpenChange: setOpenProp,
className, className,
@ -63,9 +63,15 @@ function SidebarProvider({
const isMobile = useIsMobile() const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false) const [openMobile, setOpenMobile] = React.useState(false)
const [_open, _setOpen] = React.useState(defaultOpen)
const [isPinned, setIsPinnedState] = React.useState(defaultOpen)
const isPinnedRef = React.useRef(defaultOpen)
const [_open, _setOpen] = React.useState(() => {
if (typeof window !== 'undefined') {
const match = document.cookie.match(new RegExp(`(^| )${SIDEBAR_COOKIE_NAME}=([^;]+)`))
if (match) {
return match[2] === 'true'
}
}
return defaultOpen
})
const open = openProp ?? _open const open = openProp ?? _open
const setOpen = React.useCallback( const setOpen = React.useCallback(
@ -78,72 +84,12 @@ function SidebarProvider({
[setOpenProp, open], [setOpenProp, open],
) )
const setIsPinned = React.useCallback((value: boolean | ((prev: boolean) => boolean)) => {
setIsPinnedState((prev) => {
const next = typeof value === 'function' ? value(prev) : value
isPinnedRef.current = next
return next
})
}, [])
const hoverTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const cancelCloseTimer = React.useCallback(() => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current)
hoverTimeoutRef.current = null
}
}, [])
const scheduleCloseTimer = React.useCallback(() => {
cancelCloseTimer()
hoverTimeoutRef.current = setTimeout(() => {
if (!isPinnedRef.current) {
setOpen(false)
}
}, 300)
}, [cancelCloseTimer, setOpen])
const handleTriggerMouseEnter = React.useCallback(() => {
if (isMobile) return
cancelCloseTimer()
setOpen(true)
}, [isMobile, cancelCloseTimer, setOpen])
const handleTriggerMouseLeave = React.useCallback(() => {
if (isMobile || isPinnedRef.current) return
scheduleCloseTimer()
}, [isMobile, scheduleCloseTimer])
const handleSidebarMouseEnter = React.useCallback(() => {
if (isMobile) return
cancelCloseTimer()
}, [isMobile, cancelCloseTimer])
const handleSidebarMouseLeave = React.useCallback(() => {
if (isMobile || isPinnedRef.current) return
scheduleCloseTimer()
}, [isMobile, scheduleCloseTimer])
React.useEffect(() => {
return () => {
cancelCloseTimer()
}
}, [cancelCloseTimer])
const toggleSidebar = React.useCallback(() => { const toggleSidebar = React.useCallback(() => {
cancelCloseTimer()
if (isMobile) { if (isMobile) {
return setOpenMobile((o) => !o) return setOpenMobile((o) => !o)
} }
// اگر در وضعیت پین/فیکس بود، با کلیک مجدد بسته شده و به رفتار اولیه (هاور موقت) بازمی‌گردد.
// اگر فیکس نبود (حتی اگر با هاور باز شده باشد)، قفل/فیکس شده و باز می‌ماند.
const nextPinned = !isPinnedRef.current
isPinnedRef.current = nextPinned
setIsPinnedState(nextPinned)
setOpen(nextPinned)
}, [cancelCloseTimer, isMobile, setOpen, setOpenMobile])
setOpen((o) => !o)
}, [isMobile, setOpen, setOpenMobile])
React.useEffect(() => { React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@ -158,6 +104,8 @@ function SidebarProvider({
const state = open ? 'expanded' : 'collapsed' const state = open ? 'expanded' : 'collapsed'
const noop = React.useCallback(() => {}, [])
const contextValue = React.useMemo<SidebarContextProps>( const contextValue = React.useMemo<SidebarContextProps>(
() => ({ () => ({
state, state,
@ -166,13 +114,13 @@ function SidebarProvider({
openMobile, openMobile,
setOpenMobile, setOpenMobile,
isMobile, isMobile,
isPinned,
setIsPinned,
isPinned: open,
setIsPinned: setOpen,
toggleSidebar, toggleSidebar,
handleTriggerMouseEnter,
handleTriggerMouseLeave,
handleSidebarMouseEnter,
handleSidebarMouseLeave,
handleTriggerMouseEnter: noop,
handleTriggerMouseLeave: noop,
handleSidebarMouseEnter: noop,
handleSidebarMouseLeave: noop,
}), }),
[ [
state, state,
@ -181,13 +129,8 @@ function SidebarProvider({
openMobile, openMobile,
setOpenMobile, setOpenMobile,
isMobile, isMobile,
isPinned,
setIsPinned,
toggleSidebar, toggleSidebar,
handleTriggerMouseEnter,
handleTriggerMouseLeave,
handleSidebarMouseEnter,
handleSidebarMouseLeave,
noop,
], ],
) )
@ -303,18 +246,6 @@ function Sidebar({
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l', : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className, className,
)} )}
onMouseEnter={(event) => {
onMouseEnter?.(event)
if (autoCloseOnLeave) {
handleSidebarMouseEnter?.()
}
}}
onMouseLeave={(event) => {
onMouseLeave?.(event)
if (autoCloseOnLeave) {
handleSidebarMouseLeave?.()
}
}}
{...props} {...props}
> >
<div <div
@ -332,35 +263,24 @@ function Sidebar({
function SidebarTrigger({ function SidebarTrigger({
className, className,
onClick, onClick,
onMouseEnter,
onMouseLeave,
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
const { toggleSidebar, handleTriggerMouseEnter, handleTriggerMouseLeave, isPinned } = useSidebar()
const { toggleSidebar } = useSidebar()
return ( return (
<Button <Button
data-sidebar="trigger" data-sidebar="trigger"
data-slot="sidebar-trigger" data-slot="sidebar-trigger"
data-pinned={isPinned}
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn( className={cn(
'size-9 [&_svg]:size-[18px] transition-colors',
isPinned && 'bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary',
'size-9 [&_svg]:size-[18px] transition-colors hover:bg-muted text-foreground cursor-pointer',
className, className,
)} )}
onClick={(event) => { onClick={(event) => {
onClick?.(event) onClick?.(event)
toggleSidebar() toggleSidebar()
}} }}
onMouseEnter={(event) => {
onMouseEnter?.(event)
handleTriggerMouseEnter?.()
}}
onMouseLeave={(event) => {
onMouseLeave?.(event)
handleTriggerMouseLeave?.()
}}
title="منوی سایدبار"
{...props} {...props}
> >
<Ic name="list" /> <Ic name="list" />

21
src/config/navigation.ts

@ -52,11 +52,30 @@ export function getNavigationConfig(t: (key: string) => string): NavGroupConfig[
to: '/tours/ongoing', to: '/tours/ongoing',
icon: 'calendar', icon: 'calendar',
}, },
],
},
{ {
title: t('navigation.customTrip'), title: t('navigation.customTrip'),
titleKey: 'navigation.customTrip', titleKey: 'navigation.customTrip',
to: '/tours/custom-trip',
icon: 'sparkles', icon: 'sparkles',
children: [
{
title: t('navigation.customTripOrders'),
titleKey: 'navigation.customTripOrders',
to: '/custom-trip/orders',
icon: 'clipboardCheck',
},
{
title: t('navigation.customTripOrigins'),
titleKey: 'navigation.customTripOrigins',
to: '/custom-trip/origins',
icon: 'mapPin',
},
{
title: t('navigation.customTripDestinations'),
titleKey: 'navigation.customTripDestinations',
to: '/custom-trip/destinations',
icon: 'map',
}, },
], ],
}, },

348
src/features/billing/BillingPage.tsx

@ -1,11 +1,12 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Avatar } from '@/components/ui/avatar' import { Avatar } from '@/components/ui/avatar'
import FilterSelect from '@/components/shared/FilterSelect'
import FilterSelect, { type FilterOption } from '@/components/shared/FilterSelect'
import MultiFilterSelect, { type MultiFilterOption } from '@/components/shared/MultiFilterSelect'
import { import {
Table, Table,
TableBody, TableBody,
@ -25,28 +26,13 @@ import { RejectFactorDialog } from './components/RejectFactorDialog'
import { ReceiptLightboxDialog } from './components/ReceiptLightboxDialog' import { ReceiptLightboxDialog } from './components/ReceiptLightboxDialog'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { useBillingBadgeStore } from '@/stores/billing-badge' import { useBillingBadgeStore } from '@/stores/billing-badge'
import { formatDateByLanguage } from '@/lib/date-utils'
import { formatDateByLanguage, formatDateTimeByLanguage } from '@/lib/date-utils'
import { resolveMediaUrl, getMediaFallbackUrl } from '@/lib/utils' import { resolveMediaUrl, getMediaFallbackUrl } from '@/lib/utils'
import { useDebouncedValue } from '@/hooks/use-debounced-value'
import { toast } from '@/stores/toast' import { toast } from '@/stores/toast'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
interface ServiceTabItem {
id: string
titleKey: string
icon: IconName
isCore?: boolean
}
const SERVICE_TABS: ServiceTabItem[] = [
{ id: 'all', titleKey: 'billing.tabAll', icon: 'receipt' },
{ id: 'tour', titleKey: 'billing.tabTour', icon: 'map', isCore: true },
{ id: 'simcard', titleKey: 'billing.tabSimcard', icon: 'simcard' },
{ id: 'tasrif', titleKey: 'billing.tabExchange', icon: 'exchange' },
{ id: 'shop', titleKey: 'billing.tabShop', icon: 'store' },
{ id: 'custom_trip', titleKey: 'billing.tabCustomTrip', icon: 'calculator' },
]
export default function BillingPage() { export default function BillingPage() {
const { t, language } = useTranslation() const { t, language } = useTranslation()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
@ -57,17 +43,28 @@ export default function BillingPage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [actionSuccessMessage, setActionSuccessMessage] = useState<string | null>(null) const [actionSuccessMessage, setActionSuccessMessage] = useState<string | null>(null)
// تابع کمکی برای پارس مقادیر چندگانه از URL
const parseUrlFilter = (val: string | null): string[] => {
if (!val || val === 'all') return ['all']
const items = val.split(',').map((s) => s.trim()).filter(Boolean)
return items.length > 0 ? items : ['all']
}
// Filters initialized from URL search params // Filters initialized from URL search params
const initialService = searchParams.get('service') || 'all'
const initialStatus = searchParams.get('status') || 'all'
const initialServices = parseUrlFilter(searchParams.get('service'))
const initialStatuses = parseUrlFilter(searchParams.get('status'))
const initialSearch = searchParams.get('search') || '' const initialSearch = searchParams.get('search') || ''
const initialPage = parseInt(searchParams.get('page') || '1', 10) || 1 const initialPage = parseInt(searchParams.get('page') || '1', 10) || 1
const [selectedService, setSelectedService] = useState<string>(initialService)
const [selectedStatus, setSelectedStatus] = useState<string>(initialStatus)
const [selectedServices, setSelectedServices] = useState<string[]>(initialServices)
const [selectedStatuses, setSelectedStatuses] = useState<string[]>(initialStatuses)
const [searchQuery, setSearchQuery] = useState<string>(initialSearch) const [searchQuery, setSearchQuery] = useState<string>(initialSearch)
const debouncedSearch = useDebouncedValue(searchQuery, 350)
const [currentPage, setCurrentPage] = useState<number>(initialPage) const [currentPage, setCurrentPage] = useState<number>(initialPage)
const activeRequestIdRef = useRef<number>(0)
const isFirstMountRef = useRef<boolean>(true)
// Modals state // Modals state
const [selectedFactorForDetail, setSelectedFactorForDetail] = useState<FactorItem | null>(null) const [selectedFactorForDetail, setSelectedFactorForDetail] = useState<FactorItem | null>(null)
const [selectedFactorForReject, setSelectedFactorForReject] = useState<FactorItem | null>(null) const [selectedFactorForReject, setSelectedFactorForReject] = useState<FactorItem | null>(null)
@ -79,10 +76,13 @@ export default function BillingPage() {
// Sync state to URL search params // Sync state to URL search params
const updateUrlParams = useCallback( const updateUrlParams = useCallback(
(service: string, status: string, search: string, page: number) => {
(services: string[], statuses: string[], search: string, page: number) => {
const params = new URLSearchParams() const params = new URLSearchParams()
if (service && service !== 'all') params.set('service', service)
if (status && status !== 'all') params.set('status', status)
const isAllServices = services.length === 0 || services.includes('all')
const isAllStatuses = statuses.length === 0 || statuses.includes('all')
if (!isAllServices) params.set('service', services.join(','))
if (!isAllStatuses) params.set('status', statuses.join(','))
if (search.trim()) params.set('search', search.trim()) if (search.trim()) params.set('search', search.trim())
if (page > 1) params.set('page', page.toString()) if (page > 1) params.set('page', page.toString())
setSearchParams(params, { replace: true }) setSearchParams(params, { replace: true })
@ -90,75 +90,120 @@ export default function BillingPage() {
[setSearchParams] [setSearchParams]
) )
// Sync debounced search to URL params and reset page to 1
useEffect(() => {
if (isFirstMountRef.current) {
isFirstMountRef.current = false
return
}
setCurrentPage(1)
updateUrlParams(selectedServices, selectedStatuses, debouncedSearch, 1)
}, [debouncedSearch, selectedServices, selectedStatuses, updateUrlParams])
// Sync URL changes back to state (e.g. browser back/forward or deep-link) // Sync URL changes back to state (e.g. browser back/forward or deep-link)
useEffect(() => { useEffect(() => {
const urlService = searchParams.get('service') || 'all'
const urlStatus = searchParams.get('status') || 'all'
const urlServices = parseUrlFilter(searchParams.get('service'))
const urlStatuses = parseUrlFilter(searchParams.get('status'))
const urlSearch = searchParams.get('search') || '' const urlSearch = searchParams.get('search') || ''
const urlPage = parseInt(searchParams.get('page') || '1', 10) || 1 const urlPage = parseInt(searchParams.get('page') || '1', 10) || 1
setSelectedService((prev) => (prev !== urlService ? urlService : prev))
setSelectedStatus((prev) => (prev !== urlStatus ? urlStatus : prev))
setSelectedServices((prev) => (prev.join(',') !== urlServices.join(',') ? urlServices : prev))
setSelectedStatuses((prev) => (prev.join(',') !== urlStatuses.join(',') ? urlStatuses : prev))
setSearchQuery((prev) => (prev !== urlSearch ? urlSearch : prev)) setSearchQuery((prev) => (prev !== urlSearch ? urlSearch : prev))
setCurrentPage((prev) => (prev !== urlPage ? urlPage : prev)) setCurrentPage((prev) => (prev !== urlPage ? urlPage : prev))
}, [searchParams]) }, [searchParams])
// Load factors from API
// Load factors from API with race condition protection
const loadFactors = useCallback(async () => { const loadFactors = useCallback(async () => {
const requestId = ++activeRequestIdRef.current
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)
try { try {
const offset = (currentPage - 1) * PAGE_SIZE const offset = (currentPage - 1) * PAGE_SIZE
const isAllServices = selectedServices.length === 0 || selectedServices.includes('all')
const isAllStatuses = selectedStatuses.length === 0 || selectedStatuses.includes('all')
const res = await fetchFactors({ const res = await fetchFactors({
status: selectedStatus !== 'all' ? selectedStatus : undefined,
service: selectedService !== 'all' ? selectedService : undefined,
search: searchQuery.trim() || undefined,
status: !isAllStatuses ? selectedStatuses.join(',') : undefined,
service: !isAllServices ? selectedServices.join(',') : undefined,
search: debouncedSearch.trim() || undefined,
limit: PAGE_SIZE, limit: PAGE_SIZE,
offset, offset,
}) })
if (requestId !== activeRequestIdRef.current) {
return
}
setFactors(res.results || []) setFactors(res.results || [])
setTotalCount(res.count || 0) setTotalCount(res.count || 0)
} catch (err: any) { } catch (err: any) {
if (requestId !== activeRequestIdRef.current) return
setError(err?.message || 'خطا در بارگذاری فهرست صورت‌حساب‌ها') setError(err?.message || 'خطا در بارگذاری فهرست صورت‌حساب‌ها')
} finally { } finally {
if (requestId === activeRequestIdRef.current) {
setIsLoading(false) setIsLoading(false)
} }
}, [selectedStatus, selectedService, searchQuery, currentPage])
}
}, [selectedStatuses, selectedServices, debouncedSearch, currentPage])
useEffect(() => { useEffect(() => {
loadFactors() loadFactors()
}, [loadFactors]) }, [loadFactors])
// Change Service Tab
const handleSelectServiceTab = (serviceId: string) => {
setSelectedService(serviceId)
// Change Service Filters (Multi-Select)
const handleSelectServices = (services: string[]) => {
setSelectedServices(services)
setCurrentPage(1) setCurrentPage(1)
updateUrlParams(serviceId, selectedStatus, searchQuery, 1)
updateUrlParams(services, selectedStatuses, debouncedSearch, 1)
} }
// Change Status Filter
const handleSelectStatus = (statusId: string) => {
setSelectedStatus(statusId)
// Change Status Filters (Multi-Select)
const handleSelectStatuses = (statuses: string[]) => {
setSelectedStatuses(statuses)
setCurrentPage(1) setCurrentPage(1)
updateUrlParams(selectedService, statusId, searchQuery, 1)
updateUrlParams(selectedServices, statuses, debouncedSearch, 1)
} }
// Change Search Query
// Change Search Query (instant input typing, debounced network execution)
const handleSearchChange = (query: string) => { const handleSearchChange = (query: string) => {
setSearchQuery(query) setSearchQuery(query)
setCurrentPage(1)
updateUrlParams(selectedService, selectedStatus, query, 1)
} }
// Reset Filters // Reset Filters
const handleResetFilters = () => { const handleResetFilters = () => {
setSelectedService('all')
setSelectedStatus('all')
setSelectedServices(['all'])
setSelectedStatuses(['all'])
setSearchQuery('') setSearchQuery('')
setCurrentPage(1) setCurrentPage(1)
updateUrlParams('all', 'all', '', 1)
updateUrlParams(['all'], ['all'], '', 1)
} }
// گزینه‌های فیلتر دراپ‌داون نوع سرویس
const serviceFilterOptions: MultiFilterOption[] = useMemo(
() => [
{ value: 'all', label: t('billing.allServices'), icon: 'receipt' },
{ value: 'tour', label: t('billing.serviceTour'), icon: 'map' },
{ value: 'custom_trip', label: t('billing.serviceCustomTrip'), icon: 'calculator' },
{ value: 'tasrif', label: t('billing.serviceExchange'), icon: 'exchange' },
{ value: 'shop', label: t('billing.serviceShop'), icon: 'store' },
{ value: 'simcard', label: t('billing.serviceSimcard'), icon: 'simcard' },
],
[t]
)
// گزینه‌های فیلتر دراپ‌داون وضعیت صورت‌حساب
const statusFilterOptions: MultiFilterOption[] = useMemo(
() => [
{ value: 'all', label: t('billing.allStatuses'), icon: 'list' },
{ value: 'pending', label: t('billing.statusPending'), icon: 'clock' },
{ value: 'awaiting_payment', label: t('billing.statusAwaiting'), icon: 'creditCard' },
{ value: 'approved', label: t('billing.statusApproved'), icon: 'checkCircle' },
{ value: 'rejected', label: t('billing.statusRejected'), icon: 'xCircle' },
{ value: 'expired', label: t('billing.statusExpired'), icon: 'alertCircle' },
{ value: 'cancelled', label: t('billing.statusCancelled'), icon: 'x' },
],
[t]
)
// KPI Statistics calculations // KPI Statistics calculations
const stats = useMemo<BillingStats>(() => { const stats = useMemo<BillingStats>(() => {
const total = totalCount const total = totalCount
@ -328,38 +373,6 @@ export default function BillingPage() {
</div> </div>
</div> </div>
{/* نوار تب‌های تفکیک سرویس‌ها (Service Category Tabs) با اولویت محور اصلی تورها */}
<div className="flex items-center gap-2 overflow-x-auto pb-2 sm:pb-0 scrollbar-none">
{SERVICE_TABS.map((tab) => {
const isActive = selectedService === tab.id
return (
<button
key={tab.id}
type="button"
onClick={() => handleSelectServiceTab(tab.id)}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap border shrink-0 ${
isActive
? 'bg-primary text-white border-primary shadow-xs'
: 'bg-card/60 text-grey-2 border-border-soft hover:bg-card hover:text-foreground'
}`}
>
<Ic name={tab.icon} className={`size-4 ${isActive ? 'text-white' : 'text-primary'}`} />
<span>{t(tab.titleKey)}</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/15 text-primary'
}`}
>
{t('billing.coreTourBadge')}
</span>
)}
</button>
)
})}
</div>
{/* کارت‌های KPI شاخص‌های کلیدی مالی */} {/* کارت‌های 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-5 gap-3.5">
@ -465,12 +478,12 @@ export default function BillingPage() {
</Card> </Card>
</div> </div>
{/* نوار جستجو و فیلترهای وضعیت لحظه‌ای */}
{/* نوار جستجو و فیلترهای دوگانه: ۱. نوع سرویس ۲. وضعیت صورت‌حساب */}
<Card className="border-border-soft bg-card/40 shadow-xs"> <Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex flex-col md:flex-row items-center justify-between gap-3">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-3">
{/* جستجوی متنی */} {/* جستجوی متنی */}
<div className="relative flex-1 w-full">
<div className="relative flex-1 min-w-[200px]">
<Ic <Ic
name="search" name="search"
className="absolute start-3 top-1/2 -translate-y-1/2 size-4 text-grey-3 pointer-events-none" className="absolute start-3 top-1/2 -translate-y-1/2 size-4 text-grey-3 pointer-events-none"
@ -491,48 +504,53 @@ export default function BillingPage() {
)} )}
</div> </div>
{/* چیپ‌های فیلتر سریع وضعیت */}
<div className="flex items-center gap-1.5 w-full md:w-auto overflow-x-auto pb-1 md:pb-0 scrollbar-none">
{[
{ id: 'all', label: t('billing.allStatuses') },
{ id: 'pending', label: t('billing.statusPending') },
{ id: 'awaiting_payment', label: t('billing.statusAwaiting') },
{ id: 'approved', label: t('billing.statusApproved') },
{ id: 'rejected', label: t('billing.statusRejected') },
{ id: 'expired', label: t('billing.statusExpired') },
{ id: 'cancelled', label: t('billing.statusCancelled') },
].map((st) => {
const isSelected = selectedStatus === st.id
return (
<button
key={st.id}
type="button"
onClick={() => handleSelectStatus(st.id)}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
isSelected
? 'bg-primary text-white shadow-xs'
: 'bg-surface-2 text-grey-3 hover:text-foreground hover:bg-surface-3'
}`}
>
{st.label}
</button>
)
})}
{/* فیلترهای دوگانه چندانتخابی مجزا: نوع سرویس و وضعیت صورت‌حساب */}
<div className="flex flex-wrap sm:flex-nowrap items-center gap-2.5 w-full lg:w-auto">
{/* فیلتر چندگزینه‌ای نوع سرویس */}
<div className="w-full sm:w-52">
<MultiFilterSelect
values={selectedServices}
onChange={handleSelectServices}
options={serviceFilterOptions}
allValue="all"
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"
/>
</div>
{/* فیلتر چندگزینه‌ای وضعیت صورت‌حساب */}
<div className="w-full sm:w-52">
<MultiFilterSelect
values={selectedStatuses}
onChange={handleSelectStatuses}
options={statusFilterOptions}
allValue="all"
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"
/>
</div> </div>
{/* دکمه بازنشانی فیلترها */} {/* دکمه بازنشانی فیلترها */}
{(searchQuery || selectedStatus !== 'all' || selectedService !== 'all') && (
{(searchQuery ||
(!selectedStatuses.includes('all') && selectedStatuses.length > 0) ||
(!selectedServices.includes('all') && selectedServices.length > 0)) && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleResetFilters} onClick={handleResetFilters}
className="h-9 text-xs text-grey-3 hover:text-foreground shrink-0 rounded-xl"
className="h-9 px-3 text-xs text-grey-3 hover:text-foreground shrink-0 rounded-xl hover:bg-surface-2"
title={t('billing.clearFilters')}
> >
<Ic name="x" className="size-3.5 me-1" /> <Ic name="x" className="size-3.5 me-1" />
<span>{t('billing.clearFilters')}</span> <span>{t('billing.clearFilters')}</span>
</Button> </Button>
)} )}
</div> </div>
</div>
</CardContent> </CardContent>
</Card> </Card>
@ -542,9 +560,11 @@ export default function BillingPage() {
<CardTitle className="text-xs font-bold flex items-center gap-2 text-foreground"> <CardTitle className="text-xs font-bold flex items-center gap-2 text-foreground">
<Ic name="list" className="size-4 text-primary" /> <Ic name="list" className="size-4 text-primary" />
<span>{t('billing.invoiceList')}</span> <span>{t('billing.invoiceList')}</span>
{selectedService !== 'all' && (
{selectedServices.length > 0 && !selectedServices.includes('all') && (
<Badge variant="outline" className="text-[10px] border-primary/30 text-primary"> <Badge variant="outline" className="text-[10px] border-primary/30 text-primary">
{t(`billing.tab${selectedService.charAt(0).toUpperCase() + selectedService.slice(1)}` as any) || selectedService}
{selectedServices.length === 1
? (serviceFilterOptions.find((opt) => opt.value === selectedServices[0])?.label || selectedServices[0])
: t('billing.selectedServicesCount', { count: selectedServices.length })}
</Badge> </Badge>
)} )}
</CardTitle> </CardTitle>
@ -568,16 +588,27 @@ export default function BillingPage() {
<Ic name="inbox" className="size-7" /> <Ic name="inbox" className="size-7" />
</div> </div>
<p className="text-sm font-bold text-foreground">{t('billing.noFactorsFound')}</p> <p className="text-sm font-bold text-foreground">{t('billing.noFactorsFound')}</p>
{(() => {
const isAnyFilterActive =
Boolean(searchQuery) ||
(!selectedStatuses.includes('all') && selectedStatuses.length > 0) ||
(!selectedServices.includes('all') && selectedServices.length > 0)
return (
<>
<p className="text-xs text-grey-3 max-w-sm mx-auto"> <p className="text-xs text-grey-3 max-w-sm mx-auto">
{searchQuery || selectedStatus !== 'all' || selectedService !== 'all'
{isAnyFilterActive
? t('billing.noFactorsFilter') ? t('billing.noFactorsFilter')
: t('billing.noFactorsEmpty')} : t('billing.noFactorsEmpty')}
</p> </p>
{(searchQuery || selectedStatus !== 'all' || selectedService !== 'all') && (
{isAnyFilterActive && (
<Button variant="outline" size="sm" onClick={handleResetFilters} className="text-xs rounded-xl"> <Button variant="outline" size="sm" onClick={handleResetFilters} className="text-xs rounded-xl">
{t('billing.clearFilters')} {t('billing.clearFilters')}
</Button> </Button>
)} )}
</>
)
})()}
</div> </div>
) : ( ) : (
<div className="overflow-x-auto relative"> <div className="overflow-x-auto relative">
@ -590,8 +621,9 @@ export default function BillingPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="hover:bg-transparent text-xs border-b border-border-soft bg-secondary/15 text-grey-2"> <TableRow className="hover:bg-transparent text-xs border-b border-border-soft bg-secondary/15 text-grey-2">
<TableHead className="w-16 text-center">{t('billing.id')}</TableHead>
<TableHead className="w-14 text-center">{t('billing.id')}</TableHead>
<TableHead>{t('billing.customer')}</TableHead> <TableHead>{t('billing.customer')}</TableHead>
<TableHead className="w-28 text-center">{t('billing.factorNumber')}</TableHead>
<TableHead>{t('billing.service')}</TableHead> <TableHead>{t('billing.service')}</TableHead>
<TableHead>{t('billing.amount')}</TableHead> <TableHead>{t('billing.amount')}</TableHead>
<TableHead>{t('billing.liveStatus')}</TableHead> <TableHead>{t('billing.liveStatus')}</TableHead>
@ -604,12 +636,12 @@ export default function BillingPage() {
{isLoading ? ( {isLoading ? (
Array.from({ length: 8 }).map((_, i) => ( Array.from({ length: 8 }).map((_, i) => (
<TableRow key={i} className="hover:bg-transparent"> <TableRow key={i} className="hover:bg-transparent">
{/* شناسه */}
{/* ۱. شناسه */}
<TableCell className="text-center"> <TableCell className="text-center">
<ShimmerBox width={36} height={16} rounded="md" className="mx-auto" />
<ShimmerBox width={28} height={14} rounded="md" className="mx-auto" />
</TableCell> </TableCell>
{/* خریدار */}
{/* ۲. خریدار */}
<TableCell> <TableCell>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<ShimmerCircle size={32} /> <ShimmerCircle size={32} />
@ -620,32 +652,37 @@ export default function BillingPage() {
</div> </div>
</TableCell> </TableCell>
{/* سرویس */}
{/* ۳. شماره فاکتور */}
<TableCell className="text-center">
<ShimmerBox width={56} height={18} rounded="md" className="mx-auto" />
</TableCell>
{/* ۴. سرویس */}
<TableCell> <TableCell>
<ShimmerBox width={80} height={24} rounded="full" /> <ShimmerBox width={80} height={24} rounded="full" />
</TableCell> </TableCell>
{/* مبلغ */}
{/* ۵. مبلغ */}
<TableCell> <TableCell>
<ShimmerLine width={64} height={16} rounded="sm" /> <ShimmerLine width={64} height={16} rounded="sm" />
</TableCell> </TableCell>
{/* وضعیت */}
{/* ۶. وضعیت */}
<TableCell> <TableCell>
<ShimmerBox width={96} height={24} rounded="full" /> <ShimmerBox width={96} height={24} rounded="full" />
</TableCell> </TableCell>
{/* تصویر فیش */}
{/* ۷. تصویر فیش */}
<TableCell className="text-center"> <TableCell className="text-center">
<ShimmerBox width={36} height={36} rounded="lg" className="mx-auto" /> <ShimmerBox width={36} height={36} rounded="lg" className="mx-auto" />
</TableCell> </TableCell>
{/* تاریخ */}
{/* ۸. تاریخ و ساعت */}
<TableCell> <TableCell>
<ShimmerLine width={80} height={14} rounded="sm" />
<ShimmerLine width={100} height={14} rounded="sm" />
</TableCell> </TableCell>
{/* عملیات */}
{/* ۹. عملیات */}
<TableCell className="text-end pl-4"> <TableCell className="text-end pl-4">
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<ShimmerBox width={105} height={32} rounded="xl" /> <ShimmerBox width={105} height={32} rounded="xl" />
@ -666,44 +703,63 @@ export default function BillingPage() {
isPending ? 'bg-blue-500/[0.03] hover:bg-blue-500/[0.07]' : '' isPending ? 'bg-blue-500/[0.03] hover:bg-blue-500/[0.07]' : ''
}`} }`}
> >
{/* شناسه */}
<TableCell className="text-center font-mono font-bold text-grey-3">
#{factor.id}
{/* ۱. شناسه دیتابیس (بدون هشتگ و فونت ریزتر) */}
<TableCell className="text-center font-mono font-medium text-[11px] text-grey-3/80">
{factor.id}
</TableCell> </TableCell>
{/* مشخصات مشتری و خریدار */}
{/* ۲. مشخصات مشتری و خریدار (نام و اطلاعات تماس بولدتر) */}
<TableCell> <TableCell>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Avatar className="size-8 shrink-0" />
<div className="min-w-0">
<p className="font-bold text-foreground truncate max-w-[140px] sm:max-w-[180px]">
<Avatar className="size-8.5 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]">
{user?.fullname || '—'} {user?.fullname || '—'}
</p> </p>
<p className="text-[11px] text-grey-3 truncate max-w-[140px] sm:max-w-[180px] font-mono dir-ltr text-start">
<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 || '—'} {user?.phone_number || user?.email || '—'}
</p> </p>
</div> </div>
</div> </div>
</TableCell> </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> <TableCell>
<BillingServiceBadge service={factor.service} /> <BillingServiceBadge service={factor.service} />
</TableCell> </TableCell>
{/* مبلغ */}
{/* ۵. مبلغ */}
<TableCell> <TableCell>
<span className="font-bold font-mono text-sm text-foreground"> <span className="font-bold font-mono text-sm text-foreground">
${Number(factor.amount).toLocaleString()} ${Number(factor.amount).toLocaleString()}
</span> </span>
</TableCell> </TableCell>
{/* وضعیت لحظه‌ای */}
{/* ۶. وضعیت لحظه‌ای */}
<TableCell> <TableCell>
<BillingStatusBadge status={factor.status} /> <BillingStatusBadge status={factor.status} />
</TableCell> </TableCell>
{/* پیش‌نمایش تصویر فیش */}
{/* ۷. پیش‌نمایش تصویر فیش */}
<TableCell className="text-center"> <TableCell className="text-center">
{hasReceipt ? ( {hasReceipt ? (
<button <button
@ -747,9 +803,9 @@ export default function BillingPage() {
)} )}
</TableCell> </TableCell>
{/* تاریخ صدور */}
<TableCell className="text-muted-foreground whitespace-nowrap text-[11.5px] font-medium">
{formatDateByLanguage(factor.created_at, language as any)}
{/* ۸. تاریخ و ساعت صدور (با فونت ریزتر و همراه با ساعت) */}
<TableCell className="text-muted-foreground whitespace-nowrap text-[11px] font-mono font-medium">
{formatDateTimeByLanguage(factor.created_at, language as any, { style: 'medium' })}
</TableCell> </TableCell>
{/* دکمه‌های عملیات */} {/* دکمه‌های عملیات */}
@ -791,7 +847,7 @@ export default function BillingPage() {
onClick={() => { onClick={() => {
const p = Math.max(1, currentPage - 1) const p = Math.max(1, currentPage - 1)
setCurrentPage(p) setCurrentPage(p)
updateUrlParams(selectedService, selectedStatus, searchQuery, p)
updateUrlParams(selectedServices, selectedStatuses, debouncedSearch, p)
}} }}
disabled={currentPage === 1 || isLoading} disabled={currentPage === 1 || isLoading}
> >
@ -807,7 +863,7 @@ export default function BillingPage() {
onClick={() => { onClick={() => {
const p = Math.min(totalPages, currentPage + 1) const p = Math.min(totalPages, currentPage + 1)
setCurrentPage(p) setCurrentPage(p)
updateUrlParams(selectedService, selectedStatus, searchQuery, p)
updateUrlParams(selectedServices, selectedStatuses, debouncedSearch, p)
}} }}
disabled={currentPage === totalPages || isLoading} disabled={currentPage === totalPages || isLoading}
> >
@ -826,7 +882,7 @@ export default function BillingPage() {
onClose={() => setSelectedFactorForDetail(null)} onClose={() => setSelectedFactorForDetail(null)}
onFactorUpdated={(updated) => { onFactorUpdated={(updated) => {
setFactors((prev) => prev.map((f) => (f.id === updated.id ? updated : f))) setFactors((prev) => prev.map((f) => (f.id === updated.id ? updated : f)))
setSelectedFactorForDetail(null)
setSelectedFactorForDetail((prev) => (prev && prev.id === updated.id ? updated : prev))
loadFactors() loadFactors()
useBillingBadgeStore.getState().fetchCounts() useBillingBadgeStore.getState().fetchCounts()
}} }}

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

@ -21,7 +21,7 @@ import {
import { Ic } from '@/icons' import { Ic } from '@/icons'
import type { FactorItem, FactorStatus } from '../types' import type { FactorItem, FactorStatus } from '../types'
import { BillingServiceBadge } from './BillingServiceBadge' import { BillingServiceBadge } from './BillingServiceBadge'
import { fetchFactorDetail, reviewFactor, cancelFactor } from '../services/billing-api'
import { fetchFactorDetail, reviewFactor, cancelFactor, uploadFactorTicket, deleteFactorTicket } from '../services/billing-api'
import { ShimmerLine, ShimmerBox } from '@/components/shared/shimmer_frontend' import { ShimmerLine, ShimmerBox } from '@/components/shared/shimmer_frontend'
import { toast } from '@/stores/toast' import { toast } from '@/stores/toast'
@ -104,6 +104,10 @@ export function BillingDetailModal({
const [hasChanges, setHasChanges] = useState(false) const [hasChanges, setHasChanges] = useState(false)
const [receiptError, setReceiptError] = useState(false) const [receiptError, setReceiptError] = useState(false)
const [isClearingReceipt, setIsClearingReceipt] = useState(false) const [isClearingReceipt, setIsClearingReceipt] = useState(false)
const [isUploadingTicket, setIsUploadingTicket] = useState(false)
const [isDeletingTicket, setIsDeletingTicket] = useState(false)
const [isDraggingTicket, setIsDraggingTicket] = useState(false)
const ticketFileInputRef = useRef<HTMLInputElement>(null)
const lastLoadedFactorIdRef = useRef<number | null>(null) const lastLoadedFactorIdRef = useRef<number | null>(null)
useEffect(() => { useEffect(() => {
@ -141,6 +145,13 @@ export function BillingDetailModal({
const activeFactor = currentFactor || factor const activeFactor = currentFactor || factor
if (!factor || !activeFactor) return null if (!factor || !activeFactor) return null
const detail = activeFactor.detail_service
const user = activeFactor.user_info
const isTourOrCustomTrip = activeFactor.service === 'tour' || activeFactor.service === 'custom_trip'
const isApproved = activeFactor.status === 'approved'
const isApprovedSelectedWithoutTicket =
isTourOrCustomTrip && selectedStatus === 'approved' && !activeFactor.ticket_file
const handleCopyCard = (cardNumber: string) => { const handleCopyCard = (cardNumber: string) => {
navigator.clipboard.writeText(cardNumber) navigator.clipboard.writeText(cardNumber)
setCopiedCard(true) setCopiedCard(true)
@ -153,6 +164,13 @@ export function BillingDetailModal({
toast.error('وارد کردن دلیل رد صورت‌حساب الزامی است.', 'دلیل رد الزامی است') toast.error('وارد کردن دلیل رد صورت‌حساب الزامی است.', 'دلیل رد الزامی است')
return return
} }
if (isApprovedSelectedWithoutTicket) {
toast.error(
t('billing.ticketRequiredForApprovedDesc'),
t('billing.ticketRequiredForApprovedTitle')
)
return
}
setIsSaving(true) setIsSaving(true)
try { try {
const payload = { const payload = {
@ -203,8 +221,58 @@ export function BillingDetailModal({
} }
} }
const detail = activeFactor.detail_service
const user = activeFactor.user_info
const handleUploadTicketFile = async (file: File) => {
if (!file || !activeFactor) return
setIsUploadingTicket(true)
try {
const updated = await uploadFactorTicket(activeFactor.id, file)
setCurrentFactor(updated)
if (updated.status !== 'approved') {
setSelectedStatus('approved')
setHasChanges(true)
} else {
setSelectedStatus(updated.status)
setHasChanges(false)
}
onFactorUpdated?.(updated)
toast.success(t('billing.ticketUploadSuccess'))
} catch (err: any) {
toast.error(err?.message || 'خطا در بارگذاری فایل بلیت.')
} finally {
setIsUploadingTicket(false)
if (ticketFileInputRef.current) {
ticketFileInputRef.current.value = ''
}
}
}
const handleTicketFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) handleUploadTicketFile(file)
}
const handleDeleteTicket = async () => {
if (!activeFactor) return
if (activeFactor.status === 'approved') {
toast.error(
t('billing.cannotDeleteTicketWhenApproved'),
t('billing.ticketRequiredTitle')
)
return
}
if (!window.confirm(t('billing.deleteTicketConfirm'))) return
setIsDeletingTicket(true)
try {
const updated = await deleteFactorTicket(activeFactor.id)
setCurrentFactor(updated)
onFactorUpdated?.(updated)
toast.success(t('billing.ticketDeleteSuccess'))
} catch (err: any) {
toast.error(err?.message || 'خطا در حذف فایل بلیت.')
} finally {
setIsDeletingTicket(false)
}
}
return ( return (
<Dialog open={open} onOpenChange={(val) => !val && onClose()}> <Dialog open={open} onOpenChange={(val) => !val && onClose()}>
@ -218,7 +286,12 @@ export function BillingDetailModal({
</div> </div>
<div> <div>
<DialogTitle className="text-lg font-bold flex items-center gap-2"> <DialogTitle className="text-lg font-bold flex items-center gap-2">
<span>صورت‌حساب شماره #{activeFactor.id}</span>
<span>صورت‌حساب شماره #{activeFactor.factor_number || activeFactor.id}</span>
{activeFactor.factor_number && (
<span className="text-xs font-mono font-normal text-muted-foreground">
(شناسه: #{activeFactor.id})
</span>
)}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5"> <DialogDescription className="text-xs text-muted-foreground mt-0.5">
ثبت شده در: {formatDateTimeByLanguage(activeFactor.created_at, language as any)} ثبت شده در: {formatDateTimeByLanguage(activeFactor.created_at, language as any)}
@ -300,6 +373,23 @@ export function BillingDetailModal({
</div> </div>
</div> </div>
{/* هشدار عدم امکان تایید صورت‌حساب بدون بارگذاری بلیت */}
{isApprovedSelectedWithoutTicket && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-2xl p-4 flex items-start gap-3.5 text-foreground animate-in fade-in slide-in-from-top-1 duration-150">
<div className="size-8 rounded-xl bg-amber-500/20 text-amber-700 dark:text-amber-400 flex items-center justify-center shrink-0 mt-0.5">
<Ic name="ticket" className="size-4.5" />
</div>
<div className="space-y-1 flex-1 text-xs">
<h4 className="font-bold text-amber-950 dark:text-amber-200">
{t('billing.ticketRequiredForApprovedTitle')}
</h4>
<p className="text-foreground/80 leading-relaxed font-medium">
{t('billing.ticketRequiredForApprovedDesc')}
</p>
</div>
</div>
)}
{/* کادر ویرایش/مشاهده دلیل رد صورت‌حساب در صورت انتخاب وضعیت رد شده */} {/* کادر ویرایش/مشاهده دلیل رد صورت‌حساب در صورت انتخاب وضعیت رد شده */}
{selectedStatus === 'rejected' && ( {selectedStatus === 'rejected' && (
<div className="bg-rose-500/10 border border-rose-500/25 rounded-2xl p-4 space-y-2.5 text-foreground animate-in fade-in slide-in-from-top-1 duration-150"> <div className="bg-rose-500/10 border border-rose-500/25 rounded-2xl p-4 space-y-2.5 text-foreground animate-in fade-in slide-in-from-top-1 duration-150">
@ -466,7 +556,21 @@ export function BillingDetailModal({
</div> </div>
)} )}
<div className="pt-2 border-t border-border/40 space-y-1 text-xs">
<div className="pt-2 border-t border-border/40 space-y-1.5 text-xs">
{activeFactor.factor_number && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{t('billing.factorNumber')}:</span>
<span className="font-mono font-bold text-primary bg-primary/10 px-2 py-0.5 rounded-md">
#{activeFactor.factor_number}
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">{t('billing.id')}:</span>
<span className="font-mono text-muted-foreground">
#{activeFactor.id}
</span>
</div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">شناسه سفارش مرجع:</span> <span className="text-muted-foreground">شناسه سفارش مرجع:</span>
<span className="font-mono font-medium"> <span className="font-mono font-medium">
@ -489,7 +593,7 @@ export function BillingDetailModal({
<CardHeader className="py-3 px-4 border-b border-border/40 bg-muted/20"> <CardHeader className="py-3 px-4 border-b border-border/40 bg-muted/20">
<CardTitle className="text-xs font-bold flex items-center gap-2"> <CardTitle className="text-xs font-bold flex items-center gap-2">
<Ic name="list" className="size-4 text-primary" /> <Ic name="list" className="size-4 text-primary" />
<span>ریز اقلام و مشخصات سرویس ({activeFactor.service.toUpperCase()})</span>
<span>ریز اقلام و مشخصات سرویس ({activeFactor.service?.toUpperCase() || ''})</span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-4 text-xs space-y-4"> <CardContent className="p-4 text-xs space-y-4">
@ -520,7 +624,9 @@ export function BillingDetailModal({
بزرگسال ({detail.passenger_data.adults?.count} نفر): بزرگسال ({detail.passenger_data.adults?.count} نفر):
</span> </span>
<span className="ms-2 text-muted-foreground"> <span className="ms-2 text-muted-foreground">
{detail.passenger_data.adults?.names.join('، ')}
{Array.isArray(detail.passenger_data.adults?.names)
? detail.passenger_data.adults?.names.join('، ')
: String(detail.passenger_data.adults?.names || '')}
</span> </span>
</div> </div>
)} )}
@ -532,7 +638,9 @@ export function BillingDetailModal({
کودک ({detail.passenger_data.children?.count} نفر): کودک ({detail.passenger_data.children?.count} نفر):
</span> </span>
<span className="ms-2 text-muted-foreground"> <span className="ms-2 text-muted-foreground">
{detail.passenger_data.children?.names.join('، ')}
{Array.isArray(detail.passenger_data.children?.names)
? detail.passenger_data.children?.names.join('، ')
: String(detail.passenger_data.children?.names || '')}
</span> </span>
</div> </div>
)} )}
@ -544,7 +652,9 @@ export function BillingDetailModal({
نوزاد ({detail.passenger_data.infants?.count} نفر): نوزاد ({detail.passenger_data.infants?.count} نفر):
</span> </span>
<span className="ms-2 text-muted-foreground"> <span className="ms-2 text-muted-foreground">
{detail.passenger_data.infants?.names.join('، ')}
{Array.isArray(detail.passenger_data.infants?.names)
? detail.passenger_data.infants?.names.join('، ')
: String(detail.passenger_data.infants?.names || '')}
</span> </span>
</div> </div>
)} )}
@ -668,6 +778,144 @@ export function BillingDetailModal({
</CardContent> </CardContent>
</Card> </Card>
{/* کارت مدیریت فایل بلیت (ویژه تور و سفرهای اختصاصی) */}
{isTourOrCustomTrip && (
<Card className="border-border/50 shadow-xs overflow-hidden">
<CardHeader className="py-3 px-4 border-b border-border/40 flex flex-row items-center justify-between">
<CardTitle className="text-xs font-bold flex items-center gap-2">
<div className="size-6 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Ic name="ticket" className="size-3.5" />
</div>
<span>{t('billing.ticketCardTitle')}</span>
</CardTitle>
{activeFactor.ticket_file ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<span className="size-1.5 rounded-full bg-emerald-500" />
{t('billing.ticketIssuedBadge')}
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
<span className="size-1.5 rounded-full bg-amber-500" />
{t('billing.ticketPendingBadge')}
</span>
)}
</CardHeader>
<CardContent className="p-4">
<input
ref={ticketFileInputRef}
type="file"
accept=".pdf,image/*,.doc,.docx"
className="hidden"
onChange={handleTicketFileChange}
/>
{activeFactor.ticket_file ? (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 p-3.5 rounded-xl border border-emerald-500/30 bg-emerald-500/5">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center shrink-0">
<Ic name="file" className="size-5" />
</div>
<div className="space-y-0.5">
<p className="text-xs font-bold text-foreground">
{t('billing.ticketIssuedTitle')}
</p>
<p className="text-[11px] text-muted-foreground font-mono truncate max-w-[280px] sm:max-w-md" dir="ltr">
{activeFactor.ticket_file.split('/').pop() || activeFactor.ticket_file}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto">
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs rounded-xl shadow-2xs border-border-soft hover:bg-muted/60"
onClick={() => window.open(resolveMediaUrl(activeFactor.ticket_file!), '_blank')}
>
<Ic name="download" className="size-3.5 text-primary" />
<span>{t('billing.viewTicketBtn')}</span>
</Button>
<Button
variant="outline"
size="sm"
disabled={isUploadingTicket}
className="h-8 gap-1.5 text-xs rounded-xl shadow-2xs border-border-soft hover:bg-muted/60"
onClick={() => ticketFileInputRef.current?.click()}
>
<Ic name="upload" className="size-3.5" />
<span>{isUploadingTicket ? t('billing.ticketUploading') : t('billing.replaceTicketBtn')}</span>
</Button>
<Button
variant="outline"
size="sm"
disabled={isDeletingTicket || activeFactor.status === 'approved'}
title={activeFactor.status === 'approved' ? t('billing.cannotDeleteTicketWhenApproved') : undefined}
className={cn(
"h-8 gap-1.5 text-xs rounded-xl shadow-2xs border-destructive/30 text-destructive hover:bg-destructive/10",
activeFactor.status === 'approved' && "opacity-50 cursor-not-allowed hover:bg-transparent"
)}
onClick={handleDeleteTicket}
>
<Ic name="trash" className="size-3.5" />
<span>{isDeletingTicket ? t('billing.ticketDeleting') : t('billing.deleteTicketBtn')}</span>
</Button>
</div>
</div>
) : (
<div
onDragOver={(e) => {
e.preventDefault()
setIsDraggingTicket(true)
}}
onDragLeave={() => setIsDraggingTicket(false)}
onDrop={(e) => {
e.preventDefault()
setIsDraggingTicket(false)
const file = e.dataTransfer.files?.[0]
if (file) handleUploadTicketFile(file)
}}
className={`flex flex-col items-center justify-center p-6 rounded-xl border border-dashed transition-all duration-200 text-center space-y-3 ${
isDraggingTicket
? 'border-primary bg-primary/10 ring-2 ring-primary/20'
: 'border-border/80 bg-muted/20 hover:border-primary/50 hover:bg-muted/30'
}`}
>
<div className="size-12 rounded-2xl bg-primary/10 text-primary flex items-center justify-center shadow-xs">
<Ic name="ticket" className="size-6" />
</div>
<div className="space-y-1 max-w-md">
<p className="text-xs font-bold text-foreground">
{t('billing.ticketUploadPrompt')}
</p>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t('billing.ticketDragDropPrompt')}
</p>
<p className="text-[10px] text-muted-foreground/70 font-medium">
{t('billing.ticketSupportedFormats')}
</p>
</div>
<Button
variant="default"
size="sm"
disabled={isUploadingTicket}
isLoading={isUploadingTicket}
loadingText={t('billing.ticketUploading')}
className="rounded-xl h-8 px-4 text-xs font-bold gap-1.5 bg-primary text-primary-foreground hover:brightness-105 shadow-xs"
onClick={() => ticketFileInputRef.current?.click()}
>
<Ic name="upload" className="size-3.5" />
<span>{t('billing.uploadTicketBtn')}</span>
</Button>
{!isApproved && (
<div className="flex items-center gap-2 p-2.5 rounded-xl border border-amber-500/20 bg-amber-500/10 text-[11px] text-amber-700 dark:text-amber-400 font-medium max-w-md text-start">
<Ic name="ticket" className="size-4 text-amber-500 shrink-0" />
<span>{t('billing.ticketAutoApproveNotice')}</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* کارت تصویر فیش پرداختی آپلود شده توسط کاربر */} {/* کارت تصویر فیش پرداختی آپلود شده توسط کاربر */}
<Card className="border-border/50 shadow-xs"> <Card className="border-border/50 shadow-xs">
<CardHeader className="py-3 px-4 border-b border-border/40"> <CardHeader className="py-3 px-4 border-b border-border/40">
@ -820,12 +1068,25 @@ export function BillingDetailModal({
<Button <Button
variant="default" variant="default"
size="sm" size="sm"
disabled={isSaving || isUpdating || (selectedStatus === 'rejected' && !rejectReason.trim())}
disabled={
isSaving ||
isUpdating ||
(selectedStatus === 'rejected' && !rejectReason.trim()) ||
isApprovedSelectedWithoutTicket
}
title={
isApprovedSelectedWithoutTicket
? t('billing.ticketRequiredForApprovedTitle')
: undefined
}
className={cn( className={cn(
'rounded-xl h-9 px-5 text-xs font-bold gap-1.5 shadow-xs transition-all', 'rounded-xl h-9 px-5 text-xs font-bold gap-1.5 shadow-xs transition-all',
hasChanges && !(selectedStatus === 'rejected' && !rejectReason.trim())
hasChanges &&
!(selectedStatus === 'rejected' && !rejectReason.trim()) &&
!isApprovedSelectedWithoutTicket
? 'bg-primary text-primary-foreground hover:brightness-110 ring-2 ring-primary/30' ? 'bg-primary text-primary-foreground hover:brightness-110 ring-2 ring-primary/30'
: 'bg-primary text-primary-foreground hover:brightness-105'
: 'bg-primary text-primary-foreground hover:brightness-105',
isApprovedSelectedWithoutTicket && 'opacity-60 cursor-not-allowed'
)} )}
isLoading={isSaving || isUpdating} isLoading={isSaving || isUpdating}
loadingText="در حال ذخیره..." loadingText="در حال ذخیره..."

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

@ -96,3 +96,29 @@ export async function cancelFactor(factorId: number): Promise<FactorItem> {
}) })
} }
/**
* بارگذاری فایل بلیت برای خریدهای تور و سفر اختصاصی تأییدشده
*/
export async function uploadFactorTicket(
factorId: number,
file: File
): Promise<FactorItem> {
const formData = new FormData()
formData.append('ticket_file', file)
return apiFetch<FactorItem>(`factors/${factorId}/ticket/`, {
method: 'POST',
body: formData,
})
}
/**
* حذف فایل بلیت ثبت‌شده برای فاکتور
*/
export async function deleteFactorTicket(
factorId: number
): Promise<FactorItem> {
return apiFetch<FactorItem>(`factors/${factorId}/ticket/`, {
method: 'DELETE',
})
}

2
src/features/billing/types.ts

@ -67,10 +67,12 @@ export interface UserInfo {
export interface FactorItem { export interface FactorItem {
id: number id: number
factor_number?: number
status: FactorStatus status: FactorStatus
service: FactorService service: FactorService
detail_service: DetailService | null detail_service: DetailService | null
transaction_receipt: string | null transaction_receipt: string | null
ticket_file?: string | null
description: string | null description: string | null
amount: string amount: string
card_number: string | null card_number: string | null

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

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

@ -0,0 +1,317 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Ic, IconName } from '@/icons'
import { Shimmer } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils'
import { useTranslation } from '@/i18n'
import type {
CustomTripOrderItem,
CustomTripOriginItem,
CustomTripDestinationItem,
CustomTripTransportRelationItem,
CustomTripHotelItem,
} from './types'
import {
fetchCustomTripOrders,
fetchCustomTripOrigins,
fetchCustomTripDestinations,
fetchCustomTripTransportRelations,
fetchCustomTripHotels,
} from './services/custom-trip-api'
import { CustomTripOrdersTab } from './components/CustomTripOrdersTab'
import { CustomTripOriginsTab } from './components/CustomTripOriginsTab'
import { CustomTripDestinationsTab } from './components/CustomTripDestinationsTab'
interface TabConfig {
id: string
labelKey: string
icon: IconName
}
const TABS: TabConfig[] = [
{ id: 'orders', labelKey: 'navigation.customTripOrders', icon: 'clipboardCheck' },
{ id: 'origins', labelKey: 'navigation.customTripOrigins', icon: 'mapPin' },
{ id: 'destinations', labelKey: 'navigation.customTripDestinations', icon: 'map' },
]
export default function CustomTripPage() {
const { t } = useTranslation()
const { tab: activeTabParam } = useParams<{ tab?: string }>()
const [searchParams] = useSearchParams()
const navigate = useNavigate()
// Redirect legacy tabs to destinations
useEffect(() => {
if (activeTabParam === 'transports' || activeTabParam === 'hotels') {
navigate('/custom-trip/destinations', { replace: true })
}
}, [activeTabParam, navigate])
const activeTab = activeTabParam && TABS.some((tab) => tab.id === activeTabParam) ? activeTabParam : 'orders'
const [isLoading, setIsLoading] = useState(true)
const [orders, setOrders] = useState<CustomTripOrderItem[]>([])
const [origins, setOrigins] = useState<CustomTripOriginItem[]>([])
const [destinations, setDestinations] = useState<CustomTripDestinationItem[]>([])
const [relations, setRelations] = useState<CustomTripTransportRelationItem[]>([])
const [hotels, setHotels] = useState<CustomTripHotelItem[]>([])
useEffect(() => {
let isMounted = true
async function loadData() {
setIsLoading(true)
try {
const [oData, origData, destData, relData, hotelData] = await Promise.all([
fetchCustomTripOrders(),
fetchCustomTripOrigins(),
fetchCustomTripDestinations(),
fetchCustomTripTransportRelations(),
fetchCustomTripHotels(),
])
if (isMounted) {
setOrders(oData)
setOrigins(origData)
setDestinations(destData)
setRelations(relData)
setHotels(hotelData)
}
} catch {
// Handled gracefully with fallback mock data
} finally {
if (isMounted) setIsLoading(false)
}
}
loadData()
return () => {
isMounted = false
}
}, [])
const handleTabChange = (tabId: string) => {
navigate(`/custom-trip/${tabId}`)
}
// Cross-navigation handlers to Single Page
const handleNavigateToTransports = (destCityName: string) => {
const target = destinations.find(
(d) => d.name === destCityName || destCityName.includes(d.name) || d.name.includes(destCityName)
)
if (target) {
navigate(`/custom-trip/destinations/${target.id}?tab=transports`)
} else {
navigate(`/custom-trip/destinations`)
}
}
const handleNavigateToHotels = (destCityName: string) => {
const target = destinations.find(
(d) => d.name === destCityName || destCityName.includes(d.name) || d.name.includes(destCityName)
)
if (target) {
navigate(`/custom-trip/destinations/${target.id}?tab=hotels`)
} else {
navigate(`/custom-trip/destinations`)
}
}
// Origins CRUD
const handleAddOrigin = (item: CustomTripOriginItem) => {
setOrigins((prev) => [item, ...prev])
}
const handleUpdateOrigin = (item: CustomTripOriginItem) => {
setOrigins((prev) => prev.map((o) => (o.id === item.id ? item : o)))
}
const handleDeleteOrigin = (id: string | number) => {
setOrigins((prev) => prev.filter((o) => o.id !== id))
}
// Destinations CRUD
const handleAddDestination = (item: CustomTripDestinationItem) => {
setDestinations((prev) => [item, ...prev])
}
const handleUpdateDestination = (item: CustomTripDestinationItem) => {
setDestinations((prev) => prev.map((d) => (d.id === item.id ? item : d)))
}
const handleDeleteDestination = (id: string | number) => {
setDestinations((prev) => prev.filter((d) => d.id !== id))
}
// Relations CRUD
const handleAddRelation = (item: CustomTripTransportRelationItem) => {
setRelations((prev) => [item, ...prev])
}
const handleUpdateRelation = (item: CustomTripTransportRelationItem) => {
setRelations((prev) => prev.map((r) => (r.id === item.id ? item : r)))
}
const handleDeleteRelation = (id: string | number) => {
setRelations((prev) => prev.filter((r) => r.id !== id))
}
// Hotels CRUD
const handleAddHotel = (item: CustomTripHotelItem) => {
setHotels((prev) => [item, ...prev])
// Update destination's hotel count
setDestinations((prev) =>
prev.map((d) =>
d.name === item.cityName ? { ...d, hotelsCount: d.hotelsCount + 1 } : d
)
)
}
const handleUpdateHotel = (item: CustomTripHotelItem) => {
setHotels((prev) => prev.map((h) => (h.id === item.id ? item : h)))
}
const handleDeleteHotel = (id: string | number) => {
const target = hotels.find((h) => h.id === id)
setHotels((prev) => prev.filter((h) => h.id !== id))
if (target) {
setDestinations((prev) =>
prev.map((d) =>
d.name === target.cityName ? { ...d, hotelsCount: Math.max(0, d.hotelsCount - 1) } : d
)
)
}
}
// Orders update
const handleOrderUpdated = (updated: CustomTripOrderItem) => {
setOrders((prev) => prev.map((o) => (o.id === updated.id ? updated : o)))
}
const pendingOrdersCount = orders.filter((o) => o.status === 'PENDING').length
return (
<div className="space-y-4 sm:space-y-6">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="p-2 rounded-xl bg-primary/10 text-primary">
<Ic name="sparkles" className="size-6" />
</span>
<h1 className="text-lg font-black text-foreground sm:text-xl md:text-2xl">
{t('customTrip.title')}
</h1>
</div>
<p className="text-xs text-muted-foreground sm:text-sm mt-1">
{t('customTrip.description')}
</p>
</div>
{/* Quick KPI stats */}
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-2 px-3.5 py-1.5 rounded-xl bg-card border border-border/70 text-xs shadow-2xs">
<span className="text-muted-foreground">شهرهای مبدأ:</span>
<span className="font-mono font-bold text-foreground">
{toFa(origins.length)}
</span>
</div>
<div className="flex items-center gap-2 px-3.5 py-1.5 rounded-xl bg-card border border-border/70 text-xs shadow-2xs">
<span className="text-muted-foreground">مقاصد زیارتی:</span>
<span className="font-mono font-bold text-foreground">
{toFa(destinations.length)}
</span>
</div>
<div className="flex items-center gap-2 px-3.5 py-1.5 rounded-xl bg-card border border-border/70 text-xs shadow-2xs">
<span className="text-muted-foreground">هتل‌های فعال:</span>
<span className="font-mono font-bold text-primary">
{toFa(hotels.length)}
</span>
</div>
<div className="flex items-center gap-2 px-3.5 py-1.5 rounded-xl bg-card border border-border/70 text-xs shadow-2xs">
<span className="text-muted-foreground">سفارش‌ها:</span>
<span className="font-mono font-bold text-foreground">
{toFa(orders.length)}
</span>
</div>
</div>
</div>
{/* Tabs Navigation Bar */}
<div className="flex items-center gap-1.5 p-1.5 rounded-2xl bg-muted/60 border border-border/70 overflow-x-auto">
{TABS.map((tab) => {
const isActive = activeTab === tab.id
let badgeCount: number | null = null
if (tab.id === 'origins') badgeCount = origins.length
if (tab.id === 'destinations') badgeCount = destinations.length
if (tab.id === 'transports') badgeCount = relations.length
if (tab.id === 'hotels') badgeCount = hotels.length
if (tab.id === 'orders') badgeCount = pendingOrdersCount
return (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs sm:text-sm font-semibold transition-all whitespace-nowrap cursor-pointer ${
isActive
? 'bg-card text-foreground shadow-xs border border-border/70'
: 'text-muted-foreground hover:text-foreground hover:bg-card/50'
}`}
>
<Ic
name={tab.icon}
className={`size-4 ${isActive ? 'text-primary' : 'text-muted-foreground'}`}
/>
<span>{t(tab.labelKey)}</span>
{badgeCount !== null && badgeCount > 0 && (
<span
className={`px-1.5 py-0.2 rounded-full text-[10px] font-mono font-bold ${
tab.id === 'orders' && pendingOrdersCount > 0
? 'bg-primary text-white animate-pulse'
: 'bg-muted text-muted-foreground'
}`}
>
{toFa(badgeCount)}
</span>
)}
</button>
)
})}
</div>
{/* Main Tab Content */}
{isLoading ? (
<div className="space-y-4">
<Shimmer className="h-12 w-full rounded-2xl" />
<Shimmer className="h-64 w-full rounded-2xl" />
</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}
onAddDestination={handleAddDestination}
onUpdateDestination={handleUpdateDestination}
onDeleteDestination={handleDeleteDestination}
onNavigateToTransports={handleNavigateToTransports}
onNavigateToHotels={handleNavigateToHotels}
/>
)}
</div>
)}
</div>
)
}

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

@ -0,0 +1,914 @@
import React, { useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import DeleteConfirmDialog from '@/components/shared/DeleteConfirmDialog'
import {
ServiceTranslation,
SERVICE_LANGUAGES,
type ServiceLanguageCode,
} from '@/components/shared/ServiceTranslation'
import { Ic } from '@/icons'
import { toFa } from '@/lib/utils'
import { useTranslation } from '@/i18n'
import type { CustomTripDestinationItem } from '../types'
interface CustomTripDestinationsTabProps {
destinations: CustomTripDestinationItem[]
onAddDestination: (dest: CustomTripDestinationItem) => void
onUpdateDestination?: (dest: CustomTripDestinationItem) => void
onDeleteDestination?: (id: string | number) => void
onNavigateToTransports?: (destCityName: string) => void
onNavigateToHotels?: (destCityName: string) => void
}
const PRESET_DEST_COUNTRIES = [
{ name: 'عراق', flag: '🇮🇶' },
{ name: 'عربستان سعودی', flag: '🇸🇦' },
{ name: 'ایران', flag: '🇮🇷' },
]
interface PresetDestinationCity {
id: string
country: string
names: Record<ServiceLanguageCode, string>
holyPlaces: Record<ServiceLanguageCode, string>
description: Record<ServiceLanguageCode, string>
}
const OFFICIAL_PRESET_CITIES: PresetDestinationCity[] = [
// ۱. مقاصد زیارتی عراق
{
id: 'karbala',
country: 'عراق',
names: {
ar: 'كربلاء المقدسة',
fa: 'کربلای معلی',
en: 'Karbala',
ru: 'Кербела',
id: 'Karbala',
},
holyPlaces: {
ar: 'العتبة الحسينية المقدسة، العتبة العباسية المقدسة، ما بين الحرمين الشريفين، التل الزينبي، المخيم الحسيني',
fa: 'حرم مطهر امام حسین (ع)، حرم مطهر حضرت ابوالفضل العباس (ع)، بین‌الحرمین شریف، تل زینبیه، خیمه‌گاه حسینی',
en: 'Imam Husayn Holy Shrine, Al-Abbas Holy Shrine, Bain al-Haramain, Al-Tal Al-Zaynabi, Al-Mukhayyam Al-Hussaini',
ru: 'Святыня Имама Хусейна (мир ему), Святыня Хазрата Абульфазла аль-Аббаса (мир ему), Бейн аль-Харамейн, Телль аз-Зейнабия',
id: 'Makam Suci Imam Hussain (AS), Makam Suci Hazrat Abbas (AS), Bainal Haramain, Al-Tall Al-Zainabi',
},
description: {
ar: 'كربلاء المقدسة قبلة عشاق أهل البيت (ع) وعاصمة النهضة الحسينية، تضم المرقدين المنورين للإمام الحسين (ع) وأخيه أبي الفضل العباس (ع).',
fa: 'کربلای معلی قبله‌گاه عاشقان اهل‌بیت (ع) و پایتخت معنوی نهضت حسینی است. زیارت بارگاه منور سیدالشهدا (ع) و قمر بنی‌هاشم (ع).',
en: 'Karbala is the holy sanctuary of lovers of Ahlulbayt (AS) and the capital of the Hussaini movement.',
ru: 'Кербела — священная обитель почитателей Ахль аль-Бейт (мир им) и духовный центр хусейновского движения.',
id: 'Karbala adalah tempat suci para pecinta Ahlulbait (AS) dan pusat gerakan Husaini.',
},
},
{
id: 'najaf',
country: 'عراق',
names: {
ar: 'النجف الأشرف',
fa: 'نجف اشرف',
en: 'Najaf',
ru: 'Эн-Наджаф',
id: 'Najaf',
},
holyPlaces: {
ar: 'العتبة العلوية المقدسة، مقبرة وادي السلام، مسجد الكوفة المعظم، مسجد السهلة',
fa: 'حرم مطهر حضرت امیرالمؤمنین علی (ع)، قبرستان وادی‌السلام، مسجد جامع کوفه، مسجد سهله',
en: 'Imam Ali Holy Shrine, Wadi-us-Salaam Cemetery, Great Mosque of Kufa, Al-Sahla Mosque',
ru: 'Святыня Имама Али (мир ему), Кладбище Вади ас-Салам, Мечеть Куфы, Мечеть ас-Сахла',
id: 'Makam Suci Imam Ali (AS), Pemakaman Wadi-us-Salam, Masjid Kufah, Masjid As-Sahla',
},
description: {
ar: 'النجف الأشرف، مهد العلم والحكمة ومرقد أمير المؤمنين علي بن أبي طالب (ع).',
fa: 'نجف اشرف، مهد علم و حکمت و مدفن مطهر مولای متقیان امیرالمؤمنین علی (ع) است.',
en: 'Najaf, the city of wisdom and the resting place of Commander of the Faithful Imam Ali (AS).',
ru: 'Эн-Наджаф — колыбель мудрости и место упокоения Имама Али (мир ему).',
id: 'Najaf adalah kota hikmah dan tempat suci peristirahatan Imam Ali (AS).',
},
},
{
id: 'kadhimiya_samarra',
country: 'عراق',
names: {
ar: 'الكاظمية وسامراء',
fa: 'سامرا و کاظمین',
en: 'Samarra & Kadhimiya',
ru: 'Казимия и Самарра',
id: 'Kadhimiya & Samarra',
},
holyPlaces: {
ar: 'العتبة الكاظمية المقدسة، العتبة العسكرية المقدسة في سامراء، سرداب الغيبة الشريف',
fa: 'حرم مطهر امامین جوادین (ع) در کاظمین، حرم مطهر امامین عسکریین (ع) در سامرا، سرداب مقدس غیبت',
en: 'Al-Kadhimiya Holy Shrine, Al-Askari Holy Shrine in Samarra, Holy Cellar of Occultation',
ru: 'Святыня Казимии (мир им), Святыня Аскари в Самарре, Священный Сердаб',
id: 'Makam Suci Al-Kadhimiya, Makam Suci Al-Askari di Samarra, Sardab Suci',
},
description: {
ar: 'تضم الكاظمية مرقدي الإمامين الكاظم والجواد (ع)، وسامراء تضم مرقدي الإمامين الهادي والعسكري (ع) وسرداب الغيبة.',
fa: 'کاظمین میزبان مضجع شریف امام موسی کاظم (ع) و امام جواد (ع) و سامرا دربردارنده مرقد مطهر امام هادی (ع) و امام حسن عسکری (ع) است.',
en: 'Kadhimiya embraces the holy shrines of Imam Kadhim and Imam Jawad (AS), while Samarra holds the shrines of Imam Hadi and Imam Askari (AS).',
ru: 'Казимия хранит святыни Имама Казима и Имама Джавада (мир им), а Самарра — Имама Хади и Имама Аскари (мир им).',
id: 'Kadhimiya merangkul makam suci Imam Kadhim dan Imam Jawad (AS), sementara Samarra makam Imam Hadi dan Imam Askari (AS).',
},
},
// ۲. مقاصد زیارتی عربستان سعودی
{
id: 'makkah',
country: 'عربستان سعودی',
names: {
ar: 'مكة المكرمة',
fa: 'مکه مکرمه',
en: 'Makkah',
ru: 'Мекка',
id: 'Makkah',
},
holyPlaces: {
ar: 'المسجد الحرام، الكعبة المشرفة، مقام إبراهيم (ع)، الصفا والمروة، جبل النور وغار حراء، مشاعر منى وعرفات',
fa: 'مسجد الحرام، کعبه مشرفه، مقام ابراهیم (ع)، صفا و مروه، جبل‌النور و غار حرا، منا و عرفات',
en: 'Masjid al-Haram, Holy Kaaba, Maqam Ibrahim, Safa and Marwah, Mount Noor and Hira Cave, Mina and Arafat',
ru: 'Масджид аль-Харам, Священная Кааба, Макам Ибрахима, Сафа и Марва, Пещера Хира, Мина и Арафат',
id: "Masjidil Haram, Ka'bah Suci, Maqam Ibrahim, Shafa dan Marwah, Gua Hira, Mina dan Arafah",
},
description: {
ar: 'مكة المكرمة، أقدس بقاع الأرض، مهبط الوحي وقبلة المسلمين في شتى بقاع العالم.',
fa: 'مکه مکرمه، مقدس‌ترین نقطه کره زمین، زادگاه نبی اکرم (ص) و قبله‌گاه تمامی مسلمانان جهان است.',
en: 'Makkah, the holiest city in Islam, birthplace of Prophet Muhammad (PBUH) and direction of prayer for all Muslims.',
ru: 'Мекка — священнейший город ислама, место ниспослания откровения и кибла всех мусульман.',
id: 'Makkah adalah kota paling suci dalam Islam, tempat turunnya wahyu dan kiblat umat Islam sedunia.',
},
},
{
id: 'madinah',
country: 'عربستان سعودی',
names: {
ar: 'المدينة المنورة',
fa: 'مدینه منوره',
en: 'Madinah',
ru: 'Медина',
id: 'Madinah',
},
holyPlaces: {
ar: 'المسجد النبوي الشريف، الروضة الشريفة، مقبرة البقيع الغرقد، مسجد قباء، جبل أحد ومقبرة الشهداء',
fa: 'مسجد النبی (ص)، روضه شریفه نبوی، قبرستان بقیع و مزار ائمه بقیع (ع)، مسجد قبا، کوه احد',
en: "Prophet's Mosque (Al-Masjid an-Nabawi), Rawdah ash-Sharifah, Baqi Cemetery, Quba Mosque, Mount Uhud",
ru: 'Мечеть Пророка (мир ему), Эр-Рауда, Кладбище аль-Баки, Мечеть Куба, Гора Ухуд',
id: 'Masjid Nabawi, Raudhah Syarifah, Pemakaman Baqi, Masjid Quba, Gunung Uhud',
},
description: {
ar: 'المدينة المنورة، طيبة الطيبة، مرقد النبي الأعظم (ص) ومثوى أئمة البقيع الأطهار (ع).',
fa: 'مدینه منوره یا یثرب النبی، شهر آرامش و نور، آرامگاه ابدی رسول گرامی اسلام (ص) و مدفن مطهر ائمه بقیع (ع) است.',
en: 'Madinah, the luminous city of Prophet Muhammad (PBUH) and the blessed resting place of Baqi Imams (AS).',
ru: 'Медина — лучезарный город Пророка (мир ему) и благословенное место упокоения имамов Баки (мир им).',
id: 'Madinah adalah kota bercahaya Nabi Muhammad (SAW) dan tempat peristirahatan para Imam Baqi (AS).',
},
},
// ۳. مقاصد زیارتی ایران
{
id: 'mashhad',
country: 'ایران',
names: {
ar: 'مشهد المقدسة',
fa: 'مشهد مقدس',
en: 'Mashhad',
ru: 'Мешхед',
id: 'Mashhad',
},
holyPlaces: {
ar: 'العتبة الرضوية المقدسة، صحن الثورة، جامع جوهرشاد، متاحف الروضة الرضوية',
fa: 'حرم مطهر امام رضا (ع)، صحن انقلاب، صحن آزادی، مسجد گوهرشاد، موزه آستان قدس رضوی',
en: 'Imam Reza Holy Shrine, Inqilab Courtyard, Goharshad Mosque, Astan Quds Museums',
ru: 'Святыня Имама Резы (мир ему), Двор Инкилаб, Мечеть Гохаршад, Музеи Астан Кудс',
id: 'Makam Suci Imam Ridha (AS), Halaman Inqilab, Masjid Goharshad, Museum Astan Quds',
},
description: {
ar: 'مشهد المقدسة، العاصمة الروحية ومرقد الإمام علي بن موسى الرضا (ع).',
fa: 'مشهد الرضا پایتخت معنوی ایران و بارگاه ملکوتی حضرت علی بن موسی الرضا (ع) است.',
en: 'Mashhad, spiritual capital of Iran and the holy shrine of Imam Ali ibn Musa al-Reza (AS).',
ru: 'Мешхед — духовная столица Ирана и священная обитель Имама Али ибн Мусы ар-Риды (мир ему).',
id: 'Mashhad adalah ibu kota spiritual Iran dan makam suci Imam Ali bin Musa ar-Ridha (AS).',
},
},
{
id: 'qom',
country: 'ایران',
names: {
ar: 'قم المقدسة',
fa: 'قم مقدسه',
en: 'Qom',
ru: 'Кум',
id: 'Qom',
},
holyPlaces: {
ar: 'حرم السيدة فاطمة المعصومة (ع)، مسجد جمكران المعظم، متاحف العتبة المعصومية، جبل الخضر',
fa: 'حرم مطهر حضرت فاطمه معصومه (س)، مسجد مقدس جمکران، موزه آستانه مقدسه، کوه خضر نبی',
en: 'Holy Shrine of Lady Fatima Masuma (SA), Jamkaran Mosque, Shrine Museum, Mount Khidr',
ru: 'Святыня Хазрата Фатимы Масумы (мир ей), Мечеть Джамкаран, Музей святыни',
id: "Makam Suci Sayyidah Fatimah Ma'shumah (SA), Masjid Jamkaran, Museum Suci",
},
description: {
ar: 'قم المقدسة، مرقد كريمة أهل البيت السيدة فاطمة المعصومة (ع) ومنطلق الحوزات العلمية ومسجد جمكران.',
fa: 'قم مقدسه، حرم مطهر کریمه اهل‌بیت حضرت فاطمه معصومه (س) و پایگاه حوزه‌های علمیه و مسجد جمکران است.',
en: 'Qom, sanctuary of Lady Fatima Masuma (SA) and center of Islamic scholarship and Jamkaran Mosque.',
ru: 'Кум — священная обитель Хазрата Фатимы Масумы (мир ей) и центр духовного просвещения.',
id: "Qom adalah tempat suci Sayyidah Fatimah Ma'shumah (SA) dan pusat keilmuan Islam.",
},
},
]
export function CustomTripDestinationsTab({
destinations,
onAddDestination,
onUpdateDestination,
onDeleteDestination,
onNavigateToTransports,
onNavigateToHotels,
}: CustomTripDestinationsTabProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const [search, setSearch] = useState('')
const [selectedCountry, setSelectedCountry] = useState<string>('all')
// Modals state
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingItem, setEditingItem] = useState<CustomTripDestinationItem | null>(null)
const [itemToDelete, setItemToDelete] = useState<CustomTripDestinationItem | null>(null)
const [validationError, setValidationError] = useState<string | null>(null)
// Multi-language form state
const [activeLangTab, setActiveLangTab] = useState<ServiceLanguageCode>('ar')
const [names, setNames] = useState<Record<ServiceLanguageCode, string>>({
ar: '',
fa: '',
en: '',
ru: '',
id: '',
})
const [holyPlacesMap, setHolyPlacesMap] = useState<Record<ServiceLanguageCode, string>>({
ar: '',
fa: '',
en: '',
ru: '',
id: '',
})
const [descriptionsMap, setDescriptionsMap] = useState<Record<ServiceLanguageCode, string>>({
ar: '',
fa: '',
en: '',
ru: '',
id: '',
})
const [formCountry, setFormCountry] = useState('عراق')
const [formFlag, setFormFlag] = useState('🇮🇶')
const [selectedCityPresetId, setSelectedCityPresetId] = useState<string>('')
const [formIsActive, setFormIsActive] = useState(true)
const currentLang =
SERVICE_LANGUAGES.find((l) => l.code === activeLangTab) || SERVICE_LANGUAGES[0]
const availableCitiesForCountry = useMemo(() => {
return OFFICIAL_PRESET_CITIES.filter((c) => c.country === formCountry)
}, [formCountry])
const countries = useMemo(() => {
return Array.from(new Set(destinations.map((d) => d.country)))
}, [destinations])
const filtered = useMemo(() => {
return destinations.filter((d) => {
const matchCountry = selectedCountry === 'all' || d.country === selectedCountry
const term = search.trim().toLowerCase()
const matchSearch =
!term ||
d.name.toLowerCase().includes(term) ||
(d.nameEn && d.nameEn.toLowerCase().includes(term)) ||
d.country.toLowerCase().includes(term) ||
(d.description && d.description.toLowerCase().includes(term))
return matchCountry && matchSearch
})
}, [destinations, selectedCountry, search])
const handleOpenAdd = () => {
setEditingItem(null)
setActiveLangTab('ar')
setNames({ ar: '', fa: '', en: '', ru: '', id: '' })
setHolyPlacesMap({ ar: '', fa: '', en: '', ru: '', id: '' })
setDescriptionsMap({ ar: '', fa: '', en: '', ru: '', id: '' })
setFormCountry('عراق')
setFormFlag('🇮🇶')
setSelectedCityPresetId('')
setFormIsActive(true)
setValidationError(null)
setIsFormOpen(true)
}
const handleOpenEdit = (item: CustomTripDestinationItem) => {
setEditingItem(item)
setActiveLangTab('ar')
const initialNames: Record<ServiceLanguageCode, string> = { ar: '', fa: '', en: '', ru: '', id: '' }
const initialHoly: Record<ServiceLanguageCode, string> = { ar: '', fa: '', en: '', ru: '', id: '' }
const initialDesc: Record<ServiceLanguageCode, string> = { ar: '', fa: '', en: '', ru: '', id: '' }
if (item.translations) {
for (const lang of ['ar', 'fa', 'en', 'ru', 'id'] as ServiceLanguageCode[]) {
if (item.translations[lang]?.name) initialNames[lang] = item.translations[lang].name!
if (item.translations[lang]?.description) initialDesc[lang] = item.translations[lang].description!
if (item.translations[lang]?.holyPlaces) initialHoly[lang] = item.translations[lang].holyPlaces!.join('، ')
}
}
// Fallbacks from existing legacy fields
if (!initialNames.fa) initialNames.fa = item.name || ''
if (!initialNames.ar) initialNames.ar = item.name || ''
if (!initialNames.en && item.nameEn) initialNames.en = item.nameEn
if (!initialDesc.fa && item.description) initialDesc.fa = item.description
if (!initialDesc.ar && item.description) initialDesc.ar = item.description
if (!initialHoly.fa && item.holyPlaces?.length) initialHoly.fa = item.holyPlaces.join('، ')
if (!initialHoly.ar && item.holyPlaces?.length) initialHoly.ar = item.holyPlaces.join('، ')
setNames(initialNames)
setHolyPlacesMap(initialHoly)
setDescriptionsMap(initialDesc)
setFormCountry(item.country)
setFormFlag(item.flag || '📍')
// Find if item corresponds to an official preset city
const matched = OFFICIAL_PRESET_CITIES.find(
(c) =>
c.id === item.id ||
c.names.fa === item.name ||
c.names.ar === item.name ||
(item.nameEn && c.names.en.toLowerCase() === item.nameEn.toLowerCase())
)
setSelectedCityPresetId(matched ? matched.id : 'custom')
setFormIsActive(item.isActive)
setValidationError(null)
setIsFormOpen(true)
}
const handleCountryChange = (cName: string) => {
setFormCountry(cName)
const preset = PRESET_DEST_COUNTRIES.find((p) => p.name === cName)
if (preset) {
setFormFlag(preset.flag)
}
setSelectedCityPresetId('')
}
const handlePresetCityChange = (cityId: string) => {
setSelectedCityPresetId(cityId)
if (!cityId || cityId === 'custom') return
const presetCity = OFFICIAL_PRESET_CITIES.find((c) => c.id === cityId)
if (presetCity) {
setNames({ ...presetCity.names })
setHolyPlacesMap({ ...presetCity.holyPlaces })
setDescriptionsMap({ ...presetCity.description })
setValidationError(null)
}
}
const handleSaveForm = (e?: React.FormEvent) => {
if (e) e.preventDefault()
const primaryName =
names.ar.trim() ||
names.fa.trim() ||
names[activeLangTab].trim() ||
names.en.trim() ||
names.ru.trim() ||
names.id.trim()
if (!primaryName) {
setValidationError('لطفاً نام شهر مقصد را وارد نمایید.')
return
}
const translationsRecord: Record<string, { name?: string; description?: string; holyPlaces?: string[] }> = {}
for (const lang of ['ar', 'fa', 'en', 'ru', 'id'] as ServiceLanguageCode[]) {
const n = names[lang]?.trim()
const d = descriptionsMap[lang]?.trim()
const hStr = holyPlacesMap[lang]?.trim()
const hArr = hStr ? hStr.split(/[،,]/).map((s) => s.trim()).filter(Boolean) : undefined
if (n || d || (hArr && hArr.length)) {
translationsRecord[lang] = {
name: n || undefined,
description: d || undefined,
holyPlaces: hArr,
}
}
}
const holyPlacesArray = (holyPlacesMap.fa || holyPlacesMap.ar || holyPlacesMap[activeLangTab] || '')
.split(/[،,]/)
.map((s) => s.trim())
.filter(Boolean)
if (editingItem && onUpdateDestination) {
const updated: CustomTripDestinationItem = {
...editingItem,
name: names.fa.trim() || names.ar.trim() || primaryName,
nameEn: names.en.trim() || undefined,
country: formCountry.trim(),
flag: formFlag,
description: descriptionsMap.fa.trim() || descriptionsMap.ar.trim() || undefined,
holyPlaces: holyPlacesArray.length ? holyPlacesArray : undefined,
isActive: formIsActive,
translations: translationsRecord,
}
onUpdateDestination(updated)
} else {
const newDest: CustomTripDestinationItem = {
id: `dest_${Date.now()}`,
name: names.fa.trim() || names.ar.trim() || primaryName,
nameEn: names.en.trim() || undefined,
country: formCountry.trim() || 'عراق',
flag: formFlag,
description: descriptionsMap.fa.trim() || descriptionsMap.ar.trim() || undefined,
holyPlaces: holyPlacesArray.length ? holyPlacesArray : undefined,
hotelsCount: 0,
isActive: formIsActive,
translations: translationsRecord,
}
onAddDestination(newDest)
}
setIsFormOpen(false)
}
const handleConfirmDelete = () => {
if (itemToDelete && onDeleteDestination) {
onDeleteDestination(itemToDelete.id)
}
setItemToDelete(null)
}
return (
<div className="space-y-4">
{/* 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">
<span className="text-[11px] text-muted-foreground font-medium block">کل مقاصد زیارتی</span>
<div className="mt-1 flex items-baseline gap-1.5">
<span className="text-xl font-black font-mono text-foreground">{toFa(destinations.length)}</span>
<span className="text-xs text-muted-foreground">شهر مقصد</span>
</div>
</div>
<div className="p-3.5 rounded-2xl bg-card border border-border/70 shadow-xs">
<span className="text-[11px] text-muted-foreground font-medium block">کشورهای مقصد</span>
<div className="mt-1 flex items-baseline gap-1.5">
<span className="text-xl font-black font-mono text-primary">{toFa(countries.length)}</span>
<span className="text-xs text-muted-foreground">کشور</span>
</div>
</div>
<div className="p-3.5 rounded-2xl bg-card border border-border/70 shadow-xs">
<span className="text-[11px] text-muted-foreground font-medium block">عتبات مقدسه (عراق)</span>
<div className="mt-1 flex items-baseline gap-1.5">
<span className="text-xl font-black font-mono text-emerald-600 dark:text-emerald-400">
{toFa(destinations.filter((d) => d.country === 'عراق').length)}
</span>
<span className="text-xs text-muted-foreground">شهر</span>
</div>
</div>
<div className="p-3.5 rounded-2xl bg-card border border-border/70 shadow-xs">
<span className="text-[11px] text-muted-foreground font-medium block">حرمین شریفین (سعودی)</span>
<div className="mt-1 flex items-baseline gap-1.5">
<span className="text-xl font-black font-mono text-amber-600 dark:text-amber-400">
{toFa(destinations.filter((d) => d.country === 'عربستان سعودی').length)}
</span>
<span className="text-xs text-muted-foreground">شهر</span>
</div>
</div>
</div>
{/* Filter and Action Bar */}
<div className="flex flex-col gap-3 bg-card p-3 rounded-2xl border 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" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="جستجوی شهر مقصد زیارتی، اماکن مقدسه یا کشور..."
className="ps-9 h-10 rounded-xl bg-background border-border/60 text-xs sm:text-sm"
/>
</div>
<Button
onClick={handleOpenAdd}
className="rounded-xl h-10 gap-1.5 font-bold text-xs sm:text-sm shrink-0"
>
<Ic name="plus" className="size-4" />
{t('customTrip.addDestination')}
</Button>
</div>
{/* Country filter chips */}
<div className="flex items-center gap-1.5 overflow-x-auto pt-1 pb-0.5">
<span className="text-xs text-muted-foreground font-medium shrink-0 ps-1">
{t('customTrip.filterByCountry')}:
</span>
<button
onClick={() => setSelectedCountry('all')}
className={`px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors ${
selectedCountry === 'all'
? 'bg-primary text-white shadow-xs'
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{t('customTrip.allDestinations')} ({toFa(destinations.length)})
</button>
{countries.map((c) => {
const count = destinations.filter((d) => d.country === c).length
const preset = PRESET_DEST_COUNTRIES.find((p) => p.name === c)
return (
<button
key={c}
onClick={() => setSelectedCountry(c)}
className={`flex items-center gap-1 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors ${
selectedCountry === c
? 'bg-primary text-white shadow-xs'
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<span>{preset?.flag || '📍'}</span>
<span>{c}</span>
<span className="opacity-75 font-mono text-[10px]">({toFa(count)})</span>
</button>
)
})}
</div>
</div>
{/* Destinations Table */}
<div className="overflow-hidden rounded-2xl border border-border/70 bg-card 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">ردیف</th>
<th className="px-4 py-3.5 text-start">{t('customTrip.destinationCityName')}</th>
<th className="px-4 py-3.5 text-start">{t('customTrip.country')}</th>
<th className="px-4 py-3.5 text-start">اماکن مقدسه و توضیحات</th>
<th className="px-4 py-3.5 text-center">هتل‌های مقصد</th>
<th className="px-4 py-3.5 text-center">مسیرهای متصل</th>
<th className="px-4 py-3.5 text-center">وضعیت</th>
<th className="px-4 py-3.5 text-center">{t('customTrip.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{filtered.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-xs text-muted-foreground">
مقصدی با این مشخصات یافت نشد.
</td>
</tr>
) : (
filtered.map((item, idx) => (
<tr
key={item.id}
onClick={() => navigate(`/custom-trip/destinations/${item.id}`)}
className="hover:bg-muted/40 transition-colors cursor-pointer group"
title="برای مشاهده جزئیات و مدیریت این مقصد کلیک کنید"
>
<td className="px-4 py-3.5 font-mono text-muted-foreground">{toFa(idx + 1)}</td>
<td className="px-4 py-3.5 font-bold text-foreground">
<div className="flex items-center gap-2.5">
<span className="text-xl select-none">{item.flag || '📍'}</span>
<div>
<div className="group-hover:text-primary transition-colors flex items-center gap-1.5 font-bold text-sm">
<span>{item.name}</span>
<Ic name="arrowLeft" className="size-3 opacity-0 group-hover:opacity-100 transition-opacity text-primary" />
</div>
{item.nameEn && (
<span className="text-[11px] font-normal text-muted-foreground font-mono">
{item.nameEn}
</span>
)}
</div>
</div>
</td>
<td className="px-4 py-3.5 font-medium text-foreground">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-muted/60 text-xs font-semibold">
<span>{item.flag || '📍'}</span>
<span>{item.country}</span>
</span>
</td>
<td className="px-4 py-3.5 text-start max-w-xs">
<div className="text-xs text-muted-foreground line-clamp-2">
{item.description || '—'}
</div>
{item.holyPlaces && item.holyPlaces.length > 0 && (
<div className="flex items-center gap-1 flex-wrap mt-1">
{item.holyPlaces.slice(0, 3).map((place, pIdx) => (
<span
key={pIdx}
className="text-[10px] px-1.5 py-0.5 rounded-md bg-primary/10 text-primary font-medium"
>
{place}
</span>
))}
{item.holyPlaces.length > 3 && (
<span className="text-[10px] text-muted-foreground font-mono">
+{toFa(item.holyPlaces.length - 3)}
</span>
)}
</div>
)}
</td>
<td className="px-4 py-3.5 text-center">
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-xl bg-card border border-border/70 text-xs font-semibold text-foreground">
<Ic name="bed" className="size-3.5 text-primary" />
<span className="font-mono">{toFa(item.hotelsCount)}</span>
<span className="text-[11px] text-muted-foreground">هتل</span>
</span>
</td>
<td className="px-4 py-3.5 text-center">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl bg-primary/10 border border-primary/20 text-xs font-semibold text-primary">
<Ic name="route" className="size-3.5" />
<span>روش‌های حمل‌ونقل</span>
</span>
</td>
<td className="px-4 py-3.5 text-center">
<span
className={`inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full ${
item.isActive
? 'bg-emerald-500/10 text-emerald-600'
: 'bg-muted text-muted-foreground'
}`}
>
<span
className={`size-1.5 rounded-full ${
item.isActive ? 'bg-emerald-500 animate-pulse' : 'bg-muted-foreground'
}`}
/>
{item.isActive ? t('customTrip.active') : t('customTrip.inactive')}
</span>
</td>
<td className="px-4 py-3.5 text-center">
<div className="flex items-center justify-center">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
navigate(`/custom-trip/destinations/${item.id}`)
}}
className="h-8 px-3 rounded-xl text-xs gap-1.5 font-bold text-primary border-primary/30 group-hover:bg-primary group-hover:text-white transition-all shadow-2xs"
>
<Ic name="eye" className="size-3.5" />
<span>مشاهده</span>
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Add / Edit Destination Modal */}
<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">
<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="map" className="size-5" />
</div>
<div>
<DialogTitle className="text-lg font-bold text-foreground">
{editingItem ? t('customTrip.editDestination') : t('customTrip.addDestination')}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
شهر مقصد زیارتی جدیدی را برای انتخاب زائرین تعریف یا مشخصات آن را ویرایش کنید.
</DialogDescription>
</div>
</div>
</DialogHeader>
{/* فرم: بدنه اسکرول‌شونده و فوتر فیکس‌شده */}
<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">
{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" />
<span>{validationError}</span>
</div>
)}
{/* تب‌های ۵ زبانه با کامپوننت مشترک ServiceTranslation */}
<div className="space-y-4 rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="border-b border-border-soft pb-2.5">
<ServiceTranslation
activeLang={activeLangTab}
onLangChange={(lang) => setActiveLangTab(lang)}
completedLangs={{
ar: Boolean(names.ar?.trim()),
fa: Boolean(names.fa?.trim()),
en: Boolean(names.en?.trim()),
ru: Boolean(names.ru?.trim()),
id: Boolean(names.id?.trim()),
}}
primaryLang="ar"
title="اطلاعات متنی و چندزبانه مقصد"
required
variant="pills"
/>
</div>
{/* فیلدهای انتخابی کشور و شهر بر اساس کشور انتخابی (پایین‌تر از تب‌های زبان) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 p-3.5 rounded-xl bg-surface-base/80 border border-border-soft">
{/* فیلد انتخابی کشور مقصد */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground flex items-center justify-between">
<span className="flex items-center gap-1.5">
<span>{t('customTrip.country')}</span>
<span className="text-rose-500">*</span>
</span>
<span className="text-[11px] text-muted-foreground font-normal">
({formFlag} {formCountry})
</span>
</label>
<div className="relative">
<select
value={formCountry}
onChange={(e) => handleCountryChange(e.target.value)}
className="w-full h-11 px-3 pe-8 rounded-xl border border-border-soft bg-card text-xs font-medium text-foreground focus:border-primary focus:outline-none cursor-pointer appearance-none"
>
{PRESET_DEST_COUNTRIES.map((c) => (
<option key={c.name} value={c.name}>
{c.flag} {c.name}
</option>
))}
</select>
<div className="pointer-events-none absolute inset-y-0 end-0 flex items-center px-2.5 text-muted-foreground">
<Ic name="chevDown" className="size-4" />
</div>
</div>
</div>
{/* فیلد انتخابی شهر مقصد بر اساس کشور انتخابی */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground flex items-center justify-between">
<span>شهر مقصد (وابسته به {formCountry})</span>
<span className="text-[11px] text-muted-foreground font-normal">
{availableCitiesForCountry.length > 0 ? '(مقاصد رسمی فلاتر و دیتابیس)' : ''}
</span>
</label>
<div className="relative">
<select
value={selectedCityPresetId}
onChange={(e) => handlePresetCityChange(e.target.value)}
className="w-full h-11 px-3 pe-8 rounded-xl border border-border-soft bg-card text-xs font-medium text-foreground focus:border-primary focus:outline-none cursor-pointer appearance-none"
>
<option value="">-- انتخاب شهر از مقاصد رسمی {formCountry} --</option>
{availableCitiesForCountry.map((city) => (
<option key={city.id} value={city.id}>
{city.names.fa} ({city.names.ar} - {city.names.en})
</option>
))}
<option value="custom">-- شهر سفارشی دیگر... --</option>
</select>
<div className="pointer-events-none absolute inset-y-0 end-0 flex items-center px-2.5 text-muted-foreground">
<Ic name="chevDown" className="size-4" />
</div>
</div>
</div>
</div>
{/* فیلدهای ترجمه‌پذیر متناسب با زبان فعال */}
<div className="space-y-4 pt-1" dir={currentLang.dir}>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<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">
{currentLang.badge}
</span>
<span className="text-rose-500">*</span>
</label>
<span className="text-[11px] text-muted-foreground font-mono">
{currentLang.dir.toUpperCase()}
</span>
</div>
<Input
value={names[activeLangTab] || ''}
onChange={(e) =>
setNames((prev) => ({ ...prev, [activeLangTab]: e.target.value }))
}
dir={currentLang.dir}
placeholder={`مثال: ${
currentLang.code === 'ar'
? 'كربلاء المقدسة'
: currentLang.code === 'fa'
? 'کربلای معلی'
: 'Karbala'
}`}
className="h-11 text-xs bg-surface-base rounded-xl border-border-soft"
required={activeLangTab === 'fa' || activeLangTab === 'ar'}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground flex items-center gap-1.5">
<span>اماکن مقدسه و زیارتگاه‌ها ({currentLang.name})</span>
<span className="text-[11px] text-muted-foreground font-normal">(با ویرگول جدا کنید)</span>
</label>
<Input
value={holyPlacesMap[activeLangTab] || ''}
onChange={(e) =>
setHolyPlacesMap((prev) => ({ ...prev, [activeLangTab]: e.target.value }))
}
dir={currentLang.dir}
placeholder={
currentLang.code === 'ar'
? 'حرم الإمام الحسين (ع)، حرم أبي الفضل العباس (ع)، التل الزينبي'
: 'حرم امام حسین (ع)، حرم حضرت عباس (ع)، تل زینبیه'
}
className="h-11 text-xs bg-surface-base rounded-xl border-border-soft"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">
توضیحات و راهنمای مقصد ({currentLang.name})
</label>
<textarea
value={descriptionsMap[activeLangTab] || ''}
onChange={(e) =>
setDescriptionsMap((prev) => ({ ...prev, [activeLangTab]: e.target.value }))
}
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"
/>
</div>
</div>
</div>
{/* ۳. وضعیت فعال بودن در اپلیکیشن */}
<label className="flex items-center gap-2.5 p-3 rounded-xl border border-border-soft bg-surface-base/40 cursor-pointer hover:bg-surface-base/70 transition">
<input
type="checkbox"
checked={formIsActive}
onChange={(e) => setFormIsActive(e.target.checked)}
className="rounded text-primary focus:ring-primary size-4"
/>
<div className="text-xs">
<span className="font-bold text-foreground block">
مقصد فعال است و در لیست مقاصد سفرهای اختصاصی اپلیکیشن نمایش داده شود
</span>
<span className="text-[11px] text-muted-foreground">
در صورت غیرفعال بودن، زائرین امکان انتخاب این شهر را در چت سفر اختصاصی نخواهند داشت.
</span>
</div>
</label>
</div>
{/* فوتر فیکس‌شده در پایین مدال */}
<DialogFooter className="p-4 px-6 border-t border-border-soft bg-surface-base/30 flex items-center justify-between sm:justify-between shrink-0">
<Button
variant="outline"
type="button"
onClick={() => setIsFormOpen(false)}
className="rounded-xl text-xs h-10 px-5"
>
{t('customTrip.cancel')}
</Button>
<Button
type="submit"
className="rounded-xl text-xs font-bold h-10 px-6"
>
{t('customTrip.saveChanges')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Modal using shared DeleteConfirmDialog */}
<DeleteConfirmDialog
open={!!itemToDelete}
onOpenChange={(open) => !open && setItemToDelete(null)}
title={t('customTrip.deleteDestination')}
description={`آیا از حذف مقصد «${itemToDelete?.name || ''}» (${itemToDelete?.country || ''}) اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
onConfirm={handleConfirmDelete}
/>
</div>
)
}

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

@ -0,0 +1,833 @@
import React, { useState, useMemo } from 'react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import DeleteConfirmDialog from '@/components/shared/DeleteConfirmDialog'
import {
ServiceTranslation,
SERVICE_LANGUAGES,
type ServiceLanguageCode,
} from '@/components/shared/ServiceTranslation'
import { Ic } from '@/icons'
import { toFa } from '@/lib/utils'
import { useTranslation } from '@/i18n'
import type { CustomTripHotelItem, CustomTripDestinationItem } from '../types'
interface CustomTripHotelsTabProps {
hotels: CustomTripHotelItem[]
destinations?: CustomTripDestinationItem[]
initialDestinationFilter?: string
onAddHotel: (hotel: CustomTripHotelItem) => void
onUpdateHotel?: (hotel: CustomTripHotelItem) => void
onDeleteHotel?: (id: string | number) => void
}
const COMMON_AMENITIES = [
{ id: 'wifi', name: 'اینترنت وای‌فای رایگان' },
{ id: 'restaurant', name: 'رستوران ایرانی و بوفه' },
{ id: 'shrine_transfer', name: 'ترانسفر حرم رایگان' },
{ id: 'airport_transfer', name: 'ترانسفر فرودگاهی' },
{ id: 'laundry', name: 'لاندری و خشکشویی' },
{ id: 'wheelchair', name: 'خدمات ویژه ویلچر و آسانسور' },
{ id: 'doctor', name: 'پزشک و خدمات درمانی مقیم' },
{ id: 'safety_box', name: 'صندوق امانات در اتاق' },
]
export function CustomTripHotelsTab({
hotels,
destinations = [],
initialDestinationFilter = 'all',
onAddHotel,
onUpdateHotel,
onDeleteHotel,
}: CustomTripHotelsTabProps) {
const { t } = useTranslation()
const [search, setSearch] = useState('')
const [selectedDestination, setSelectedDestination] = useState(initialDestinationFilter)
const [starsFilter, setStarsFilter] = useState<'all' | '5' | '4' | '3'>('all')
const [viewMode, setViewMode] = useState<'cards' | 'table'>('cards')
// Modals state
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingItem, setEditingItem] = useState<CustomTripHotelItem | null>(null)
const [itemToDelete, setItemToDelete] = useState<CustomTripHotelItem | null>(null)
const [validationError, setValidationError] = useState<string | null>(null)
// Multi-language form state
const [activeLangTab, setActiveLangTab] = useState<ServiceLanguageCode>('ar')
const [names, setNames] = useState<Record<ServiceLanguageCode, string>>({
ar: '',
fa: '',
en: '',
ru: '',
id: '',
})
const [descriptions, setDescriptions] = useState<Record<ServiceLanguageCode, string>>({
ar: '',
fa: '',
en: '',
ru: '',
id: '',
})
const [formCityName, setFormCityName] = useState('')
const [formStars, setFormStars] = useState<number>(5)
const [formDistanceToShrine, setFormDistanceToShrine] = useState('')
const [formAmenities, setFormAmenities] = useState<string[]>([])
const [formPriceAdult, setFormPriceAdult] = useState<number>(100)
const [formPriceChild, setFormPriceChild] = useState<number>(50)
const [formIsActive, setFormIsActive] = useState(true)
const currentLang =
SERVICE_LANGUAGES.find((l) => l.code === activeLangTab) || SERVICE_LANGUAGES[0]
// Unique city names
const cities = useMemo(() => {
const list = Array.from(new Set(hotels.map((h) => h.cityName)))
destinations.forEach((d) => {
if (!list.includes(d.name)) list.push(d.name)
})
return list
}, [hotels, destinations])
const filtered = useMemo(() => {
return hotels.filter((h) => {
const matchCity =
selectedDestination === 'all' ||
h.cityName.toLowerCase().includes(selectedDestination.toLowerCase()) ||
selectedDestination.toLowerCase().includes(h.cityName.toLowerCase())
const matchStars =
starsFilter === 'all' || String(h.stars) === starsFilter
const term = search.trim().toLowerCase()
const matchSearch =
!term ||
h.name.toLowerCase().includes(term) ||
h.cityName.toLowerCase().includes(term) ||
(h.distanceToShrine && h.distanceToShrine.toLowerCase().includes(term)) ||
(h.description && h.description.toLowerCase().includes(term)) ||
h.amenities.some((a) => a.toLowerCase().includes(term))
return matchCity && matchStars && matchSearch
})
}, [hotels, selectedDestination, starsFilter, search])
const handleOpenAdd = () => {
setEditingItem(null)
setActiveLangTab('ar')
setNames({ ar: '', fa: '', en: '', ru: '', id: '' })
setDescriptions({ ar: '', fa: '', en: '', ru: '', id: '' })
setFormCityName(cities[0] || 'کربلای معلی')
setFormStars(5)
setFormDistanceToShrine('۴۰۰ متر تا حرم مطهر (۵ دقیقه پیاده)')
setFormAmenities(['اینترنت وای‌فای رایگان', 'رستوران ایرانی و بوفه'])
setFormPriceAdult(110)
setFormPriceChild(55)
setFormIsActive(true)
setValidationError(null)
setIsFormOpen(true)
}
const handleOpenEdit = (item: CustomTripHotelItem) => {
setEditingItem(item)
setActiveLangTab('ar')
const initialNames: Record<ServiceLanguageCode, string> = { ar: '', fa: '', en: '', ru: '', id: '' }
const initialDesc: Record<ServiceLanguageCode, string> = { ar: '', fa: '', en: '', ru: '', id: '' }
if (item.translations) {
for (const lang of ['ar', 'fa', 'en', 'ru', 'id'] as ServiceLanguageCode[]) {
if (item.translations[lang]?.name) initialNames[lang] = item.translations[lang].name!
if (item.translations[lang]?.description) initialDesc[lang] = item.translations[lang].description!
}
}
if (!initialNames.fa) initialNames.fa = item.name
if (!initialNames.ar) initialNames.ar = item.name
if (!initialDesc.fa) initialDesc.fa = item.description || ''
if (!initialDesc.ar) initialDesc.ar = item.description || ''
setNames(initialNames)
setDescriptions(initialDesc)
setFormCityName(item.cityName)
setFormStars(item.stars || 5)
setFormDistanceToShrine(item.distanceToShrine || '')
setFormAmenities(item.amenities || [])
setFormPriceAdult(item.pricePerDayAdult || 0)
setFormPriceChild(item.pricePerDayChild || 0)
setFormIsActive(item.isActive !== false)
setValidationError(null)
setIsFormOpen(true)
}
const handleToggleAmenity = (amenityName: string) => {
setFormAmenities((prev) =>
prev.includes(amenityName) ? prev.filter((a) => a !== amenityName) : [...prev, amenityName]
)
}
const handleSaveForm = (e?: React.FormEvent) => {
if (e) e.preventDefault()
const primaryName =
names.ar.trim() ||
names.fa.trim() ||
names[activeLangTab].trim() ||
names.en.trim() ||
names.ru.trim() ||
names.id.trim()
if (!primaryName || !formCityName.trim()) {
setValidationError('لطفاً عنوان هتل و شهر مقصد را مشخص کنید.')
return
}
const translationsRecord: Record<string, { name?: string; description?: string }> = {}
for (const lang of ['ar', 'fa', 'en', 'ru', 'id'] as ServiceLanguageCode[]) {
const n = names[lang]?.trim()
const d = descriptions[lang]?.trim()
if (n || d) {
translationsRecord[lang] = {
name: n || undefined,
description: d || undefined,
}
}
}
if (editingItem && onUpdateHotel) {
const updated: CustomTripHotelItem = {
...editingItem,
name: names.fa.trim() || names.ar.trim() || primaryName,
cityName: formCityName.trim(),
stars: Number(formStars),
distanceToShrine: formDistanceToShrine.trim(),
description: descriptions.fa.trim() || descriptions.ar.trim() || '',
amenities: formAmenities,
pricePerDayAdult: Number(formPriceAdult),
pricePerDayChild: Number(formPriceChild),
isActive: formIsActive,
translations: translationsRecord,
}
onUpdateHotel(updated)
} else {
const newHotel: CustomTripHotelItem = {
id: `hotel_${Date.now()}`,
name: names.fa.trim() || names.ar.trim() || primaryName,
cityName: formCityName.trim(),
stars: Number(formStars),
score: 4.8,
distanceToShrine: formDistanceToShrine.trim(),
description: descriptions.fa.trim() || descriptions.ar.trim() || '',
amenities: formAmenities,
pricePerDayAdult: Number(formPriceAdult),
pricePerDayChild: Number(formPriceChild),
isActive: formIsActive,
translations: translationsRecord,
}
onAddHotel(newHotel)
}
setIsFormOpen(false)
}
const handleConfirmDelete = () => {
if (itemToDelete && onDeleteHotel) {
onDeleteHotel(itemToDelete.id)
}
setItemToDelete(null)
}
const renderStars = (count: number) => {
return (
<div className="flex items-center gap-0.5" title={`${count} ستاره`}>
{Array.from({ length: 5 }).map((_, i) => (
<span
key={i}
className={`text-xs ${i < count ? 'text-amber-500' : 'text-muted-foreground/30'}`}
>
★
</span>
))}
</div>
)
}
return (
<div className="space-y-4">
{/* 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">
<span className="text-[11px] text-muted-foreground font-medium block">کل هتل‌های ثبت‌شده</span>
<div className="mt-1 flex items-baseline gap-1.5">
<span className="text-xl font-black font-mono text-foreground">{toFa(hotels.length)}</span>
<span className="text-xs text-muted-foreground">هتل</span>
</div>
</div>
<div className="p-3.5 rounded-2xl bg-card border border-border/70 shadow-xs">
<span className="text-[11px] text-muted-foreground font-medium block">هتل‌های ۵ ستاره VIP</span>
<div className="mt-1 flex items-baseline gap-1.5">
<span className="text-xl font-black font-mono text-amber-500">
{toFa(hotels.filter((h) => h.stars === 5).length)}
</span>
<span className="text-xs text-muted-foreground">هتل ۵ ستاره</span>
</div>
</div>
<div className="p-3.5 rounded-2xl bg-card border border-border/70 shadow-xs">
<span className="text-[11px] text-muted-foreground font-medium block">هتل‌های ۴ ستاره</span>
<div className="mt-1 flex items-baseline gap-1.5">
<span className="text-xl font-black font-mono text-primary">
{toFa(hotels.filter((h) => h.stars === 4).length)}
</span>
<span className="text-xs text-muted-foreground">هتل ۴ ستاره</span>
</div>
</div>
<div className="p-3.5 rounded-2xl bg-card border border-border/70 shadow-xs">
<span className="text-[11px] text-muted-foreground font-medium block">شهرهای دارای اقامتگاه</span>
<div className="mt-1 flex items-baseline gap-1.5">
<span className="text-xl font-black font-mono text-emerald-600 dark:text-emerald-400">
{toFa(cities.length)}
</span>
<span className="text-xs text-muted-foreground">مقصد زیارتی</span>
</div>
</div>
</div>
{/* Destination Filter Tabs & Controls */}
<div className="flex flex-col gap-3 bg-card p-3 rounded-2xl border border-border/70 shadow-xs">
<div className="flex items-center gap-1.5 overflow-x-auto pb-1">
<span className="text-xs text-muted-foreground font-medium shrink-0 ps-1">
{t('customTrip.selectDestination')}:
</span>
<button
onClick={() => setSelectedDestination('all')}
className={`px-3.5 py-1.5 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors ${
selectedDestination === 'all'
? 'bg-primary text-white shadow-xs'
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{t('customTrip.allDestinations')} ({toFa(hotels.length)})
</button>
{cities.map((cName) => {
const count = hotels.filter((h) => h.cityName.includes(cName) || cName.includes(h.cityName)).length
const destObj = destinations.find((d) => d.name === cName)
return (
<button
key={cName}
onClick={() => setSelectedDestination(cName)}
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors ${
selectedDestination === cName
? 'bg-primary text-white shadow-xs'
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<span>{destObj?.flag || '📍'}</span>
<span>{cName}</span>
<span className="opacity-75 font-mono text-[10px]">({toFa(count)})</span>
</button>
)
})}
</div>
{/* Search, Stars Filter & Add Button */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2.5 pt-2 border-t border-border/50">
<div className="relative flex-1">
<Ic name="search" className="absolute top-1/2 -translate-y-1/2 start-3 size-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="جستجوی نام هتل، شهر، نزدیکی به حرم یا امکانات..."
className="ps-9 h-9 rounded-xl bg-background border-border/60 text-xs"
/>
</div>
<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">
<button
onClick={() => setStarsFilter('all')}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
starsFilter === 'all' ? 'bg-card text-foreground shadow-2xs font-bold' : 'text-muted-foreground'
}`}
>
همه ستاره‌ها
</button>
<button
onClick={() => setStarsFilter('5')}
className={`px-2 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 ${
starsFilter === '5' ? 'bg-card text-foreground shadow-2xs font-bold' : 'text-muted-foreground'
}`}
>
<span className="text-amber-500">★</span>
<span>۵ ستاره</span>
</button>
<button
onClick={() => setStarsFilter('4')}
className={`px-2 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 ${
starsFilter === '4' ? 'bg-card text-foreground shadow-2xs font-bold' : 'text-muted-foreground'
}`}
>
<span className="text-amber-500">★</span>
<span>۴ ستاره</span>
</button>
</div>
{/* View Mode Switcher */}
<div className="flex items-center bg-muted/60 p-1 rounded-xl border border-border/50">
<button
onClick={() => setViewMode('cards')}
className={`p-1.5 rounded-lg transition-colors ${
viewMode === 'cards' ? 'bg-card text-foreground shadow-2xs' : 'text-muted-foreground'
}`}
title={t('customTrip.cardView')}
>
<Ic name="grid" className="size-4" />
</button>
<button
onClick={() => setViewMode('table')}
className={`p-1.5 rounded-lg transition-colors ${
viewMode === 'table' ? 'bg-card text-foreground shadow-2xs' : 'text-muted-foreground'
}`}
title={t('customTrip.tableView')}
>
<Ic name="list" className="size-4" />
</button>
</div>
<Button
onClick={handleOpenAdd}
className="rounded-xl h-9 px-3.5 gap-1.5 font-bold text-xs shrink-0"
>
<Ic name="plus" className="size-4" />
{t('customTrip.addHotel')}
</Button>
</div>
</div>
</div>
{/* Hotels Content */}
{filtered.length === 0 ? (
<div className="p-12 text-center rounded-2xl border border-border/70 bg-card shadow-xs">
<Ic name="bed" className="size-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-xs text-muted-foreground font-medium">
هتلی با این مشخصات یافت نشد.
</p>
</div>
) : viewMode === 'cards' ? (
/* Cards Grid */
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filtered.map((hotel) => (
<div
key={hotel.id}
className="p-4 rounded-2xl border border-border/70 bg-card hover:border-primary/40 transition-all shadow-xs flex flex-col justify-between gap-3.5"
>
<div>
{/* Header */}
<div className="flex items-start justify-between gap-2 border-b border-border/50 pb-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-bold text-foreground">{hotel.name}</h4>
{renderStars(hotel.stars || 5)}
</div>
<div className="flex items-center gap-2 text-xs">
<span className="inline-flex items-center gap-1 font-semibold text-primary">
<Ic name="mapPin" className="size-3.5" />
<span>{hotel.cityName}</span>
</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEdit(hotel)}
className="size-7 p-0 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground"
>
<Ic name="pencil" className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setItemToDelete(hotel)}
className="size-7 p-0 rounded-lg hover:bg-rose-500/10 text-muted-foreground hover:text-rose-600"
>
<Ic name="trash" className="size-3.5" />
</Button>
</div>
</div>
{/* Distance to Shrine */}
<div className="mt-3 p-2.5 rounded-xl bg-muted/30 border border-border/50 flex items-center gap-2 text-xs">
<span className="text-base select-none">🕌</span>
<div>
<span className="text-muted-foreground font-medium">نزدیکی به حرم: </span>
<span className="font-semibold text-foreground">
{hotel.distanceToShrine || 'فاصله مناسب تا حرم مطهر'}
</span>
</div>
</div>
{/* Description */}
{hotel.description && (
<p className="mt-2 text-xs text-muted-foreground leading-relaxed line-clamp-2">
{hotel.description}
</p>
)}
{/* Amenities Chips */}
{hotel.amenities && hotel.amenities.length > 0 && (
<div className="mt-3">
<span className="text-[11px] text-muted-foreground font-medium block mb-1.5">
{t('customTrip.amenities')}:
</span>
<div className="flex items-center gap-1.5 flex-wrap">
{hotel.amenities.map((am, i) => (
<span
key={i}
className="text-[10px] px-2 py-0.5 rounded-lg bg-muted text-foreground border border-border/60 font-medium"
>
{am}
</span>
))}
</div>
</div>
)}
</div>
{/* Footer with Price */}
<div className="pt-3 border-t border-border/50 flex items-center justify-between text-xs">
<div>
<span className="text-muted-foreground block text-[11px]">نرخ روزانه بزرگسال:</span>
<span className="font-mono font-bold text-foreground text-sm">
${toFa(hotel.pricePerDayAdult || 0)}
</span>
</div>
<div className="text-end">
<span className="text-muted-foreground block text-[11px]">نرخ کودک:</span>
<span className="font-mono font-bold text-muted-foreground text-sm">
${toFa(hotel.pricePerDayChild || 0)}
</span>
</div>
</div>
</div>
))}
</div>
) : (
/* Table View */
<div className="overflow-hidden rounded-2xl border border-border/70 bg-card 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">ردیف</th>
<th className="px-4 py-3.5 text-start">{t('customTrip.hotelName')}</th>
<th className="px-4 py-3.5 text-start">شهر مقصد</th>
<th className="px-4 py-3.5 text-center">ستاره</th>
<th className="px-4 py-3.5 text-start">فاصله تا حرم</th>
<th className="px-4 py-3.5 text-center">قیمت هر شب (بزرگسال)</th>
<th className="px-4 py-3.5 text-center">{t('customTrip.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{filtered.map((hotel, idx) => (
<tr key={hotel.id} className="hover:bg-muted/20 transition-colors">
<td className="px-4 py-3.5 font-mono text-muted-foreground">{toFa(idx + 1)}</td>
<td className="px-4 py-3.5 font-bold text-foreground">
<div>{hotel.name}</div>
</td>
<td className="px-4 py-3.5 font-medium text-primary">{hotel.cityName}</td>
<td className="px-4 py-3.5 text-center">{renderStars(hotel.stars || 5)}</td>
<td className="px-4 py-3.5 text-start text-xs text-muted-foreground">
{hotel.distanceToShrine || '—'}
</td>
<td className="px-4 py-3.5 text-center font-mono font-bold text-foreground">
${toFa(hotel.pricePerDayAdult || 0)}
</td>
<td className="px-4 py-3.5 text-center">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEdit(hotel)}
className="size-8 p-0 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground"
>
<Ic name="pencil" className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setItemToDelete(hotel)}
className="size-8 p-0 rounded-lg hover:bg-rose-500/10 text-muted-foreground hover:text-rose-600"
>
<Ic name="trash" className="size-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Add / Edit Hotel Modal */}
<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">
<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" />
</div>
<div>
<DialogTitle className="text-lg font-bold text-foreground">
{editingItem ? t('customTrip.editHotel') : t('customTrip.addHotel')}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
مشخصات هتل، امکانات، فاصله تا حرم و قیمت روزانه را برای مقصد زیارتی ثبت کنید.
</DialogDescription>
</div>
</div>
</DialogHeader>
{/* فرم: بدنه اسکرول‌شونده و فوتر فیکس‌شده */}
<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">
{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" />
<span>{validationError}</span>
</div>
)}
{/* ۱. انتخاب مقصد و درجه ستاره */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground block">
شهر مقصد <span className="text-rose-500">*</span>
</label>
<select
value={formCityName}
onChange={(e) => setFormCityName(e.target.value)}
className="w-full h-11 px-3 text-xs rounded-xl bg-surface-base border border-border-soft text-foreground font-medium focus:ring-1 focus:ring-primary focus:outline-none"
>
{cities.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground block">
تعداد ستاره هتل
</label>
<select
value={formStars}
onChange={(e) => setFormStars(Number(e.target.value))}
className="w-full h-11 px-3 text-xs rounded-xl bg-surface-base border border-border-soft text-foreground font-medium focus:ring-1 focus:ring-primary focus:outline-none"
>
<option value={5}>⭐⭐⭐⭐⭐ ۵ ستاره لوکس</option>
<option value={4}>⭐⭐⭐⭐ ۴ ستاره تاپ</option>
<option value={3}>⭐⭐⭐ ۳ ستاره استاندارد</option>
<option value={2}>⭐⭐ ۲ ستاره اقتصادی</option>
</select>
</div>
</div>
{/* ۲. تب‌های ۵ زبانه با کامپوننت مشترک ServiceTranslation */}
<div className="space-y-3 rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="border-b border-border-soft pb-2.5">
<ServiceTranslation
activeLang={activeLangTab}
onLangChange={(lang) => setActiveLangTab(lang)}
completedLangs={{
ar: Boolean(names.ar?.trim()),
fa: Boolean(names.fa?.trim()),
en: Boolean(names.en?.trim()),
ru: Boolean(names.ru?.trim()),
id: Boolean(names.id?.trim()),
}}
primaryLang="ar"
title="اطلاعات متنی و چندزبانه هتل"
required
variant="pills"
/>
</div>
{/* فیلدهای ترجمه‌پذیر متناسب با زبان فعال */}
<div className="space-y-4 pt-1" dir={currentLang.dir}>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<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">
{currentLang.badge}
</span>
<span className="text-rose-500">*</span>
</label>
<span className="text-[11px] text-muted-foreground font-mono">
{currentLang.dir.toUpperCase()}
</span>
</div>
<Input
value={names[activeLangTab] || ''}
onChange={(e) =>
setNames((prev) => ({ ...prev, [activeLangTab]: e.target.value }))
}
dir={currentLang.dir}
placeholder={`مثال: ${
currentLang.code === 'ar'
? 'فندق البارون كربلاء'
: currentLang.code === 'fa'
? 'هتل بارون کربلا'
: 'Baron Hotel Karbala'
}`}
className="h-11 text-xs bg-surface-base rounded-xl border-border-soft"
required={activeLangTab === 'fa' || activeLangTab === 'ar'}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">
توضیحات و خدمات هتل ({currentLang.name})
</label>
<textarea
value={descriptions[activeLangTab] || ''}
onChange={(e) =>
setDescriptions((prev) => ({ ...prev, [activeLangTab]: e.target.value }))
}
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"
/>
</div>
</div>
</div>
{/* ۳. نزدیکی به حرم */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground block">
میزان نزدیکی به حرم
</label>
<Input
value={formDistanceToShrine}
onChange={(e) => setFormDistanceToShrine(e.target.value)}
placeholder="مثال: ۳۰۰ متر تا باب‌القبله حرم مطهر (۵ دقیقه پیاده) / ترانسفر رایگان"
className="h-11 text-xs bg-surface-base rounded-xl border-border-soft"
/>
</div>
{/* ۴. امکانات و خدمات هتل */}
<div className="space-y-2">
<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">
{COMMON_AMENITIES.map((am) => {
const isChecked = formAmenities.includes(am.name)
return (
<label
key={am.id}
className="flex items-center gap-2 text-xs font-medium text-foreground cursor-pointer select-none"
>
<input
type="checkbox"
checked={isChecked}
onChange={() => handleToggleAmenity(am.name)}
className="rounded text-primary size-4"
/>
<span>{am.name}</span>
</label>
)
})}
</div>
</div>
{/* ۵. قیمت‌ها و وضعیت */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground block">
قیمت روزانه بزرگسال (دلار)
</label>
<Input
type="number"
value={formPriceAdult}
onChange={(e) => setFormPriceAdult(Number(e.target.value))}
className="h-11 text-xs bg-surface-base rounded-xl border-border-soft font-mono"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground block">
قیمت روزانه کودک (دلار)
</label>
<Input
type="number"
value={formPriceChild}
onChange={(e) => setFormPriceChild(Number(e.target.value))}
className="h-11 text-xs bg-surface-base rounded-xl border-border-soft font-mono"
/>
</div>
</div>
{/* ۶. وضعیت فعال */}
<label className="flex items-center gap-2.5 p-3 rounded-xl border border-border-soft bg-surface-base/40 cursor-pointer hover:bg-surface-base/70 transition">
<input
type="checkbox"
checked={formIsActive}
onChange={(e) => setFormIsActive(e.target.checked)}
className="rounded text-primary focus:ring-primary size-4"
/>
<div className="text-xs">
<span className="font-bold text-foreground block">
هتل فعال است و در لیست پیشنهادهای اقامت اپلیکیشن نمایش داده شود
</span>
<span className="text-[11px] text-muted-foreground">
در صورت غیرفعال بودن، این اقامتگاه برای زائرین در بخش رزرو ظاهر نمی‌شود.
</span>
</div>
</label>
</div>
{/* فوتر فیکس‌شده در پایین مدال */}
<DialogFooter className="p-4 px-6 border-t border-border-soft bg-surface-base/30 flex items-center justify-between sm:justify-between shrink-0">
<Button
variant="outline"
type="button"
onClick={() => setIsFormOpen(false)}
className="rounded-xl text-xs h-10 px-5"
>
{t('customTrip.cancel')}
</Button>
<Button
type="submit"
className="rounded-xl text-xs font-bold h-10 px-6"
>
{t('customTrip.saveChanges')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Modal using shared DeleteConfirmDialog */}
<DeleteConfirmDialog
open={!!itemToDelete}
onOpenChange={(open) => !open && setItemToDelete(null)}
title={t('customTrip.deleteHotel')}
description={`آیا از حذف هتل «${itemToDelete?.name || ''}» در شهر ${itemToDelete?.cityName || ''} اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
onConfirm={handleConfirmDelete}
/>
</div>
)
}

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

@ -0,0 +1,421 @@
import React, { useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
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 { updateCustomTripOrder } from '../services/custom-trip-api'
interface CustomTripOrdersTabProps {
orders: CustomTripOrderItem[]
onOrderUpdated: (updated: CustomTripOrderItem) => void
}
export function CustomTripOrdersTab({ orders, onOrderUpdated }: CustomTripOrdersTabProps) {
const { t, language } = useTranslation()
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [selectedOrder, setSelectedOrder] = useState<CustomTripOrderItem | null>(null)
// Dialog edit states
const [editStatus, setEditStatus] = useState<CustomTripOrderStatus>('PENDING')
const [editPrice, setEditPrice] = useState<number>(0)
const [editNotes, setEditNotes] = useState<string>('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [toastMessage, setToastMessage] = useState<string | null>(null)
const handleOpenReview = (order: CustomTripOrderItem) => {
setSelectedOrder(order)
setEditStatus(order.status)
setEditPrice(order.priceEstimate || 0)
setEditNotes(order.adminNotes || '')
}
const handleSaveFeedback = async () => {
if (!selectedOrder) return
setIsSubmitting(true)
try {
const updated = await updateCustomTripOrder(selectedOrder.id, {
status: editStatus,
priceEstimate: Number(editPrice),
adminNotes: editNotes,
})
onOrderUpdated(updated)
setSelectedOrder(null)
setToastMessage(t('customTrip.successFeedbackSaved'))
setTimeout(() => setToastMessage(null), 4000)
} catch {
// Handled gracefully
} finally {
setIsSubmitting(false)
}
}
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">
<Ic name="checkCircle" className="size-3.5" />
{t('customTrip.statusApproved')}
</span>
)
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">
<Ic name="calculator" className="size-3.5" />
{t('customTrip.statusAwaitingPayment')}
</span>
)
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">
<Ic name="history" className="size-3.5" />
{t('customTrip.statusPending')}
</span>
)
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">
<Ic name="xCircle" className="size-3.5" />
{t('customTrip.statusRejected')}
</span>
)
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">
{status}
</span>
)
}
}
const filteredOrders = orders.filter((order) => {
const matchesStatus = statusFilter === 'all' || order.status === statusFilter
const term = search.trim().toLowerCase()
const matchesSearch =
!term ||
order.id.toString().includes(term) ||
(order.user.fullName && order.user.fullName.toLowerCase().includes(term)) ||
(order.originCity && order.originCity.toLowerCase().includes(term)) ||
order.destinations.some((d) => d.toLowerCase().includes(term))
return matchesStatus && matchesSearch
})
return (
<div className="space-y-4">
{/* Toast notification */}
{toastMessage && (
<div className="p-3 bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-sm font-medium rounded-xl flex items-center gap-2">
<Ic name="checkCircle" className="size-4" />
<span>{toastMessage}</span>
</div>
)}
{/* Filter and Search Bar */}
<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/70">
<div className="relative flex-1">
<Ic name="search" className="absolute top-1/2 -translate-y-1/2 start-3 size-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="جستجو بر اساس شناسه، نام مسافر یا مقصد..."
className="ps-9 h-10 rounded-xl bg-background border-border/60"
/>
</div>
<div className="flex items-center gap-2">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-10 px-3 text-xs sm:text-sm rounded-xl bg-background border border-border/70 text-foreground font-medium focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="all">همه وضعیت‌ها</option>
<option value="PENDING">{t('customTrip.statusPending')}</option>
<option value="APPROVED">{t('customTrip.statusApproved')}</option>
<option value="AWAITING_PAYMENT">{t('customTrip.statusAwaitingPayment')}</option>
<option value="REJECTED">{t('customTrip.statusRejected')}</option>
</select>
</div>
</div>
{/* Orders Table */}
<div className="overflow-hidden rounded-2xl border border-border/70 bg-card">
<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">{t('customTrip.orderId')}</th>
<th className="px-4 py-3.5 text-start">{t('customTrip.customer')}</th>
<th className="px-4 py-3.5 text-start">مسیر سفر</th>
<th className="px-4 py-3.5 text-center">{t('customTrip.passengers')}</th>
<th className="px-4 py-3.5 text-center">{t('customTrip.priceEstimate')}</th>
<th className="px-4 py-3.5 text-center">{t('customTrip.status')}</th>
<th className="px-4 py-3.5 text-center">{t('customTrip.createdDate')}</th>
<th className="px-4 py-3.5 text-end">{t('customTrip.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{filteredOrders.length === 0 ? (
<tr>
<td colSpan={8} 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>
) : (
filteredOrders.map((order) => (
<tr key={order.id} className="hover:bg-muted/20 transition-colors">
<td className="px-4 py-3.5 font-mono font-bold text-foreground">
#{toFa(order.id)}
</td>
<td className="px-4 py-3.5">
<div className="font-semibold text-foreground">
{order.user.fullName || order.user.username}
</div>
<div className="font-mono text-xs text-muted-foreground mt-0.5">
{order.user.phoneNumber || order.user.email || '—'}
</div>
</td>
<td className="px-4 py-3.5">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-medium text-foreground">{order.originCity}</span>
{order.destinations.map((dest, i) => (
<React.Fragment key={i}>
<span className="text-muted-foreground/60 text-xs">←</span>
<span className="px-2 py-0.5 rounded-lg bg-primary/10 text-primary font-medium text-xs">
{dest}
</span>
</React.Fragment>
))}
</div>
{order.legs && order.legs.length > 0 && (
<div className="text-xs text-muted-foreground mt-1 truncate max-w-xs">
{order.legs.map((l) => `${l.toCity}: ${l.hotelName ?? ''}`).join(' | ')}
</div>
)}
</td>
<td className="px-4 py-3.5 text-center">
<span className="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-lg bg-muted text-muted-foreground">
<Ic name="users" className="size-3" />
{toFa(order.adultsCount)} ب
{order.childrenCount > 0 && ` + ${toFa(order.childrenCount)} ک`}
{order.infantsCount > 0 && ` + ${toFa(order.infantsCount)} ن`}
</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">
{formatDateByLanguage(order.created, language)}
</td>
<td className="px-4 py-3.5 text-end">
<Button
size="sm"
variant="outline"
onClick={() => handleOpenReview(order)}
className="rounded-xl gap-1.5 h-8 text-xs font-semibold hover:border-primary hover:text-primary"
>
<Ic name="edit" className="size-3.5" />
{t('customTrip.reviewOrder')}
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Review & Feedback Modal */}
{selectedOrder && (
<Dialog open={!!selectedOrder} onOpenChange={(open) => !open && setSelectedOrder(null)}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl p-6">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-bold text-foreground flex items-center gap-2">
<Ic name="clipboardCheck" className="size-5 text-primary" />
{t('customTrip.orderDetailTitle')} #{toFa(selectedOrder.id)}
</DialogTitle>
{getStatusBadge(selectedOrder.status)}
</div>
<DialogDescription className="text-xs text-muted-foreground mt-1">
اطلاعات سفارش ثبت‌شده توسط مسافر را بررسی کنید، قیمت نهایی و فیدبک را ثبت نمایید.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 my-2">
{/* Customer info card */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 p-3.5 rounded-xl bg-muted/40 border border-border/60 text-xs sm:text-sm">
<div>
<span className="text-muted-foreground text-xs block">نام مسافر:</span>
<span className="font-bold text-foreground">
{selectedOrder.user.fullName || selectedOrder.user.username}
</span>
</div>
<div>
<span className="text-muted-foreground text-xs block">تلفن تماس:</span>
<span className="font-mono text-foreground font-medium dir-ltr inline-block">
{selectedOrder.user.phoneNumber || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground text-xs block">تعداد مسافران:</span>
<span className="font-semibold text-foreground">
{toFa(selectedOrder.adultsCount)} بزرگسال
{selectedOrder.childrenCount > 0 && `، ${toFa(selectedOrder.childrenCount)} کودک`}
{selectedOrder.infantsCount > 0 && `، ${toFa(selectedOrder.infantsCount)} نوزاد`}
</span>
</div>
</div>
{/* Itinerary Legs */}
<div className="space-y-2">
<h4 className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
مراحل و مقاصد انتخابی سفر:
</h4>
<div className="space-y-2">
{selectedOrder.legs && selectedOrder.legs.length > 0 ? (
selectedOrder.legs.map((leg, index) => (
<div
key={index}
className="p-3 rounded-xl border border-border/70 bg-card flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs sm:text-sm"
>
<div className="flex items-center gap-2">
<span className="size-6 rounded-full bg-primary/10 text-primary font-bold flex items-center justify-center text-xs">
{toFa(index + 1)}
</span>
<div>
<span className="font-bold text-foreground">{leg.fromCity}</span>
<span className="text-muted-foreground mx-1.5">←</span>
<span className="font-bold text-primary">{leg.toCity}</span>
{leg.stayNights && (
<span className="text-xs text-muted-foreground ms-2">
({toFa(leg.stayNights)} شب اقامت)
</span>
)}
</div>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{leg.transportMethod && (
<span className="inline-flex items-center gap-1 bg-muted px-2 py-0.5 rounded-lg">
<Ic name="route" className="size-3" />
{leg.transportMethod}
</span>
)}
{leg.hotelName && (
<span className="inline-flex items-center gap-1 bg-muted px-2 py-0.5 rounded-lg text-foreground font-medium">
<Ic name="bed" className="size-3 text-primary" />
{leg.hotelName}
</span>
)}
</div>
</div>
))
) : (
<div className="text-xs text-muted-foreground p-3 rounded-xl bg-muted/20">
مبدأ: {selectedOrder.originCity} | مقاصد: {selectedOrder.destinations.join('، ')}
</div>
)}
</div>
</div>
{/* User note */}
{selectedOrder.detail && (
<div className="p-3 rounded-xl bg-blue-500/5 border border-blue-500/20 text-xs">
<span className="font-bold text-blue-600 dark:text-blue-400 block mb-1">
یادداشت و درخواست ویژه مسافر:
</span>
<p className="text-foreground/90">{selectedOrder.detail}</p>
</div>
)}
{/* Admin Actions Form */}
<div className="border-t border-border/70 pt-4 space-y-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">
{t('customTrip.status')}:
</label>
<select
value={editStatus}
onChange={(e) => setEditStatus(e.target.value as CustomTripOrderStatus)}
className="w-full h-10 px-3 text-xs sm:text-sm rounded-xl bg-background border border-border/70 text-foreground font-medium focus:ring-1 focus:ring-primary focus:outline-none"
>
<option value="PENDING">{t('customTrip.statusPending')}</option>
<option value="APPROVED">{t('customTrip.statusApproved')}</option>
<option value="AWAITING_PAYMENT">{t('customTrip.statusAwaitingPayment')}</option>
<option value="REJECTED">{t('customTrip.statusRejected')}</option>
<option value="CANCELLED">{t('customTrip.statusCancelled')}</option>
</select>
</div>
<div>
<label className="text-xs font-semibold text-foreground block mb-1">
{t('customTrip.priceEstimate')} (دلار $):
</label>
<Input
type="number"
value={editPrice}
onChange={(e) => setEditPrice(Number(e.target.value))}
className="h-10 rounded-xl font-mono text-start"
placeholder="مبلغ تخمینی..."
/>
</div>
</div>
<div>
<label className="text-xs font-semibold text-foreground block mb-1">
{t('customTrip.adminFeedback')}:
</label>
<textarea
rows={3}
value={editNotes}
onChange={(e) => setEditNotes(e.target.value)}
placeholder={t('customTrip.adminFeedbackPlaceholder')}
className="w-full p-3 text-xs sm:text-sm rounded-xl bg-background border border-border/70 text-foreground placeholder:text-muted-foreground focus:ring-1 focus:ring-primary focus:outline-none"
/>
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setSelectedOrder(null)}
className="rounded-xl text-xs"
>
انصراف
</Button>
<Button
onClick={handleSaveFeedback}
disabled={isSubmitting}
className="rounded-xl text-xs font-bold gap-1.5"
>
<Ic name="check" className="size-4" />
{isSubmitting ? 'در حال ثبت...' : t('customTrip.updateStatus')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
)
}

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

@ -0,0 +1,578 @@
import React, { useState, useMemo } from 'react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import DeleteConfirmDialog from '@/components/shared/DeleteConfirmDialog'
import {
ServiceTranslation,
SERVICE_LANGUAGES,
type ServiceLanguageCode,
} from '@/components/shared/ServiceTranslation'
import { Ic } from '@/icons'
import { toFa } from '@/lib/utils'
import { useTranslation } from '@/i18n'
import type { CustomTripOriginItem } from '../types'
interface CustomTripOriginsTabProps {
origins: CustomTripOriginItem[]
onAddOrigin: (origin: CustomTripOriginItem) => void
onUpdateOrigin?: (origin: CustomTripOriginItem) => void
onDeleteOrigin?: (id: string | number) => void
}
const PRESET_COUNTRIES = [
{ name: 'عُمان', code: 'OM', flag: '🇴🇲' },
{ name: 'بحرین', code: 'BH', flag: '🇧🇭' },
{ name: 'کویت', code: 'KW', flag: '🇰🇼' },
{ name: 'عربستان سعودی', code: 'SA', flag: '🇸🇦' },
{ name: 'ایران', code: 'IR', flag: '🇮🇷' },
{ name: 'عراق', code: 'IQ', flag: '🇮🇶' },
{ name: 'امارات متحده عربی', code: 'AE', flag: '🇦🇪' },
{ name: 'قطر', code: 'QA', flag: '🇶🇦' },
]
export function CustomTripOriginsTab({
origins,
onAddOrigin,
onUpdateOrigin,
onDeleteOrigin,
}: CustomTripOriginsTabProps) {
const { t } = useTranslation()
const [search, setSearch] = useState('')
const [selectedCountry, setSelectedCountry] = useState<string>('all')
// Modals state
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingItem, setEditingItem] = useState<CustomTripOriginItem | null>(null)
const [itemToDelete, setItemToDelete] = useState<CustomTripOriginItem | null>(null)
const [validationError, setValidationError] = useState<string | null>(null)
// Multi-language form state
const [activeLangTab, setActiveLangTab] = useState<ServiceLanguageCode>('ar')
const [names, setNames] = useState<Record<ServiceLanguageCode, string>>({
ar: '',
fa: '',
en: '',
ru: '',
id: '',
})
const [formCountry, setFormCountry] = useState('عُمان')
const [formFlag, setFormFlag] = useState('🇴🇲')
const [formIsInternational, setFormIsInternational] = useState(true)
const [formIsActive, setFormIsActive] = useState(true)
const currentLang =
SERVICE_LANGUAGES.find((l) => l.code === activeLangTab) || SERVICE_LANGUAGES[0]
// Available countries in current origins list for filter
const countries = useMemo(() => {
const list = Array.from(new Set(origins.map((o) => o.country)))
return list
}, [origins])
const filtered = useMemo(() => {
return origins.filter((o) => {
const matchCountry = selectedCountry === 'all' || o.country === selectedCountry
const term = search.trim().toLowerCase()
const matchSearch =
!term ||
o.name.toLowerCase().includes(term) ||
(o.nameEn && o.nameEn.toLowerCase().includes(term)) ||
o.country.toLowerCase().includes(term)
return matchCountry && matchSearch
})
}, [origins, selectedCountry, search])
const handleOpenAdd = () => {
setEditingItem(null)
setActiveLangTab('ar')
setNames({ ar: '', fa: '', en: '', ru: '', id: '' })
setFormCountry('عُمان')
setFormFlag('🇴🇲')
setFormIsInternational(true)
setFormIsActive(true)
setValidationError(null)
setIsFormOpen(true)
}
const handleOpenEdit = (item: CustomTripOriginItem) => {
setEditingItem(item)
setActiveLangTab('ar')
const initialNames: Record<ServiceLanguageCode, string> = { ar: '', fa: '', en: '', ru: '', id: '' }
if (item.translations) {
for (const lang of ['ar', 'fa', 'en', 'ru', 'id'] as ServiceLanguageCode[]) {
if (item.translations[lang]?.name) initialNames[lang] = item.translations[lang].name!
}
}
if (!initialNames.fa) initialNames.fa = item.name.replace(/\s*\([A-Za-z ]+\)/g, '').trim()
if (!initialNames.ar) initialNames.ar = item.name.replace(/\s*\([A-Za-z ]+\)/g, '').trim()
if (!initialNames.en && item.nameEn) initialNames.en = item.nameEn
setNames(initialNames)
setFormCountry(item.country)
setFormFlag(item.flag || '📍')
setFormIsInternational(item.isInternational)
setFormIsActive(item.isActive)
setValidationError(null)
setIsFormOpen(true)
}
const handleCountryChange = (countryName: string) => {
setFormCountry(countryName)
const preset = PRESET_COUNTRIES.find((c) => c.name === countryName)
if (preset) {
setFormFlag(preset.flag)
setFormIsInternational(preset.name !== 'ایران')
}
}
const handleSaveForm = (e?: React.FormEvent) => {
if (e) e.preventDefault()
const primaryName =
names.ar.trim() ||
names.fa.trim() ||
names[activeLangTab].trim() ||
names.en.trim() ||
names.ru.trim() ||
names.id.trim()
if (!primaryName) {
setValidationError('لطفاً نام شهر مبدأ را وارد نمایید.')
return
}
const translationsRecord: Record<string, { name?: string }> = {}
for (const lang of ['ar', 'fa', 'en', 'ru', 'id'] as ServiceLanguageCode[]) {
const n = names[lang]?.trim()
if (n) {
translationsRecord[lang] = { name: n }
}
}
if (editingItem && onUpdateOrigin) {
const updated: CustomTripOriginItem = {
...editingItem,
name: names.fa.trim() || names.ar.trim() || primaryName,
nameEn: names.en.trim() || undefined,
country: formCountry.trim(),
flag: formFlag,
isInternational: formIsInternational,
isActive: formIsActive,
translations: translationsRecord,
}
onUpdateOrigin(updated)
} else {
const newItem: CustomTripOriginItem = {
id: `origin_${Date.now()}`,
name: names.fa.trim() || names.ar.trim() || primaryName,
nameEn: names.en.trim() || undefined,
country: formCountry.trim() || 'عُمان',
flag: formFlag,
isInternational: formIsInternational,
isActive: formIsActive,
translations: translationsRecord,
}
onAddOrigin(newItem)
}
setIsFormOpen(false)
}
const handleConfirmDelete = () => {
if (itemToDelete && onDeleteOrigin) {
onDeleteOrigin(itemToDelete.id)
}
setItemToDelete(null)
}
const internationalCount = origins.filter((o) => o.isInternational).length
const domesticCount = origins.filter((o) => !o.isInternational).length
return (
<div className="space-y-4">
{/* KPI mini overview */}
<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">
<span className="text-[11px] text-muted-foreground font-medium block">کل شهرهای مبدأ</span>
<div className="mt-1 flex items-baseline gap-1.5">
<span className="text-xl font-black font-mono text-foreground">{toFa(origins.length)}</span>
<span className="text-xs text-muted-foreground">شهر</span>
</div>
</div>
<div className="p-3.5 rounded-2xl bg-card border border-border/70 shadow-xs">
<span className="text-[11px] text-muted-foreground font-medium block">کشورهای مبدأ</span>
<div className="mt-1 flex items-baseline gap-1.5">
<span className="text-xl font-black font-mono text-primary">{toFa(countries.length)}</span>
<span className="text-xs text-muted-foreground">کشور منطقه</span>
</div>
</div>
<div className="p-3.5 rounded-2xl bg-card border border-border/70 shadow-xs">
<span className="text-[11px] text-muted-foreground font-medium block">مبدأهای بین‌المللی</span>
<div className="mt-1 flex items-baseline gap-1.5">
<span className="text-xl font-black font-mono text-blue-600 dark:text-blue-400">{toFa(internationalCount)}</span>
<span className="text-xs text-muted-foreground">پرواز خارجی</span>
</div>
</div>
<div className="p-3.5 rounded-2xl bg-card border border-border/70 shadow-xs">
<span className="text-[11px] text-muted-foreground font-medium block">مبدأهای داخلی (ایران)</span>
<div className="mt-1 flex items-baseline gap-1.5">
<span className="text-xl font-black font-mono text-emerald-600 dark:text-emerald-400">{toFa(domesticCount)}</span>
<span className="text-xs text-muted-foreground">داخلی</span>
</div>
</div>
</div>
{/* Filter and Action Bar */}
<div className="flex flex-col gap-3 bg-card p-3 rounded-2xl border 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" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="جستجوی شهر مبدأ، نام انگلیسی یا کشور..."
className="ps-9 h-10 rounded-xl bg-background border-border/60 text-xs sm:text-sm"
/>
</div>
<Button
onClick={handleOpenAdd}
className="rounded-xl h-10 gap-1.5 font-bold text-xs sm:text-sm shrink-0"
>
<Ic name="plus" className="size-4" />
{t('customTrip.addOrigin')}
</Button>
</div>
{/* Country filter chips */}
<div className="flex items-center gap-1.5 overflow-x-auto pt-1 pb-0.5">
<span className="text-xs text-muted-foreground font-medium shrink-0 ps-1">
{t('customTrip.filterByCountry')}:
</span>
<button
onClick={() => setSelectedCountry('all')}
className={`px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors ${
selectedCountry === 'all'
? 'bg-primary text-white shadow-xs'
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{t('customTrip.allCountries')} ({toFa(origins.length)})
</button>
{countries.map((c) => {
const count = origins.filter((o) => o.country === c).length
const preset = PRESET_COUNTRIES.find((p) => p.name === c)
return (
<button
key={c}
onClick={() => setSelectedCountry(c)}
className={`flex items-center gap-1 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors ${
selectedCountry === c
? 'bg-primary text-white shadow-xs'
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<span>{preset?.flag || '📍'}</span>
<span>{c}</span>
<span className="opacity-75 font-mono text-[10px]">({toFa(count)})</span>
</button>
)
})}
</div>
</div>
{/* Origins Table */}
<div className="overflow-hidden rounded-2xl border border-border/70 bg-card 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">ردیف</th>
<th className="px-4 py-3.5 text-start">{t('customTrip.originCityName')}</th>
<th className="px-4 py-3.5 text-start">{t('customTrip.country')}</th>
<th className="px-4 py-3.5 text-center">نوع خروج / پرواز</th>
<th className="px-4 py-3.5 text-center">وضعیت</th>
<th className="px-4 py-3.5 text-center">{t('customTrip.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-xs text-muted-foreground">
شهری با این مشخصات یافت نشد.
</td>
</tr>
) : (
filtered.map((item, idx) => (
<tr key={item.id} className="hover:bg-muted/20 transition-colors">
<td className="px-4 py-3.5 font-mono text-muted-foreground">{toFa(idx + 1)}</td>
<td className="px-4 py-3.5 font-bold text-foreground">
<div className="flex items-center gap-2">
<span className="text-base select-none">{item.flag || '📍'}</span>
<div>
<div>{item.name}</div>
{item.nameEn && (
<span className="text-[11px] font-normal text-muted-foreground font-mono">
{item.nameEn}
</span>
)}
</div>
</div>
</td>
<td className="px-4 py-3.5 font-medium text-foreground">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-muted/60 text-xs font-semibold">
<span>{item.flag || '📍'}</span>
<span>{item.country}</span>
</span>
</td>
<td className="px-4 py-3.5 text-center">
<span
className={`inline-flex px-2.5 py-0.5 rounded-full text-xs font-semibold ${
item.isInternational
? 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20'
: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20'
}`}
>
{item.isInternational ? 'بین‌المللی (خارجی)' : 'داخلی (ایران)'}
</span>
</td>
<td className="px-4 py-3.5 text-center">
<button
type="button"
onClick={() => {
if (onUpdateOrigin) {
onUpdateOrigin({ ...item, isActive: !item.isActive })
}
}}
className={`inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full transition-all cursor-pointer ${
item.isActive
? 'bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
<span
className={`size-1.5 rounded-full ${
item.isActive ? 'bg-emerald-500 animate-pulse' : 'bg-muted-foreground'
}`}
/>
{item.isActive ? t('customTrip.active') : t('customTrip.inactive')}
</button>
</td>
<td className="px-4 py-3.5 text-center">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEdit(item)}
className="size-8 p-0 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground"
title={t('customTrip.editOrigin')}
>
<Ic name="pencil" className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setItemToDelete(item)}
className="size-8 p-0 rounded-lg hover:bg-rose-500/10 text-muted-foreground hover:text-rose-600"
title={t('customTrip.deleteOrigin')}
>
<Ic name="trash" className="size-3.5" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Add / Edit Modal */}
<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">
<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" />
</div>
<div>
<DialogTitle className="text-lg font-bold text-foreground">
{editingItem ? t('customTrip.editOrigin') : t('customTrip.addOrigin')}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
مشخصات شهر مبدأ را برای شروع سفرهای زیارتی زائرین وارد یا ویرایش کنید.
</DialogDescription>
</div>
</div>
</DialogHeader>
{/* فرم: بدنه اسکرول‌شونده و فوتر فیکس‌شده */}
<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">
{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" />
<span>{validationError}</span>
</div>
)}
{/* ۱. انتخاب کشور مبدأ */}
<div className="space-y-2">
<label className="text-xs font-bold text-foreground block">
{t('customTrip.country')} <span className="text-rose-500">*</span>
</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{PRESET_COUNTRIES.map((c) => {
const isSelected = formCountry === c.name
return (
<button
key={c.code}
type="button"
onClick={() => handleCountryChange(c.name)}
className={`h-11 px-3 rounded-xl border text-xs font-bold transition flex items-center justify-center gap-2 cursor-pointer ${
isSelected
? 'border-primary bg-primary/10 text-primary shadow-xs'
: 'border-border-soft bg-surface-base/50 text-muted-foreground hover:text-foreground hover:bg-card'
}`}
>
<span className="text-base">{c.flag}</span>
<span className="truncate">{c.name}</span>
</button>
)
})}
</div>
</div>
{/* ۲. تب‌های ۵ زبانه با کامپوننت مشترک ServiceTranslation */}
<div className="space-y-3 rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="border-b border-border-soft pb-2.5">
<ServiceTranslation
activeLang={activeLangTab}
onLangChange={(lang) => setActiveLangTab(lang)}
completedLangs={{
ar: Boolean(names.ar?.trim()),
fa: Boolean(names.fa?.trim()),
en: Boolean(names.en?.trim()),
ru: Boolean(names.ru?.trim()),
id: Boolean(names.id?.trim()),
}}
primaryLang="ar"
title="نام شهر مبدأ به زبان‌های مختلف"
required
variant="pills"
/>
</div>
{/* فیلد نام شهر متناسب با زبان فعال */}
<div className="space-y-1.5 pt-1" dir={currentLang.dir}>
<div className="flex items-center justify-between">
<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">
{currentLang.badge}
</span>
<span className="text-rose-500">*</span>
</label>
<span className="text-[11px] text-muted-foreground font-mono">
{currentLang.dir.toUpperCase()}
</span>
</div>
<Input
value={names[activeLangTab] || ''}
onChange={(e) =>
setNames((prev) => ({ ...prev, [activeLangTab]: e.target.value }))
}
dir={currentLang.dir}
placeholder={`مثال: ${
currentLang.code === 'ar'
? 'مسقط'
: currentLang.code === 'fa'
? 'مسقط'
: 'Muscat'
}`}
className="h-11 text-xs bg-surface-base rounded-xl border-border-soft"
required={activeLangTab === 'fa' || activeLangTab === 'ar'}
/>
</div>
</div>
{/* ۳. گزینه‌های وضعیت و بین‌المللی */}
<div className="space-y-2">
<label className="flex items-center gap-2.5 p-3 rounded-xl border border-border-soft bg-surface-base/40 cursor-pointer hover:bg-surface-base/70 transition">
<input
type="checkbox"
checked={formIsInternational}
onChange={(e) => setFormIsInternational(e.target.checked)}
className="rounded text-primary focus:ring-primary size-4"
/>
<div className="text-xs">
<span className="font-bold text-foreground block">
این مبدأ خارج از ایران (بین‌المللی) است
</span>
<span className="text-[11px] text-muted-foreground">
برای مبدأهای کشورهای منطقه مانند عمان، بحرین، کویت و عربستان
</span>
</div>
</label>
<label className="flex items-center gap-2.5 p-3 rounded-xl border border-border-soft bg-surface-base/40 cursor-pointer hover:bg-surface-base/70 transition">
<input
type="checkbox"
checked={formIsActive}
onChange={(e) => setFormIsActive(e.target.checked)}
className="rounded text-primary focus:ring-primary size-4"
/>
<div className="text-xs">
<span className="font-bold text-foreground block">
شهر فعال است و در لیست مبدأهای اپلیکیشن نمایش داده شود
</span>
<span className="text-[11px] text-muted-foreground">
در صورت غیرفعال بودن، زائرین امکان آغاز سفر از این شهر را نخواهند داشت.
</span>
</div>
</label>
</div>
</div>
{/* فوتر فیکس‌شده در پایین مدال */}
<DialogFooter className="p-4 px-6 border-t border-border-soft bg-surface-base/30 flex items-center justify-between sm:justify-between shrink-0">
<Button
variant="outline"
type="button"
onClick={() => setIsFormOpen(false)}
className="rounded-xl text-xs h-10 px-5"
>
{t('customTrip.cancel')}
</Button>
<Button
type="submit"
className="rounded-xl text-xs font-bold h-10 px-6"
>
{t('customTrip.saveChanges')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Modal using shared DeleteConfirmDialog */}
<DeleteConfirmDialog
open={!!itemToDelete}
onOpenChange={(open) => !open && setItemToDelete(null)}
title={t('customTrip.deleteOrigin')}
description={`آیا از حذف شهر مبدأ «${itemToDelete?.name || ''}» (${itemToDelete?.country || ''}) اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
onConfirm={handleConfirmDelete}
/>
</div>
)
}

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

@ -0,0 +1,749 @@
import React, { useState, useMemo } from 'react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import DeleteConfirmDialog from '@/components/shared/DeleteConfirmDialog'
import { Ic } from '@/icons'
import { toFa } from '@/lib/utils'
import { useTranslation } from '@/i18n'
import type {
CustomTripTransportRelationItem,
CustomTripOriginItem,
CustomTripDestinationItem,
} from '../types'
interface CustomTripTransportsTabProps {
relations: CustomTripTransportRelationItem[]
origins: CustomTripOriginItem[]
destinations: CustomTripDestinationItem[]
initialDestinationFilter?: string
onAddRelation: (relation: CustomTripTransportRelationItem) => void
onUpdateRelation: (relation: CustomTripTransportRelationItem) => void
onDeleteRelation: (id: string | number) => void
}
export function CustomTripTransportsTab({
relations,
origins,
destinations,
initialDestinationFilter = 'all',
onAddRelation,
onUpdateRelation,
onDeleteRelation,
}: CustomTripTransportsTabProps) {
const { t } = useTranslation()
const [search, setSearch] = useState('')
const [selectedDestination, setSelectedDestination] = useState<string>(initialDestinationFilter)
const [methodFilter, setMethodFilter] = useState<'all' | 'flight' | 'train' | 'road'>('all')
const [viewMode, setViewMode] = useState<'cards' | 'table'>('cards')
// Modals state
const [isAddOpen, setIsAddOpen] = useState(false)
const [editingItem, setEditingItem] = useState<CustomTripTransportRelationItem | null>(null)
const [itemToDelete, setItemToDelete] = useState<CustomTripTransportRelationItem | null>(null)
// Form state
const [formFromCity, setFormFromCity] = useState('')
const [formToCity, setFormToCity] = useState('')
const [formHasAirplane, setFormHasAirplane] = useState(true)
const [formHasTrain, setFormHasTrain] = useState(false)
const [formHasRoad, setFormHasRoad] = useState(false)
const [formAirplaneDetails, setFormAirplaneDetails] = useState('')
const [formTrainDetails, setFormTrainDetails] = useState('')
const [formRoadDetails, setFormRoadDetails] = useState('')
const [formIsActive, setFormIsActive] = useState(true)
// List of destination names
const destinationNames = useMemo(() => {
return Array.from(new Set(destinations.map((d) => d.name)))
}, [destinations])
const filtered = useMemo(() => {
return relations.filter((rel) => {
const matchDest =
selectedDestination === 'all' ||
rel.toCity.toLowerCase().includes(selectedDestination.toLowerCase()) ||
selectedDestination.toLowerCase().includes(rel.toCity.toLowerCase())
const matchMethod =
methodFilter === 'all' ||
(methodFilter === 'flight' && rel.hasAirplane) ||
(methodFilter === 'train' && rel.hasTrain) ||
(methodFilter === 'road' && rel.hasRoad)
const term = search.trim().toLowerCase()
const matchSearch =
!term ||
rel.fromCity.toLowerCase().includes(term) ||
rel.toCity.toLowerCase().includes(term) ||
rel.fromCountry.toLowerCase().includes(term) ||
rel.toCountry.toLowerCase().includes(term) ||
(rel.airplaneDetails && rel.airplaneDetails.toLowerCase().includes(term)) ||
(rel.roadDetails && rel.roadDetails.toLowerCase().includes(term))
return matchDest && matchMethod && matchSearch
})
}, [relations, selectedDestination, methodFilter, search])
// Quick toggle for a transport method on a relation
const handleToggleMethod = (
rel: CustomTripTransportRelationItem,
type: 'airplane' | 'train' | 'road',
e: React.MouseEvent
) => {
e.stopPropagation()
const updated: CustomTripTransportRelationItem = {
...rel,
hasAirplane: type === 'airplane' ? !rel.hasAirplane : rel.hasAirplane,
hasTrain: type === 'train' ? !rel.hasTrain : rel.hasTrain,
hasRoad: type === 'road' ? !rel.hasRoad : rel.hasRoad,
}
onUpdateRelation(updated)
}
const handleOpenAdd = () => {
setEditingItem(null)
setFormFromCity(origins[0]?.name || '')
setFormToCity(destinations[0]?.name || '')
setFormHasAirplane(true)
setFormHasTrain(false)
setFormHasRoad(false)
setFormAirplaneDetails('')
setFormTrainDetails('')
setFormRoadDetails('')
setFormIsActive(true)
setIsAddOpen(true)
}
const handleOpenEdit = (item: CustomTripTransportRelationItem) => {
setEditingItem(item)
setFormFromCity(item.fromCity)
setFormToCity(item.toCity)
setFormHasAirplane(item.hasAirplane)
setFormHasTrain(item.hasTrain)
setFormHasRoad(item.hasRoad)
setFormAirplaneDetails(item.airplaneDetails || '')
setFormTrainDetails(item.trainDetails || '')
setFormRoadDetails(item.roadDetails || '')
setFormIsActive(item.isActive)
setIsAddOpen(true)
}
const handleSaveForm = () => {
if (!formFromCity.trim() || !formToCity.trim()) return
const fromOrigin = origins.find((o) => o.name === formFromCity)
const toDest = destinations.find((d) => d.name === formToCity)
if (editingItem) {
const updated: CustomTripTransportRelationItem = {
...editingItem,
fromCity: formFromCity.trim(),
fromCountry: fromOrigin?.country || editingItem.fromCountry,
toCity: formToCity.trim(),
toCountry: toDest?.country || editingItem.toCountry,
hasAirplane: formHasAirplane,
hasTrain: formHasTrain,
hasRoad: formHasRoad,
airplaneDetails: formAirplaneDetails.trim() || undefined,
trainDetails: formTrainDetails.trim() || undefined,
roadDetails: formRoadDetails.trim() || undefined,
isActive: formIsActive,
}
onUpdateRelation(updated)
} else {
const newItem: CustomTripTransportRelationItem = {
id: `rel_${Date.now()}`,
fromCity: formFromCity.trim(),
fromCountry: fromOrigin?.country || 'منطقه',
toCity: formToCity.trim(),
toCountry: toDest?.country || 'عراق',
hasAirplane: formHasAirplane,
hasTrain: formHasTrain,
hasRoad: formHasRoad,
airplaneDetails: formAirplaneDetails.trim() || undefined,
trainDetails: formTrainDetails.trim() || undefined,
roadDetails: formRoadDetails.trim() || undefined,
isActive: formIsActive,
}
onAddRelation(newItem)
}
setIsAddOpen(false)
}
const handleConfirmDelete = () => {
if (itemToDelete) {
onDeleteRelation(itemToDelete.id)
}
setItemToDelete(null)
}
return (
<div className="space-y-4">
{/* Information Header Banner */}
<div className="p-4 rounded-2xl bg-primary/5 border border-primary/20 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 shadow-xs">
<div className="flex items-center gap-3">
<span className="p-2.5 rounded-xl bg-primary/10 text-primary shrink-0">
<Ic name="route" className="size-5" />
</span>
<div>
<h3 className="text-sm font-bold text-foreground">
{t('customTrip.transportRelationTitle')}
</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{t('customTrip.transportRelationDesc')}
</p>
</div>
</div>
<Button
onClick={handleOpenAdd}
className="rounded-xl h-9 px-4 gap-1.5 font-bold text-xs shrink-0 self-end sm:self-auto"
>
<Ic name="plus" className="size-4" />
{t('customTrip.addRelation')}
</Button>
</div>
{/* Destination Filter Tabs */}
<div className="flex flex-col gap-3 bg-card p-3 rounded-2xl border border-border/70 shadow-xs">
<div className="flex items-center gap-2 overflow-x-auto pb-1">
<span className="text-xs text-muted-foreground font-medium shrink-0 ps-1">
{t('customTrip.selectDestination')}:
</span>
<button
onClick={() => setSelectedDestination('all')}
className={`px-3.5 py-1.5 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors ${
selectedDestination === 'all'
? 'bg-primary text-white shadow-xs'
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{t('customTrip.allDestinations')} ({toFa(relations.length)})
</button>
{destinationNames.map((dName) => {
const count = relations.filter(
(r) => r.toCity.includes(dName) || dName.includes(r.toCity)
).length
const destObj = destinations.find((d) => d.name === dName)
return (
<button
key={dName}
onClick={() => setSelectedDestination(dName)}
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors ${
selectedDestination === dName
? 'bg-primary text-white shadow-xs'
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<span>{destObj?.flag || '📍'}</span>
<span>{dName}</span>
<span className="opacity-75 font-mono text-[10px]">({toFa(count)})</span>
</button>
)
})}
</div>
{/* Search, Method Filter & View Switcher */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2.5 pt-2 border-t border-border/50">
<div className="relative flex-1">
<Ic name="search" className="absolute top-1/2 -translate-y-1/2 start-3 size-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="جستجوی شهر مبدأ، مقصد یا مشخصات مسیر..."
className="ps-9 h-9 rounded-xl bg-background border-border/60 text-xs"
/>
</div>
<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">
<button
onClick={() => setMethodFilter('all')}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
methodFilter === 'all' ? 'bg-card text-foreground shadow-2xs font-bold' : 'text-muted-foreground'
}`}
>
همه روش‌ها
</button>
<button
onClick={() => setMethodFilter('flight')}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 ${
methodFilter === 'flight' ? 'bg-card text-foreground shadow-2xs font-bold' : 'text-muted-foreground'
}`}
>
<span>✈️</span>
<span>هواپیما</span>
</button>
<button
onClick={() => setMethodFilter('train')}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 ${
methodFilter === 'train' ? 'bg-card text-foreground shadow-2xs font-bold' : 'text-muted-foreground'
}`}
>
<span>🚆</span>
<span>قطار</span>
</button>
<button
onClick={() => setMethodFilter('road')}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1 ${
methodFilter === 'road' ? 'bg-card text-foreground shadow-2xs font-bold' : 'text-muted-foreground'
}`}
>
<span>🚐</span>
<span>زمینی</span>
</button>
</div>
{/* View Mode Toggle */}
<div className="flex items-center bg-muted/60 p-1 rounded-xl border border-border/50">
<button
onClick={() => setViewMode('cards')}
className={`p-1.5 rounded-lg transition-colors ${
viewMode === 'cards' ? 'bg-card text-foreground shadow-2xs' : 'text-muted-foreground'
}`}
title={t('customTrip.cardView')}
>
<Ic name="grid" className="size-4" />
</button>
<button
onClick={() => setViewMode('table')}
className={`p-1.5 rounded-lg transition-colors ${
viewMode === 'table' ? 'bg-card text-foreground shadow-2xs' : 'text-muted-foreground'
}`}
title={t('customTrip.tableView')}
>
<Ic name="list" className="size-4" />
</button>
</div>
</div>
</div>
</div>
{/* Main Relations View */}
{filtered.length === 0 ? (
<div className="p-12 text-center rounded-2xl border border-border/70 bg-card shadow-xs">
<Ic name="route" className="size-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-xs text-muted-foreground font-medium">
مسیری با فیلترهای انتخابی یافت نشد.
</p>
</div>
) : viewMode === 'cards' ? (
/* Cards View */
<div className="grid grid-cols-1 md:grid-cols-2 gap-3.5">
{filtered.map((rel) => {
const activeMethodsCount =
(rel.hasAirplane ? 1 : 0) + (rel.hasTrain ? 1 : 0) + (rel.hasRoad ? 1 : 0)
return (
<div
key={rel.id}
className="p-4 rounded-2xl border border-border/70 bg-card hover:border-primary/40 transition-all shadow-xs flex flex-col justify-between gap-3"
>
{/* Route Header */}
<div className="flex items-center justify-between gap-2 border-b border-border/50 pb-3">
<div className="flex items-center gap-2 flex-wrap text-sm font-bold text-foreground">
<span className="px-2 py-0.5 rounded-lg bg-muted text-xs font-semibold text-foreground">
{rel.fromCity}
</span>
<span className="text-primary font-mono text-base">──────→</span>
<span className="px-2 py-0.5 rounded-lg bg-primary/10 text-xs font-semibold text-primary">
{rel.toCity}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEdit(rel)}
className="size-7 p-0 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground"
>
<Ic name="pencil" className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setItemToDelete(rel)}
className="size-7 p-0 rounded-lg hover:bg-rose-500/10 text-muted-foreground hover:text-rose-600"
>
<Ic name="trash" className="size-3.5" />
</Button>
</div>
</div>
{/* Transportation Toggles (Airplane, Train, Road) */}
<div>
<span className="text-[11px] text-muted-foreground block mb-2 font-medium">
{t('customTrip.availableMethods')} ({toFa(activeMethodsCount)} روش مجاز):
</span>
<div className="grid grid-cols-3 gap-2">
{/* Airplane */}
<button
type="button"
onClick={(e) => handleToggleMethod(rel, 'airplane', e)}
className={`p-2.5 rounded-xl border text-xs font-semibold flex flex-col items-center gap-1 transition-all cursor-pointer ${
rel.hasAirplane
? 'bg-blue-500/10 border-blue-500/30 text-blue-600 dark:text-blue-400 shadow-2xs'
: 'bg-muted/30 border-border/50 text-muted-foreground opacity-60 hover:opacity-100'
}`}
>
<span className="text-base">✈️</span>
<span>هواپیما</span>
<span
className={`text-[10px] px-1.5 py-0.2 rounded-full font-mono ${
rel.hasAirplane ? 'bg-blue-500 text-white' : 'bg-muted text-muted-foreground'
}`}
>
{rel.hasAirplane ? 'فعال' : 'غیرفعال'}
</span>
</button>
{/* Train */}
<button
type="button"
onClick={(e) => handleToggleMethod(rel, 'train', e)}
className={`p-2.5 rounded-xl border text-xs font-semibold flex flex-col items-center gap-1 transition-all cursor-pointer ${
rel.hasTrain
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400 shadow-2xs'
: 'bg-muted/30 border-border/50 text-muted-foreground opacity-60 hover:opacity-100'
}`}
>
<span className="text-base">🚆</span>
<span>قطار</span>
<span
className={`text-[10px] px-1.5 py-0.2 rounded-full font-mono ${
rel.hasTrain ? 'bg-emerald-500 text-white' : 'bg-muted text-muted-foreground'
}`}
>
{rel.hasTrain ? 'فعال' : 'غیرفعال'}
</span>
</button>
{/* Road */}
<button
type="button"
onClick={(e) => handleToggleMethod(rel, 'road', e)}
className={`p-2.5 rounded-xl border text-xs font-semibold flex flex-col items-center gap-1 transition-all cursor-pointer ${
rel.hasRoad
? 'bg-amber-500/10 border-amber-500/30 text-amber-600 dark:text-amber-400 shadow-2xs'
: 'bg-muted/30 border-border/50 text-muted-foreground opacity-60 hover:opacity-100'
}`}
>
<span className="text-base">🚐</span>
<span>زمینی / جاده‌ای</span>
<span
className={`text-[10px] px-1.5 py-0.2 rounded-full font-mono ${
rel.hasRoad ? 'bg-amber-500 text-white' : 'bg-muted text-muted-foreground'
}`}
>
{rel.hasRoad ? 'فعال' : 'غیرفعال'}
</span>
</button>
</div>
</div>
{/* Details snippet if available */}
{(rel.airplaneDetails || rel.roadDetails || rel.trainDetails) && (
<div className="pt-2 border-t border-border/40 text-[11px] text-muted-foreground space-y-1 bg-muted/20 p-2 rounded-xl">
{rel.hasAirplane && rel.airplaneDetails && (
<div className="flex items-center gap-1.5">
<span>✈️</span>
<span>{rel.airplaneDetails}</span>
</div>
)}
{rel.hasTrain && rel.trainDetails && (
<div className="flex items-center gap-1.5">
<span>🚆</span>
<span>{rel.trainDetails}</span>
</div>
)}
{rel.hasRoad && rel.roadDetails && (
<div className="flex items-center gap-1.5">
<span>🚐</span>
<span>{rel.roadDetails}</span>
</div>
)}
</div>
)}
</div>
)
})}
</div>
) : (
/* Matrix / Table View */
<div className="overflow-hidden rounded-2xl border border-border/70 bg-card 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">شهر مبدأ</th>
<th className="px-4 py-3.5 text-start">شهر مقصد</th>
<th className="px-4 py-3.5 text-center">✈️ هواپیما</th>
<th className="px-4 py-3.5 text-center">🚆 قطار</th>
<th className="px-4 py-3.5 text-center">🚐 زمینی / جاده‌ای</th>
<th className="px-4 py-3.5 text-center">{t('customTrip.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{filtered.map((rel) => (
<tr key={rel.id} className="hover:bg-muted/20 transition-colors">
<td className="px-4 py-3.5 font-bold text-foreground">
<span className="inline-flex items-center gap-1.5">
<span>{rel.fromCity}</span>
<span className="text-[10px] text-muted-foreground font-normal">
({rel.fromCountry})
</span>
</span>
</td>
<td className="px-4 py-3.5 font-bold text-primary">
<span className="inline-flex items-center gap-1.5">
<span>{rel.toCity}</span>
<span className="text-[10px] text-muted-foreground font-normal">
({rel.toCountry})
</span>
</span>
</td>
<td className="px-4 py-3.5 text-center">
<button
type="button"
onClick={(e) => handleToggleMethod(rel, 'airplane', e)}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-semibold cursor-pointer ${
rel.hasAirplane
? 'bg-blue-500/10 text-blue-600 dark:text-blue-400'
: 'bg-muted/40 text-muted-foreground opacity-50'
}`}
>
<span>✈️</span>
<span>{rel.hasAirplane ? 'دارد' : 'ندارد'}</span>
</button>
</td>
<td className="px-4 py-3.5 text-center">
<button
type="button"
onClick={(e) => handleToggleMethod(rel, 'train', e)}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-semibold cursor-pointer ${
rel.hasTrain
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'bg-muted/40 text-muted-foreground opacity-50'
}`}
>
<span>🚆</span>
<span>{rel.hasTrain ? 'دارد' : 'ندارد'}</span>
</button>
</td>
<td className="px-4 py-3.5 text-center">
<button
type="button"
onClick={(e) => handleToggleMethod(rel, 'road', e)}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-semibold cursor-pointer ${
rel.hasRoad
? 'bg-amber-500/10 text-amber-600 dark:text-amber-400'
: 'bg-muted/40 text-muted-foreground opacity-50'
}`}
>
<span>🚐</span>
<span>{rel.hasRoad ? 'دارد' : 'ندارد'}</span>
</button>
</td>
<td className="px-4 py-3.5 text-center">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEdit(rel)}
className="size-8 p-0 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground"
>
<Ic name="pencil" className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setItemToDelete(rel)}
className="size-8 p-0 rounded-lg hover:bg-rose-500/10 text-muted-foreground hover:text-rose-600"
>
<Ic name="trash" className="size-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Add / Edit Relation Modal */}
<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">
<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" />
</div>
<div>
<DialogTitle className="text-lg font-bold text-foreground">
{editingItem ? 'ویرایش مسیر و حمل‌ونقل' : 'تعریف ارتباط و مسیر جدید'}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
مشخص کنید برای رسیدن از شهر مبدأ به شهر مقصد، کدام روش‌های حمل‌ونقل در دسترس هستند.
</DialogDescription>
</div>
</div>
</DialogHeader>
{/* فرم: بدنه اسکرول‌شونده و فوتر فیکس‌شده */}
<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="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground block">
شهر مبدأ <span className="text-rose-500">*</span>
</label>
<select
value={formFromCity}
onChange={(e) => setFormFromCity(e.target.value)}
className="w-full h-11 px-3 text-xs rounded-xl bg-surface-base border border-border-soft text-foreground font-medium focus:ring-1 focus:ring-primary focus:outline-none"
>
{origins.map((o) => (
<option key={o.id} value={o.name}>
{o.flag} {o.name}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground block">
شهر مقصد <span className="text-rose-500">*</span>
</label>
<select
value={formToCity}
onChange={(e) => setFormToCity(e.target.value)}
className="w-full h-11 px-3 text-xs rounded-xl bg-surface-base border border-border-soft text-foreground font-medium focus:ring-1 focus:ring-primary focus:outline-none"
>
{destinations.map((d) => (
<option key={d.id} value={d.name}>
{d.flag} {d.name}
</option>
))}
</select>
</div>
</div>
{/* روش‌های حمل‌ونقل مجاز */}
<div className="space-y-3 rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs font-bold text-foreground block">
روش‌های حمل‌ونقل فعال برای این مسیر:
</span>
{/* Airplane */}
<div className="space-y-2 p-3 rounded-xl bg-surface-base/50 border border-border-soft">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={formHasAirplane}
onChange={(e) => setFormHasAirplane(e.target.checked)}
className="rounded text-primary size-4"
/>
<span className="text-xs font-bold text-foreground">✈️ هوایی (هواپیما)</span>
</label>
{formHasAirplane && (
<Input
value={formAirplaneDetails}
onChange={(e) => setFormAirplaneDetails(e.target.value)}
placeholder="توضیحات پرواز (مثال: پرواز مستقیم نجف یا مشهد...)"
className="h-9 rounded-xl text-xs bg-card"
/>
)}
</div>
{/* Train */}
<div className="space-y-2 p-3 rounded-xl bg-surface-base/50 border border-border-soft">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={formHasTrain}
onChange={(e) => setFormHasTrain(e.target.checked)}
className="rounded text-primary size-4"
/>
<span className="text-xs font-bold text-foreground">🚆 ریلی (قطار)</span>
</label>
{formHasTrain && (
<Input
value={formTrainDetails}
onChange={(e) => setFormTrainDetails(e.target.value)}
placeholder="توضیحات قطار (مثال: قطار ۵ ستاره فدک...)"
className="h-9 rounded-xl text-xs bg-card"
/>
)}
</div>
{/* Road */}
<div className="space-y-2 p-3 rounded-xl bg-surface-base/50 border border-border-soft">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={formHasRoad}
onChange={(e) => setFormHasRoad(e.target.checked)}
className="rounded text-primary size-4"
/>
<span className="text-xs font-bold text-foreground">
🚐 زمینی و جاده‌ای (اتوبوس VIP، تاکسی، ون)
</span>
</label>
{formHasRoad && (
<Input
value={formRoadDetails}
onChange={(e) => setFormRoadDetails(e.target.value)}
placeholder="توضیحات حمل‌ونقل زمینی (مثال: اتوبوس VIP از مرز مهران...)"
className="h-9 rounded-xl text-xs bg-card"
/>
)}
</div>
</div>
</div>
{/* فوتر فیکس‌شده در پایین مدال */}
<DialogFooter className="p-4 px-6 border-t border-border-soft bg-surface-base/30 flex items-center justify-between sm:justify-between shrink-0">
<Button
variant="outline"
type="button"
onClick={() => setIsAddOpen(false)}
className="rounded-xl text-xs h-10 px-5"
>
{t('customTrip.cancel')}
</Button>
<Button
type="submit"
className="rounded-xl text-xs font-bold h-10 px-6"
>
{t('customTrip.saveChanges')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Modal using shared DeleteConfirmDialog */}
<DeleteConfirmDialog
open={!!itemToDelete}
onOpenChange={(open) => !open && setItemToDelete(null)}
title="حذف مسیر حمل‌ونقل"
description={`آیا از حذف مسیر «${itemToDelete?.fromCity || ''}» به «${itemToDelete?.toCity || ''}» اطمینان دارید؟`}
onConfirm={handleConfirmDelete}
/>
</div>
)
}

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

@ -0,0 +1,715 @@
import { apiFetch } from '@/services/http'
import type {
CustomTripOrderItem,
CustomTripOriginItem,
CustomTripDestinationItem,
CustomTripTransportItem,
CustomTripTransportRelationItem,
CustomTripHotelItem,
CustomTripOrderStatus,
} from '../types'
// Mock fallback orders
const MOCK_ORDERS: CustomTripOrderItem[] = [
{
id: 1042,
user: {
id: 201,
username: 'mohammed_ali',
fullName: 'محمد علی الحسینی',
phoneNumber: '+968 9123 4567',
email: 'm.ali@example.com',
},
originCity: 'مسقط',
destinations: ['مشهد', 'کربلا'],
legs: [
{
fromCity: 'مسقط',
toCity: 'مشهد',
transportMethod: 'هوایی (پرواز مستقیم سلام‌ایر)',
hotelName: 'هتل قصر طلایی مشهد',
stayNights: 4,
mealPlan: 'فول‌برد (صبحانه، ناهار، شام)',
},
{
fromCity: 'مشهد',
toCity: 'کربلا',
transportMethod: 'هوایی (مشهد - نجف چارتر)',
hotelName: 'هتل بارون کربلا',
stayNights: 5,
mealPlan: 'فول‌برد (صبحانه، ناهار، شام)',
},
],
adultsCount: 3,
childrenCount: 1,
infantsCount: 0,
priceEstimate: 1450,
status: 'PENDING',
created: '2026-10-04T12:30:00Z',
detail: 'درخواست سفر زیارتی برای ۳ بزرگسال و ۱ کودک، نیازمند ترانسفر فرودگاهی و خدمات ویلچر در حرم مطهر.',
adminNotes: 'در حال استعلام نرخ هتل بارون برای ایام عید.',
},
{
id: 1038,
user: {
id: 204,
username: 'fatemeh_zahra',
fullName: 'فاطمه الزهراء',
phoneNumber: '+965 9988 7766',
email: 'fatemeh@example.com',
},
originCity: 'کویت',
destinations: ['نجف', 'کربلا', 'سامرا'],
legs: [
{
fromCity: 'کویت',
toCity: 'نجف',
transportMethod: 'هوایی (هواپیمایی الجزیره)',
hotelName: 'هتل قصر الدر نجف',
stayNights: 3,
mealPlan: 'صبحانه',
},
{
fromCity: 'نجف',
toCity: 'کربلا',
transportMethod: 'زمینی (ون VIP اختصاصی)',
hotelName: 'هتل ریحانة کربلا',
stayNights: 4,
mealPlan: 'فول‌برد',
},
{
fromCity: 'کربلا',
toCity: 'سامرا',
transportMethod: 'زمینی (خودرو اختصاصی)',
hotelName: 'اقامت نیم‌روزه سامرا',
stayNights: 1,
mealPlan: 'ناهار',
},
],
adultsCount: 2,
childrenCount: 0,
infantsCount: 0,
priceEstimate: 980,
status: 'APPROVED',
created: '2026-10-03T18:15:00Z',
detail: 'سفر دونفره اختصاصی برای سالگرد ازدواج. هتل‌های نزدیک به حرم اولویت دارند.',
adminNotes: 'تأیید شد و پیش‌فاکتور برای زائر ارسال شد.',
},
{
id: 1031,
user: {
id: 215,
username: 'sadeq_bahraini',
fullName: 'صادق البحرانی',
phoneNumber: '+973 3344 5566',
email: 'sadeq@example.com',
},
originCity: 'منامه',
destinations: ['مشهد'],
legs: [
{
fromCity: 'منامه',
toCity: 'مشهد',
transportMethod: 'هوایی (گلف ایر / فلای دبی ترانزیت)',
hotelName: 'هتل مجلل درویشی مشهد',
stayNights: 6,
mealPlan: 'فول‌برد',
},
],
adultsCount: 2,
childrenCount: 2,
infantsCount: 1,
priceEstimate: 620,
status: 'AWAITING_PAYMENT',
created: '2026-10-02T09:40:00Z',
detail: 'سفر خانوادگی به مشهد مقدس همراه با دو فرزند خردسال و یک نوزاد.',
adminNotes: 'فاکتور صادر شده و منتظر پرداخت زائر است.',
},
]
// Mock Origins organized by core countries
const MOCK_ORIGINS: CustomTripOriginItem[] = [
// عمان (Oman)
{ id: 'muscat', name: 'مسقط', nameEn: 'Muscat', country: 'عُمان', countryCode: 'OM', flag: '🇴🇲', isInternational: true, isActive: true },
{ id: 'salalah', name: 'صلاله', nameEn: 'Salalah', country: 'عُمان', countryCode: 'OM', flag: '🇴🇲', isInternational: true, isActive: true },
// بحرین (Bahrain)
{ id: 'manama', name: 'منامه', nameEn: 'Manama', country: 'بحرین', countryCode: 'BH', flag: '🇧🇭', isInternational: true, isActive: true },
{ id: 'muharraq', name: 'المحرق', nameEn: 'Muharraq', country: 'بحرین', countryCode: 'BH', flag: '🇧🇭', isInternational: true, isActive: true },
// کویت (Kuwait)
{ id: 'kuwait_city', name: 'کویت', nameEn: 'Kuwait City', country: 'کویت', countryCode: 'KW', flag: '🇰🇼', isInternational: true, isActive: true },
{ id: 'al_ahmadi', name: 'الاحمدی', nameEn: 'Al Ahmadi', country: 'کویت', countryCode: 'KW', flag: '🇰🇼', isInternational: true, isActive: true },
// عربستان سعودی (Saudi Arabia)
{ id: 'riyadh', name: 'ریاض', nameEn: 'Riyadh', country: 'عربستان سعودی', countryCode: 'SA', flag: '🇸🇦', isInternational: true, isActive: true },
{ id: 'jeddah', name: 'جده', nameEn: 'Jeddah', country: 'عربستان سعودی', countryCode: 'SA', flag: '🇸🇦', isInternational: true, isActive: true },
{ id: 'dammam', name: 'دمام', nameEn: 'Dammam', country: 'عربستان سعودی', countryCode: 'SA', flag: '🇸🇦', isInternational: true, isActive: true },
{ id: 'qatif', name: 'قطیف', nameEn: 'Qatif', country: 'عربستان سعودی', countryCode: 'SA', flag: '🇸🇦', isInternational: true, isActive: true },
// ایران (Iran)
{ id: 'tehran', name: 'تهران', nameEn: 'Tehran', country: 'ایران', countryCode: 'IR', flag: '🇮🇷', isInternational: false, isActive: true },
{ id: 'mashhad', name: 'مشهد', nameEn: 'Mashhad', country: 'ایران', countryCode: 'IR', flag: '🇮🇷', isInternational: false, isActive: true },
{ id: 'qom', name: 'قم', nameEn: 'Qom', country: 'ایران', countryCode: 'IR', flag: '🇮🇷', isInternational: false, isActive: true },
{ id: 'isfahan', name: 'اصفهان', nameEn: 'Isfahan', country: 'ایران', countryCode: 'IR', flag: '🇮🇷', isInternational: false, isActive: true },
{ id: 'shiraz', name: 'شیراز', nameEn: 'Shiraz', country: 'ایران', countryCode: 'IR', flag: '🇮🇷', isInternational: false, isActive: true },
{ id: 'ahvaz', name: 'اهواز', nameEn: 'Ahvaz', country: 'ایران', countryCode: 'IR', flag: '🇮🇷', isInternational: false, isActive: true },
// عراق (Iraq)
{ id: 'baghdad', name: 'بغداد', nameEn: 'Baghdad', country: 'عراق', countryCode: 'IQ', flag: '🇮🇶', isInternational: true, isActive: true },
{ id: 'basra', name: 'بصره', nameEn: 'Basra', country: 'عراق', countryCode: 'IQ', flag: '🇮🇶', isInternational: true, isActive: true },
]
// Mock Destinations
const MOCK_DESTINATIONS: CustomTripDestinationItem[] = [
{
id: 'karbala',
name: 'کربلای معلی',
nameEn: 'Karbala',
country: 'عراق',
flag: '🇮🇶',
description: 'حرم مطهر امام حسین (ع)، حرم حضرت ابوالفضل العباس (ع)، خیمه‌گاه و تل زینبیه',
holyPlaces: ['حرم سیدالشهدا (ع)', 'حرم حضرت عباس (ع)', 'مقام حضرت علی‌اکبر (ع)', 'تل زینبیه'],
hotelsCount: 6,
isActive: true,
},
{
id: 'najaf',
name: 'نجف اشرف',
nameEn: 'Najaf',
country: 'عراق',
flag: '🇮🇶',
description: 'حرم مطهر امیرالمؤمنین امام علی (ع)، مسجد کوفه، مسجد سهله و وادی‌السلام',
holyPlaces: ['حرم امیرالمؤمنین (ع)', 'مسجد کوفه', 'مسجد سهله', 'قبرستان وادی‌السلام'],
hotelsCount: 5,
isActive: true,
},
{
id: 'mashhad',
name: 'مشهد مقدس',
nameEn: 'Mashhad',
country: 'ایران',
flag: '🇮🇷',
description: 'بارگاه ملکوتی حضرت علی بن موسی الرضا (ع)، خواجه ربیع و خواجه اباصلت',
holyPlaces: ['حرم مطهر امام رضا (ع)', 'مسجد گوهرشاد', 'خواجه ربیع'],
hotelsCount: 8,
isActive: true,
},
{
id: 'qom',
name: 'قم مقدسه',
nameEn: 'Qom',
country: 'ایران',
flag: '🇮🇷',
description: 'حرم مطهر حضرت فاطمه معصومه (س)، مسجد مقدس جمکران و کوه خضر نبی',
holyPlaces: ['حرم حضرت فاطمه معصومه (س)', 'مسجد مقدس جمکران'],
hotelsCount: 4,
isActive: true,
},
{
id: 'medina',
name: 'مدینه منوره',
nameEn: 'Medina',
country: 'عربستان سعودی',
flag: '🇸🇦',
description: 'مسجد النبی (ص)، مرقد مطهر رسول اکرم (ص)، قبرستان بقیع، مسجد قبا و مسجد قبلتین',
holyPlaces: ['مسجد النبی (ص)', 'قبرستان بقیع', 'مسجد قبا', 'شهدای احد'],
hotelsCount: 5,
isActive: true,
},
{
id: 'mecca',
name: 'مکه مکرمه',
nameEn: 'Mecca',
country: 'عربستان سعودی',
flag: '🇸🇦',
description: 'مسجد الحرام، کعبه مشرفه، غار حرا، کوه نور و صحرای عرفات و منا',
holyPlaces: ['مسجد الحرام', 'کعبه مشرفه', 'غار حرا', 'منا و عرفات'],
hotelsCount: 6,
isActive: true,
},
{
id: 'samarra',
name: 'سامرا و کاظمین',
nameEn: 'Samarra & Kadhimiya',
country: 'عراق',
flag: '🇮🇶',
description: 'حرم مطهر امامین عسکریین (ع) در سامرا و حرم امامین جوادین (ع) در کاظمین',
holyPlaces: ['حرم امام هادی و عسکری (ع)', 'حرم امام جواد و کاظم (ع)'],
hotelsCount: 3,
isActive: true,
},
]
// Mock Relations: From Origins to Destinations with Airplane, Train, Road
const MOCK_RELATIONS: CustomTripTransportRelationItem[] = [
// Karbala Relations
{
id: 'rel_mashhad_karbala',
fromCity: 'مشهد ',
fromCountry: 'ایران',
toCity: 'کربلای معلی',
toCountry: 'عراق',
hasAirplane: true,
hasTrain: false,
hasRoad: false,
airplaneDetails: 'پرواز مستقیم مشهد به نجف / بغداد',
isActive: true,
},
{
id: 'rel_muscat_karbala',
fromCity: 'مسقط ',
fromCountry: 'عُمان',
toCity: 'کربلای معلی',
toCountry: 'عراق',
hasAirplane: true,
hasTrain: false,
hasRoad: false,
airplaneDetails: 'پرواز سلام‌ایر به نجف اشرف',
isActive: true,
},
{
id: 'rel_tehran_karbala',
fromCity: 'تهران ',
fromCountry: 'ایران',
toCity: 'کربلای معلی',
toCountry: 'عراق',
hasAirplane: true,
hasTrain: false,
hasRoad: true,
airplaneDetails: 'پرواز فرودگاه امام خمینی به نجف',
roadDetails: 'اتوبوس‌های VIP مستقیم از ترمینال غرب تا مرز مهران یا کربلا',
isActive: true,
},
{
id: 'rel_qom_karbala',
fromCity: 'قم ',
fromCountry: 'ایران',
toCity: 'کربلای معلی',
toCountry: 'عراق',
hasAirplane: false,
hasTrain: false,
hasRoad: true,
roadDetails: 'ون و اتوبوس‌های زیارتی مستقیم از قم تا کربلا (مرز مهران)',
isActive: true,
},
{
id: 'rel_kuwait_karbala',
fromCity: 'کویت ',
fromCountry: 'کویت',
toCity: 'کربلای معلی',
toCountry: 'عراق',
hasAirplane: true,
hasTrain: false,
hasRoad: true,
airplaneDetails: 'پرواز کویت به نجف اشرف (الجزیره)',
roadDetails: 'ترانسفر زمینی اختصاصی از گذرگاه مرزی سفوان / بصره',
isActive: true,
},
{
id: 'rel_manama_karbala',
fromCity: 'منامه ',
fromCountry: 'بحرین',
toCity: 'کربلای معلی',
toCountry: 'عراق',
hasAirplane: true,
hasTrain: false,
hasRoad: false,
airplaneDetails: 'پرواز مستقیم گلف‌ایر به فرودگاه نجف',
isActive: true,
},
{
id: 'rel_ahvaz_karbala',
fromCity: 'اهواز ',
fromCountry: 'ایران',
toCity: 'کربلای معلی',
toCountry: 'عراق',
hasAirplane: true,
hasTrain: false,
hasRoad: true,
airplaneDetails: 'پرواز فصلی اهواز به نجف',
roadDetails: 'ترانسفر زمینی از مرز چذابه و شلمچه با اتوبوس و ون',
isActive: true,
},
// Mashhad Relations
{
id: 'rel_tehran_mashhad',
fromCity: 'تهران ',
fromCountry: 'ایران',
toCity: 'مشهد مقدس',
toCountry: 'ایران',
hasAirplane: true,
hasTrain: true,
hasRoad: true,
airplaneDetails: 'پروازهای روزانه ایران‌ایر و ماهان',
trainDetails: 'قطارهای ۵ ستاره فدک، زندگی و نورالرضا',
roadDetails: 'اتوبوس‌های ۲۵ نفره تخت‌شو',
isActive: true,
},
{
id: 'rel_muscat_mashhad',
fromCity: 'مسقط ',
fromCountry: 'عُمان',
toCity: 'مشهد مقدس',
toCountry: 'ایران',
hasAirplane: true,
hasTrain: false,
hasRoad: false,
airplaneDetails: 'پرواز مستقیم بین‌المللی عمان به مشهد',
isActive: true,
},
{
id: 'rel_manama_mashhad',
fromCity: 'منامه ',
fromCountry: 'بحرین',
toCity: 'مشهد مقدس',
toCountry: 'ایران',
hasAirplane: true,
hasTrain: false,
hasRoad: false,
airplaneDetails: 'پرواز ترانزیتی بحرین به مشهد',
isActive: true,
},
{
id: 'rel_isfahan_mashhad',
fromCity: 'اصفهان ',
fromCountry: 'ایران',
toCity: 'مشهد مقدس',
toCountry: 'ایران',
hasAirplane: true,
hasTrain: true,
hasRoad: true,
airplaneDetails: 'پروازهای منظم اصفهان به مشهد',
trainDetails: 'قطار ۴ تخته و پلور سبز',
roadDetails: 'اتوبوس‌های VIP',
isActive: true,
},
// Najaf Relations
{
id: 'rel_tehran_najaf',
fromCity: 'تهران ',
fromCountry: 'ایران',
toCity: 'نجف اشرف',
toCountry: 'عراق',
hasAirplane: true,
hasTrain: false,
hasRoad: true,
airplaneDetails: 'پروازهای مستقیم فرودگاه امام به نجف',
roadDetails: 'اتوبوس و ترانسفر زمینی از مرز مهران',
isActive: true,
},
{
id: 'rel_riyadh_najaf',
fromCity: 'ریاض ',
fromCountry: 'عربستان سعودی',
toCity: 'نجف اشرف',
toCountry: 'عراق',
hasAirplane: true,
hasTrain: false,
hasRoad: false,
airplaneDetails: 'پرواز مستقیم فرودگاه ملک خالد به نجف',
isActive: true,
},
]
// Mock Legacy Transports
const MOCK_TRANSPORTS: CustomTripTransportItem[] = [
{ id: 1, fromCity: 'مشهد', toCity: 'کربلا', method: 1, methodName: 'هوایی (پرواز چارتر)', priceAdult: 220, priceChild: 180, priceInfant: 40, isActive: true },
{ id: 2, fromCity: 'تهران', toCity: 'مشهد', method: 1, methodName: 'هوایی (ایران‌ایر / ماهان)', priceAdult: 65, priceChild: 50, priceInfant: 15, isActive: true },
{ id: 3, fromCity: 'تهران', toCity: 'مشهد', method: 2, methodName: 'قطار ۵ ستاره فدک', priceAdult: 35, priceChild: 25, priceInfant: 5, isActive: true },
{ id: 4, fromCity: 'مسقط', toCity: 'مشهد', method: 1, methodName: 'هوایی بین‌المللی (سلام ایر)', priceAdult: 280, priceChild: 230, priceInfant: 50, isActive: true },
{ id: 5, fromCity: 'قم', toCity: 'کربلا', method: 3, methodName: 'ترانسفر زمینی اتوبوس VIP اختصاصی', priceAdult: 45, priceChild: 35, priceInfant: 0, isActive: true },
]
// Mock Hotels with Stars, Distance to Shrine, Amenities, and Details
const MOCK_HOTELS: CustomTripHotelItem[] = [
// Karbala Hotels
{
id: 'hotel_baron_karbala',
name: 'هتل بارون کربلا (Baron Hotel Karbala)',
cityName: 'کربلای معلی',
stars: 5,
score: 4.8,
distanceToShrine: '۷۰۰ متر تا حرم مطهر (ترانسفر رایگان شبانه‌روزی هر ۱۰ دقیقه)',
distanceToShrineKm: 0.7,
amenities: ['اینترنت رایگان وای‌فای', 'رستوران بوفه ایرانی و بین‌المللی', 'ترانسفر رایگان به حرم', 'لاندری و اتوشویی', 'خدمات ویژه ویلچر', 'پزشک مقیم'],
description: 'یکی از لوکس‌ترین و مجهزترین هتل‌های ۵ ستاره شهر مقدس کربلا با خدمات درجه یک بین‌المللی، بوفه مجلل و منظره رو به شهر.',
pricePerDayAdult: 130,
pricePerDayChild: 65,
image: '',
isActive: true,
},
{
id: 'hotel_rotana_karbala',
name: 'هتل ریحانة روتانا کربلا (Rotana)',
cityName: 'کربلای معلی',
stars: 5,
score: 4.9,
distanceToShrine: '۴۰۰ متر تا باب بغداد حرم مطهر حضرت عباس (ع)',
distanceToShrineKm: 0.4,
amenities: ['وای‌فای فیبر نوری', 'بوفه باز مجلل', 'کافی‌شاپ ۲۴ ساعته', 'سرویس اتاق شبانه‌روزی', 'صندوق امانات'],
description: 'هتل زنجیره‌ای روتانا با طراحی چشم‌نواز اسلامی و مدرن، دسترسی پیاده آسان به باب‌القبله و خدمات پذیرایی VIP.',
pricePerDayAdult: 145,
pricePerDayChild: 70,
image: '',
isActive: true,
},
{
id: 'hotel_qasr_dor_karbala',
name: 'هتل قصر الدر کربلا (Qasr Al-Durr)',
cityName: 'کربلای معلی',
stars: 4,
score: 4.6,
distanceToShrine: '۲۵۰ متر تا بین‌الحرمین شریفین',
distanceToShrineKm: 0.25,
amenities: ['اینترنت پرسرعت', 'آشپز ایرانی', 'آسانسورهای سریع', 'سرویس فرنگی و ایرانی', 'نزدیکی فوق‌العاده به حرم'],
description: 'هتل محبوب زائرین ایرانی و کشورهای حوزه خلیج فارس به دلیل نزدیکی استثنایی به حرم مطهر و کیفیت بالای غذاهای ایرانی.',
pricePerDayAdult: 85,
pricePerDayChild: 45,
image: '',
isActive: true,
},
// Najaf Hotels
{
id: 'hotel_qasr_dor_najaf',
name: 'هتل قصر الدر نجف (Qasr Al-Durr Najaf)',
cityName: 'نجف اشرف',
stars: 5,
score: 4.7,
distanceToShrine: '۲۰۰ متر تا صحن حضرت فاطمه زهرا (س) و باب‌القبله',
distanceToShrineKm: 0.2,
amenities: ['وای‌فای رایگان', 'رستوران ایرانی و عربی', 'آسانسورهای مدرن', 'خدمات نظافت روزانه', 'سرویس چای رایگان'],
description: 'مجلل‌ترین هتل مشرف به بارگاه ملکوتی امیرالمؤمنین (ع) با لابی باشکوه و موقعیت مکانی بی‌نظیر برای پیاده‌روی سالمندان.',
pricePerDayAdult: 95,
pricePerDayChild: 50,
image: '',
isActive: true,
},
{
id: 'hotel_kowsar_najaf',
name: 'هتل کوثر الندا نجف (Kowsar Hotel)',
cityName: 'نجف اشرف',
stars: 4,
score: 4.5,
distanceToShrine: '۳۵۰ متر تا حرم مطهر علوی (شارع الرسول)',
distanceToShrineKm: 0.35,
amenities: ['اینترنت در لابی و اتاق', 'صبحانه و شام سلف‌سرویس', 'لاندری', 'سرویس ایرانی و فرنگی'],
description: 'هتل تمیز و خوش‌ساخت در قلب بافت سنتی و بازارهای نجف با دسترسی سریع به شارع الرسول و وادی‌السلام.',
pricePerDayAdult: 70,
pricePerDayChild: 35,
image: '',
isActive: true,
},
// Mashhad Hotels
{
id: 'hotel_ghasr_talaee_mashhad',
name: 'هتل بین‌المللی قصر طلایی (Ghasr Talaee)',
cityName: 'مشهد مقدس',
stars: 5,
score: 4.9,
distanceToShrine: '۱.۲ کیلومتر (خیابان امام رضا - ترانسفر رفت و برگشت حرم رایگان)',
distanceToShrineKm: 1.2,
amenities: ['ترانسفر فرودگاهی و حرم', 'مجموعه آبی و اسپا', 'چندین رستوران سنتی و بین‌المللی', 'اینترنت رایگان', 'اتاق کودک'],
description: 'برترین هتل کشور و مشهد مقدس با امکانات لوکس اقامتی، صبحانه بوفه معروف و پذیرایی شایسته زائرین امام رضا (ع).',
pricePerDayAdult: 110,
pricePerDayChild: 55,
image: '',
isActive: true,
},
{
id: 'hotel_darvishi_mashhad',
name: 'هتل مجلل درویشی مشهد (Darvishi)',
cityName: 'مشهد مقدس',
stars: 5,
score: 4.8,
distanceToShrine: '۱.۴ کیلومتر (خیابان امام رضا - ترانسفر حرم هر ۲۰ دقیقه)',
distanceToShrineKm: 1.4,
amenities: ['مجموعه سونا و جکوزی', 'رستوران آتریوم', 'اینترنت پرسرعت', 'خدمات CIP فرودگاهی', 'ترانسفر حرم'],
description: 'تنها هتل آتریوم شرق کشور با معماری باشکوه مرتفع و خدمات ویژه VIP برای سفرهای زیارتی خانواده‌ها.',
pricePerDayAdult: 120,
pricePerDayChild: 60,
image: '',
isActive: true,
},
]
export async function fetchCustomTripOrders(): Promise<CustomTripOrderItem[]> {
try {
const res = await apiFetch<any>('custom/orders/?view=admin')
if (Array.isArray(res)) return res
if (res?.results && Array.isArray(res.results)) return res.results
return MOCK_ORDERS
} catch {
return MOCK_ORDERS
}
}
export async function updateCustomTripOrder(
orderId: number,
data: {
status?: CustomTripOrderStatus
priceEstimate?: number
adminNotes?: string
}
): Promise<CustomTripOrderItem> {
try {
return await apiFetch<CustomTripOrderItem>(`custom/orders/${orderId}/`, {
method: 'PATCH',
body: JSON.stringify(data),
})
} catch {
const found = MOCK_ORDERS.find((o) => o.id === orderId)
if (found) {
if (data.status) found.status = data.status
if (data.priceEstimate !== undefined) found.priceEstimate = data.priceEstimate
if (data.adminNotes !== undefined) found.adminNotes = data.adminNotes
return { ...found }
}
throw new Error('سفارش یافت نشد.')
}
}
const STORAGE_KEYS = {
DESTINATIONS: 'aqila_custom_destinations',
HOTELS: 'aqila_custom_hotels',
RELATIONS: 'aqila_custom_relations',
ORIGINS: 'aqila_custom_origins',
}
function getStored<T>(key: string, fallback: T): T {
try {
if (typeof window !== 'undefined' && window.localStorage) {
const raw = window.localStorage.getItem(key)
if (raw) return JSON.parse(raw)
}
} catch {}
return fallback
}
function setStored<T>(key: string, value: T): void {
try {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.setItem(key, JSON.stringify(value))
}
} catch {}
}
export async function fetchCustomTripOrigins(): Promise<CustomTripOriginItem[]> {
return getStored(STORAGE_KEYS.ORIGINS, MOCK_ORIGINS)
}
export async function saveCustomTripOrigin(item: CustomTripOriginItem): Promise<CustomTripOriginItem> {
const list = await fetchCustomTripOrigins()
const exists = list.some((o) => o.id === item.id)
const updated = exists ? list.map((o) => (o.id === item.id ? item : o)) : [item, ...list]
setStored(STORAGE_KEYS.ORIGINS, updated)
return item
}
export async function deleteCustomTripOrigin(id: string | number): Promise<void> {
const list = await fetchCustomTripOrigins()
setStored(STORAGE_KEYS.ORIGINS, list.filter((o) => o.id !== id))
}
export async function fetchCustomTripDestinations(): Promise<CustomTripDestinationItem[]> {
return getStored(STORAGE_KEYS.DESTINATIONS, MOCK_DESTINATIONS)
}
export async function fetchCustomTripDestinationById(id: string | number): Promise<CustomTripDestinationItem | null> {
const list = await fetchCustomTripDestinations()
const found = list.find(
(d) =>
String(d.id) === String(id) ||
d.name === String(id) ||
(d.nameEn && d.nameEn.toLowerCase() === String(id).toLowerCase())
)
return found || null
}
export async function saveCustomTripDestination(item: CustomTripDestinationItem): Promise<CustomTripDestinationItem> {
const list = await fetchCustomTripDestinations()
const exists = list.some((d) => d.id === item.id)
const updated = exists ? list.map((d) => (d.id === item.id ? item : d)) : [item, ...list]
setStored(STORAGE_KEYS.DESTINATIONS, updated)
return item
}
export async function deleteCustomTripDestination(id: string | number): Promise<void> {
const list = await fetchCustomTripDestinations()
setStored(STORAGE_KEYS.DESTINATIONS, list.filter((d) => d.id !== id))
}
export async function fetchCustomTripTransportRelations(): Promise<CustomTripTransportRelationItem[]> {
return getStored(STORAGE_KEYS.RELATIONS, MOCK_RELATIONS)
}
export async function saveCustomTripTransportRelation(
item: CustomTripTransportRelationItem
): Promise<CustomTripTransportRelationItem> {
const list = await fetchCustomTripTransportRelations()
const exists = list.some((r) => r.id === item.id)
const updated = exists ? list.map((r) => (r.id === item.id ? item : r)) : [item, ...list]
setStored(STORAGE_KEYS.RELATIONS, updated)
return item
}
export async function deleteCustomTripTransportRelation(id: string | number): Promise<void> {
const list = await fetchCustomTripTransportRelations()
setStored(STORAGE_KEYS.RELATIONS, list.filter((r) => r.id !== id))
}
export async function fetchCustomTripTransports(): Promise<CustomTripTransportItem[]> {
return MOCK_TRANSPORTS
}
export async function fetchCustomTripHotels(): Promise<CustomTripHotelItem[]> {
return getStored(STORAGE_KEYS.HOTELS, MOCK_HOTELS)
}
export async function saveCustomTripHotel(item: CustomTripHotelItem): Promise<CustomTripHotelItem> {
const list = await fetchCustomTripHotels()
const exists = list.some((h) => h.id === item.id)
const updated = exists ? list.map((h) => (h.id === item.id ? item : h)) : [item, ...list]
setStored(STORAGE_KEYS.HOTELS, updated)
// Also sync hotelsCount in destination
const dests = await fetchCustomTripDestinations()
const matchingDest = dests.find(
(d) => d.name === item.cityName || item.cityName.includes(d.name) || d.name.includes(item.cityName)
)
if (matchingDest) {
const destHotels = updated.filter(
(h) => h.cityName === matchingDest.name || matchingDest.name.includes(h.cityName) || h.cityName.includes(matchingDest.name)
)
matchingDest.hotelsCount = destHotels.length
await saveCustomTripDestination(matchingDest)
}
return item
}
export async function deleteCustomTripHotel(id: string | number): Promise<void> {
const list = await fetchCustomTripHotels()
const target = list.find((h) => h.id === id)
const remaining = list.filter((h) => h.id !== id)
setStored(STORAGE_KEYS.HOTELS, remaining)
if (target) {
const dests = await fetchCustomTripDestinations()
const matchingDest = dests.find(
(d) => d.name === target.cityName || target.cityName.includes(d.name) || d.name.includes(target.cityName)
)
if (matchingDest) {
const destHotels = remaining.filter(
(h) => h.cityName === matchingDest.name || matchingDest.name.includes(h.cityName) || h.cityName.includes(matchingDest.name)
)
matchingDest.hotelsCount = destHotels.length
await saveCustomTripDestination(matchingDest)
}
}
}

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

@ -0,0 +1,111 @@
export type CustomTripOrderStatus =
| 'PENDING'
| 'AWAITING_PAYMENT'
| 'APPROVED'
| 'REJECTED'
| 'EXPIRED'
| 'CANCELLED'
export interface CustomTripUser {
id: number
username: string
fullName?: string
email?: string
phoneNumber?: string
}
export interface CustomTripLeg {
fromCity: string
toCity: string
transportMethod?: string
hotelName?: string
stayNights?: number
mealPlan?: string
}
export interface CustomTripOrderItem {
id: number
user: CustomTripUser
originCity: string
destinations: string[]
legs: CustomTripLeg[]
adultsCount: number
childrenCount: number
infantsCount: number
priceEstimate: number
status: CustomTripOrderStatus
created: string
detail?: string
adminNotes?: string
}
export interface CustomTripOriginItem {
id: string | number
name: string
nameEn?: string
country: string
countryCode?: string
flag?: string
isInternational: boolean
isActive: boolean
translations?: Record<string, { name?: string; description?: string }>
}
export interface CustomTripDestinationItem {
id: string | number
name: string
nameEn?: string
country: string
flag?: string
description?: string
holyPlaces?: string[]
hotelsCount: number
isActive: boolean
translations?: Record<string, { name?: string; description?: string; holyPlaces?: string[] }>
}
export interface CustomTripTransportRelationItem {
id: string | number
fromCity: string
fromCountry: string
toCity: string
toCountry: string
hasAirplane: boolean
hasTrain: boolean
hasRoad: boolean
airplaneDetails?: string
trainDetails?: string
roadDetails?: string
guideNote?: string
isActive: boolean
}
export interface CustomTripTransportItem {
id: number
fromCity: string
toCity: string
method: 1 | 2 | 3 // 1: Airplane, 2: Train, 3: Road
methodName: string
priceAdult: number
priceChild: number
priceInfant: number
isActive: boolean
}
export interface CustomTripHotelItem {
id: number | string
name: string
cityName: string
stars: number // 1 to 5
score: number
distanceToShrine: string
distanceToShrineKm?: number
amenities: string[]
description: string
guideNote?: string
pricePerDayAdult: number
pricePerDayChild: number
image?: string
isActive: boolean
translations?: Record<string, { name?: string; description?: string }>
}

25
src/features/tours/TourOrdersPage.tsx

@ -545,6 +545,31 @@ export default function TourOrdersPage() {
</div> </div>
)} )}
{detailOrder.ticket_file && (
<div className="flex items-center justify-between p-3.5 rounded-xl border border-emerald-500/30 bg-emerald-500/5">
<div className="flex items-center gap-2.5">
<div className="size-8 rounded-lg bg-emerald-500/15 text-emerald-500 flex items-center justify-center shrink-0">
<Ic name="ticket" className="size-4" />
</div>
<div>
<p className="text-xs font-bold text-foreground">بلیت الکترونیکی صادر شده است</p>
<p className="text-[10px] text-muted-foreground truncate max-w-[200px]" dir="ltr">
{detailOrder.ticket_file.split('/').pop()}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs rounded-lg gap-1 border-emerald-500/40 text-emerald-600 hover:bg-emerald-500/10"
onClick={() => window.open(detailOrder.ticket_file!, '_blank')}
>
<Ic name="download" className="size-3" />
<span>مشاهده بلیت</span>
</Button>
</div>
)}
<div className="flex items-center justify-between p-3 rounded-xl bg-surface-2/60 border border-border/40"> <div className="flex items-center justify-between p-3 rounded-xl bg-surface-2/60 border border-border/40">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-grey-3">وضعیت فعلی سفارش:</span> <span className="text-grey-3">وضعیت فعلی سفارش:</span>

1
src/features/tours/types.ts

@ -141,6 +141,7 @@ export interface TourOrderItem {
transaction_id?: string | null transaction_id?: string | null
payment_receipt?: string | null payment_receipt?: string | null
receipt_verified: boolean receipt_verified: boolean
ticket_file?: string | null
} }
export interface DrfPaginatedResponse<T> { export interface DrfPaginatedResponse<T> {

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

@ -231,7 +231,7 @@ export function UserFactorsTab({ userId }: UserFactorsTabProps) {
{factors.map((f) => ( {factors.map((f) => (
<tr key={f.id} className="hover:bg-muted/30 transition-colors"> <tr key={f.id} className="hover:bg-muted/30 transition-colors">
<td className="py-3 px-4 font-mono font-semibold text-foreground"> <td className="py-3 px-4 font-mono font-semibold text-foreground">
#{f.id}
#{f.factor_number || f.id}
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<span className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-muted text-foreground"> <span className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-muted text-foreground">

1
src/features/users/types.ts

@ -82,6 +82,7 @@ export type FactorService = 'shop' | 'simcard' | 'tour' | 'tasrif' | 'custom_tri
export interface UserFactorItem { export interface UserFactorItem {
id: number id: number
factor_number?: number
user: { user: {
id: number id: number
fullname: string fullname: string

11
src/icons.tsx

@ -82,7 +82,10 @@ const PATHS: Record<string, string> = {
checkCircle: '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>', checkCircle: '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
xCircle: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>', xCircle: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
dollarSign: '<line x1="12" x2="12" y1="2" y2="22"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>', dollarSign: '<line x1="12" x2="12" y1="2" y2="22"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>',
clock: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
alertCircle: '<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>',
image: '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>', image: '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
ticket: '<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/>',
/* آیکون‌های اختصاصی خدمات سامانه */ /* آیکون‌های اختصاصی خدمات سامانه */
simcard: '<path d="M6 2h8l6 6v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z"/><rect x="8" y="10" width="8" height="9" rx="1.5"/><line x1="12" y1="10" x2="12" y2="19"/><line x1="8" y1="14.5" x2="16" y2="14.5"/>', simcard: '<path d="M6 2h8l6 6v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z"/><rect x="8" y="10" width="8" height="9" rx="1.5"/><line x1="12" y1="10" x2="12" y2="19"/><line x1="8" y1="14.5" x2="16" y2="14.5"/>',
guide: '<circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>', guide: '<circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>',
@ -102,6 +105,14 @@ const PATHS: Record<string, string> = {
key: '<path d="m21 2-2 2m-1.5 1.5L14 9l-1.5-1.5L11 9l-1.5-1.5L8 9c-3.3 0-6 2.7-6 6s2.7 6 6 6 6-2.7 6-6l-1.5-1.5L14 12l1.5-1.5L17 12l1.5-1.5L20 12l2-2V2z"/><circle cx="7.5" cy="15.5" r="1.5"/>', key: '<path d="m21 2-2 2m-1.5 1.5L14 9l-1.5-1.5L11 9l-1.5-1.5L8 9c-3.3 0-6 2.7-6 6s2.7 6 6 6 6-2.7 6-6l-1.5-1.5L14 12l1.5-1.5L17 12l1.5-1.5L20 12l2-2V2z"/><circle cx="7.5" cy="15.5" r="1.5"/>',
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>', copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
route: '<circle cx="6" cy="19" r="3"/><path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15"/><circle cx="18" cy="5" r="3"/>',
bed: '<path d="M2 4v16"/><path d="M2 8h18a2 2 0 0 1 2 2v10"/><path d="M2 17h20"/><path d="M6 8v9"/>',
arrowLeft: '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
plane: '<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/>',
train: '<rect width="16" height="16" x="4" y="3" rx="2"/><path d="M4 11h16"/><path d="M12 3v8"/><path d="m8 19-2 3"/><path d="m18 22-2-3"/><circle cx="8" cy="15" r="1"/><circle cx="16" cy="15" r="1"/>',
bus: '<path d="M8 6v6"/><path d="M15 6v6"/><path d="M2 12h19.6"/><path d="M18 18h3s.5-1.7.8-2.8c.1-.4.2-.8.2-1.2V6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v8c0 .4.1.8.2 1.2.3 1.1.8 2.8.8 2.8h3"/><circle cx="7" cy="18" r="2"/><path d="M9 18h5"/><circle cx="16" cy="18" r="2"/>',
building: '<rect width="16" height="20" x="4" y="2" rx="2"/><path d="M9 22v-4h6v4"/><path d="M8 6h.01"/><path d="M16 6h.01"/><path d="M12 6h.01"/><path d="M12 10h.01"/><path d="M12 14h.01"/><path d="M16 10h.01"/><path d="M16 14h.01"/><path d="M8 10h.01"/><path d="M8 14h.01"/>',
} }
export type IconName = keyof typeof PATHS export type IconName = keyof typeof PATHS

154
src/locales/ar.json

@ -8,26 +8,31 @@
"tours": "الرحلات", "tours": "الرحلات",
"allTours": "جميع الرحلات", "allTours": "جميع الرحلات",
"ongoingTours": "الرحلات الجارية", "ongoingTours": "الرحلات الجارية",
"customTrip": "رحلة مخصصة (Custom Trip)",
"customTrip": "رحلة مخصصة",
"customTripOrders": "طلبات الرحلات المخصصة",
"customTripOrigins": "مدن الانطلاق",
"customTripDestinations": "مدن الوجهات",
"customTripTransports": "وسائل النقل والترابري",
"customTripHotels": "الفنادق والإقامة",
"tourOrders": "الطلبات والحجوزات", "tourOrders": "الطلبات والحجوزات",
"services": "مركز الخدمات (الكل)",
"simcard": "شريحة الاتصال (Simcard)",
"zayerGuide": "دليل الزائر (Zayer Guide)",
"exchange": "الصرافة الإلكترونية (Exchange)",
"duas": "الأدعية والزيارات (Duas)",
"services": "مركز الخدمات",
"simcard": "شريحة الاتصال",
"zayerGuide": "دليل الزائر",
"exchange": "الصرافة الإلكترونية",
"duas": "الأدعية والزيارات",
"duasList": "الأدعية والزيارات", "duasList": "الأدعية والزيارات",
"categories": "التصنيفات", "categories": "التصنيفات",
"cities": "المدن المقدسة", "cities": "المدن المقدسة",
"store": "متجر نصرة العقيلة (Store)",
"store": "متجر نصرة العقيلة",
"storeProducts": "المنتجات", "storeProducts": "المنتجات",
"storeCategories": "التصنيفات", "storeCategories": "التصنيفات",
"costEstimate": "تقدير التكلفة (Cost Estimate)",
"support": "الدعم الفني (Support)",
"supporters": "فريق الدعم (Supporters)",
"faq": "الأسئلة الشائعة (FAQ)",
"costEstimate": "تقدير التكلفة",
"support": "الدعم الفني",
"supporters": "فريق الدعم",
"faq": "الأسئلة الشائعة",
"faqCategories": "التصنيفات الموضوعية", "faqCategories": "التصنيفات الموضوعية",
"faqQuestions": "الأسئلة والأجوبة", "faqQuestions": "الأسئلة والأجوبة",
"consultant": "الدعم الفني (Support)",
"consultant": "الدعم الفني",
"billing": "الفواتير والمالية", "billing": "الفواتير والمالية",
"settings": "الإعدادات", "settings": "الإعدادات",
"logout": "تسجيل الخروج", "logout": "تسجيل الخروج",
@ -68,7 +73,7 @@
"serverStatus": { "serverStatus": {
"title": "حالة النظام والخادم", "title": "حالة النظام والخادم",
"description": "ملخص اتصال الخادم، وتكوين واجهة برمجة التطبيقات وبيانات الحساب", "description": "ملخص اتصال الخادم، وتكوين واجهة برمجة التطبيقات وبيانات الحساب",
"backendStatus": "حالة خادم جانغو (Backend)",
"backendStatus": "حالة خادم جانغو",
"connected": "متصل ونشط", "connected": "متصل ونشط",
"checking": "جاري الفحص...", "checking": "جاري الفحص...",
"disconnected": "انقطع الاتصال", "disconnected": "انقطع الاتصال",
@ -80,21 +85,21 @@
"apiPrefix": "بادئة API", "apiPrefix": "بادئة API",
"authHeader": "ترويسة المصادقة", "authHeader": "ترويسة المصادقة",
"currentUserTitle": "بيانات الحساب الحالي", "currentUserTitle": "بيانات الحساب الحالي",
"roleSuperuser": "المدير العام (Superuser)",
"roleStaff": "مدير النظام (Staff)",
"roleSuperuser": "المدير العام",
"roleStaff": "مدير النظام",
"roleUser": "مستخدم", "roleUser": "مستخدم",
"fullName": "الاسم الكامل:", "fullName": "الاسم الكامل:",
"email": "البريد الإلكتروني:", "email": "البريد الإلكتروني:",
"userId": "معرف المستخدم (ID):",
"userId": "معرف المستخدم:",
"connectionStable": "الاتصال بالخادم مستقر", "connectionStable": "الاتصال بالخادم مستقر",
"connectionTesting": "جاري اختبار الاتصال...", "connectionTesting": "جاري اختبار الاتصال...",
"connectionUnreachable": "الخادم غير متاح" "connectionUnreachable": "الخادم غير متاح"
}, },
"globalSearch": { "globalSearch": {
"placeholder": "البحث في الصفحات والعمليات والأوامر... (Ctrl+K)",
"placeholder": "البحث في الصفحات والعمليات والأوامر...",
"empty": "لم يتم العثور على أي نتائج.", "empty": "لم يتم العثور على أي نتائج.",
"systemSections": "أقسام النظام", "systemSections": "أقسام النظام",
"toggleTheme": "تغيير السمة (Theme)",
"toggleTheme": "تغيير السمة",
"toggleThemeDesc": "تبديل المظهر بين الوضع الداكن والفاتح", "toggleThemeDesc": "تبديل المظهر بين الوضع الداكن والفاتح",
"logoutAccount": "تسجيل الخروج من الحساب" "logoutAccount": "تسجيل الخروج من الحساب"
}, },
@ -250,6 +255,8 @@
"invoice": "فاتورة", "invoice": "فاتورة",
"invoices": "الفواتير", "invoices": "الفواتير",
"id": "المعرف", "id": "المعرف",
"factorNumber": "رقم الفاتورة",
"factorNumberCopied": "تم نسخ رقم الفاتورة",
"viewDetails": "عرض التفاصيل", "viewDetails": "عرض التفاصيل",
"viewReceipt": "عرض الإيصال", "viewReceipt": "عرض الإيصال",
"noReceiptUploaded": "لم يتم رفع إيصال", "noReceiptUploaded": "لم يتم رفع إيصال",
@ -260,6 +267,10 @@
"serviceCustomTrip": "رحلة مخصصة", "serviceCustomTrip": "رحلة مخصصة",
"allStatuses": "جميع الحالات", "allStatuses": "جميع الحالات",
"allServices": "جميع الخدمات", "allServices": "جميع الخدمات",
"filterService": "نوع الخدمة",
"filterStatus": "حالة الفاتورة",
"selectedServicesCount": "{count} خدمات",
"selectedStatusesCount": "{count} حالات",
"noFactorsFound": "لم يتم العثور على فواتير.", "noFactorsFound": "لم يتم العثور على فواتير.",
"noFactorsFilter": "لا توجد نتائج تطابق معايير التصفية الحالية.", "noFactorsFilter": "لا توجد نتائج تطابق معايير التصفية الحالية.",
"noFactorsEmpty": "لم يتم إصدار أي فواتير في النظام بعد.", "noFactorsEmpty": "لم يتم إصدار أي فواتير في النظام بعد.",
@ -273,7 +284,28 @@
"tabCustomTrip": "الرحلات الخاصة", "tabCustomTrip": "الرحلات الخاصة",
"coreTourBadge": "الخدمة الأساسية", "coreTourBadge": "الخدمة الأساسية",
"liveStatus": "الحالة المباشرة", "liveStatus": "الحالة المباشرة",
"quickFilter": "تصفية الحالة"
"quickFilter": "تصفية الحالة",
"ticketCardTitle": "إدارة وإصدار تذكرة السفر",
"ticketIssuedBadge": "تم إصدار التذكرة",
"ticketPendingBadge": "في انتظار إصدار التذكرة",
"ticketIssuedTitle": "تم تحميل ملف التذكرة الإلكترونية",
"ticketUploadPrompt": "لإصدار التذكرة وإرسالها للمسافر، يرجى اختيار وتحميل ملف التذكرة (PDF أو صورة أو مستند).",
"uploadTicketBtn": "تحميل ملف التذكرة",
"replaceTicketBtn": "تغيير واستبدال التذكرة",
"viewTicketBtn": "عرض / تنزيل التذكرة",
"deleteTicketBtn": "حذف التذكرة",
"deleteTicketConfirm": "هل أنت متأكد من حذف ملف التذكرة لهذا الطلب؟",
"ticketUploadSuccess": "تم تحميل ملف التذكرة بنجاح.",
"ticketDeleteSuccess": "تم حذف ملف التذكرة بنجاح.",
"ticketUploading": "جاري تحميل التذكرة...",
"ticketDeleting": "جاري حذف التذكرة...",
"ticketDragDropPrompt": "اسحب ملف التذكرة وأفلته هنا أو اختره من الزر أدناه",
"ticketSupportedFormats": "الصيغ المدعومة: PDF, PNG, JPG (الحد الأقصى ۱۰ ميجابايت)",
"ticketAutoApproveNotice": "ملاحظة: رفع ملف التذكرة إلزامي لتغيير الحالة إلى «مؤكدة / مدفوعة». بعد الرفع، يرجى الضغط على «حفظ الحالة» للاعتماد النهائي.",
"ticketRequiredTitle": "رفع التذكرة إلزامي",
"ticketRequiredForApprovedTitle": "ملف تذكرة السفر إلزامي",
"ticketRequiredForApprovedDesc": "تغيير الحالة إلى «مؤكدة / مدفوعة» يتطلب رفع ملف التذكرة الإلكترونية. يرجى أولاً رفع التذكرة من قسم «إدارة وإصدار تذكرة السفر» أدناه.",
"cannotDeleteTicketWhenApproved": "الفاتورة في حالة «مؤكدة / مدفوعة» ولا يمكن حذف تذكرتها. يمكنك استبدال ملف التذكرة إذا لزم الأمر."
}, },
"users": { "users": {
"title": "إدارة المستخدمين", "title": "إدارة المستخدمين",
@ -371,7 +403,7 @@
"tabNotifications": "نظام الإشعارات", "tabNotifications": "نظام الإشعارات",
"kpiTotalAdmins": "إجمالي المشرفين", "kpiTotalAdmins": "إجمالي المشرفين",
"kpiActiveAdmins": "المشرفون النشطون", "kpiActiveAdmins": "المشرفون النشطون",
"kpiSuperusers": "المشرفون المتميزون (Superuser)",
"kpiSuperusers": "المشرفون المتميزون",
"kpiInactiveAdmins": "المشرفون غير النشطين", "kpiInactiveAdmins": "المشرفون غير النشطين",
"searchAdminsPlaceholder": "البحث عن الاسم، البريد، رقم الهاتف...", "searchAdminsPlaceholder": "البحث عن الاسم، البريد، رقم الهاتف...",
"refreshBtn": "تحديث", "refreshBtn": "تحديث",
@ -427,7 +459,7 @@
"adminPhoneLabel": "رقم الهاتف المحمول", "adminPhoneLabel": "رقم الهاتف المحمول",
"adminEmailOrPhoneHint": "يجب تقديم البريد الإلكتروني أو رقم الهاتف المحمول على الأقل.", "adminEmailOrPhoneHint": "يجب تقديم البريد الإلكتروني أو رقم الهاتف المحمول على الأقل.",
"initialPasswordLabel": "كلمة المرور الأولية", "initialPasswordLabel": "كلمة المرور الأولية",
"roleSuperuserTitle": "صلاحيات المشرف المتميز (Superuser)",
"roleSuperuserTitle": "صلاحيات المشرف المتميز",
"roleSuperuserDesc": "وصول كامل إلى جميع الأقسام والإعدادات وقدرات النظام", "roleSuperuserDesc": "وصول كامل إلى جميع الأقسام والإعدادات وقدرات النظام",
"accountActiveTitle": "الحساب نشط", "accountActiveTitle": "الحساب نشط",
"accountActiveDesc": "سيتمكن المشرف من تسجيل الدخول مباشرة بعد الإنشاء.", "accountActiveDesc": "سيتمكن المشرف من تسجيل الدخول مباشرة بعد الإنشاء.",
@ -437,13 +469,89 @@
"errorAdminCreateFailed": "فشل في إنشاء المشرف الجديد.", "errorAdminCreateFailed": "فشل في إنشاء المشرف الجديد.",
"createAdminBtn": "إنشاء المشرف", "createAdminBtn": "إنشاء المشرف",
"creatingAdmin": "جاري إنشاء الحساب...", "creatingAdmin": "جاري إنشاء الحساب...",
"notificationsTabTitle": "إدارة الإشعارات وإرسال التنبيهات (Push Notifications)",
"notificationsTabDesc": "هذا القسم مخصص لإدارة قوالب الرسائل، وإرسال الإشعارات الجماعية، واستهداف الجمهور، والربط مع خدمة FCM، وسيتم تفعيله قريباً.",
"notificationsTabTitle": "إدارة الإشعارات وإرسال التنبيهات",
"notificationsTabDesc": "هذا القسم مخصص لإدارة قوالب الرسائل، وإرسال الإشعارات الجماعية، واستهداف الجمهور، والربط مع خدمة الإشعارات، وسيتم تفعيله قريباً.",
"notificationsUpcomingFeaturesTitle": "الميزات قيد التنفيذ لهذا القسم:", "notificationsUpcomingFeaturesTitle": "الميزات قيد التنفيذ لهذا القسم:",
"notificationsFeature1": "إرسال إشعارات مخصصة لمستخدمي التطبيق والويب مصنفة حسب اللغة", "notificationsFeature1": "إرسال إشعارات مخصصة لمستخدمي التطبيق والويب مصنفة حسب اللغة",
"notificationsFeature2": "جدولة تلقائية للتذكير بالرحلات والجولات والمدفوعات المعلقة", "notificationsFeature2": "جدولة تلقائية للتذكير بالرحلات والجولات والمدفوعات المعلقة",
"notificationsFeature3": "تقارير تحليلية لمعدلات المشاهدة والنقر والتسليم الناجح" "notificationsFeature3": "تقارير تحليلية لمعدلات المشاهدة والنقر والتسليم الناجح"
}, },
"customTrip": {
"title": "إدارة الرحلات المخصصة",
"description": "مراجعة طلبات المستخدمين وتقدير الأسعار وتحديد الحالة وإدارة المدن ووسائل النقل والفنادق",
"tabOrders": "طلبات الرحلات المخصصة",
"tabOrigins": "مدن الانطلاق",
"tabDestinations": "مدن الوجهات",
"tabTransports": "وسائل النقل والترابري",
"tabHotels": "الفنادق والإقامة",
"orderId": "رقم الطلب",
"customer": "المستخدم المتقدم",
"originCity": "مدينة الانطلاق",
"destinationsCount": "عدد الوجهات",
"passengers": "عدد المسافرين",
"priceEstimate": "تقدير السعر",
"status": "حالة الطلب",
"createdDate": "تاريخ الطلب",
"actions": "الإجراءات",
"statusAwaitingPayment": "في انتظار الدفع",
"statusPending": "قيد المراجعة",
"statusApproved": "تمت الموافقة",
"statusRejected": "مرفوض",
"statusExpired": "منتهي الصلاحية",
"statusCancelled": "ملغى",
"reviewOrder": "مراجعة وإرسال ملاحظات",
"orderDetailTitle": "مراجعة تفاصيل طلب الرحلة المخصصة",
"adminFeedback": "ملاحظات الدعم والرد",
"adminFeedbackPlaceholder": "اكتب الشروط أو الأسعار المقترحة للمستخدم...",
"updateStatus": "تحديث الحالة وإرسال الرد",
"originCityName": "اسم مدينة الانطلاق",
"country": "البلد",
"destinationCityName": "اسم مدينة الوجهة",
"transportFrom": "من مدينة",
"transportTo": "إلى مدينة",
"transportMethod": "وسيلة النقل",
"airplane": "جوي (طيران)",
"train": "سككي (قطار)",
"roadAccess": "بري (طرق)",
"hotelName": "اسم الفندق",
"hotelScore": "تقييم الفندق",
"pricePerDay": "السعر اليومي (للبالغ)",
"addOrigin": "إضافة مدينة انطلاق",
"addDestination": "إضافة مدينة وجهة",
"addTransport": "إضافة مسار نقل",
"addHotel": "إضافة فندق",
"noOrdersFound": "لم يتم العثور على أي طلبات.",
"saveChanges": "حفظ التغييرات",
"successFeedbackSaved": "تم حفظ الرد وتحديث حالة الطلب بنجاح.",
"editOrigin": "تعديل مدينة المنطلق",
"deleteOrigin": "حذف مدينة المنطلق",
"editDestination": "تعديل مدينة الوجهة",
"deleteDestination": "حذف مدينة الوجهة",
"editHotel": "تعديل الفندق",
"deleteHotel": "حذف الفندق",
"hotelStars": "عدد النجوم",
"distanceToShrine": "القرب من الحرم",
"amenities": "مرافق وخدمات الفندق",
"hotelDescription": "وصف الفندق",
"filterByCountry": "تصفية حسب الدولة",
"allCountries": "جميع الدول",
"active": "مفعل",
"inactive": "غير مفعل",
"relationMatrix": "مصفوفة المسارات ووسائل النقل",
"relationDesc": "تحديد وإدارة وسائل النقل المتاحة (طيران، قطار، بري) من كل منطلق إلى هذه الوجهة",
"availableMethods": "وسائل النقل المتاحة",
"confirmDelete": "تأكيد الحذف",
"confirmDeleteDesc": "هل أنت متأكد من حذف هذا العنصر؟ لا يمكن التراجع عن هذا الإجراء.",
"delete": "حذف",
"cancel": "إلغاء",
"cardView": "عرض البطاقات",
"tableView": "عرض الجدول",
"selectDestination": "اختيار الوجهة",
"allDestinations": "جميع الوجهات",
"transportRelationTitle": "مسارات السفر ووسائل النقل",
"transportRelationDesc": "إدارة الربط بين مدن المنطلق والوجهات وتحديد خيارات الطيران والقطار والنقل البري",
"addRelation": "إضافة مسار جديد"
},
"languages": { "languages": {
"fa": "فارسی", "fa": "فارسی",
"ar": "العربية", "ar": "العربية",

110
src/locales/en.json

@ -9,6 +9,11 @@
"allTours": "All Tours", "allTours": "All Tours",
"ongoingTours": "Ongoing Tours", "ongoingTours": "Ongoing Tours",
"customTrip": "Custom Trip", "customTrip": "Custom Trip",
"customTripOrders": "Trip Requests & Orders",
"customTripOrigins": "Origin Cities",
"customTripDestinations": "Destination Cities",
"customTripTransports": "Transportation",
"customTripHotels": "Hotels & Stays",
"tourOrders": "Orders & Bookings", "tourOrders": "Orders & Bookings",
"services": "Services Center", "services": "Services Center",
"simcard": "SIM Card", "simcard": "SIM Card",
@ -250,6 +255,8 @@
"invoice": "Invoice", "invoice": "Invoice",
"invoices": "Invoices", "invoices": "Invoices",
"id": "ID", "id": "ID",
"factorNumber": "Invoice Number",
"factorNumberCopied": "Invoice number copied to clipboard",
"viewDetails": "View Details", "viewDetails": "View Details",
"viewReceipt": "View Receipt", "viewReceipt": "View Receipt",
"noReceiptUploaded": "No receipt uploaded", "noReceiptUploaded": "No receipt uploaded",
@ -260,6 +267,10 @@
"serviceCustomTrip": "Custom Trip", "serviceCustomTrip": "Custom Trip",
"allStatuses": "All Statuses", "allStatuses": "All Statuses",
"allServices": "All Services", "allServices": "All Services",
"filterService": "Service Type",
"filterStatus": "Invoice Status",
"selectedServicesCount": "{count} Services",
"selectedStatusesCount": "{count} Statuses",
"noFactorsFound": "No invoices found.", "noFactorsFound": "No invoices found.",
"noFactorsFilter": "No records match current filters. You can clear filters.", "noFactorsFilter": "No records match current filters. You can clear filters.",
"noFactorsEmpty": "No invoices have been issued in the system yet.", "noFactorsEmpty": "No invoices have been issued in the system yet.",
@ -273,7 +284,28 @@
"tabCustomTrip": "Custom Trips", "tabCustomTrip": "Custom Trips",
"coreTourBadge": "Primary Core", "coreTourBadge": "Primary Core",
"liveStatus": "Live Status", "liveStatus": "Live Status",
"quickFilter": "Status Filter"
"quickFilter": "Status Filter",
"ticketCardTitle": "Travel Ticket Management",
"ticketIssuedBadge": "Ticket Issued",
"ticketPendingBadge": "Pending Ticket Issuance",
"ticketIssuedTitle": "Electronic ticket file has been uploaded",
"ticketUploadPrompt": "To issue the ticket for the passenger, select and upload the ticket file (PDF, image, or document).",
"uploadTicketBtn": "Upload Ticket File",
"replaceTicketBtn": "Replace Ticket File",
"viewTicketBtn": "View / Download Ticket",
"deleteTicketBtn": "Delete Ticket",
"deleteTicketConfirm": "Are you sure you want to delete this order's ticket file?",
"ticketUploadSuccess": "Ticket file uploaded successfully.",
"ticketDeleteSuccess": "Ticket file deleted successfully.",
"ticketUploading": "Uploading ticket...",
"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.",
"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.",
"cannotDeleteTicketWhenApproved": "The invoice is in 'Approved / Paid' status and its ticket cannot be deleted. You can replace the ticket file if needed."
}, },
"users": { "users": {
"title": "User Management", "title": "User Management",
@ -444,6 +476,82 @@
"notificationsFeature2": "Automated scheduling for trip reminders, tours, and pending payments", "notificationsFeature2": "Automated scheduling for trip reminders, tours, and pending payments",
"notificationsFeature3": "Analytics reports on impressions, open rates, and successful delivery" "notificationsFeature3": "Analytics reports on impressions, open rates, and successful delivery"
}, },
"customTrip": {
"title": "Custom Trip Management",
"description": "Review user requests, price estimates, statuses, and manage origins, destinations, transport, and hotels",
"tabOrders": "Trip Requests & Orders",
"tabOrigins": "Origin Cities",
"tabDestinations": "Destination Cities",
"tabTransports": "Transportation",
"tabHotels": "Hotels & Stays",
"orderId": "Order ID",
"customer": "Customer",
"originCity": "Origin City",
"destinationsCount": "Destinations",
"passengers": "Passengers",
"priceEstimate": "Estimated Price",
"status": "Order Status",
"createdDate": "Created Date",
"actions": "Actions",
"statusAwaitingPayment": "Awaiting Payment",
"statusPending": "Pending Review",
"statusApproved": "Approved",
"statusRejected": "Rejected",
"statusExpired": "Expired",
"statusCancelled": "Cancelled",
"reviewOrder": "Review & Feedback",
"orderDetailTitle": "Custom Trip Order Details",
"adminFeedback": "Support Notes & Feedback",
"adminFeedbackPlaceholder": "Enter pricing details, instructions, or notes for the customer...",
"updateStatus": "Update Status & Send Feedback",
"originCityName": "Origin City Name",
"country": "Country",
"destinationCityName": "Destination City Name",
"transportFrom": "From City",
"transportTo": "To City",
"transportMethod": "Transport Method",
"airplane": "Air (Flight)",
"train": "Train (Railway)",
"roadAccess": "Road (Land)",
"hotelName": "Hotel Name",
"hotelScore": "Hotel Score",
"pricePerDay": "Daily Rate (Adult)",
"addOrigin": "Add Origin City",
"addDestination": "Add Destination City",
"addTransport": "Add Transport Route",
"addHotel": "Add Hotel",
"noOrdersFound": "No orders found.",
"saveChanges": "Save Changes",
"successFeedbackSaved": "Feedback and order status updated successfully.",
"editOrigin": "Edit Origin City",
"deleteOrigin": "Delete Origin City",
"editDestination": "Edit Destination City",
"deleteDestination": "Delete Destination City",
"editHotel": "Edit Hotel",
"deleteHotel": "Delete Hotel",
"hotelStars": "Star Rating",
"distanceToShrine": "Distance to Holy Shrine",
"amenities": "Hotel Amenities",
"hotelDescription": "Hotel Description",
"filterByCountry": "Filter by Country",
"allCountries": "All Countries",
"active": "Active",
"inactive": "Inactive",
"relationMatrix": "Route & Transport Matrix",
"relationDesc": "Manage available transportation methods (flight, train, road) from each origin to this destination",
"availableMethods": "Available Transport Methods",
"confirmDelete": "Confirm Deletion",
"confirmDeleteDesc": "Are you sure you want to delete this item? This action cannot be undone.",
"delete": "Delete",
"cancel": "Cancel",
"cardView": "Card View",
"tableView": "Table View",
"selectDestination": "Select Destination",
"allDestinations": "All Destinations",
"transportRelationTitle": "Trip Routes & Transport Methods",
"transportRelationDesc": "Manage relations between origins and destinations with flight, train, and road options",
"addRelation": "Add New Route"
},
"languages": { "languages": {
"fa": "فارسی", "fa": "فارسی",
"ar": "العربية", "ar": "العربية",

158
src/locales/fa.json

@ -8,26 +8,31 @@
"tours": "تورها", "tours": "تورها",
"allTours": "فهرست همه تورها", "allTours": "فهرست همه تورها",
"ongoingTours": "تورهای در حال برگزاری", "ongoingTours": "تورهای در حال برگزاری",
"customTrip": "سفر اختصاصی (Custom Trip)",
"customTrip": "سفر اختصاصی",
"customTripOrders": "سفارش‌های ثبت‌شده",
"customTripOrigins": "شهرهای مبدأ",
"customTripDestinations": "شهرهای مقصد",
"customTripTransports": "حمل‌ونقل و ترابری",
"customTripHotels": "هتل‌ها و اقامتگاه‌ها",
"tourOrders": "سفارشات و رزروها", "tourOrders": "سفارشات و رزروها",
"services": "مرکز خدمات (همه)",
"simcard": "سیم‌کارت (Simcard)",
"zayerGuide": "راهنمای زائر (Zayer Guide)",
"exchange": "صرافی آنلاین (Exchange)",
"duas": "ادعیه و زیارات (Duas)",
"services": "مرکز خدمات",
"simcard": "سیم‌کارت",
"zayerGuide": "راهنمای زائر",
"exchange": "صرافی آنلاین",
"duas": "ادعیه و زیارات",
"duasList": "ادعیه و زیارات", "duasList": "ادعیه و زیارات",
"categories": "دسته‌بندی‌ها", "categories": "دسته‌بندی‌ها",
"cities": "شهرهای زیارتی", "cities": "شهرهای زیارتی",
"store": "فروشگاه نصرت عقیله (Store)",
"store": "فروشگاه نصرت عقیله",
"storeProducts": "محصولات", "storeProducts": "محصولات",
"storeCategories": "دسته‌بندی‌ها", "storeCategories": "دسته‌بندی‌ها",
"costEstimate": "تخمین هزینه (Cost Estimate)",
"support": "پشتیبانی (Support)",
"supporters": "پشتیبان‌ها (Supporters)",
"faq": "سوالات متداول (FAQ)",
"costEstimate": "تخمین هزینه",
"support": "پشتیبانی",
"supporters": "پشتیبان‌ها",
"faq": "سوالات متداول",
"faqCategories": "دسته‌بندی‌های موضوعی", "faqCategories": "دسته‌بندی‌های موضوعی",
"faqQuestions": "پرسش و پاسخ‌ها", "faqQuestions": "پرسش و پاسخ‌ها",
"consultant": "پشتیبانی (Support)",
"consultant": "پشتیبانی",
"billing": "صورت‌حساب‌ها و مالی", "billing": "صورت‌حساب‌ها و مالی",
"settings": "تنظیمات", "settings": "تنظیمات",
"logout": "خروج از سیستم", "logout": "خروج از سیستم",
@ -68,7 +73,7 @@
"serverStatus": { "serverStatus": {
"title": "وضعیت سیستم و سرور", "title": "وضعیت سیستم و سرور",
"description": "خلاصه ارتباط سرور، پیکربندی API و اطلاعات حساب", "description": "خلاصه ارتباط سرور، پیکربندی API و اطلاعات حساب",
"backendStatus": "وضعیت سرور جنگو (Backend)",
"backendStatus": "وضعیت سرور جنگو",
"connected": "آنلاین و متصل", "connected": "آنلاین و متصل",
"checking": "در حال بررسی...", "checking": "در حال بررسی...",
"disconnected": "قطع ارتباط", "disconnected": "قطع ارتباط",
@ -80,21 +85,21 @@
"apiPrefix": "پیشوند API", "apiPrefix": "پیشوند API",
"authHeader": "هدر احراز هویت", "authHeader": "هدر احراز هویت",
"currentUserTitle": "اطلاعات حساب کاربری جاری", "currentUserTitle": "اطلاعات حساب کاربری جاری",
"roleSuperuser": "مدیر ارشد (Superuser)",
"roleStaff": "مدیر سیستم (Staff)",
"roleSuperuser": "مدیر ارشد",
"roleStaff": "مدیر سیستم",
"roleUser": "کاربر", "roleUser": "کاربر",
"fullName": "نام و نام خانوادگی:", "fullName": "نام و نام خانوادگی:",
"email": "ایمیل:", "email": "ایمیل:",
"userId": "شناسه کاربری (ID):",
"userId": "شناسه کاربری:",
"connectionStable": "ارتباط با سرور پایدار است", "connectionStable": "ارتباط با سرور پایدار است",
"connectionTesting": "در حال آزمایش اتصال...", "connectionTesting": "در حال آزمایش اتصال...",
"connectionUnreachable": "سرور در دسترس نیست" "connectionUnreachable": "سرور در دسترس نیست"
}, },
"globalSearch": { "globalSearch": {
"placeholder": "جستجوی صفحات، عملیات‌ها یا فرمان‌ها... (Ctrl+K)",
"placeholder": "جستجوی صفحات، عملیات‌ها یا فرمان‌ها...",
"empty": "هیچ نتیجه‌ای یافت نشد.", "empty": "هیچ نتیجه‌ای یافت نشد.",
"systemSections": "بخش‌های سیستم", "systemSections": "بخش‌های سیستم",
"toggleTheme": "تغییر پوسته (Theme)",
"toggleTheme": "تغییر پوسته",
"toggleThemeDesc": "تغییر تم به حالت تیره یا روشن", "toggleThemeDesc": "تغییر تم به حالت تیره یا روشن",
"logoutAccount": "خروج از حساب کاربری" "logoutAccount": "خروج از حساب کاربری"
}, },
@ -148,7 +153,7 @@
"subtitle": "پایش وضعیت زنده فروش، برنامه‌های سفر، مسافران، اطلاعات قیمتی و ترجمه‌های چندزبانه", "subtitle": "پایش وضعیت زنده فروش، برنامه‌های سفر، مسافران، اطلاعات قیمتی و ترجمه‌های چندزبانه",
"totalRegistered": "{count} تور ثبت‌شده", "totalRegistered": "{count} تور ثبت‌شده",
"ongoingTitle": "تورهای در حال برگزاری", "ongoingTitle": "تورهای در حال برگزاری",
"ongoingSubtitle": "پایش زنده وضعیت سفرهای جاری، زمان‌بندی روزانه (Itinerary)، موقعیت مرحله فعلی و وضعیت مسافران",
"ongoingSubtitle": "پایش زنده وضعیت سفرهای جاری، زمان‌بندی روزانه، موقعیت مرحله فعلی و وضعیت مسافران",
"totalOngoing": "{count} تور فعال و در جریان", "totalOngoing": "{count} تور فعال و در جریان",
"ordersTitle": "سفارشات و رزروهای تور", "ordersTitle": "سفارشات و رزروهای تور",
"ordersSubtitle": "پیگیری سفارشات، نظارت بر پرداخت‌ها و اعتبارسنجی مدارک و فیش‌های واریزی مسافران", "ordersSubtitle": "پیگیری سفارشات، نظارت بر پرداخت‌ها و اعتبارسنجی مدارک و فیش‌های واریزی مسافران",
@ -250,6 +255,8 @@
"invoice": "صورت‌حساب", "invoice": "صورت‌حساب",
"invoices": "صورت‌حساب‌ها", "invoices": "صورت‌حساب‌ها",
"id": "شناسه", "id": "شناسه",
"factorNumber": "شماره فاکتور",
"factorNumberCopied": "شماره فاکتور کپی شد",
"viewDetails": "مشاهده جزئیات", "viewDetails": "مشاهده جزئیات",
"viewReceipt": "مشاهده فیش", "viewReceipt": "مشاهده فیش",
"noReceiptUploaded": "فیشی بارگذاری نشده است", "noReceiptUploaded": "فیشی بارگذاری نشده است",
@ -260,6 +267,10 @@
"serviceCustomTrip": "سفر اختصاصی", "serviceCustomTrip": "سفر اختصاصی",
"allStatuses": "همه وضعیت‌ها", "allStatuses": "همه وضعیت‌ها",
"allServices": "همه سرویس‌ها", "allServices": "همه سرویس‌ها",
"filterService": "نوع سرویس",
"filterStatus": "وضعیت فاکتور",
"selectedServicesCount": "{count} سرویس",
"selectedStatusesCount": "{count} وضعیت",
"noFactorsFound": "هیچ صورت‌حسابی یافت نشد.", "noFactorsFound": "هیچ صورت‌حسابی یافت نشد.",
"noFactorsFilter": "رکوردی با فیلترهای فعلی همخوانی ندارد. می‌توانید فیلترها را پاک کنید.", "noFactorsFilter": "رکوردی با فیلترهای فعلی همخوانی ندارد. می‌توانید فیلترها را پاک کنید.",
"noFactorsEmpty": "تاکنون هیچ صورت‌حسابی در سیستم صادر نشده است.", "noFactorsEmpty": "تاکنون هیچ صورت‌حسابی در سیستم صادر نشده است.",
@ -273,7 +284,28 @@
"tabCustomTrip": "سفرهای اختصاصی", "tabCustomTrip": "سفرهای اختصاصی",
"coreTourBadge": "هسته اصلی", "coreTourBadge": "هسته اصلی",
"liveStatus": "وضعیت زنده", "liveStatus": "وضعیت زنده",
"quickFilter": "فیلتر وضعیت"
"quickFilter": "فیلتر وضعیت",
"ticketCardTitle": "مدیریت و صدور بلیت سفر",
"ticketIssuedBadge": "بلیت صادر شده",
"ticketPendingBadge": "در انتظار صدور بلیت",
"ticketIssuedTitle": "فایل بلیت الکترونیکی بارگذاری شده است",
"ticketUploadPrompt": "جهت صدور و ارسال بلیت برای مسافر، فایل بلیت (PDF، تصویر یا سند) را انتخاب یا بارگذاری نمایید.",
"uploadTicketBtn": "بارگذاری فایل بلیت",
"replaceTicketBtn": "تغییر و جایگزینی فایل بلیت",
"viewTicketBtn": "مشاهده / دریافت بلیت",
"deleteTicketBtn": "حذف بلیت",
"deleteTicketConfirm": "آیا از حذف فایل بلیت این سفارش اطمینان دارید؟",
"ticketUploadSuccess": "فایل بلیت با موفقیت بارگذاری شد.",
"ticketDeleteSuccess": "فایل بلیت با موفقیت حذف شد.",
"ticketUploading": "در حال بارگذاری بلیت...",
"ticketDeleting": "در حال حذف بلیت...",
"ticketDragDropPrompt": "فایل بلیت را اینجا بکشید و رها کنید یا از دکمه زیر انتخاب نمایید",
"ticketSupportedFormats": "فرمت‌های مجاز: PDF, PNG, JPG (حداکثر ۱۰ مگابایت)",
"ticketAutoApproveNotice": "نکته: برای تغییر وضعیت به «پرداخت شده»، بارگذاری بلیت الزامی است. پس از آپلود بلیت، دکمه «ثبت وضعیت» را جهت تایید نهایی بزنید.",
"ticketRequiredTitle": "بارگذاری بلیت الزامی است",
"ticketRequiredForApprovedTitle": "بارگذاری فایل بلیت سفر الزامی است",
"ticketRequiredForApprovedDesc": "تغییر وضعیت به «پرداخت شده» منوط به بارگذاری فایل بلیت الکترونیکی است. لطفاً ابتدا از بخش «مدیریت و صدور بلیت سفر» در پایین، فایل بلیت را آپلود نمایید.",
"cannotDeleteTicketWhenApproved": "صورت‌حساب در وضعیت «پرداخت شده» قرار دارد و امکان حذف بلیت آن وجود ندارد. در صورت نیاز از گزینه تغییر و جایگزینی فایل بلیت استفاده نمایید."
}, },
"users": { "users": {
"title": "مدیریت کاربران", "title": "مدیریت کاربران",
@ -368,10 +400,10 @@
"pageTitle": "تنظیمات سامانه", "pageTitle": "تنظیمات سامانه",
"pageDescription": "مدیریت مدیران، تعیین و تغییر رمزهای عبور و پیکربندی ماژول‌های سامانه", "pageDescription": "مدیریت مدیران، تعیین و تغییر رمزهای عبور و پیکربندی ماژول‌های سامانه",
"tabAdmins": "مدیریت مدیران و دسترسی‌ها", "tabAdmins": "مدیریت مدیران و دسترسی‌ها",
"tabNotifications": "سامانه اعلان‌ها (Notifications)",
"tabNotifications": "سامانه اعلان‌ها",
"kpiTotalAdmins": "کل مدیران سیستم", "kpiTotalAdmins": "کل مدیران سیستم",
"kpiActiveAdmins": "مدیران فعال", "kpiActiveAdmins": "مدیران فعال",
"kpiSuperusers": "مدیران ارشد (Superuser)",
"kpiSuperusers": "مدیران ارشد",
"kpiInactiveAdmins": "مدیران غیرفعال", "kpiInactiveAdmins": "مدیران غیرفعال",
"searchAdminsPlaceholder": "جستجوی نام، ایمیل، شماره تماس...", "searchAdminsPlaceholder": "جستجوی نام، ایمیل، شماره تماس...",
"refreshBtn": "تازه‌سازی", "refreshBtn": "تازه‌سازی",
@ -427,7 +459,7 @@
"adminPhoneLabel": "شماره تلفن همراه", "adminPhoneLabel": "شماره تلفن همراه",
"adminEmailOrPhoneHint": "حداقل یکی از موارد ایمیل یا تلفن همراه باید وارد شود.", "adminEmailOrPhoneHint": "حداقل یکی از موارد ایمیل یا تلفن همراه باید وارد شود.",
"initialPasswordLabel": "رمز عبور اولیه", "initialPasswordLabel": "رمز عبور اولیه",
"roleSuperuserTitle": "دسترسی مدیر ارشد (Superuser)",
"roleSuperuserTitle": "دسترسی مدیر ارشد",
"roleSuperuserDesc": "دسترسی کامل به کلیه بخش‌ها، تنظیمات و قابلیت‌های سیستم", "roleSuperuserDesc": "دسترسی کامل به کلیه بخش‌ها، تنظیمات و قابلیت‌های سیستم",
"accountActiveTitle": "وضعیت حساب فعال باشد", "accountActiveTitle": "وضعیت حساب فعال باشد",
"accountActiveDesc": "مدیر بلافاصله پس از ایجاد قادر به ورود به پنل خواهد بود.", "accountActiveDesc": "مدیر بلافاصله پس از ایجاد قادر به ورود به پنل خواهد بود.",
@ -437,13 +469,89 @@
"errorAdminCreateFailed": "خطا در ایجاد مدیر جدید.", "errorAdminCreateFailed": "خطا در ایجاد مدیر جدید.",
"createAdminBtn": "ایجاد مدیر جدید", "createAdminBtn": "ایجاد مدیر جدید",
"creatingAdmin": "در حال ایجاد حساب...", "creatingAdmin": "در حال ایجاد حساب...",
"notificationsTabTitle": "سامانه مدیریت و ارسال اعلان‌ها (Push Notifications)",
"notificationsTabDesc": "این بخش برای مدیریت قالب‌های پیام، ارسال اعلان‌های همگانی، گروه‌بندی مخاطبان و اتصال به سرویس FCM اختصاص یافته است و به زودی فعال خواهد شد.",
"notificationsTabTitle": "سامانه مدیریت و ارسال اعلان‌ها",
"notificationsTabDesc": "این بخش برای مدیریت قالب‌های پیام، ارسال اعلان‌های همگانی، گروه‌بندی مخاطبان و اتصال به سرویس اعلان اختصاص یافته است و به زودی فعال خواهد شد.",
"notificationsUpcomingFeaturesTitle": "قابلیت‌های در دست پیاده‌سازی این بخش:", "notificationsUpcomingFeaturesTitle": "قابلیت‌های در دست پیاده‌سازی این بخش:",
"notificationsFeature1": "ارسال اعلان اختصاصی به کاربران اپلیکیشن و وب با تفکیک زبان", "notificationsFeature1": "ارسال اعلان اختصاصی به کاربران اپلیکیشن و وب با تفکیک زبان",
"notificationsFeature2": "زمان‌بندی خودکار یادآورهای سفر، تور و پرداخت‌های مالی", "notificationsFeature2": "زمان‌بندی خودکار یادآورهای سفر، تور و پرداخت‌های مالی",
"notificationsFeature3": "گزارش‌های تحلیلی نرخ مشاهده، کلیک و تحویل موفق پیام‌ها" "notificationsFeature3": "گزارش‌های تحلیلی نرخ مشاهده، کلیک و تحویل موفق پیام‌ها"
}, },
"customTrip": {
"title": "مدیریت سفرهای اختصاصی",
"description": "بررسی سفارش‌های کاربران، قیمت‌گذاری و تعیین وضعیت، و مدیریت مقاصد، ترابری و هتل‌ها",
"tabOrders": "سفارش‌های ثبت‌شده",
"tabOrigins": "شهرهای مبدأ",
"tabDestinations": "شهرهای مقصد",
"tabTransports": "حمل‌ونقل و ترابری",
"tabHotels": "هتل‌ها و اقامتگاه‌ها",
"orderId": "شماره سفارش",
"customer": "کاربر متقاضی",
"originCity": "شهر مبدأ",
"destinationsCount": "تعداد مقاصد",
"passengers": "تعداد مسافران",
"priceEstimate": "برآورد قیمت",
"status": "وضعیت سفارش",
"createdDate": "تاریخ ثبت",
"actions": "عملیات",
"statusAwaitingPayment": "در انتظار پرداخت",
"statusPending": "در حال بررسی",
"statusApproved": "تأیید شده",
"statusRejected": "رد شده",
"statusExpired": "منقضی شده",
"statusCancelled": "لغو شده",
"reviewOrder": "بررسی و ارسال فیدبک",
"orderDetailTitle": "بررسی جزئیات سفارش سفر اختصاصی",
"adminFeedback": "توضیحات و فیدبک پشتیبانی",
"adminFeedbackPlaceholder": "توضیحات، شرایط یا قیمت پیشنهادی را برای کاربر بنویسید...",
"updateStatus": "به‌روزرسانی وضعیت و ارسال فیدبک",
"originCityName": "نام شهر مبدأ",
"country": "کشور",
"destinationCityName": "نام شهر مقصد",
"transportFrom": "از مبدأ",
"transportTo": "به مقصد",
"transportMethod": "روش جابجایی",
"airplane": "هوایی (هواپیما)",
"train": "ریلی (قطار)",
"roadAccess": "زمینی (جاده‌ای)",
"hotelName": "نام هتل",
"hotelScore": "امتیاز هتل",
"pricePerDay": "قیمت روزانه (بزرگسال)",
"addOrigin": "افزودن شهر مبدأ",
"addDestination": "افزودن شهر مقصد",
"addTransport": "افزودن مسیر ترابری",
"addHotel": "افزودن هتل",
"noOrdersFound": "سفارشی یافت نشد.",
"saveChanges": "ذخیره تغییرات",
"successFeedbackSaved": "فیدبک و وضعیت سفارش با موفقیت ثبت شد.",
"editOrigin": "ویرایش شهر مبدأ",
"deleteOrigin": "حذف شهر مبدأ",
"editDestination": "ویرایش شهر مقصد",
"deleteDestination": "حذف شهر مقصد",
"editHotel": "ویرایش هتل",
"deleteHotel": "حذف هتل",
"hotelStars": "تعداد ستاره",
"distanceToShrine": "میزان نزدیکی به حرم",
"amenities": "امکانات و خدمات هتل",
"hotelDescription": "توضیحات هتل",
"filterByCountry": "فیلتر بر اساس کشور",
"allCountries": "همه کشورها",
"active": "فعال",
"inactive": "غیرفعال",
"relationMatrix": "ماتریس مسیرها و حمل‌ونقل",
"relationDesc": "تعیین و مدیریت روش‌های حمل‌ونقل مجاز (هواپیما، قطار، زمینی) از هر مبدأ به این مقصد",
"availableMethods": "روش‌های حمل‌ونقل در دسترس",
"confirmDelete": "تأیید حذف",
"confirmDeleteDesc": "آیا از حذف این مورد اطمینان دارید؟ این عمل قابل بازگشت نیست.",
"delete": "حذف",
"cancel": "انصراف",
"cardView": "نمای کارتی",
"tableView": "نمای جدولی",
"selectDestination": "انتخاب مقصد",
"allDestinations": "همه مقاصد",
"transportRelationTitle": "مسیرهای سفر و روش‌های حمل‌ونقل",
"transportRelationDesc": "مدیریت ارتباط بین شهرهای مبدأ و مقاصد و مشخص‌کردن گزینه‌های هواپیما، قطار و زمینی",
"addRelation": "افزودن مسیر جدید"
},
"languages": { "languages": {
"fa": "فارسی", "fa": "فارسی",
"ar": "العربية", "ar": "العربية",

Loading…
Cancel
Save