Add checks list page and richer order/check admin UX.
Replace the checks redirect with a real list, expand per-order check management, and add a row actions menu on the orders table. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -56,7 +56,7 @@ export function useDashboard() {
|
||||
link: '/orders?tab=pending_review',
|
||||
},
|
||||
{
|
||||
label: 'در انتظار چک',
|
||||
label: 'بررسی چک',
|
||||
value: dashboardStats.value.pendingCheck,
|
||||
icon: 'wallet',
|
||||
tone: 'purple',
|
||||
|
||||
@@ -749,7 +749,7 @@ export const reportSalesByCompany = [
|
||||
|
||||
export const reportOrdersByStatus = [
|
||||
{ status: 'در انتظار بررسی', count: 18 },
|
||||
{ status: 'در انتظار چک', count: 9 },
|
||||
{ status: 'بررسی چک', count: 9 },
|
||||
{ status: 'نیاز به اصلاح', count: 6 },
|
||||
{ status: 'تأیید نهایی', count: 24 },
|
||||
{ status: 'ارسال شده', count: 12 },
|
||||
|
||||
+7
-6
@@ -34,11 +34,17 @@ const router = createRouter({
|
||||
component: () => import('@/views/orders/OrderPricingView.vue'),
|
||||
meta: { title: 'قیمتگذاری سفارش' },
|
||||
},
|
||||
{
|
||||
path: 'checks',
|
||||
name: 'checks',
|
||||
component: () => import('@/views/checks/ChecksListView.vue'),
|
||||
meta: { title: 'چکها' },
|
||||
},
|
||||
{
|
||||
path: 'orders/:id/checks',
|
||||
name: 'order-checks',
|
||||
component: () => import('@/views/checks/CheckManagementView.vue'),
|
||||
meta: { title: 'مدیریت چکها' },
|
||||
meta: { title: 'مدیریت چکهای سفارش' },
|
||||
},
|
||||
{
|
||||
/* API links often use /orders/:id — open list + detail via query */
|
||||
@@ -79,11 +85,6 @@ const router = createRouter({
|
||||
component: () => import('@/views/catalogs/CatalogsView.vue'),
|
||||
meta: { title: 'کالیتهها' },
|
||||
},
|
||||
{
|
||||
path: 'checks',
|
||||
name: 'checks',
|
||||
redirect: { name: 'order-checks', params: { id: 'ord-1' } },
|
||||
},
|
||||
{
|
||||
path: 'cash-payments',
|
||||
name: 'cash-payments',
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
Order,
|
||||
OrderItem,
|
||||
OrderStatus,
|
||||
OrderStatusHistoryItem,
|
||||
Product,
|
||||
RawOrderStatus,
|
||||
SettlementMethod,
|
||||
@@ -242,6 +243,7 @@ export type ApiOrder = Order & {
|
||||
checkRassDate?: string
|
||||
items?: OrderItem[]
|
||||
checks?: Check[]
|
||||
statusHistory?: OrderStatusHistoryItem[]
|
||||
/** وضعیتهای مجاز بعدی از دید ماشین وضعیت سرور */
|
||||
allowedTransitions?: string[]
|
||||
cashReceipt?: { id: string } | null
|
||||
@@ -269,6 +271,8 @@ export const ordersApi = {
|
||||
/* ── Checks ────────────────────────────────────────────────── */
|
||||
|
||||
export const checksApi = {
|
||||
list: (status?: Check['status']) =>
|
||||
apiRequest<Check[]>('/admin/checks', { query: { status } }),
|
||||
listByOrder: (orderId: string) =>
|
||||
apiRequest<Check[]>(`/admin/orders/${orderId}/checks`),
|
||||
updateStatus: (id: string, body: { status: Check['status']; note?: string }) =>
|
||||
|
||||
@@ -106,6 +106,32 @@ export interface Check {
|
||||
sayadId: string
|
||||
dueDate: string
|
||||
status: CheckStatus
|
||||
checkNumber?: string
|
||||
ownerNationalId?: string
|
||||
ownerFirstName?: string
|
||||
ownerLastName?: string
|
||||
branchName?: string
|
||||
frontImage?: string
|
||||
backImage?: string
|
||||
notes?: string
|
||||
mode?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
/** فقط در لیست سراسری چکها */
|
||||
orderNumber?: string
|
||||
customerName?: string
|
||||
orderStatus?: string
|
||||
settlementType?: SettlementMethod | null
|
||||
}
|
||||
|
||||
export interface OrderStatusHistoryItem {
|
||||
id: string
|
||||
fromStatus?: string | null
|
||||
toStatus: string
|
||||
changedBy: string
|
||||
note?: string
|
||||
reason?: string
|
||||
changedAt: string
|
||||
}
|
||||
|
||||
export interface CheckDeadline {
|
||||
|
||||
@@ -17,5 +17,10 @@ export const ADMIN_NAV_ITEMS: NavItem[] = [
|
||||
|
||||
export function isNavActive(path: string, currentPath: string): boolean {
|
||||
if (path === '/') return currentPath === '/'
|
||||
return currentPath.startsWith(path)
|
||||
// چکهای یک سفارش زیر منوی «چکها» فعال میشود، نه «سفارشها»
|
||||
if (/^\/orders\/[^/]+\/checks/.test(currentPath)) {
|
||||
if (path === '/checks') return true
|
||||
if (path === '/orders') return false
|
||||
}
|
||||
return currentPath === path || currentPath.startsWith(`${path}/`)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface StatusMeta {
|
||||
export const ORDER_STATUS_META: Record<OrderStatus, StatusMeta> = {
|
||||
pending_review: { label: 'در انتظار بررسی', tone: 'warning' },
|
||||
awaiting_customer: { label: 'در انتظار مشتری', tone: 'info' },
|
||||
pending_check: { label: 'در انتظار چک', tone: 'purple' },
|
||||
pending_check: { label: 'بررسی چک', tone: 'purple' },
|
||||
pending_payment: { label: 'بررسی رسید پرداخت', tone: 'purple' },
|
||||
needs_correction: { label: 'نیاز به اصلاح', tone: 'danger' },
|
||||
check_correction: { label: 'نیاز به اصلاح چک', tone: 'warning' },
|
||||
@@ -59,7 +59,7 @@ export const ORDER_TABS = [
|
||||
{ key: 'all', label: 'همه' },
|
||||
{ key: 'pending_review', label: 'در انتظار بررسی' },
|
||||
{ key: 'awaiting_customer', label: 'در انتظار مشتری' },
|
||||
{ key: 'pending_check', label: 'در انتظار چک' },
|
||||
{ key: 'pending_check', label: 'بررسی چک' },
|
||||
{ key: 'pending_payment', label: 'بررسی رسید' },
|
||||
{ key: 'check_correction', label: 'اصلاح چک' },
|
||||
{ key: 'needs_correction', label: 'نیاز به اصلاح' },
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import { checksApi, mapAdminOrder, ordersApi, type ApiOrder } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { formatCurrency, formatDate } from '@/utils/format'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { confirm } from '@/composables/useConfirm'
|
||||
import { rawStatusLabel } from '@/utils/orderStatus'
|
||||
import type { Check } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const activeTab = ref<'info' | 'images' | 'history'>('info')
|
||||
const activeTab = ref<'info' | 'history'>('info')
|
||||
const order = ref<ApiOrder | null>(null)
|
||||
const orderChecks = ref<Check[]>([])
|
||||
const busyId = ref<string | null>(null)
|
||||
const expandedId = ref<string | null>(null)
|
||||
const previewImage = ref<string | null>(null)
|
||||
|
||||
const displayOrder = computed(() =>
|
||||
order.value ? mapAdminOrder(order.value) : null,
|
||||
@@ -27,6 +29,40 @@ const totalCheckAmount = computed(() =>
|
||||
orderChecks.value.reduce((sum, c) => sum + c.amount, 0),
|
||||
)
|
||||
|
||||
const reviewHistory = computed(() => {
|
||||
const items = order.value?.statusHistory ?? []
|
||||
const checkRelated = new Set([
|
||||
'check_in_progress',
|
||||
'awaiting_check_review',
|
||||
'check_needs_fix',
|
||||
'check_rejected',
|
||||
'checks_approved',
|
||||
])
|
||||
const filtered = items.filter(
|
||||
(h) =>
|
||||
checkRelated.has(h.toStatus) ||
|
||||
(h.fromStatus && checkRelated.has(h.fromStatus)) ||
|
||||
/چک/.test(h.note || '') ||
|
||||
/چک/.test(h.reason || ''),
|
||||
)
|
||||
const source = filtered.length ? filtered : items
|
||||
return [...source].sort(
|
||||
(a, b) => new Date(b.changedAt).getTime() - new Date(a.changedAt).getTime(),
|
||||
)
|
||||
})
|
||||
|
||||
const checkReviewNotes = computed(() =>
|
||||
orderChecks.value
|
||||
.filter((c) => c.notes?.trim())
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
row: c.row,
|
||||
status: c.status,
|
||||
notes: c.notes!,
|
||||
updatedAt: c.updatedAt,
|
||||
})),
|
||||
)
|
||||
|
||||
async function loadData() {
|
||||
const orderId = String(route.params.id)
|
||||
try {
|
||||
@@ -36,6 +72,7 @@ async function loadData() {
|
||||
])
|
||||
order.value = orderData
|
||||
orderChecks.value = checksData
|
||||
if (checksData.length === 1) expandedId.value = checksData[0]!.id
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بارگذاری چکها انجام نشد.')
|
||||
}
|
||||
@@ -55,6 +92,61 @@ function checkBadgeStatus(status: Check['status']) {
|
||||
return 'pending_check_review'
|
||||
}
|
||||
|
||||
function actorLabel(changedBy?: string) {
|
||||
if (!changedBy) return '—'
|
||||
if (changedBy === 'customer') return 'مشتری'
|
||||
if (changedBy.startsWith('admin:')) return 'ادمین'
|
||||
return changedBy
|
||||
}
|
||||
|
||||
function formatDateTime(iso?: string) {
|
||||
if (!iso) return '—'
|
||||
try {
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(iso))
|
||||
} catch {
|
||||
return iso.slice(0, 16).replace('T', ' ')
|
||||
}
|
||||
}
|
||||
|
||||
function ownerName(check: Check) {
|
||||
return [check.ownerFirstName, check.ownerLastName].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function detailFields(check: Check) {
|
||||
return [
|
||||
{ label: 'بانک', value: check.bankName },
|
||||
{ label: 'شعبه', value: check.branchName },
|
||||
{ label: 'شناسه صیادی', value: check.sayadId, ltr: true },
|
||||
{ label: 'شماره چک', value: check.checkNumber, ltr: true },
|
||||
{ label: 'صاحب چک', value: ownerName(check) },
|
||||
{ label: 'کد ملی', value: check.ownerNationalId, ltr: true },
|
||||
{ label: 'یادداشت', value: check.notes },
|
||||
].filter((f) => !!f.value?.trim())
|
||||
}
|
||||
|
||||
function toggleExpand(check: Check) {
|
||||
expandedId.value = expandedId.value === check.id ? null : check.id
|
||||
}
|
||||
|
||||
async function applyCheckStatus(check: Check, status: 'approved' | 'rejected') {
|
||||
busyId.value = check.id
|
||||
try {
|
||||
const updated = await checksApi.updateStatus(check.id, { status })
|
||||
const index = orderChecks.value.findIndex((c) => c.id === check.id)
|
||||
if (index !== -1) orderChecks.value[index] = updated
|
||||
order.value = await ordersApi.get(String(route.params.id))
|
||||
return true
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بهروزرسانی وضعیت چک انجام نشد.')
|
||||
return false
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCheckStatus(check: Check, status: Check['status']) {
|
||||
if (status !== 'approved' && status !== 'rejected') return
|
||||
|
||||
@@ -78,16 +170,9 @@ async function updateCheckStatus(check: Check, status: Check['status']) {
|
||||
)
|
||||
if (!ok) return
|
||||
|
||||
busyId.value = check.id
|
||||
try {
|
||||
const updated = await checksApi.updateStatus(check.id, { status })
|
||||
const index = orderChecks.value.findIndex((c) => c.id === check.id)
|
||||
if (index !== -1) orderChecks.value[index] = updated
|
||||
const success = await applyCheckStatus(check, status)
|
||||
if (success) {
|
||||
toast.success(status === 'approved' ? 'چک تأیید شد.' : 'چک رد شد.')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بهروزرسانی وضعیت چک انجام نشد.')
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,18 +193,9 @@ async function approveAll() {
|
||||
if (!ok) return
|
||||
|
||||
for (const check of pending) {
|
||||
busyId.value = check.id
|
||||
try {
|
||||
const updated = await checksApi.updateStatus(check.id, { status: 'approved' })
|
||||
const index = orderChecks.value.findIndex((c) => c.id === check.id)
|
||||
if (index !== -1) orderChecks.value[index] = updated
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بهروزرسانی وضعیت چک انجام نشد.')
|
||||
busyId.value = null
|
||||
return
|
||||
}
|
||||
const success = await applyCheckStatus(check, 'approved')
|
||||
if (!success) return
|
||||
}
|
||||
busyId.value = null
|
||||
toast.success('همه چکهای در انتظار تأیید شدند.')
|
||||
}
|
||||
</script>
|
||||
@@ -128,15 +204,36 @@ async function approveAll() {
|
||||
<div class="checks-page">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-header__title">مدیریت چکها</h1>
|
||||
<p class="admin-page-header__breadcrumb">سفارشها / مدیریت سفارش</p>
|
||||
<h1 class="admin-page-header__title">مدیریت چکهای سفارش</h1>
|
||||
<p class="admin-page-header__breadcrumb">
|
||||
سفارشها
|
||||
<template v-if="displayOrder"> / {{ displayOrder.orderNumber }}</template>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="displayOrder" class="checks-page__order-meta">
|
||||
<strong>{{ displayOrder.orderNumber }}</strong>
|
||||
<StatusBadge :status="displayOrder.status" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checks-page__stats">
|
||||
<div class="checks-page__stat">
|
||||
<span>تعداد</span>
|
||||
<strong>{{ orderChecks.length }}</strong>
|
||||
</div>
|
||||
<div class="checks-page__stat">
|
||||
<span>راس</span>
|
||||
<strong>{{ order?.checkRassDays ?? '—' }} روز</strong>
|
||||
</div>
|
||||
<div class="checks-page__stat">
|
||||
<span>مجموع چک</span>
|
||||
<strong>{{ formatCurrency(totalCheckAmount) }}</strong>
|
||||
</div>
|
||||
<div class="checks-page__stat">
|
||||
<span>مبلغ سفارش</span>
|
||||
<strong>{{ displayOrder?.totalAmount ? formatCurrency(displayOrder.totalAmount) : '—' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-tabs">
|
||||
<button
|
||||
type="button"
|
||||
@@ -144,15 +241,7 @@ async function approveAll() {
|
||||
:class="{ active: activeTab === 'info' }"
|
||||
@click="activeTab = 'info'"
|
||||
>
|
||||
اطلاعات چکها ({{ orderChecks.length }})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-tab"
|
||||
:class="{ active: activeTab === 'images' }"
|
||||
@click="activeTab = 'images'"
|
||||
>
|
||||
تصاویر چکها
|
||||
چکها ({{ orderChecks.length }})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -160,130 +249,117 @@ async function approveAll() {
|
||||
:class="{ active: activeTab === 'history' }"
|
||||
@click="activeTab = 'history'"
|
||||
>
|
||||
تاریخچه بررسی
|
||||
تاریخچه
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="activeTab === 'info'">
|
||||
<div class="admin-summary-grid">
|
||||
<div class="admin-summary-item">
|
||||
<p class="admin-summary-item__label">تعداد چکها</p>
|
||||
<p class="admin-summary-item__value">{{ orderChecks.length }} فقره</p>
|
||||
</div>
|
||||
<div class="admin-summary-item">
|
||||
<p class="admin-summary-item__label">راس چکها</p>
|
||||
<p class="admin-summary-item__value">
|
||||
<AdminIcon name="check" :size="14" />
|
||||
{{ order?.checkRassDays ?? '—' }} روز
|
||||
</p>
|
||||
<p v-if="order?.checkRassDate" class="checks-page__rass-date">
|
||||
تاریخ راس: {{ formatDate(order.checkRassDate) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-summary-item">
|
||||
<p class="admin-summary-item__label">مجموع چکها</p>
|
||||
<p class="admin-summary-item__value">{{ formatCurrency(totalCheckAmount) }}</p>
|
||||
</div>
|
||||
<div class="admin-summary-item">
|
||||
<p class="admin-summary-item__label">مبلغ کل سفارش</p>
|
||||
<p class="admin-summary-item__value">
|
||||
{{ displayOrder?.totalAmount ? formatCurrency(displayOrder.totalAmount) : '—' }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="!orderChecks.length" class="admin-empty-placeholder admin-card">
|
||||
چکی برای این سفارش ثبت نشده است.
|
||||
</div>
|
||||
|
||||
<div class="checks-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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="check in orderChecks" :key="check.id">
|
||||
<td>{{ check.row }}</td>
|
||||
<td>{{ formatCurrency(check.amount) }}</td>
|
||||
<td>{{ check.bankName }}</td>
|
||||
<td>{{ check.sayadId }}</td>
|
||||
<td>{{ formatDate(check.dueDate) }}</td>
|
||||
<td><StatusBadge :status="checkBadgeStatus(check.status)" /></td>
|
||||
<td>
|
||||
<div v-if="check.status === 'pending_review'" class="admin-action-group">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline-success admin-btn--xs"
|
||||
:disabled="busyId === check.id"
|
||||
@click="updateCheckStatus(check, 'approved')"
|
||||
>
|
||||
تأیید
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline-danger admin-btn--xs"
|
||||
:disabled="busyId === check.id"
|
||||
@click="updateCheckStatus(check, 'rejected')"
|
||||
>
|
||||
رد
|
||||
</button>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="checks-page__list admin-card">
|
||||
<div
|
||||
v-for="check in orderChecks"
|
||||
:key="check.id"
|
||||
class="check-row"
|
||||
:class="{ open: expandedId === check.id }"
|
||||
>
|
||||
<button type="button" class="check-row__main" @click="toggleExpand(check)">
|
||||
<span class="check-row__chevron" aria-hidden="true">{{ expandedId === check.id ? '▾' : '◂' }}</span>
|
||||
|
||||
<span
|
||||
v-if="check.frontImage || check.backImage"
|
||||
class="check-row__thumb"
|
||||
@click.stop="previewImage = (check.frontImage || check.backImage)!"
|
||||
>
|
||||
<img :src="check.frontImage || check.backImage" alt="" />
|
||||
</span>
|
||||
<span v-else class="check-row__thumb check-row__thumb--empty">بدون عکس</span>
|
||||
|
||||
<span class="check-row__meta">
|
||||
<strong>چک {{ check.row }}</strong>
|
||||
<small>
|
||||
{{ formatCurrency(check.amount) }}
|
||||
<template v-if="check.bankName"> · {{ check.bankName }}</template>
|
||||
· {{ formatDate(check.dueDate) }}
|
||||
</small>
|
||||
</span>
|
||||
|
||||
<div class="compact-only admin-card-list">
|
||||
<article v-for="check in orderChecks" :key="check.id" class="admin-card-item">
|
||||
<div class="admin-card-item__header">
|
||||
<p class="admin-card-item__title">چک {{ check.row }} — {{ formatCurrency(check.amount) }}</p>
|
||||
<StatusBadge :status="checkBadgeStatus(check.status)" />
|
||||
</button>
|
||||
|
||||
<div class="check-row__actions" @click.stop>
|
||||
<template v-if="check.status === 'pending_review'">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline-success admin-btn--xs"
|
||||
:disabled="busyId === check.id"
|
||||
@click="updateCheckStatus(check, 'approved')"
|
||||
>
|
||||
تأیید
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline-danger admin-btn--xs"
|
||||
:disabled="busyId === check.id"
|
||||
@click="updateCheckStatus(check, 'rejected')"
|
||||
>
|
||||
رد
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="admin-card-item__meta">
|
||||
<span>بانک {{ check.bankName }}</span>
|
||||
<span>سررسید {{ formatDate(check.dueDate) }}</span>
|
||||
<span dir="ltr">{{ check.sayadId }}</span>
|
||||
|
||||
<div v-if="expandedId === check.id" class="check-row__panel">
|
||||
<div v-if="detailFields(check).length" class="check-row__fields">
|
||||
<div
|
||||
v-for="field in detailFields(check)"
|
||||
:key="field.label"
|
||||
class="check-row__field"
|
||||
>
|
||||
<span>{{ field.label }}</span>
|
||||
<strong :dir="field.ltr ? 'ltr' : undefined">{{ field.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="check-row__hint">جزئیات متنی بیشتری ثبت نشده است.</p>
|
||||
|
||||
<div class="check-row__photos">
|
||||
<button
|
||||
v-if="check.frontImage"
|
||||
type="button"
|
||||
class="check-row__photo"
|
||||
@click="previewImage = check.frontImage!"
|
||||
>
|
||||
<img :src="check.frontImage" alt="روی چک" />
|
||||
<span>روی</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="check.backImage"
|
||||
type="button"
|
||||
class="check-row__photo"
|
||||
@click="previewImage = check.backImage!"
|
||||
>
|
||||
<img :src="check.backImage" alt="پشت چک" />
|
||||
<span>پشت</span>
|
||||
</button>
|
||||
<p v-if="!check.frontImage && !check.backImage" class="check-row__hint">
|
||||
تصویری ثبت نشده.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="check.status === 'pending_review'" class="admin-card-item__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline-success admin-btn--xs"
|
||||
:disabled="busyId === check.id"
|
||||
@click="updateCheckStatus(check, 'approved')"
|
||||
>
|
||||
تأیید
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline-danger admin-btn--xs"
|
||||
:disabled="busyId === check.id"
|
||||
@click="updateCheckStatus(check, 'rejected')"
|
||||
>
|
||||
رد
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checks-page__footer">
|
||||
<p class="checks-page__note">
|
||||
راس چکها بر اساس تاریخ ثبت سفارش محاسبه شده است.
|
||||
<template v-if="order?.checkRassDate">
|
||||
راس: {{ formatDate(order.checkRassDate) }}
|
||||
</template>
|
||||
<template v-else>راس بر اساس تاریخ ثبت سفارش محاسبه میشود.</template>
|
||||
</p>
|
||||
<div class="admin-action-group">
|
||||
<button type="button" class="admin-btn admin-btn--success" @click="approveAll">
|
||||
تأیید همه چکها
|
||||
</button>
|
||||
<button type="button" class="admin-btn admin-btn--outline">
|
||||
<AdminIcon name="print" :size="16" />
|
||||
چاپ لیستها
|
||||
<button type="button" class="admin-btn admin-btn--success admin-btn--sm" @click="approveAll">
|
||||
تأیید همه
|
||||
</button>
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="goBack">
|
||||
بازگشت
|
||||
@@ -292,45 +368,352 @@ async function approveAll() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="admin-empty-placeholder admin-card">
|
||||
{{ activeTab === 'images' ? 'تصاویر چکها' : 'تاریخچه بررسی' }} — بهزودی
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="!reviewHistory.length && !checkReviewNotes.length"
|
||||
class="admin-empty-placeholder admin-card"
|
||||
>
|
||||
هنوز تاریخچهای ثبت نشده است.
|
||||
</div>
|
||||
<div v-else class="checks-page__history">
|
||||
<article
|
||||
v-for="item in reviewHistory"
|
||||
:key="item.id"
|
||||
class="admin-card checks-page__history-item"
|
||||
>
|
||||
<div class="checks-page__history-item-head">
|
||||
<strong>
|
||||
{{ item.fromStatus ? rawStatusLabel(item.fromStatus) : '—' }}
|
||||
→
|
||||
{{ rawStatusLabel(item.toStatus) }}
|
||||
</strong>
|
||||
<span>{{ formatDateTime(item.changedAt) }}</span>
|
||||
</div>
|
||||
<p class="checks-page__history-meta">توسط {{ actorLabel(item.changedBy) }}</p>
|
||||
<p v-if="item.note" class="checks-page__history-note">{{ item.note }}</p>
|
||||
<p v-if="item.reason" class="checks-page__history-note">دلیل: {{ item.reason }}</p>
|
||||
</article>
|
||||
|
||||
<article
|
||||
v-for="note in checkReviewNotes"
|
||||
:key="`note-${note.id}`"
|
||||
class="admin-card checks-page__history-item"
|
||||
>
|
||||
<div class="checks-page__history-item-head">
|
||||
<strong>یادداشت چک {{ note.row }}</strong>
|
||||
<span>{{ formatDateTime(note.updatedAt) }}</span>
|
||||
</div>
|
||||
<StatusBadge :status="checkBadgeStatus(note.status)" />
|
||||
<p class="checks-page__history-note">{{ note.notes }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="previewImage"
|
||||
class="checks-page__lightbox"
|
||||
@click.self="previewImage = null"
|
||||
>
|
||||
<button type="button" class="checks-page__lightbox-close" @click="previewImage = null">
|
||||
بستن
|
||||
</button>
|
||||
<img :src="previewImage" alt="پیشنمایش" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.checks-page__order-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
font-size: 0.9375rem;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.checks-page__rass-date {
|
||||
margin: 4px 0 0;
|
||||
.checks-page__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.checks-page__stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.checks-page__stat span {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.checks-page__table {
|
||||
margin-top: 16px;
|
||||
.checks-page__stat strong {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.checks-page__list {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.check-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.check-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.check-row.open {
|
||||
background: color-mix(in srgb, var(--color-primary-soft) 55%, transparent);
|
||||
}
|
||||
|
||||
.check-row__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.check-row__chevron {
|
||||
width: 14px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.check-row__thumb {
|
||||
width: 44px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: #111;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.check-row__thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.check-row__thumb--empty {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.check-row__meta {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.check-row__meta strong {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.check-row__meta small {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.check-row__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.check-row__panel {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 16px;
|
||||
align-items: flex-start;
|
||||
padding: 8px 0 4px 24px;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
}
|
||||
|
||||
.check-row__fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.check-row__field {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-border-light);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.check-row__field span {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.check-row__field strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.check-row__photos {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.check-row__photo {
|
||||
width: 96px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
cursor: zoom-in;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.check-row__photo img {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.check-row__photo span {
|
||||
display: block;
|
||||
padding: 4px;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.check-row__hint {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.checks-page__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.checks-page__note {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.checks-page__history {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.checks-page__history-item {
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.checks-page__history-item-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.checks-page__history-meta,
|
||||
.checks-page__history-note {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.checks-page__history-meta {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.checks-page__lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 300000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.84);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.checks-page__lightbox img {
|
||||
max-width: min(900px, 100%);
|
||||
max-height: 88vh;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.checks-page__lightbox-close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
inset-inline-end: 14px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.checks-page__stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.check-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.check-row__actions {
|
||||
padding-inline-start: 24px;
|
||||
}
|
||||
|
||||
.check-row__panel {
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import { checksApi, ordersApi } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { formatCurrency, formatDateTime } from '@/utils/format'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { Check } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const checkList = ref<Check[]>([])
|
||||
const searchQuery = ref('')
|
||||
const statusFilter = ref<'all' | Check['status']>('all')
|
||||
const busyId = ref<string | null>(null)
|
||||
|
||||
const filteredChecks = computed(() => {
|
||||
let list = checkList.value
|
||||
if (statusFilter.value !== 'all') {
|
||||
list = list.filter((c) => c.status === statusFilter.value)
|
||||
}
|
||||
const q = searchQuery.value.trim()
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(c) =>
|
||||
(c.orderNumber ?? '').includes(q) ||
|
||||
(c.customerName ?? '').includes(q) ||
|
||||
(c.bankName ?? '').includes(q) ||
|
||||
(c.sayadId ?? '').includes(q),
|
||||
)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const pendingCount = computed(
|
||||
() => checkList.value.filter((c) => c.status === 'pending_review').length,
|
||||
)
|
||||
|
||||
function checkBadgeStatus(status: Check['status']) {
|
||||
if (status === 'approved') return 'final_approval'
|
||||
if (status === 'rejected') return 'needs_correction'
|
||||
return 'pending_check_review'
|
||||
}
|
||||
|
||||
async function loadChecksFromOrders() {
|
||||
const orders = await ordersApi.list()
|
||||
checkList.value = orders.flatMap((order) =>
|
||||
(order.checks ?? []).map((check) => ({
|
||||
...check,
|
||||
orderNumber: order.orderNumber,
|
||||
customerName: order.customerName,
|
||||
orderStatus: order.status,
|
||||
settlementType: order.settlementMethod ?? order.settlementType ?? null,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
async function loadChecks() {
|
||||
try {
|
||||
checkList.value = await checksApi.list()
|
||||
} catch {
|
||||
try {
|
||||
await loadChecksFromOrders()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بارگذاری چکها انجام نشد.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadChecks()
|
||||
})
|
||||
|
||||
function openOrderChecks(check: Check) {
|
||||
router.push(`/orders/${check.orderId}/checks`)
|
||||
}
|
||||
|
||||
async function updateCheckStatus(check: Check, status: Check['status']) {
|
||||
busyId.value = check.id
|
||||
try {
|
||||
const updated = await checksApi.updateStatus(check.id, { status })
|
||||
const index = checkList.value.findIndex((c) => c.id === check.id)
|
||||
if (index !== -1) {
|
||||
checkList.value[index] = {
|
||||
...checkList.value[index]!,
|
||||
...updated,
|
||||
orderNumber: check.orderNumber,
|
||||
customerName: check.customerName,
|
||||
orderStatus: check.orderStatus,
|
||||
settlementType: check.settlementType,
|
||||
}
|
||||
}
|
||||
toast.success(status === 'approved' ? 'چک تأیید شد.' : 'چک رد شد.')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بهروزرسانی وضعیت چک انجام نشد.')
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="checks-list-page">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-header__title">چکها</h1>
|
||||
<p class="admin-page-header__breadcrumb">مالی / همه چکهای سفارشها</p>
|
||||
</div>
|
||||
<div class="checks-list-page__summary">
|
||||
<span>{{ checkList.length }} چک</span>
|
||||
<span v-if="pendingCount">{{ pendingCount }} در انتظار بررسی</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checks-list-page__filters admin-card">
|
||||
<div class="checks-list-page__search">
|
||||
<AdminIcon name="search" :size="18" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="checks-list-page__search-input"
|
||||
placeholder="جستجو در شماره سفارش، مشتری، بانک، صیاد..."
|
||||
/>
|
||||
</div>
|
||||
<select v-model="statusFilter" class="admin-input">
|
||||
<option value="all">همه وضعیتها</option>
|
||||
<option value="pending_review">در انتظار بررسی</option>
|
||||
<option value="approved">تأیید شده</option>
|
||||
<option value="rejected">رد شده</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="checks-list-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="check in filteredChecks" :key="check.id">
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="checks-list-page__order-link"
|
||||
@click="openOrderChecks(check)"
|
||||
>
|
||||
{{ check.orderNumber || check.orderId }}
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ check.customerName || '—' }}</td>
|
||||
<td>{{ check.row }}</td>
|
||||
<td>{{ formatCurrency(check.amount) }}</td>
|
||||
<td>{{ check.bankName || '—' }}</td>
|
||||
<td dir="ltr">{{ check.sayadId || '—' }}</td>
|
||||
<td>{{ check.dueDate.slice(0, 10) }}</td>
|
||||
<td><StatusBadge :status="checkBadgeStatus(check.status)" /></td>
|
||||
<td>
|
||||
<div class="admin-action-group">
|
||||
<template v-if="check.status === 'pending_review'">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline-success admin-btn--xs"
|
||||
:disabled="busyId === check.id"
|
||||
@click="updateCheckStatus(check, 'approved')"
|
||||
>
|
||||
تأیید
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline-danger admin-btn--xs"
|
||||
:disabled="busyId === check.id"
|
||||
@click="updateCheckStatus(check, 'rejected')"
|
||||
>
|
||||
رد
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline admin-btn--xs"
|
||||
@click="openOrderChecks(check)"
|
||||
>
|
||||
جزئیات سفارش
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="!filteredChecks.length" class="checks-list-page__empty">
|
||||
چکی با این فیلتر یافت نشد.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="compact-only admin-card-list">
|
||||
<article v-for="check in filteredChecks" :key="check.id" class="admin-card-item">
|
||||
<div class="admin-card-item__header">
|
||||
<p class="admin-card-item__title">
|
||||
{{ check.orderNumber || check.orderId }} — چک {{ check.row }}
|
||||
</p>
|
||||
<StatusBadge :status="checkBadgeStatus(check.status)" />
|
||||
</div>
|
||||
<div class="admin-card-item__meta">
|
||||
<span>{{ check.customerName || '—' }}</span>
|
||||
<span>{{ formatCurrency(check.amount) }}</span>
|
||||
<span>{{ check.bankName || '—' }}</span>
|
||||
<span>سررسید {{ check.dueDate.slice(0, 10) }}</span>
|
||||
<span v-if="check.createdAt">ثبت {{ formatDateTime(check.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="admin-card-item__actions">
|
||||
<template v-if="check.status === 'pending_review'">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline-success admin-btn--xs"
|
||||
:disabled="busyId === check.id"
|
||||
@click="updateCheckStatus(check, 'approved')"
|
||||
>
|
||||
تأیید
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline-danger admin-btn--xs"
|
||||
:disabled="busyId === check.id"
|
||||
@click="updateCheckStatus(check, 'rejected')"
|
||||
>
|
||||
رد
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline admin-btn--xs"
|
||||
@click="openOrderChecks(check)"
|
||||
>
|
||||
جزئیات سفارش
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!filteredChecks.length" class="checks-list-page__empty">
|
||||
چکی با این فیلتر یافت نشد.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.checks-list-page__summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.checks-list-page__filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.checks-list-page__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-border-light);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.checks-list-page__search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.checks-list-page__table {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.checks-list-page__order-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.checks-list-page__order-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.checks-list-page__empty {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
@@ -7,6 +8,7 @@ import OrderAddModal from '@/views/orders/components/OrderAddModal.vue'
|
||||
import { useOrdersPageContext } from '@/composables/useOrdersPage'
|
||||
import { ORDER_TABS } from '@/utils/orderStatus'
|
||||
import { formatCurrency, formatDateTime, formatMeterage } from '@/utils/format'
|
||||
import type { Order } from '@/types'
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
@@ -26,6 +28,52 @@ const {
|
||||
openAddModal,
|
||||
} = useOrdersPageContext()
|
||||
|
||||
const menuOrder = ref<Order | null>(null)
|
||||
const menuStyle = ref<Record<string, string>>({})
|
||||
|
||||
function closeMenu() {
|
||||
menuOrder.value = null
|
||||
}
|
||||
|
||||
async function toggleMenu(order: Order, event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
if (menuOrder.value?.id === order.id) {
|
||||
closeMenu()
|
||||
return
|
||||
}
|
||||
const btn = event.currentTarget as HTMLElement
|
||||
const rect = btn.getBoundingClientRect()
|
||||
menuOrder.value = order
|
||||
await nextTick()
|
||||
const menuWidth = 168
|
||||
const left = Math.min(
|
||||
Math.max(8, rect.right - menuWidth),
|
||||
window.innerWidth - menuWidth - 8,
|
||||
)
|
||||
menuStyle.value = {
|
||||
position: 'fixed',
|
||||
top: `${rect.bottom + 4}px`,
|
||||
left: `${left}px`,
|
||||
width: `${menuWidth}px`,
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentClick() {
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
window.addEventListener('scroll', closeMenu, true)
|
||||
window.addEventListener('resize', closeMenu)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
window.removeEventListener('scroll', closeMenu, true)
|
||||
window.removeEventListener('resize', closeMenu)
|
||||
})
|
||||
|
||||
function handlePricing() {
|
||||
if (!selectedOrder.value) return
|
||||
closeDetailModal()
|
||||
@@ -43,6 +91,25 @@ function handleCashReceipt() {
|
||||
closeDetailModal()
|
||||
goCashReceipt(selectedOrder.value)
|
||||
}
|
||||
|
||||
function runMenuAction(action: 'detail' | 'pricing' | 'checks' | 'cash') {
|
||||
const order = menuOrder.value
|
||||
if (!order) return
|
||||
closeMenu()
|
||||
if (action === 'detail') {
|
||||
openOrderDetail(order)
|
||||
return
|
||||
}
|
||||
if (action === 'pricing') {
|
||||
goPricing(order)
|
||||
return
|
||||
}
|
||||
if (action === 'checks') {
|
||||
goChecks(order)
|
||||
return
|
||||
}
|
||||
goCashReceipt(order)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -116,8 +183,15 @@ function handleCashReceipt() {
|
||||
<td>{{ formatMeterage(order.meterage) }}</td>
|
||||
<td><StatusBadge :status="order.status" /></td>
|
||||
<td>{{ formatDateTime(order.createdAt) }}</td>
|
||||
<td>
|
||||
<button type="button" class="orders-page__more-btn" @click.stop>
|
||||
<td class="orders-page__actions-cell">
|
||||
<button
|
||||
type="button"
|
||||
class="orders-page__more-btn"
|
||||
:aria-expanded="menuOrder?.id === order.id"
|
||||
aria-haspopup="menu"
|
||||
aria-label="عملیات سفارش"
|
||||
@click="toggleMenu(order, $event)"
|
||||
>
|
||||
<AdminIcon name="more" :size="18" />
|
||||
</button>
|
||||
</td>
|
||||
@@ -138,6 +212,51 @@ function handleCashReceipt() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="menuOrder"
|
||||
class="orders-page__menu"
|
||||
role="menu"
|
||||
:style="menuStyle"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="orders-page__menu-item"
|
||||
@click="runMenuAction('detail')"
|
||||
>
|
||||
جزئیات سفارش
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="orders-page__menu-item"
|
||||
@click="runMenuAction('pricing')"
|
||||
>
|
||||
قیمتگذاری
|
||||
</button>
|
||||
<button
|
||||
v-if="menuOrder.settlementMethod === 'check'"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="orders-page__menu-item"
|
||||
@click="runMenuAction('checks')"
|
||||
>
|
||||
مدیریت چکها
|
||||
</button>
|
||||
<button
|
||||
v-if="menuOrder.settlementMethod === 'cash'"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="orders-page__menu-item"
|
||||
@click="runMenuAction('cash')"
|
||||
>
|
||||
رسید پرداخت
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<BaseModal
|
||||
:open="showDetailModal && !!selectedOrder"
|
||||
title="جزئیات سفارش"
|
||||
@@ -231,12 +350,23 @@ function handleCashReceipt() {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.orders-page__actions-cell {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.orders-page__more-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.orders-page__more-btn:hover,
|
||||
.orders-page__more-btn[aria-expanded='true'] {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border-light);
|
||||
}
|
||||
|
||||
.admin-table tbody tr {
|
||||
@@ -251,3 +381,32 @@ function handleCashReceipt() {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.orders-page__menu {
|
||||
z-index: 300000;
|
||||
min-width: 160px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.orders-page__menu-item {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
text-align: start;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.orders-page__menu-item:hover {
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user