590 lines
29 KiB
TypeScript
590 lines
29 KiB
TypeScript
import axios from 'axios'
|
||
import Head from 'next/head'
|
||
import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'
|
||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||
import SectionMain from '../../components/SectionMain'
|
||
import CardBox from '../../components/CardBox'
|
||
import BaseButton from '../../components/BaseButton'
|
||
import { getPageTitle } from '../../config'
|
||
import { useAppSelector } from '../../stores/hooks'
|
||
|
||
type Option = {
|
||
id: string
|
||
label?: string
|
||
name?: string
|
||
company_name?: string
|
||
name_ar?: string
|
||
name_en?: string
|
||
}
|
||
|
||
type Order = {
|
||
id: string
|
||
order_code?: string
|
||
customer_name?: string
|
||
customer_phone?: string
|
||
delivery_address?: string
|
||
package_description?: string
|
||
weight_size?: string
|
||
cod_amount?: string | number
|
||
current_status?: string
|
||
previous_status?: string
|
||
notes?: string
|
||
delivery_lat?: string | number
|
||
delivery_lng?: string | number
|
||
createdAt?: string
|
||
last_status_at?: string
|
||
merchant?: Option
|
||
delivery_city?: Option
|
||
delivery_region?: Option
|
||
assigned_driver?: Option
|
||
order_status_logs_order?: StatusLog[]
|
||
}
|
||
|
||
type StatusLog = {
|
||
id: string
|
||
from_status?: string
|
||
to_status?: string
|
||
comment?: string
|
||
changed_at?: string
|
||
createdAt?: string
|
||
}
|
||
|
||
type FormState = {
|
||
merchant: string
|
||
customer_name: string
|
||
customer_phone: string
|
||
delivery_city: string
|
||
delivery_region: string
|
||
delivery_address: string
|
||
package_description: string
|
||
weight_size: string
|
||
cod_amount: string
|
||
notes: string
|
||
}
|
||
|
||
const teal = '#01696f'
|
||
|
||
const statusMeta = {
|
||
pending: { label: 'قيد الانتظار', color: 'bg-slate-100 text-slate-700 ring-slate-200' },
|
||
assigned: { label: 'تم التعيين', color: 'bg-blue-100 text-blue-700 ring-blue-200' },
|
||
picked_up: { label: 'تم الاستلام', color: 'bg-yellow-100 text-yellow-800 ring-yellow-200' },
|
||
out_for_delivery: { label: 'في الطريق للتسليم', color: 'bg-orange-100 text-orange-700 ring-orange-200' },
|
||
delivered: { label: 'تم التسليم', color: 'bg-emerald-100 text-emerald-700 ring-emerald-200' },
|
||
failed_attempt: { label: 'محاولة فاشلة', color: 'bg-red-100 text-red-700 ring-red-200' },
|
||
rescheduled: { label: 'أعيدت الجدولة', color: 'bg-cyan-100 text-cyan-700 ring-cyan-200' },
|
||
cancelled: { label: 'ملغي', color: 'bg-zinc-200 text-zinc-800 ring-zinc-300' },
|
||
returned: { label: 'مرتجع', color: 'bg-purple-100 text-purple-700 ring-purple-200' },
|
||
}
|
||
|
||
const nextStatuses = {
|
||
pending: ['assigned', 'cancelled'],
|
||
assigned: ['picked_up'],
|
||
picked_up: ['out_for_delivery'],
|
||
out_for_delivery: ['delivered', 'failed_attempt'],
|
||
failed_attempt: ['rescheduled'],
|
||
rescheduled: ['out_for_delivery'],
|
||
delivered: [],
|
||
cancelled: [],
|
||
returned: [],
|
||
}
|
||
|
||
const emptyForm: FormState = {
|
||
merchant: '',
|
||
customer_name: '',
|
||
customer_phone: '',
|
||
delivery_city: '',
|
||
delivery_region: '',
|
||
delivery_address: '',
|
||
package_description: '',
|
||
weight_size: '',
|
||
cod_amount: '',
|
||
notes: '',
|
||
}
|
||
|
||
const labelFor = (status?: string) => statusMeta[status || 'pending']?.label || status || '—'
|
||
|
||
const badgeClass = (status?: string) =>
|
||
`inline-flex items-center rounded-full px-3 py-1 text-xs font-bold ring-1 ${
|
||
statusMeta[status || 'pending']?.color || statusMeta.pending.color
|
||
}`
|
||
|
||
const optionLabel = (option?: Option) =>
|
||
option?.company_name || option?.name_ar || option?.name || option?.label || option?.name_en || '—'
|
||
|
||
const todayIso = () => new Date().toISOString().slice(0, 10)
|
||
|
||
const OperationsOrdersPage = () => {
|
||
const { currentUser } = useAppSelector((state) => state.auth)
|
||
const [orders, setOrders] = useState<Order[]>([])
|
||
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null)
|
||
const [merchants, setMerchants] = useState<Option[]>([])
|
||
const [drivers, setDrivers] = useState<Option[]>([])
|
||
const [cities, setCities] = useState<Option[]>([])
|
||
const [regions, setRegions] = useState<Option[]>([])
|
||
const [form, setForm] = useState<FormState>(emptyForm)
|
||
const [filters, setFilters] = useState({ query: '', status: '' })
|
||
const [nextStatus, setNextStatus] = useState('')
|
||
const [statusDriver, setStatusDriver] = useState('')
|
||
const [statusComment, setStatusComment] = useState('')
|
||
const [loading, setLoading] = useState(true)
|
||
const [saving, setSaving] = useState(false)
|
||
const [notice, setNotice] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||
|
||
const fetchOrders = useCallback(async () => {
|
||
const params: Record<string, string | number> = { page: 0, limit: 50, field: 'createdAt', sort: 'desc' }
|
||
if (filters.status) params.current_status = filters.status
|
||
if (filters.query) {
|
||
if (/^[0-9+\-\s]+$/.test(filters.query)) {
|
||
params.customer_phone = filters.query
|
||
} else {
|
||
params.order_code = filters.query
|
||
}
|
||
}
|
||
|
||
const { data } = await axios.get('orders', { params })
|
||
setOrders(Array.isArray(data.rows) ? data.rows : [])
|
||
}, [filters.query, filters.status])
|
||
|
||
const fetchLookups = useCallback(async () => {
|
||
const [merchantRes, driverRes, cityRes, regionRes] = await Promise.all([
|
||
axios.get('merchants', { params: { page: 0, limit: 100 } }),
|
||
axios.get('drivers', { params: { page: 0, limit: 100 } }),
|
||
axios.get('cities', { params: { page: 0, limit: 100 } }),
|
||
axios.get('regions', { params: { page: 0, limit: 100 } }),
|
||
])
|
||
|
||
setMerchants(merchantRes.data.rows || [])
|
||
setDrivers(driverRes.data.rows || [])
|
||
setCities(cityRes.data.rows || [])
|
||
setRegions(regionRes.data.rows || [])
|
||
}, [])
|
||
|
||
const refresh = useCallback(async () => {
|
||
setLoading(true)
|
||
try {
|
||
await Promise.all([fetchOrders(), fetchLookups()])
|
||
} catch (error) {
|
||
console.error('Failed to load parcel operations data', error)
|
||
setNotice({ type: 'error', text: 'تعذر تحميل بيانات العمليات. تحقق من الصلاحيات أو الاتصال.' })
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [fetchLookups, fetchOrders])
|
||
|
||
useEffect(() => {
|
||
if (!currentUser?.id) {
|
||
setLoading(false)
|
||
return undefined
|
||
}
|
||
|
||
refresh().catch((error) => {
|
||
console.error('Initial operations refresh failed', error)
|
||
})
|
||
const timer = window.setInterval(() => {
|
||
fetchOrders().catch((error) => {
|
||
console.error('Auto refresh failed', error)
|
||
})
|
||
}, 30000)
|
||
|
||
return () => window.clearInterval(timer)
|
||
}, [currentUser?.id, fetchOrders, refresh])
|
||
|
||
const selectedAllowedStatuses = useMemo(() => {
|
||
if (!selectedOrder) return []
|
||
return nextStatuses[selectedOrder.current_status || 'pending'] || []
|
||
}, [selectedOrder])
|
||
|
||
const stats = useMemo(() => {
|
||
const today = todayIso()
|
||
const todaysOrders = orders.filter((order) => order.createdAt?.slice(0, 10) === today)
|
||
return [
|
||
{ label: 'طلبات اليوم', value: todaysOrders.length, hint: 'تحديث تلقائي كل 30 ثانية' },
|
||
{ label: 'قيد الانتظار', value: orders.filter((order) => order.current_status === 'pending').length, hint: 'تحتاج إجراء' },
|
||
{ label: 'تم التسليم', value: orders.filter((order) => order.current_status === 'delivered').length, hint: 'طلبات مكتملة' },
|
||
{ label: 'فشل التسليم', value: orders.filter((order) => order.current_status === 'failed_attempt').length, hint: 'تحتاج متابعة' },
|
||
]
|
||
}, [orders])
|
||
|
||
const revenueToday = useMemo(
|
||
() =>
|
||
orders
|
||
.filter((order) => order.createdAt?.slice(0, 10) === todayIso())
|
||
.reduce((sum, order) => sum + Number(order.cod_amount || 0), 0),
|
||
[orders],
|
||
)
|
||
|
||
const handleFormChange = (field: keyof FormState, value: string) => {
|
||
setForm((current) => ({ ...current, [field]: value }))
|
||
}
|
||
|
||
const handleCreateOrder = async (event: React.FormEvent<HTMLFormElement>) => {
|
||
event.preventDefault()
|
||
|
||
if (!form.customer_name.trim() || !form.customer_phone.trim() || !form.delivery_address.trim()) {
|
||
setNotice({ type: 'error', text: 'اسم العميل، الهاتف، والعنوان حقول مطلوبة.' })
|
||
return
|
||
}
|
||
|
||
setSaving(true)
|
||
setNotice(null)
|
||
try {
|
||
const orderCode = `ORD-${Date.now().toString().slice(-8)}`
|
||
await axios.post('orders', {
|
||
data: {
|
||
...form,
|
||
order_code: orderCode,
|
||
current_status: 'pending',
|
||
previous_status: 'pending',
|
||
placed_at: new Date().toISOString(),
|
||
last_status_at: new Date().toISOString(),
|
||
merchant: form.merchant || null,
|
||
delivery_city: form.delivery_city || null,
|
||
delivery_region: form.delivery_region || null,
|
||
cod_amount: form.cod_amount || 0,
|
||
},
|
||
})
|
||
setForm(emptyForm)
|
||
await fetchOrders()
|
||
setNotice({ type: 'success', text: `تم إنشاء الطلب ${orderCode} وأصبح جاهزاً للتعيين.` })
|
||
} catch (error) {
|
||
console.error('Create order failed', error)
|
||
setNotice({ type: 'error', text: 'تعذر إنشاء الطلب. تأكد من البيانات والصلاحيات.' })
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const openOrder = async (order: Order) => {
|
||
setSelectedOrder(order)
|
||
setNextStatus('')
|
||
setStatusDriver(order.assigned_driver?.id || '')
|
||
setStatusComment('')
|
||
try {
|
||
const { data } = await axios.get(`orders/${order.id}`)
|
||
setSelectedOrder(data)
|
||
setStatusDriver(data?.assigned_driver?.id || '')
|
||
} catch (error) {
|
||
console.error('Load order detail failed', error)
|
||
setNotice({ type: 'error', text: 'تعذر فتح تفاصيل الطلب.' })
|
||
}
|
||
}
|
||
|
||
const handleStatusChange = async () => {
|
||
if (!selectedOrder || !nextStatus) {
|
||
setNotice({ type: 'error', text: 'اختر الحالة التالية أولاً.' })
|
||
return
|
||
}
|
||
|
||
if (nextStatus === 'assigned' && !statusDriver) {
|
||
setNotice({ type: 'error', text: 'يجب اختيار سائق قبل تحويل الطلب إلى تم التعيين.' })
|
||
return
|
||
}
|
||
|
||
setSaving(true)
|
||
setNotice(null)
|
||
try {
|
||
const { data } = await axios.put(`orders/${selectedOrder.id}/status`, {
|
||
data: {
|
||
current_status: nextStatus,
|
||
assigned_driver: statusDriver || undefined,
|
||
comment: statusComment,
|
||
},
|
||
})
|
||
setSelectedOrder(data)
|
||
setNextStatus('')
|
||
setStatusComment('')
|
||
await fetchOrders()
|
||
setNotice({ type: 'success', text: 'تم تحديث حالة الطلب وتسجيلها في السجل.' })
|
||
} catch (error) {
|
||
console.error('Status update failed', error)
|
||
setNotice({ type: 'error', text: 'رفض الخادم هذا الانتقال. تأكد من التسلسل والصلاحيات.' })
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const sortedLogs = [...(selectedOrder?.order_status_logs_order || [])].sort(
|
||
(a, b) => new Date(b.changed_at || b.createdAt || '').getTime() - new Date(a.changed_at || a.createdAt || '').getTime(),
|
||
)
|
||
|
||
return (
|
||
<>
|
||
<Head>
|
||
<title>{getPageTitle('Parcel Operations')}</title>
|
||
</Head>
|
||
<SectionMain>
|
||
<div dir="rtl" className="min-h-screen space-y-6 text-slate-900" style={{ fontFamily: 'Cairo, ui-sans-serif, system-ui' }}>
|
||
<section className="overflow-hidden rounded-[2rem] bg-gradient-to-br from-[#01696f] via-[#07575d] to-[#0f2730] p-6 text-white shadow-2xl shadow-teal-900/20 md:p-8">
|
||
<div className="grid gap-6 lg:grid-cols-[1.4fr_0.8fr]">
|
||
<div>
|
||
<div className="mb-5 inline-flex rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
|
||
مركز عمليات الطرود · [اسم شركتك]
|
||
</div>
|
||
<h1 className="text-3xl font-extrabold leading-tight md:text-5xl">إدارة طلبات التوصيل من الإدخال حتى تغيير الحالة</h1>
|
||
<p className="mt-4 max-w-2xl text-sm leading-7 text-teal-50 md:text-base">
|
||
شريحة تشغيلية أولى تربط إنشاء الطلب، قائمة الطلبات، تفاصيل الطلب، وتحديث الحالة وفق مسار العمل المعتمد وبصلاحيات الخادم.
|
||
</p>
|
||
<div className="mt-6 flex flex-wrap gap-3">
|
||
<BaseButton label="إنشاء طلب جديد" color="info" className="border-white/20 bg-white text-[#01696f] hover:bg-teal-50" onClick={() => document.getElementById('create-order-form')?.scrollIntoView({ behavior: 'smooth' })} />
|
||
<BaseButton label="العودة للوحة الإدارة" color="white" outline href="/dashboard" className="border-white/30 text-white hover:bg-white/10" />
|
||
</div>
|
||
</div>
|
||
<div className="rounded-3xl border border-white/10 bg-white/10 p-5 backdrop-blur">
|
||
<p className="text-sm text-teal-50">المستخدم الحالي</p>
|
||
<p className="mt-2 text-xl font-bold">{currentUser?.firstName || currentUser?.email || 'مشغل النظام'}</p>
|
||
<p className="text-sm text-teal-100">الدور: {currentUser?.app_role?.name || 'غير محدد'}</p>
|
||
<div className="mt-6 rounded-2xl bg-white p-4 text-slate-900">
|
||
<p className="text-sm text-slate-500">تحصيل اليوم COD</p>
|
||
<p className="mt-1 text-3xl font-extrabold" style={{ color: teal }}>{revenueToday.toLocaleString()} د.أ</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{notice && (
|
||
<div className={`rounded-2xl border px-4 py-3 text-sm font-semibold ${notice.type === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-800' : 'border-red-200 bg-red-50 text-red-800'}`}>
|
||
{notice.text}
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid gap-4 md:grid-cols-4">
|
||
{stats.map((stat) => (
|
||
<CardBox key={stat.label} className="border-0 bg-white shadow-sm ring-1 ring-slate-100">
|
||
<p className="text-sm font-semibold text-slate-500">{stat.label}</p>
|
||
<p className="mt-2 text-3xl font-extrabold" style={{ color: teal }}>{loading ? '…' : stat.value}</p>
|
||
<p className="mt-2 text-xs text-slate-400">{stat.hint}</p>
|
||
</CardBox>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.35fr]">
|
||
<CardBox className="border-0 bg-white shadow-sm ring-1 ring-slate-100">
|
||
<div id="create-order-form" className="mb-5 flex items-center justify-between gap-3">
|
||
<div>
|
||
<p className="text-sm font-bold text-teal-700">طلب جديد</p>
|
||
<h2 className="text-2xl font-extrabold text-slate-900">إدخال طلب يدوي</h2>
|
||
</div>
|
||
<span className="rounded-full bg-teal-50 px-3 py-1 text-xs font-bold text-teal-700">الحالة الافتراضية: قيد الانتظار</span>
|
||
</div>
|
||
<form className="grid gap-4" onSubmit={handleCreateOrder}>
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<FieldLabel label="التاجر">
|
||
<select className="ops-input" value={form.merchant} onChange={(event) => handleFormChange('merchant', event.target.value)}>
|
||
<option value="">بدون تاجر</option>
|
||
{merchants.map((merchant) => <option key={merchant.id} value={merchant.id}>{optionLabel(merchant)}</option>)}
|
||
</select>
|
||
</FieldLabel>
|
||
<FieldLabel label="قيمة التحصيل COD">
|
||
<input className="ops-input" inputMode="decimal" value={form.cod_amount} onChange={(event) => handleFormChange('cod_amount', event.target.value)} placeholder="0.00" />
|
||
</FieldLabel>
|
||
</div>
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<FieldLabel label="اسم العميل *">
|
||
<input className="ops-input" value={form.customer_name} onChange={(event) => handleFormChange('customer_name', event.target.value)} placeholder="مثال: أحمد محمد" />
|
||
</FieldLabel>
|
||
<FieldLabel label="هاتف العميل *">
|
||
<input className="ops-input" value={form.customer_phone} onChange={(event) => handleFormChange('customer_phone', event.target.value)} placeholder="07xxxxxxxx" />
|
||
</FieldLabel>
|
||
</div>
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<FieldLabel label="المدينة">
|
||
<select className="ops-input" value={form.delivery_city} onChange={(event) => handleFormChange('delivery_city', event.target.value)}>
|
||
<option value="">اختر المدينة</option>
|
||
{cities.map((city) => <option key={city.id} value={city.id}>{optionLabel(city)}</option>)}
|
||
</select>
|
||
</FieldLabel>
|
||
<FieldLabel label="المنطقة">
|
||
<select className="ops-input" value={form.delivery_region} onChange={(event) => handleFormChange('delivery_region', event.target.value)}>
|
||
<option value="">اختر المنطقة</option>
|
||
{regions.map((region) => <option key={region.id} value={region.id}>{optionLabel(region)}</option>)}
|
||
</select>
|
||
</FieldLabel>
|
||
</div>
|
||
<FieldLabel label="العنوان الكامل *">
|
||
<textarea className="ops-input min-h-20" value={form.delivery_address} onChange={(event) => handleFormChange('delivery_address', event.target.value)} placeholder="الحي، الشارع، أقرب معلم" />
|
||
</FieldLabel>
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<FieldLabel label="وصف الطرد">
|
||
<input className="ops-input" value={form.package_description} onChange={(event) => handleFormChange('package_description', event.target.value)} placeholder="ملابس، مستندات، إلكترونيات..." />
|
||
</FieldLabel>
|
||
<FieldLabel label="الوزن/الحجم">
|
||
<input className="ops-input" value={form.weight_size} onChange={(event) => handleFormChange('weight_size', event.target.value)} placeholder="اختياري" />
|
||
</FieldLabel>
|
||
</div>
|
||
<FieldLabel label="ملاحظات داخلية">
|
||
<input className="ops-input" value={form.notes} onChange={(event) => handleFormChange('notes', event.target.value)} placeholder="أي تعليمات خاصة للتسليم" />
|
||
</FieldLabel>
|
||
<BaseButton type="submit" color="info" label={saving ? 'جارٍ الحفظ...' : 'إنشاء الطلب'} disabled={saving} className="w-full border-[#01696f] bg-[#01696f] py-3 text-white hover:bg-[#07575d]" />
|
||
</form>
|
||
</CardBox>
|
||
|
||
<CardBox className="border-0 bg-white shadow-sm ring-1 ring-slate-100" hasComponentLayout>
|
||
<div className="border-b border-slate-100 p-5">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div>
|
||
<p className="text-sm font-bold text-teal-700">قائمة التشغيل</p>
|
||
<h2 className="text-2xl font-extrabold">آخر 50 طلب</h2>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<input className="ops-input w-full md:w-56" value={filters.query} onChange={(event) => setFilters((current) => ({ ...current, query: event.target.value }))} placeholder="بحث برقم الطلب أو الهاتف" />
|
||
<select className="ops-input w-40" value={filters.status} onChange={(event) => setFilters((current) => ({ ...current, status: event.target.value }))}>
|
||
<option value="">كل الحالات</option>
|
||
{Object.entries(statusMeta).map(([value, meta]) => <option key={value} value={value}>{meta.label}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full text-right text-sm">
|
||
<thead className="bg-slate-50 text-xs font-bold text-slate-500">
|
||
<tr>
|
||
<th className="px-4 py-3">رقم الطلب</th>
|
||
<th className="px-4 py-3">العميل</th>
|
||
<th className="px-4 py-3">التاجر</th>
|
||
<th className="px-4 py-3">السائق</th>
|
||
<th className="px-4 py-3">الحالة</th>
|
||
<th className="px-4 py-3">COD</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{orders.map((order) => (
|
||
<tr key={order.id} className="cursor-pointer transition hover:bg-teal-50/60" onClick={() => openOrder(order)}>
|
||
<td className="px-4 py-4 font-extrabold text-teal-700">{order.order_code || order.id.slice(0, 8)}</td>
|
||
<td className="px-4 py-4"><p className="font-bold">{order.customer_name || '—'}</p><p className="text-xs text-slate-400">{order.customer_phone || '—'}</p></td>
|
||
<td className="px-4 py-4 text-slate-600">{optionLabel(order.merchant)}</td>
|
||
<td className="px-4 py-4 text-slate-600">{optionLabel(order.assigned_driver)}</td>
|
||
<td className="px-4 py-4"><span className={badgeClass(order.current_status)}>{labelFor(order.current_status)}</span></td>
|
||
<td className="px-4 py-4 font-bold">{Number(order.cod_amount || 0).toLocaleString()}</td>
|
||
</tr>
|
||
))}
|
||
{!orders.length && (
|
||
<tr>
|
||
<td colSpan={6} className="px-4 py-12 text-center text-slate-500">
|
||
لا توجد طلبات مطابقة حالياً. ابدأ بإنشاء أول طلب من النموذج المجاور.
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</CardBox>
|
||
</div>
|
||
|
||
<CardBox className="border-0 bg-white shadow-sm ring-1 ring-slate-100">
|
||
{!selectedOrder ? (
|
||
<div className="rounded-3xl border border-dashed border-slate-200 bg-slate-50 p-10 text-center">
|
||
<p className="text-xl font-extrabold">اختر طلباً من القائمة</p>
|
||
<p className="mt-2 text-sm text-slate-500">ستظهر هنا تفاصيل الطلب، الإجراءات التالية المسموحة، وسجل الحالات.</p>
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-6 lg:grid-cols-[1fr_0.9fr]">
|
||
<div>
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<p className="text-sm font-bold text-teal-700">تفاصيل الطلب</p>
|
||
<h2 className="text-3xl font-extrabold">{selectedOrder.order_code || selectedOrder.id.slice(0, 8)}</h2>
|
||
</div>
|
||
<span className={badgeClass(selectedOrder.current_status)}>{labelFor(selectedOrder.current_status)}</span>
|
||
</div>
|
||
<div className="mt-5 grid gap-3 md:grid-cols-2">
|
||
<Info label="العميل" value={selectedOrder.customer_name} />
|
||
<Info label="الهاتف" value={selectedOrder.customer_phone} />
|
||
<Info label="التاجر" value={optionLabel(selectedOrder.merchant)} />
|
||
<Info label="السائق" value={optionLabel(selectedOrder.assigned_driver)} />
|
||
<Info label="المدينة" value={optionLabel(selectedOrder.delivery_city)} />
|
||
<Info label="المنطقة" value={optionLabel(selectedOrder.delivery_region)} />
|
||
<Info label="العنوان" value={selectedOrder.delivery_address} wide />
|
||
<Info label="وصف الطرد" value={selectedOrder.package_description} />
|
||
<Info label="COD" value={`${Number(selectedOrder.cod_amount || 0).toLocaleString()} د.أ`} />
|
||
</div>
|
||
</div>
|
||
<div className="rounded-3xl bg-slate-50 p-5">
|
||
<h3 className="text-xl font-extrabold">تغيير الحالة</h3>
|
||
<p className="mt-1 text-sm text-slate-500">الواجهة تعرض فقط الانتقالات التالية، والخادم يرفض أي انتقال غير مسموح.</p>
|
||
<div className="mt-4 grid gap-3">
|
||
<FieldLabel label="الحالة التالية">
|
||
<select className="ops-input bg-white" value={nextStatus} onChange={(event) => setNextStatus(event.target.value)}>
|
||
<option value="">اختر الإجراء التالي</option>
|
||
{selectedAllowedStatuses.map((status) => <option key={status} value={status}>{labelFor(status)}</option>)}
|
||
</select>
|
||
</FieldLabel>
|
||
{nextStatus === 'assigned' && (
|
||
<FieldLabel label="السائق المعيّن *">
|
||
<select className="ops-input bg-white" value={statusDriver} onChange={(event) => setStatusDriver(event.target.value)}>
|
||
<option value="">اختر السائق</option>
|
||
{drivers.map((driver) => <option key={driver.id} value={driver.id}>{optionLabel(driver)}</option>)}
|
||
</select>
|
||
</FieldLabel>
|
||
)}
|
||
<FieldLabel label="تعليق على الحركة">
|
||
<textarea className="ops-input min-h-20 bg-white" value={statusComment} onChange={(event) => setStatusComment(event.target.value)} placeholder="مثال: تم التواصل مع العميل وتأكيد الموعد" />
|
||
</FieldLabel>
|
||
<BaseButton color="info" label={saving ? 'جارٍ التحديث...' : 'تطبيق تغيير الحالة'} disabled={saving || !selectedAllowedStatuses.length} onClick={handleStatusChange} className="border-[#01696f] bg-[#01696f] py-3 text-white hover:bg-[#07575d]" />
|
||
{!selectedAllowedStatuses.length && <p className="text-sm font-semibold text-slate-500">هذه حالة نهائية ولا توجد انتقالات متاحة.</p>}
|
||
</div>
|
||
</div>
|
||
<div className="lg:col-span-2">
|
||
<h3 className="mb-4 text-xl font-extrabold">سجل الحالات</h3>
|
||
<div className="space-y-3">
|
||
{sortedLogs.map((log) => (
|
||
<div key={log.id} className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm">
|
||
<div className="flex flex-wrap items-center gap-2 text-sm font-bold">
|
||
<span className={badgeClass(log.from_status)}>{labelFor(log.from_status)}</span>
|
||
<span className="text-slate-300">←</span>
|
||
<span className={badgeClass(log.to_status)}>{labelFor(log.to_status)}</span>
|
||
<span className="mr-auto text-xs text-slate-400">{new Date(log.changed_at || log.createdAt || '').toLocaleString('ar')}</span>
|
||
</div>
|
||
{log.comment && <p className="mt-2 text-sm text-slate-600">{log.comment}</p>}
|
||
</div>
|
||
))}
|
||
{!sortedLogs.length && <p className="rounded-2xl bg-slate-50 p-5 text-center text-sm text-slate-500">لا توجد حركات مسجلة بعد. أول تغيير حالة سيظهر هنا.</p>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardBox>
|
||
</div>
|
||
<style jsx global>{`
|
||
.ops-input {
|
||
width: 100%;
|
||
border-radius: 1rem;
|
||
border: 1px solid rgb(226 232 240);
|
||
background: rgb(248 250 252);
|
||
padding: 0.75rem 1rem;
|
||
font-size: 0.875rem;
|
||
font-weight: 600;
|
||
color: rgb(15 23 42);
|
||
outline: none;
|
||
transition: border-color 150ms ease, box-shadow 150ms ease, background 150ms ease;
|
||
}
|
||
.ops-input:focus {
|
||
border-color: #01696f;
|
||
background: white;
|
||
box-shadow: 0 0 0 3px rgba(1, 105, 111, 0.14);
|
||
}
|
||
`}</style>
|
||
</SectionMain>
|
||
</>
|
||
)
|
||
}
|
||
|
||
const FieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||
<label className="block">
|
||
<span className="mb-2 block text-sm font-extrabold text-slate-700">{label}</span>
|
||
{children}
|
||
</label>
|
||
)
|
||
|
||
const Info = ({ label, value, wide }: { label: string; value?: string | number; wide?: boolean }) => (
|
||
<div className={`rounded-2xl bg-slate-50 p-4 ${wide ? 'md:col-span-2' : ''}`}>
|
||
<p className="text-xs font-bold text-slate-400">{label}</p>
|
||
<p className="mt-1 font-extrabold text-slate-800">{value || '—'}</p>
|
||
</div>
|
||
)
|
||
|
||
OperationsOrdersPage.getLayout = function getLayout(page: ReactElement) {
|
||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||
}
|
||
|
||
export default OperationsOrdersPage
|