Files
nademitex_admin/src/views/checks/CheckManagementView.vue
T
2026-10-29 15:24:44 +03:30

771 lines
20 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { checksApi, mapAdminOrder, ordersApi, type ApiOrder } from '@/services/api'
import { ApiError } from '@/services/http'
import { formatCurrency, formatDate, formatDateTime as formatPersianDateTime } from '@/utils/format'
import { useToast } from '@/composables/useToast'
import { confirm } from '@/composables/useConfirm'
import { rawStatusLabel } from '@/utils/orderStatus'
import type { Check, RawOrderStatus } from '@/types'
const route = useRoute()
const router = useRouter()
const toast = useToast()
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,
)
const totalCheckAmount = computed(() =>
orderChecks.value.reduce((sum, c) => sum + c.amount, 0),
)
const pendingChecks = computed(() =>
orderChecks.value.filter((c) => c.status === 'pending_review'),
)
const allChecksApproved = computed(
() =>
orderChecks.value.length > 0 &&
orderChecks.value.every((c) => c.status === 'approved'),
)
const canFinalApprove = computed(() => {
if (!allChecksApproved.value || !order.value) return false
const status = (order.value.rawStatus ?? order.value.status) as RawOrderStatus
return status === 'checks_approved'
})
const finalizing = ref(false)
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 {
const [orderData, checksData] = await Promise.all([
ordersApi.get(orderId),
checksApi.listByOrder(orderId),
])
order.value = orderData
orderChecks.value = checksData
if (checksData.length === 1) expandedId.value = checksData[0]!.id
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری چک‌ها انجام نشد.')
}
}
onMounted(() => {
void loadData()
})
function goBack() {
router.push('/orders')
}
function checkBadgeStatus(status: Check['status']) {
if (status === 'approved') return 'check_approved'
if (status === 'rejected') return 'check_rejected'
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) {
return formatPersianDateTime(iso)
}
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
const amountLabel = formatCurrency(check.amount)
const ok = await confirm(
status === 'approved'
? {
title: 'تأیید چک',
message: `آیا از تأیید چک ردیف ${check.row} به مبلغ ${amountLabel} مطمئن هستید؟`,
tone: 'primary',
confirmLabel: 'تأیید چک',
cancelLabel: 'انصراف',
}
: {
title: 'رد چک',
message: `آیا از رد چک ردیف ${check.row} به مبلغ ${amountLabel} مطمئن هستید؟`,
tone: 'danger',
confirmLabel: 'رد چک',
cancelLabel: 'انصراف',
},
)
if (!ok) return
const success = await applyCheckStatus(check, status)
if (success) {
toast.success(status === 'approved' ? 'چک تأیید شد.' : 'چک رد شد.')
}
}
async function approveAll() {
const pending = pendingChecks.value
if (!pending.length) {
toast.error('چکی برای تأیید وجود ندارد.')
return
}
const ok = await confirm({
title: 'تأیید همه چک‌ها',
message: `آیا از تأیید ${pending.length} چک در انتظار بررسی مطمئن هستید؟`,
tone: 'primary',
confirmLabel: 'تأیید همه',
cancelLabel: 'انصراف',
})
if (!ok) return
for (const check of pending) {
const success = await applyCheckStatus(check, 'approved')
if (!success) return
}
toast.success('همه چک‌های در انتظار تأیید شدند.')
}
async function finalApproveOrder() {
if (!order.value || finalizing.value) return
const ok = await confirm({
title: 'تأیید نهایی سفارش',
message:
'با تأیید نهایی، سفارش از مرحله بررسی چک خارج می‌شود و وارد آماده‌سازی می‌گردد. ادامه می‌دهید؟',
tone: 'primary',
confirmLabel: 'تأیید نهایی',
cancelLabel: 'انصراف',
})
if (!ok) return
finalizing.value = true
try {
const updated = await ordersApi.updateStatus(order.value.id, {
status: 'final_approved',
note: 'تأیید نهایی پس از تأیید همه چک‌ها',
})
order.value = updated
toast.success('سفارش تأیید نهایی شد.')
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'تأیید نهایی انجام نشد.')
} finally {
finalizing.value = false
}
}
</script>
<template>
<div class="checks-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">مدیریت چک‌های سفارش</h1>
</div>
<div v-if="displayOrder" class="checks-page__order-meta">
<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"
class="admin-tab"
:class="{ active: activeTab === 'info' }"
@click="activeTab = 'info'"
>
چک‌ها ({{ orderChecks.length }})
</button>
<button
type="button"
class="admin-tab"
:class="{ active: activeTab === 'history' }"
@click="activeTab = 'history'"
>
تاریخچه
</button>
</div>
<template v-if="activeTab === 'info'">
<div v-if="!orderChecks.length" class="admin-empty-placeholder admin-card">
چکی برای این سفارش ثبت نشده است.
</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>
<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 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>
</div>
<div class="checks-page__footer">
<p class="checks-page__note">
<template v-if="canFinalApprove">
همه چک‌ها تأیید شده‌اند. برای ادامه، تأیید نهایی سفارش را ثبت کنید.
</template>
<template v-else-if="order?.checkRassDate">
راس: {{ formatDate(order.checkRassDate) }}
</template>
<template v-else>راس نسبت به تاریخ صدور فاکتور ثابت است.</template>
</p>
<div class="admin-action-group">
<button
v-if="canFinalApprove"
type="button"
class="admin-btn admin-btn--success admin-btn--sm"
:disabled="finalizing"
@click="finalApproveOrder"
>
{{ finalizing ? 'در حال ثبت…' : 'تأیید نهایی سفارش' }}
</button>
<button
v-if="pendingChecks.length"
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">
بازگشت
</button>
</div>
</div>
</template>
<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;
align-items: center;
gap: 8px;
}
.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);
}
.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: 12px;
margin-top: 14px;
flex-wrap: wrap;
}
.checks-page__note {
margin: 0;
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>