Initialize the admin app with banner management for app sections.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import AdminSelect from '@/components/ui/AdminSelect.vue'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import { mapAdminOrder, ordersApi, shipmentsApi } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { SHIPMENT_STATUS_BADGE, SHIPMENT_STATUS_LABELS } from '@/utils/adminLabels'
|
||||
import { formatDateTime, formatMeterage } from '@/utils/format'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { Order, Shipment, ShipmentStatus } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const shipmentList = ref<Shipment[]>([])
|
||||
const orders = ref<Order[]>([])
|
||||
const searchQuery = ref(typeof route.query.order === 'string' ? route.query.order : '')
|
||||
const statusFilter = ref<'all' | ShipmentStatus>('all')
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const formError = ref('')
|
||||
const saving = ref(false)
|
||||
const toast = useToast()
|
||||
|
||||
const statusFilterOptions = [
|
||||
{ value: 'all', label: 'همه وضعیتها' },
|
||||
{ value: 'preparing', label: 'در حال آمادهسازی' },
|
||||
{ value: 'ready', label: 'آماده ارسال' },
|
||||
{ value: 'shipped', label: 'ارسال شده' },
|
||||
{ value: 'delivered', label: 'تحویل شده' },
|
||||
]
|
||||
|
||||
const shipmentStatusOptions = [
|
||||
{ value: 'preparing', label: 'در حال آمادهسازی' },
|
||||
{ value: 'ready', label: 'آماده ارسال' },
|
||||
{ value: 'shipped', label: 'ارسال شده' },
|
||||
{ value: 'delivered', label: 'تحویل شده' },
|
||||
]
|
||||
|
||||
const emptyForm = () => ({
|
||||
orderId: '',
|
||||
carrier: '',
|
||||
trackingNumber: '',
|
||||
meterage: 1000,
|
||||
status: 'preparing' as ShipmentStatus,
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const filteredShipments = computed(() => {
|
||||
let list = shipmentList.value
|
||||
if (statusFilter.value !== 'all') {
|
||||
list = list.filter((s) => s.status === statusFilter.value)
|
||||
}
|
||||
const q = searchQuery.value.trim()
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(s) =>
|
||||
s.orderNumber.includes(q) ||
|
||||
s.customerName.includes(q) ||
|
||||
s.trackingNumber.includes(q) ||
|
||||
s.carrier.includes(q),
|
||||
)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const isFormValid = computed(() => form.orderId.trim() && form.carrier.trim())
|
||||
|
||||
const availableOrders = computed(() => {
|
||||
const shippedIds = new Set(shipmentList.value.map((s) => s.orderId))
|
||||
return orders.value.filter((o) => !shippedIds.has(o.id))
|
||||
})
|
||||
|
||||
const orderSelectOptions = computed(() =>
|
||||
availableOrders.value.map((order) => ({
|
||||
value: order.id,
|
||||
label: `${order.orderNumber} — ${order.customerName}`,
|
||||
})),
|
||||
)
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [shipmentsData, ordersData] = await Promise.all([
|
||||
shipmentsApi.list(),
|
||||
ordersApi.list(),
|
||||
])
|
||||
shipmentList.value = shipmentsData
|
||||
orders.value = ordersData.map((o) => mapAdminOrder(o) as Order)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بارگذاری ارسالها انجام نشد.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadData()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query.order,
|
||||
(orderRef) => {
|
||||
if (typeof orderRef === 'string' && orderRef.trim()) {
|
||||
searchQuery.value = orderRef.trim()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function onOrderChange() {
|
||||
const order = orders.value.find((o) => o.id === form.orderId)
|
||||
if (order) form.meterage = order.meterage
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
Object.assign(form, emptyForm())
|
||||
formError.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
async function submitShipment() {
|
||||
if (!isFormValid.value) {
|
||||
formError.value = 'سفارش و باربری الزامی هستند.'
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const created = await shipmentsApi.create({
|
||||
orderId: form.orderId,
|
||||
carrier: form.carrier.trim(),
|
||||
trackingNumber: form.trackingNumber.trim() || undefined,
|
||||
meterage: form.meterage,
|
||||
status: form.status,
|
||||
})
|
||||
shipmentList.value.unshift(created)
|
||||
toast.success('بارنامه ثبت شد.')
|
||||
closeModal()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'ثبت بارنامه انجام نشد.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function advanceStatus(shipment: Shipment) {
|
||||
try {
|
||||
const updated = await shipmentsApi.advanceStatus(shipment.id)
|
||||
const index = shipmentList.value.findIndex((s) => s.id === shipment.id)
|
||||
if (index !== -1) shipmentList.value[index] = updated
|
||||
toast.success('وضعیت ارسال بهروزرسانی شد.')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بهروزرسانی وضعیت انجام نشد.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shipping-page">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-header__title">ارسال و باربری</h1>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
|
||||
ثبت بارنامه
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="shipping-page__filters admin-card">
|
||||
<div class="shipping-page__search">
|
||||
<AdminIcon name="search" :size="18" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="shipping-page__search-input"
|
||||
placeholder="جستجو در سفارش، مشتری، باربری..."
|
||||
/>
|
||||
</div>
|
||||
<AdminSelect
|
||||
v-model="statusFilter"
|
||||
class="shipping-page__status-select"
|
||||
aria-label="فیلتر وضعیت ارسال"
|
||||
:options="statusFilterOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminPageSkeleton v-if="loading" variant="table" />
|
||||
<template v-else>
|
||||
<div class="shipping-page__table admin-card desktop-only">
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>شماره سفارش</th>
|
||||
<th>مشتری</th>
|
||||
<th>باربری</th>
|
||||
<th>شماره بارنامه</th>
|
||||
<th>متراژ</th>
|
||||
<th>وضعیت</th>
|
||||
<th>تاریخ ارسال</th>
|
||||
<th>تاریخ تحویل</th>
|
||||
<th>عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="shipment in filteredShipments" :key="shipment.id">
|
||||
<td>{{ shipment.orderNumber }}</td>
|
||||
<td>{{ shipment.customerName }}</td>
|
||||
<td>{{ shipment.carrier }}</td>
|
||||
<td dir="ltr">{{ shipment.trackingNumber }}</td>
|
||||
<td>{{ formatMeterage(shipment.meterage) }}</td>
|
||||
<td>
|
||||
<span class="admin-badge" :class="SHIPMENT_STATUS_BADGE[shipment.status]">
|
||||
{{ SHIPMENT_STATUS_LABELS[shipment.status] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ shipment.shippedAt ? formatDateTime(shipment.shippedAt) : '—' }}</td>
|
||||
<td>{{ shipment.deliveredAt ? formatDateTime(shipment.deliveredAt) : '—' }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="shipment.status !== 'delivered'"
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline admin-btn--xs"
|
||||
@click="advanceStatus(shipment)"
|
||||
>
|
||||
مرحله بعد
|
||||
</button>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="compact-only admin-card-list">
|
||||
<article v-for="shipment in filteredShipments" :key="shipment.id" class="admin-card-item">
|
||||
<div class="admin-card-item__header">
|
||||
<p class="admin-card-item__title">{{ shipment.orderNumber }}</p>
|
||||
<span class="admin-badge" :class="SHIPMENT_STATUS_BADGE[shipment.status]">
|
||||
{{ SHIPMENT_STATUS_LABELS[shipment.status] }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-card-item__meta">
|
||||
<span>{{ shipment.customerName }}</span>
|
||||
<span>{{ shipment.carrier }}</span>
|
||||
<span>{{ formatMeterage(shipment.meterage) }}</span>
|
||||
<span dir="ltr">{{ shipment.trackingNumber }}</span>
|
||||
</div>
|
||||
<div v-if="shipment.status !== 'delivered'" class="admin-card-item__actions">
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--xs" @click="advanceStatus(shipment)">
|
||||
مرحله بعد
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<BaseModal :open="showModal" title="ثبت بارنامه" size="lg" @close="closeModal">
|
||||
<form class="admin-form" @submit.prevent="submitShipment">
|
||||
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label id="ship-order-label">سفارش <span class="admin-form__required">*</span></label>
|
||||
<AdminSelect
|
||||
v-model="form.orderId"
|
||||
block
|
||||
aria-label="انتخاب سفارش"
|
||||
placeholder="انتخاب سفارش"
|
||||
:options="orderSelectOptions"
|
||||
@update:model-value="onOrderChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="ship-carrier">باربری <span class="admin-form__required">*</span></label>
|
||||
<input id="ship-carrier" v-model="form.carrier" type="text" class="admin-input" />
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="ship-tracking">شماره بارنامه</label>
|
||||
<input id="ship-tracking" v-model="form.trackingNumber" type="text" class="admin-input" dir="ltr" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="ship-meterage">متراژ</label>
|
||||
<input id="ship-meterage" v-model.number="form.meterage" type="number" min="1" class="admin-input" />
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label id="ship-status-label">وضعیت</label>
|
||||
<AdminSelect
|
||||
v-model="form.status"
|
||||
block
|
||||
aria-label="وضعیت ارسال"
|
||||
:options="shipmentStatusOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="button" class="admin-btn admin-btn--outline" @click="closeModal">انصراف</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--primary"
|
||||
:disabled="!isFormValid || saving"
|
||||
@click="submitShipment"
|
||||
>
|
||||
ثبت بارنامه
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shipping-page__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shipping-page__search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.shipping-page__search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.shipping-page__status-select {
|
||||
flex: 0 1 220px;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.shipping-page__table {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user