Browse Source
feat: add tour orders page, billing management, and custom trip configuration features
master
feat: add tour orders page, billing management, and custom trip configuration features
master
27 changed files with 7597 additions and 370 deletions
-
9src/app/router.tsx
-
58src/components/shared/FilterSelect.tsx
-
298src/components/shared/MultiFilterSelect.tsx
-
4src/components/ui/select.tsx
-
128src/components/ui/sidebar.tsx
-
21src/config/navigation.ts
-
348src/features/billing/BillingPage.tsx
-
285src/features/billing/components/BillingDetailModal.tsx
-
26src/features/billing/services/billing-api.ts
-
2src/features/billing/types.ts
-
1616src/features/custom-trip/CustomTripDestinationDetailPage.tsx
-
317src/features/custom-trip/CustomTripPage.tsx
-
914src/features/custom-trip/components/CustomTripDestinationsTab.tsx
-
833src/features/custom-trip/components/CustomTripHotelsTab.tsx
-
421src/features/custom-trip/components/CustomTripOrdersTab.tsx
-
578src/features/custom-trip/components/CustomTripOriginsTab.tsx
-
749src/features/custom-trip/components/CustomTripTransportsTab.tsx
-
715src/features/custom-trip/services/custom-trip-api.ts
-
111src/features/custom-trip/types.ts
-
25src/features/tours/TourOrdersPage.tsx
-
1src/features/tours/types.ts
-
2src/features/users/components/UserFactorsTab.tsx
-
1src/features/users/types.ts
-
11src/icons.tsx
-
154src/locales/ar.json
-
110src/locales/en.json
-
158src/locales/fa.json
@ -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> |
|||
) |
|||
} |
|||
1616
src/features/custom-trip/CustomTripDestinationDetailPage.tsx
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -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> |
|||
) |
|||
} |
|||
@ -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> |
|||
) |
|||
} |
|||
@ -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> |
|||
) |
|||
} |
|||
@ -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> |
|||
) |
|||
} |
|||
@ -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> |
|||
) |
|||
} |
|||
@ -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> |
|||
) |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ -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 }> |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue