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:
2026-08-09 16:17:52 +03:30
co-authored by Cursor
parent f46b02efa8
commit c69c358a4d
10 changed files with 1071 additions and 170 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ export function useDashboard() {
link: '/orders?tab=pending_review',
},
{
label: 'در انتظار چک',
label: 'بررسی چک',
value: dashboardStats.value.pendingCheck,
icon: 'wallet',
tone: 'purple',
+1 -1
View File
@@ -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
View File
@@ -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',
+4
View File
@@ -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 }) =>
+26
View File
@@ -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 {
+6 -1
View File
@@ -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}/`)
}
+2 -2
View File
@@ -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: 'نیاز به اصلاح' },
+540 -157
View File
@@ -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>
+323
View File
@@ -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>