Files
nademitex_admin/src/views/checks/CheckManagementView.vue
T

771 lines
20 KiB
Vue
Raw Normal View History

<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'
2026-09-10 16:13:32 +03:30
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
2026-09-10 16:13:32 +03:30
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>
2026-10-29 15:24:44 +03:30
<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>