fixed ui and some bugs

This commit is contained in:
2026-06-28 21:48:17 +03:30
parent 34be7a66dc
commit 218d3e9f5e
25 changed files with 1374 additions and 371 deletions
@@ -16,12 +16,7 @@
/>
</MobileCardList>
<AccountingMobileDetailSheet
v-model:visible="sheetVisible"
:item="selectedItem"
@info="$emit('info', $event)"
@orders="$emit('orders', $event)"
/>
<AccountingMobileDetailSheet v-model:visible="sheetVisible" :item="selectedItem" />
</template>
<script setup lang="ts">
@@ -33,11 +28,6 @@ import { useMobileTableList } from '@/components/mobile/composables/useMobileTab
import AccountingMobileDetailSheet from './AccountingMobileDetailSheet.vue'
import AccountingMobileListItem from './AccountingMobileListItem.vue'
defineEmits<{
(e: 'info', item: TotalAccounting): void
(e: 'orders', item: TotalAccounting): void
}>()
const { sheetVisible, selectedItem, openSheet, syncSelectedItem } = useMobileDetailSheet<TotalAccounting>()
const { loading, search, items, hasMore, reload, loadMore, fetch } = useMobileTableList<TotalAccounting>({
@@ -47,5 +37,5 @@ const { loading, search, items, hasMore, reload, loadMore, fetch } = useMobileTa
onMounted(() => reload())
defineExpose({ fetch, openSheet })
defineExpose({ fetch })
</script>
@@ -1,32 +1,13 @@
<template>
<MobileDetailSheet v-model:visible="visibleProxy" :item="item" :title="sheetTitle">
<template #default="{ item: row }">
<MobileSheetHeader :title="sheetTitle" :show-title="isMobile">
<template #actions>
<MobileIconButton plain icon="vuesax-linear:stickynote" color="#10913A" :size="22" @click="$emit('info', row)" />
<MobileIconButton plain icon="vuesax-linear:receipt-item" color="#10913A" :size="22" @click="$emit('orders', row)" />
</template>
</MobileSheetHeader>
<div class="space-y-2">
<MobileStatCard bordered variant="success" label="موجودی ریالی" :value="usePrice(row.balance_cash ?? 0, true, '')">
<template #prefix>
<BaseIcon icon="vuesax-linear:empty-wallet" size="18" color="#15803d" />
</template>
</MobileStatCard>
<MobileStatCard bordered variant="success" :label="metalLabel.goldInventory()" :value="`${row.balance_gold ?? 0} گرم`">
<template #prefix>
<BaseIcon icon="vuesax-linear:coin" size="18" color="#15803d" />
</template>
</MobileStatCard>
</div>
<MobileSheetHeader :title="sheetTitle" :show-title="isMobile" />
<MobileInfoGrid>
<MobileDetailRow label="نام مشتری" :value="row.customer_to?.name || '-'" />
<MobileDetailRow label="نام مشتری دعوت شده" :value="row.customer?.name || '-'" />
<MobileDetailRow label="زمان ثبت" :value="row.created_at ? toJalali(row.created_at) : '-'" />
<MobileDetailRow label="نقد (ریال)" :value="usePrice(row.cash ?? 0, true, '')" />
<MobileDetailRow :label="m('طلا (گرم)')" :value="`${row.gold ?? 0}`" />
<MobileDetailRow :label="metalLabel.goldUnit()" :value="`${row.gold ?? 0}`" />
<MobileDetailRow label="توضیحات" full-width :value="row.note || '-'" />
</MobileInfoGrid>
</template>
@@ -36,32 +17,22 @@
<script setup lang="ts">
import type { TotalAccounting } from '@/model/accounting'
import MobileDetailRow from '@/components/mobile/MobileDetailRow.vue'
import MobileStatCard from '@/components/mobile/MobileStatCard.vue'
import MobileDetailSheet from '@/components/mobile/MobileDetailSheet.vue'
import MobileIconButton from '@/components/mobile/MobileIconButton.vue'
import MobileInfoGrid from '@/components/mobile/MobileInfoGrid.vue'
import MobileSheetHeader from '@/components/mobile/MobileSheetHeader.vue'
import { isMobile } from '@core/utils/is'
import { toJalali } from '@core/utils/useDataTime'
import { usePrice } from '@core/utils/usePrice'
import { metalLabel } from '@/utils/adminMetalLabels'
import { m } from '@/utils/metalLabels'
const props = defineProps<{
visible: boolean
item: TotalAccounting | null
}>()
const props = defineProps<{ visible: boolean; item: TotalAccounting | null }>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'info', item: TotalAccounting): void
(e: 'orders', item: TotalAccounting): void
}>()
const emit = defineEmits<{ (e: 'update:visible', value: boolean): void }>()
const visibleProxy = computed({
get: () => props.visible,
set: (value: boolean) => emit('update:visible', value)
set: (v) => emit('update:visible', v)
})
const sheetTitle = computed(() => props.item?.customer_to?.name || props.item?.customer?.name || 'حسابداری')
const sheetTitle = computed(() => props.item?.customer_to?.name || 'گزارش')
</script>
@@ -1,18 +1,16 @@
<template>
<MobileCard clickable @click="$emit('open', item)">
<MobileCard @click="$emit('open', item)">
<div class="space-y-1">
<div>
<div class="flex justify-between items-center gap-x-3 gap-y-1 text-xs">
<div class="truncate text-sm font-700 text-gray-900">{{ customerToName }}</div>
<div class="text-xs text-gray-400">{{ createdAtLabel }}</div>
</div>
<span class="text-xs text-gray-600">نقد: <span class="font-600 text-gray-900">{{ cashLabel }}</span></span>
<span class="text-xs text-gray-600">{{ metalLabel.goldUnit() }}: <span class="font-600 text-gray-900">{{ goldLabel }}</span></span>
<div class="flex justify-between items-center gap-x-3 text-xs">
<div class="truncate text-sm font-700 text-gray-900">{{ customerName }}</div>
<div class="text-xs text-gray-400">{{ createdAtLabel }}</div>
</div>
<div v-if="item.note" class="text-xs text-gray-600">{{ item.note }}</div>
<div class="flex gap-3 text-xs">
<span class="text-gray-600">نقد: <span class="font-600 text-gray-900">{{ cashLabel }}</span></span>
<span class="text-gray-600">{{ metalLabel.goldUnit() }}: <span class="font-600 text-gray-900">{{ goldLabel }}</span></span>
</div>
</div>
</MobileCard>
</template>
@@ -23,15 +21,11 @@ import { toJalali } from '@core/utils/useDataTime'
import { usePrice } from '@core/utils/usePrice'
import { metalLabel } from '@/utils/adminMetalLabels'
const props = defineProps<{
item: TotalAccounting
}>()
const props = defineProps<{ item: TotalAccounting }>()
defineEmits<{
(e: 'open', item: TotalAccounting): void
}>()
defineEmits<{ (e: 'open', item: TotalAccounting): void }>()
const customerToName = computed(() => props.item.customer_to?.name || '-')
const customerName = computed(() => props.item.customer_to?.name || '-')
const createdAtLabel = computed(() => (props.item.created_at ? toJalali(props.item.created_at) : '-'))
const cashLabel = computed(() => usePrice(props.item.cash ?? 0, true, ''))
const goldLabel = computed(() => `${props.item.gold ?? 0} گرم`)
@@ -0,0 +1,823 @@
<template>
<div class="cs-wrap">
<!-- ─── بنر آمار ─── -->
<div class="cs-banner">
<div class="cs-banner__title">همگام‌سازی مشتریان</div>
<!-- آمار اصلی: ردیف بالا -->
<div class="cs-stats-main">
<div class="cs-stat cs-stat--neutral">
<div class="cs-stat__num">
<template v-if="status">{{ status.panel_total }}</template>
<a-spin v-else size="small" />
</div>
<div class="cs-stat__label">مشتریان پنل</div>
</div>
<div class="cs-sep-arrows">
<Icon icon="vuesax-linear:arrow-left" :size="16" />
<Icon icon="vuesax-linear:arrow-right" :size="16" />
</div>
<div class="cs-stat cs-stat--tahesab">
<div class="cs-stat__num">
<template v-if="status?.tahesab_total != null">{{ status.tahesab_total }}</template>
<a-spin v-else-if="loadingTahesab" size="small" />
<span v-else class="cs-stat__hint">تب دوم</span>
</div>
<div class="cs-stat__label">مشتریان ته‌حساب</div>
</div>
</div>
<!-- آمار تفاوت: ردیف پایین -->
<div class="cs-stats-diff">
<div class="cs-stat cs-stat--amber">
<div class="cs-stat__num">
<template v-if="status">{{ status.panel_only }}</template>
<a-spin v-else-if="loadingPanel" size="small" />
<span v-else>—</span>
</div>
<div class="cs-stat__label">پنل — بدون ته‌حساب</div>
</div>
<div class="cs-stat cs-stat--sky">
<div class="cs-stat__num">
<template v-if="status?.tahesab_only_count != null">{{ status.tahesab_only_count }}</template>
<a-spin v-else-if="loadingTahesab" size="small" />
<span v-else class="cs-stat__hint">تب دوم</span>
</div>
<div class="cs-stat__label">ته‌حساب — بدون پنل</div>
</div>
</div>
<!-- progress -->
<div v-if="status" class="cs-progress">
<div class="cs-progress__labels">
<span>{{ registeredPercent }}٪ پنل در ته‌حساب</span>
<span>{{ status.panel_registered }} / {{ status.panel_total }}</span>
</div>
<div class="cs-track"><div class="cs-track__fill" :style="{ width: registeredPercent + '%' }" /></div>
</div>
</div>
<!-- ─── تب‌ها ─── -->
<div class="cs-tabs">
<button
class="cs-tab"
:class="{ 'cs-tab--active': activeTab === 'panel' }"
@click="onTabChange('panel')"
>
<Icon icon="vuesax-linear:arrow-left" :size="14" />
پنل ← ته‌حساب
<span v-if="status?.panel_only" class="cs-tab__badge cs-tab__badge--amber">{{ status.panel_only }}</span>
</button>
<button
class="cs-tab"
:class="{ 'cs-tab--active': activeTab === 'tahesab' }"
@click="onTabChange('tahesab')"
>
<Icon icon="vuesax-linear:arrow-right" :size="14" />
ته‌حساب ← پنل
<span v-if="status?.tahesab_only_count" class="cs-tab__badge cs-tab__badge--sky">{{ status.tahesab_only_count }}</span>
</button>
</div>
<!-- ─── تب ۱: پنل → ته‌حساب ─── -->
<div v-show="activeTab === 'panel'" class="cs-card">
<div class="cs-card__head">
<div class="cs-card__head-right">
<span class="cs-dot cs-dot--amber" />
<span class="cs-card__head-title">مشتریان پنل بدون کد ته‌حساب</span>
</div>
<div class="cs-card__head-actions">
<a-input
v-model:value="panelSearch"
placeholder="جستجو..."
allow-clear
size="small"
class="cs-search"
@change="onPanelSearch"
/>
<a-button
type="primary"
size="small"
:loading="syncingPanel"
:disabled="!status?.panel_only"
@click="syncAllPanel"
>
<template #icon><Icon icon="vuesax-linear:send-2" :size="13" /></template>
ثبت همه در ته‌حساب
</a-button>
</div>
</div>
<div v-if="syncingPanel || panelSyncResult" class="cs-result">
<template v-if="syncingPanel">
<a-spin size="small" />
<span>در حال ثبت مشتریان پنل در ته‌حساب...</span>
</template>
<template v-else-if="panelSyncResult">
<span class="cs-result__ok">✓ {{ panelSyncResult.success }} موفق</span>
<span v-if="panelSyncResult.failed" class="cs-result__fail">✗ {{ panelSyncResult.failed }} ناموفق</span>
<a-button v-if="panelSyncResult.failed" type="link" size="small" @click="showPanelErrors = !showPanelErrors">
{{ showPanelErrors ? 'پنهان' : 'جزئیات' }}
</a-button>
<a-button type="link" size="small" @click="panelSyncResult = null">بستن</a-button>
</template>
<div v-if="showPanelErrors && panelSyncResult?.errors.length" class="cs-result__errors">
<div v-for="e in panelSyncResult.errors" :key="e.mobile"><b>{{ e.name }}</b> — {{ e.error }}</div>
</div>
</div>
<div v-if="loadingPanel" class="cs-loading"><a-spin /></div>
<template v-else-if="panelList.length">
<!-- موبایل -->
<div class="cs-mobile-list">
<div
v-for="c in panelList" :key="c.id"
class="cs-mobile-row"
:class="{ 'cs-mobile-row--done': panelDone.has(c.id) }"
>
<div class="cs-mobile-row__info">
<div class="cs-mobile-row__name">{{ c.name }}</div>
<div class="cs-mobile-row__mobile">{{ c.mobile }}</div>
</div>
<span v-if="panelDone.has(c.id)" class="cs-done-badge">✓ ثبت شد</span>
<a-button v-else type="primary" ghost size="small"
:loading="panelRegisteringId === c.id" @click="registerOnePanel(c)">
ارسال به ته‌حساب
</a-button>
</div>
</div>
<!-- دسکتاپ -->
<table class="cs-table">
<thead><tr>
<th>#</th><th>نام مشتری</th><th>موبایل</th><th>تاریخ عضویت</th><th>عملیات</th>
</tr></thead>
<tbody>
<tr v-for="(c, idx) in panelList" :key="c.id"
:class="{ 'cs-table__row--done': panelDone.has(c.id) }">
<td class="cs-table__num">{{ (panelPagination.page - 1) * 20 + idx + 1 }}</td>
<td class="cs-table__name">{{ c.name }}</td>
<td class="cs-table__mobile">{{ c.mobile }}</td>
<td class="cs-table__date">{{ toJalali(c.created_at) }}</td>
<td class="cs-table__action">
<span v-if="panelDone.has(c.id)" class="cs-done-badge">✓ ثبت شد</span>
<a-button v-else type="primary" ghost size="small"
:loading="panelRegisteringId === c.id" @click="registerOnePanel(c)">
<template #icon><Icon icon="vuesax-linear:send-2" :size="13" /></template>
ارسال به ته‌حساب
</a-button>
</td>
</tr>
</tbody>
</table>
<div v-if="panelPagination.lastPage > 1" class="cs-pagination">
<a-pagination
v-model:current="panelPagination.page"
:total="panelPagination.total"
:page-size="20" size="small" show-less-items
@change="p => loadPanel(p)"
/>
</div>
</template>
<div v-else class="cs-empty">
<div class="cs-empty__icon"><Icon icon="vuesax-linear:tick-circle" :size="28" /></div>
<div class="cs-empty__title">همه مشتریان پنل در ته‌حساب ثبت شده‌اند</div>
</div>
</div>
<!-- ─── تب ۲: ته‌حساب → پنل ─── -->
<div v-show="activeTab === 'tahesab'" class="cs-card">
<div class="cs-card__head">
<div class="cs-card__head-right">
<span class="cs-dot cs-dot--sky" />
<span class="cs-card__head-title">مشتریان ته‌حساب بدون حساب پنل</span>
</div>
<div class="cs-card__head-actions">
<a-button
type="primary"
size="small"
:loading="syncingTahesab"
:disabled="!status?.tahesab_only_count || selectedTahesab.size === 0"
@click="importSelected"
>
<template #icon><Icon icon="vuesax-linear:import" :size="13" /></template>
وارد کردن انتخاب‌شده‌ها ({{ selectedTahesab.size }})
</a-button>
<a-button
size="small"
:loading="syncingTahesab"
:disabled="!tahesabOnlyList.length"
@click="importAll"
>
وارد کردن همه ({{ status?.tahesab_only_count ?? 0 }})
</a-button>
</div>
</div>
<div v-if="syncingTahesab || tahesabSyncResult" class="cs-result">
<template v-if="syncingTahesab">
<a-spin size="small" />
<span>در حال وارد کردن مشتریان ته‌حساب به پنل...</span>
</template>
<template v-else-if="tahesabSyncResult">
<span class="cs-result__ok">✓ {{ tahesabSyncResult.success }} موفق</span>
<span v-if="tahesabSyncResult.failed" class="cs-result__fail">✗ {{ tahesabSyncResult.failed }} ناموفق</span>
<a-button v-if="tahesabSyncResult.failed" type="link" size="small" @click="showTahesabErrors = !showTahesabErrors">
{{ showTahesabErrors ? 'پنهان' : 'جزئیات' }}
</a-button>
<a-button type="link" size="small" @click="tahesabSyncResult = null">بستن</a-button>
</template>
<div v-if="showTahesabErrors && tahesabSyncResult?.errors.length" class="cs-result__errors">
<div v-for="e in tahesabSyncResult.errors" :key="e.name"><b>{{ e.name }}</b> — {{ e.error }}</div>
</div>
</div>
<div v-if="loadingTahesab" class="cs-loading"><a-spin /></div>
<template v-else-if="tahesabOnlyList.length">
<!-- موبایل -->
<div class="cs-mobile-list">
<div
v-for="c in pagedTahesab" :key="c.code"
class="cs-mobile-row"
:class="{ 'cs-mobile-row--done': tahesabDone.has(c.code) }"
>
<a-checkbox
:checked="selectedTahesab.has(c.code)"
:disabled="tahesabDone.has(c.code)"
class="cs-mobile-row__check"
@change="toggleTahesab(c.code)"
/>
<div class="cs-mobile-row__info">
<div class="cs-mobile-row__name">{{ c.name }}</div>
<div class="cs-mobile-row__mobile">{{ c.tel || '—' }} · کد {{ c.code }}</div>
</div>
<span v-if="tahesabDone.has(c.code)" class="cs-done-badge">✓ وارد شد</span>
<a-button v-else type="primary" ghost size="small"
@click="importOne(c)">
وارد کردن
</a-button>
</div>
</div>
<!-- دسکتاپ -->
<table class="cs-table">
<thead><tr>
<th><a-checkbox :checked="allPageSelected" :indeterminate="someSelected" @change="toggleAll" /></th>
<th>#</th><th>نام مشتری</th><th>شماره تلفن</th><th>کد ته‌حساب</th><th>گروه</th><th>عملیات</th>
</tr></thead>
<tbody>
<tr v-for="(c, idx) in pagedTahesab" :key="c.code"
:class="{ 'cs-table__row--done': tahesabDone.has(c.code) }">
<td>
<a-checkbox
:checked="selectedTahesab.has(c.code)"
:disabled="tahesabDone.has(c.code)"
@change="toggleTahesab(c.code)"
/>
</td>
<td class="cs-table__num">{{ (tahesabPage - 1) * 20 + idx + 1 }}</td>
<td class="cs-table__name">{{ c.name }}</td>
<td class="cs-table__mobile">{{ c.tel || '—' }}</td>
<td class="cs-table__mobile">{{ c.code }}</td>
<td class="cs-table__date">{{ c.group || '—' }}</td>
<td class="cs-table__action">
<span v-if="tahesabDone.has(c.code)" class="cs-done-badge">✓ وارد شد</span>
<a-button v-else type="primary" ghost size="small" @click="importOne(c)">
<template #icon><Icon icon="vuesax-linear:import" :size="13" /></template>
وارد کردن
</a-button>
</td>
</tr>
</tbody>
</table>
<div v-if="tahesabLastPage > 1" class="cs-pagination">
<a-pagination
v-model:current="tahesabPage"
:total="tahesabOnlyList.length"
:page-size="20" size="small" show-less-items
@change="p => { tahesabPage = p; selectedTahesab.clear() }"
/>
</div>
</template>
<div v-else class="cs-empty">
<div class="cs-empty__icon"><Icon icon="vuesax-linear:tick-circle" :size="28" /></div>
<div class="cs-empty__title">همه مشتریان ته‌حساب در پنل ثبت شده‌اند</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, reactive } from 'vue'
import { message } from 'ant-design-vue/es'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { toJalali } from '@core/utils/useDataTime'
import {
getCustomerSyncStatus,
syncPanelToTahesab,
importFromTahesab,
registerCustomerInTahesab,
type CustomerSyncStatus,
type PanelOnlyCustomer,
type TahesabOnlyCustomer,
type SyncPanelResult,
} from '@/api/tahesab'
// ─── state ───
const loadingPanel = ref(true) // شروع با true تا اولین load کامل بشه
const loadingTahesab = ref(false)
const syncingPanel = ref(false)
const syncingTahesab = ref(false)
const activeTab = ref<'panel' | 'tahesab'>('panel')
const tahesabFetched = ref(false) // آیا مشتریان ته‌حساب قبلاً fetch شده؟
const status = ref<CustomerSyncStatus | null>(null)
// تب ۱
const panelSearch = ref('')
const panelList = ref<PanelOnlyCustomer[]>([])
const panelPagination = ref({ page: 1, lastPage: 1, total: 0 })
const panelDone = reactive(new Set<number>())
const panelRegisteringId = ref<number | null>(null)
const panelSyncResult = ref<SyncPanelResult | null>(null)
const showPanelErrors = ref(false)
// تب ۲
const tahesabOnlyList = ref<TahesabOnlyCustomer[]>([])
const tahesabPage = ref(1)
const tahesabDone = reactive(new Set<number>())
const selectedTahesab = reactive(new Set<number>())
const tahesabSyncResult = ref<SyncPanelResult | null>(null)
const showTahesabErrors = ref(false)
// ─── computed ───
const registeredPercent = computed(() => {
if (!status.value?.panel_total) return 0
return Math.round((status.value.panel_registered / status.value.panel_total) * 100)
})
const tahesabLastPage = computed(() => Math.max(1, Math.ceil(tahesabOnlyList.value.length / 20)))
const pagedTahesab = computed(() => {
const s = (tahesabPage.value - 1) * 20
return tahesabOnlyList.value.slice(s, s + 20)
})
const allPageSelected = computed(() =>
pagedTahesab.value.length > 0 &&
pagedTahesab.value.every(c => selectedTahesab.has(c.code) || tahesabDone.has(c.code))
)
const someSelected = computed(() =>
pagedTahesab.value.some(c => selectedTahesab.has(c.code)) && !allPageSelected.value
)
// ─── load panel only (سریع — بدون Tahesab API) ───
let panelSearchTimer: ReturnType<typeof setTimeout> | null = null
async function load(panelPage = 1) {
loadingPanel.value = true
try {
const res = await getCustomerSyncStatus({
search: panelSearch.value || undefined,
page: panelPage,
})
status.value = res
panelList.value = res.panel_only_list.data
panelPagination.value = {
page: res.panel_only_list.current_page,
lastPage: res.panel_only_list.last_page,
total: res.panel_only_list.total,
}
// داده‌های ته‌حساب فقط اگر قبلاً fetch شده باشه بروز کن
if (tahesabFetched.value) {
tahesabOnlyList.value = res.tahesab_only_list.data ?? []
}
} catch (e) { console.error(e) }
finally { loadingPanel.value = false }
}
// ─── load tahesab customers (کند — فقط وقتی تب ته‌حساب باز میشه) ───
async function loadTahesab() {
if (tahesabFetched.value) return
loadingTahesab.value = true
try {
const res = await getCustomerSyncStatus({ include_tahesab: 1 } as any)
if (status.value) {
status.value.tahesab_total = res.tahesab_total ?? 0
status.value.tahesab_only_count = res.tahesab_only_count ?? 0
} else {
status.value = res
}
tahesabOnlyList.value = res.tahesab_only_list?.data ?? []
tahesabFetched.value = true
} catch (e) { console.error(e) }
finally { loadingTahesab.value = false }
}
function onTabChange(tab: 'panel' | 'tahesab') {
activeTab.value = tab
if (tab === 'tahesab') loadTahesab()
}
async function loadPanel(page = 1) {
loadingPanel.value = true
try {
const res = await getCustomerSyncStatus({ search: panelSearch.value || undefined, page })
if (status.value) {
status.value.panel_total = res.panel_total
status.value.panel_registered = res.panel_registered
status.value.panel_only = res.panel_only
}
panelList.value = res.panel_only_list.data
panelPagination.value = {
page: res.panel_only_list.current_page,
lastPage: res.panel_only_list.last_page,
total: res.panel_only_list.total,
}
} catch { } finally { loadingPanel.value = false }
}
function onPanelSearch() {
if (panelSearchTimer) clearTimeout(panelSearchTimer)
panelSearchTimer = setTimeout(() => loadPanel(1), 400)
}
// ─── تب ۱: عملیات ───
async function registerOnePanel(customer: PanelOnlyCustomer) {
panelRegisteringId.value = customer.id
try {
const res = await registerCustomerInTahesab(customer.id)
if (res.ok) {
panelDone.add(customer.id)
if (status.value) { status.value.panel_registered++; status.value.panel_only-- }
message.success(`${customer.name} در ته‌حساب ثبت شد`)
} else {
message.error((res as any).message || 'ثبت ناموفق بود')
}
} catch { message.error('خطا در ارتباط با سرور') }
finally { panelRegisteringId.value = null }
}
async function syncAllPanel() {
syncingPanel.value = true
panelSyncResult.value = null
showPanelErrors.value = false
try {
const res = await syncPanelToTahesab()
panelSyncResult.value = res
if (res.success) message.success(`${res.success} مشتری ثبت شد`)
if (res.failed) message.warning(`${res.failed} مشتری ثبت نشد`)
await load(1)
panelDone.clear()
} catch { message.error('خطا در ارتباط با سرور') }
finally { syncingPanel.value = false }
}
// ─── تب ۲: عملیات ───
function toggleTahesab(code: number) {
if (selectedTahesab.has(code)) selectedTahesab.delete(code)
else selectedTahesab.add(code)
}
function toggleAll() {
if (allPageSelected.value) {
pagedTahesab.value.forEach(c => selectedTahesab.delete(c.code))
} else {
pagedTahesab.value.forEach(c => { if (!tahesabDone.has(c.code)) selectedTahesab.add(c.code) })
}
}
async function doImport(customers: TahesabOnlyCustomer[]) {
syncingTahesab.value = true
tahesabSyncResult.value = null
showTahesabErrors.value = false
try {
const res = await importFromTahesab(customers)
tahesabSyncResult.value = res
if (res.success) {
message.success(`${res.success} مشتری وارد پنل شد`)
customers.forEach(c => { if (!res.errors.find((e: any) => e.code === c.code)) tahesabDone.add(c.code) })
}
if (res.failed) message.warning(`${res.failed} مشتری وارد نشد`)
await load(panelPagination.value.page)
selectedTahesab.clear()
} catch { message.error('خطا در ارتباط با سرور') }
finally { syncingTahesab.value = false }
}
async function importOne(customer: TahesabOnlyCustomer) { await doImport([customer]) }
async function importSelected() { await doImport(tahesabOnlyList.value.filter(c => selectedTahesab.has(c.code))) }
async function importAll() { await doImport(tahesabOnlyList.value) }
onMounted(() => load(1))
</script>
<style lang="less" scoped>
.cs-wrap {
display: flex;
flex-direction: column;
gap: 14px;
padding: 8px 4px 24px;
}
/* ─── بنر ─── */
.cs-banner {
border-radius: 14px;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 55%, #0ea5e9 100%);
padding: 14px 16px;
color: #fff;
box-shadow: 0 4px 20px rgba(79, 70, 229, 0.35);
display: flex;
flex-direction: column;
gap: 12px;
@media (min-width: 640px) { padding: 20px 24px; gap: 16px; }
&__title { font-size: 14px; font-weight: 700; }
}
/* ردیف اصلی: پنل ↔ ته‌حساب */
.cs-stats-main {
display: flex;
align-items: center;
gap: 8px;
}
/* ردیف تفاوت */
.cs-stats-diff {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.cs-sep-arrows {
display: flex;
flex-direction: column;
align-items: center;
color: rgba(255,255,255,0.4);
flex-shrink: 0;
line-height: 1;
}
.cs-stat {
border-radius: 10px;
padding: 8px 10px;
text-align: center;
flex: 1;
&--neutral { background: rgba(255,255,255,0.12); }
&--tahesab { background: rgba(14,165,233,0.25); }
&--amber { background: rgba(251,191,36,0.2); }
&--sky { background: rgba(14,165,233,0.2); }
&__num {
font-size: 20px;
font-weight: 800;
line-height: 1;
min-height: 26px;
display: flex;
align-items: center;
justify-content: center;
@media (min-width: 640px) { font-size: 24px; }
}
&__label {
font-size: 10px;
color: rgba(255,255,255,0.65);
margin-top: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__hint { font-size: 10px; color: rgba(255,255,255,0.4); font-weight: 400; }
}
/* progress */
.cs-progress {
&__labels {
display: flex;
justify-content: space-between;
font-size: 10px;
color: rgba(255,255,255,0.6);
margin-bottom: 5px;
}
}
.cs-track {
height: 7px;
border-radius: 99px;
background: rgba(255,255,255,0.2);
overflow: hidden;
&__fill {
height: 100%;
border-radius: 99px;
background: #34d399;
transition: width 0.7s ease;
}
}
/* ─── تب‌ها ─── */
.cs-tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--admin-border);
overflow-x: auto;
&::-webkit-scrollbar { display: none; }
}
.cs-tab {
display: flex;
align-items: center;
gap: 5px;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
color: var(--admin-text-secondary);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
flex-shrink: 0;
@media (min-width: 640px) { padding: 8px 16px; font-size: 13px; }
&:hover { color: var(--admin-text); }
&--active {
color: #7c3aed;
border-bottom-color: #7c3aed;
}
&__badge {
font-size: 11px;
font-weight: 700;
padding: 1px 6px;
border-radius: 99px;
&--amber { background: rgba(245,158,11,0.15); color: #d97706; }
&--sky { background: rgba(14,165,233,0.15); color: #0ea5e9; }
}
}
/* ─── کارت ─── */
.cs-card {
border-radius: 12px;
border: 1px solid var(--admin-border-strong);
background: var(--admin-bg-surface);
overflow: hidden;
&__head {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--admin-border);
background: var(--admin-bg-muted);
@media (min-width: 640px) {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
&__head-right {
display: flex;
align-items: center;
gap: 8px;
}
&__head-title {
font-size: 13px;
font-weight: 600;
color: var(--admin-text);
}
&__head-actions {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
.ant-btn { flex-shrink: 0; }
}
}
/* روی موبایل دکمه‌های head رو کوچیک‌تر نشون بده */
@media (max-width: 639px) {
.cs-card__head-title { font-size: 12px; }
}
.cs-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&--amber { background: #f59e0b; }
&--sky { background: #0ea5e9; }
}
.cs-search { width: 150px; }
@media (max-width: 639px) { .cs-search { flex: 1; min-width: 0; } }
/* ─── نتیجه sync ─── */
.cs-result {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 10px 16px;
border-bottom: 1px solid var(--admin-border);
background: var(--admin-bg-muted);
font-size: 13px;
color: var(--admin-text);
&__ok { background: rgba(52,211,153,0.15); color: #059669; font-weight: 600; border-radius: 99px; padding: 2px 10px; font-size: 12px; }
&__fail { background: rgba(239,68,68,0.12); color: #dc2626; font-weight: 600; border-radius: 99px; padding: 2px 10px; font-size: 12px; }
&__errors { width: 100%; margin-top: 4px; max-height: 100px; overflow-y: auto; border-radius: 8px; background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.2); padding: 8px 12px; font-size: 11px; color: #dc2626; line-height: 1.8; }
}
.cs-loading { display: flex; justify-content: center; padding: 48px; }
/* ─── موبایل ─── */
.cs-mobile-list {
display: block;
@media (min-width: 640px) { display: none; }
}
.cs-mobile-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--admin-border);
transition: opacity 0.2s;
&--done { opacity: 0.45; }
&__check { flex-shrink: 0; }
&__info { flex: 1; min-width: 0; }
&__name { font-size: 13px; font-weight: 600; color: var(--admin-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
&__mobile{ font-size: 11px; color: var(--admin-text-secondary); font-family: monospace; margin-top: 2px; }
}
/* ─── جدول دسکتاپ ─── */
.cs-table {
display: none;
width: 100%;
border-collapse: collapse;
font-size: 13px;
@media (min-width: 640px) { display: table; }
thead tr { background: var(--admin-bg-muted); border-bottom: 1px solid var(--admin-border); }
th { padding: 10px 14px; text-align: right; font-size: 11px; font-weight: 600; color: var(--admin-text-secondary); white-space: nowrap; }
tbody tr { border-bottom: 1px solid var(--admin-border); transition: background 0.15s; }
tbody tr:hover { background: var(--admin-bg-hover); }
tbody tr.cs-table__row--done { opacity: 0.45; }
td { padding: 10px 14px; color: var(--admin-text); }
&__num { color: var(--admin-text-secondary); font-size: 12px; width: 40px; }
&__name { font-weight: 600; }
&__mobile { font-family: monospace; font-size: 12px; color: var(--admin-text-muted); }
&__date { font-size: 12px; color: var(--admin-text-secondary); }
&__action { text-align: center; white-space: nowrap; }
}
.cs-done-badge {
display: inline-flex;
align-items: center;
background: rgba(52,211,153,0.15);
color: #059669;
font-size: 11px;
font-weight: 700;
padding: 2px 10px;
border-radius: 99px;
white-space: nowrap;
}
.cs-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 52px 16px;
gap: 8px;
&__icon { width: 52px; height: 52px; border-radius: 50%; background: rgba(52,211,153,0.12); color: #059669; display: flex; align-items: center; justify-content: center; }
&__title { font-size: 14px; font-weight: 700; color: var(--admin-text); }
}
.cs-pagination {
display: flex;
justify-content: center;
padding: 12px;
border-top: 1px solid var(--admin-border);
}
</style>
@@ -0,0 +1,79 @@
<template>
<MobileCardList
v-model:search="search"
:loading="loading"
:has-more="hasMore"
:show-empty="!loading && items.length === 0"
empty-text="سندی یافت نشد"
@search="reload"
@load-more="loadMore"
>
<TahesabMobileListItem
v-for="item in items"
:key="item.id"
:item="item"
@open="openSheet"
/>
</MobileCardList>
<TahesabMobileDetailSheet
v-model:visible="sheetVisible"
:item="selectedItem"
@retry="openRetry"
@register-customer="openRegister"
/>
<TahesabRetryModal
v-model="retryVisible"
mode="retry-sanad"
:tahesab-id="selectedItem?.id"
@success="onRetrySuccess"
/>
<TahesabRetryModal
v-model="registerVisible"
mode="register-customer"
:customer-id="selectedItem?.customer_id"
:customer-name="selectedItem?.customer?.name"
@success="onRetrySuccess"
/>
</template>
<script setup lang="ts">
import { getTahesabTable, type TahesabRecord } from '@/api/tahesab'
import MobileCardList from '@/components/mobile/MobileCardList.vue'
import { useMobileDetailSheet } from '@/components/mobile/composables/useMobileDetailSheet'
import { useMobileTableList } from '@/components/mobile/composables/useMobileTableList'
import TahesabRetryModal from '@/components/TahesabRetryModal.vue'
import TahesabMobileDetailSheet from './TahesabMobileDetailSheet.vue'
import TahesabMobileListItem from './TahesabMobileListItem.vue'
const { sheetVisible, selectedItem, openSheet, syncSelectedItem } = useMobileDetailSheet<TahesabRecord>()
const retryVisible = ref(false)
const registerVisible = ref(false)
const { loading, search, items, hasMore, reload, loadMore, fetch } = useMobileTableList<TahesabRecord>({
fetchApi: getTahesabTable,
onFetched: (rows) => syncSelectedItem(rows)
})
function openRetry(item: TahesabRecord) {
selectedItem.value = item
sheetVisible.value = false
retryVisible.value = true
}
function openRegister(item: TahesabRecord) {
selectedItem.value = item
sheetVisible.value = false
registerVisible.value = true
}
async function onRetrySuccess() {
await fetch()
}
onMounted(() => reload())
defineExpose({ fetch })
</script>
@@ -0,0 +1,80 @@
<template>
<MobileDetailSheet v-model:visible="visibleProxy" :item="item" :title="sheetTitle">
<template #default="{ item: row }">
<MobileSheetHeader :title="sheetTitle" :show-title="isMobile">
<template #actions>
<MobileIconButton
v-if="!row.tahesab_id && !row.customer?.moshtari_code"
plain
icon="vuesax-linear:profile-add"
color="#7C3AED"
:size="22"
@click="$emit('register-customer', row)"
/>
<MobileIconButton
v-if="canRetry(row)"
plain
icon="vuesax-linear:refresh-circle"
color="#CA8A04"
:size="22"
@click="$emit('retry', row)"
/>
</template>
</MobileSheetHeader>
<MobileInfoGrid>
<MobileDetailRow label="شناسه" :value="row.id" />
<MobileDetailRow label="مشتری" :value="row.customer?.name || '-'" />
<MobileDetailRow label="موبایل" :value="row.customer?.mobile || '-'" />
<MobileDetailRow label="نوع" :value="row.type || '-'" />
<MobileDetailRow label="مقدار" :value="row.amount ?? '-'" />
<MobileDetailRow label="شرح" full-width :value="row.desc || '-'" />
<MobileDetailRow label="وضعیت" :value="tahesabStatusLabel(row.sync_status, row.tahesab_id)" />
<MobileDetailRow label="کد ته‌حساب" :value="row.tahesab_id || '-'" />
<MobileDetailRow label="زمان" :value="row.created_at ? toJalali(row.created_at) : '-'" />
<MobileDetailRow v-if="row.res" label="پاسخ API" full-width :value="row.res" />
</MobileInfoGrid>
<div
v-if="row.sync_log"
class="mt-3 rounded-lg bg-red-50 p-3 text-sm text-red-700 whitespace-pre-wrap"
>
{{ row.sync_log }}
</div>
</template>
</MobileDetailSheet>
</template>
<script setup lang="ts">
import type { TahesabRecord } from '@/api/tahesab'
import MobileDetailRow from '@/components/mobile/MobileDetailRow.vue'
import MobileDetailSheet from '@/components/mobile/MobileDetailSheet.vue'
import MobileIconButton from '@/components/mobile/MobileIconButton.vue'
import MobileInfoGrid from '@/components/mobile/MobileInfoGrid.vue'
import MobileSheetHeader from '@/components/mobile/MobileSheetHeader.vue'
import { canRetryTahesab, tahesabStatusLabel } from '@/pages/menuPages/accounting/utils/tahesabStatus'
import { isMobile } from '@core/utils/is'
import { toJalali } from '@core/utils/useDataTime'
const props = defineProps<{
visible: boolean
item: TahesabRecord | null
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'retry', item: TahesabRecord): void
(e: 'register-customer', item: TahesabRecord): void
}>()
const visibleProxy = computed({
get: () => props.visible,
set: (value: boolean) => emit('update:visible', value)
})
const sheetTitle = computed(() => props.item?.customer?.name || 'سند ته‌حساب')
function canRetry(row: TahesabRecord) {
return canRetryTahesab(row)
}
</script>
@@ -0,0 +1,46 @@
<template>
<MobileCard clickable @click="$emit('open', item)">
<div class="space-y-1">
<div class="flex items-center justify-between gap-2">
<div class="truncate text-sm font-700 text-gray-900">{{ customerName }}</div>
<span class="shrink-0 rounded-full px-2 py-0.5 text-[11px] font-600" :class="statusClass">
{{ statusText }}
</span>
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-gray-600">
<span>نوع: <span class="font-600 text-gray-900">{{ item.type || '-' }}</span></span>
<span>مقدار: <span class="font-600 text-gray-900">{{ item.amount ?? '-' }}</span></span>
</div>
<div class="text-xs text-gray-400">{{ createdAtLabel }}</div>
</div>
</MobileCard>
</template>
<script setup lang="ts">
import type { TahesabRecord } from '@/api/tahesab'
import MobileCard from '@/components/mobile/MobileCard.vue'
import { toJalali } from '@core/utils/useDataTime'
import { tahesabStatusLabel } from '@/pages/menuPages/accounting/utils/tahesabStatus'
const props = defineProps<{
item: TahesabRecord
}>()
defineEmits<{
(e: 'open', item: TahesabRecord): void
}>()
const customerName = computed(() => props.item.customer?.name || '-')
const createdAtLabel = computed(() =>
props.item.created_at ? toJalali(props.item.created_at) : '-'
)
const statusText = computed(() =>
tahesabStatusLabel(props.item.sync_status, props.item.tahesab_id)
)
const statusClass = computed(() => {
const text = statusText.value
if (text === 'موفق') return 'bg-green-100 text-green-700'
if (text === 'ناموفق') return 'bg-red-100 text-red-700'
return 'bg-amber-100 text-amber-700'
})
</script>
@@ -2,141 +2,31 @@
<div class="min-w-0 w-full max-w-full overflow-x-hidden">
<BasicShowContext title="حسابداری">
<div v-if="!isMobile" class="min-w-0 w-full max-w-full overflow-x-auto">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }">
<div class="flex justify-center gap-2">
<a-tooltip
v-if="showTahesab && record.tahesab_record_id"
placement="bottom"
class="cursor-pointer"
@click="openTahesabDetail(record)"
>
<template #title><span>جزئیات ته‌حساب</span></template>
<Icon icon="vuesax-linear:document-text" color="#10913A" />
</a-tooltip>
<a-tooltip
v-if="showTahesab && record.tahesab_failed_id"
placement="bottom"
class="cursor-pointer"
@click="openTahesabRetry(record)"
>
<template #title><span>ارسال به ته‌حساب</span></template>
<Icon icon="vuesax-linear:refresh-circle" color="#CA8A04" />
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<template #title><span>برداشت و واریز</span></template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('orders', record)">
<template #title><span>سفارشات</span></template>
<Icon icon="vuesax-linear:receipt-item" color="#10913A" />
</a-tooltip>
</div>
</template>
</BaseTable>
<BaseTable v-bind="tblProps" @register="submit" />
</div>
<AccountingMobileCardList
v-else
ref="mobileListRef"
@info="emits('info', $event)"
@orders="emits('orders', $event)"
/>
<AccountingMobileCardList v-else ref="mobileListRef" />
</BasicShowContext>
<TahesabRetryModal
v-if="showTahesab"
v-model="tahesabRetryVisible"
mode="retry-sanad"
:tahesab-id="selectedTahesabId"
@success="fetch"
/>
<BasicModal
v-if="showTahesab"
v-model:visible="tahesabDetailVisible" title="جزئیات سند ته‌حساب" width="36rem" :footer="null">
<div class="space-y-2 text-sm">
<div v-for="item in tahesabDetailItems" :key="item.label" class="flex justify-between gap-3 border-b border-gray-100 py-2">
<span class="text-gray-500">{{ item.label }}</span>
<span class="break-all text-left">{{ item.value ?? '-' }}</span>
</div>
<div v-if="tahesabDetail?.sync_log" class="rounded-lg bg-red-50 p-3 text-red-700 whitespace-pre-wrap">
{{ tahesabDetail.sync_log }}
</div>
</div>
</BasicModal>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ref } from 'vue'
import { getTotalAccountingTable } from '@/api/accounting'
import { getTahesabDetail, type TahesabRecord } from '@/api/tahesab'
import { getAccountingColumns } from '@/pages/menuPages/accounting/hooks/accountingColumns'
import AccountingMobileCardList from '@/pages/menuPages/accounting/components/AccountingMobileCardList.vue'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import type { TableInput } from '@core/type/baseTable'
import { isMobile } from '@core/utils/is'
import TahesabRetryModal from '@/components/TahesabRetryModal.vue'
import BasicModal from '@/components/BasicModal.vue'
const props = withDefaults(
defineProps<{
customerId?: number
showTahesab?: boolean
}>(),
{
showTahesab: false
}
)
const emits = defineEmits<{
(e: 'info', record: unknown): void
(e: 'orders', record: unknown): void
}>()
const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
const tahesabRetryVisible = ref(false)
const tahesabDetailVisible = ref(false)
const selectedTahesabId = ref<number>()
const tahesabDetail = ref<TahesabRecord | null>(null)
const tahesabDetailItems = computed(() => {
const row = tahesabDetail.value
if (!row) return []
return [
{ label: 'شناسه', value: row.id },
{ label: 'مشتری', value: row.customer?.name },
{ label: 'نوع', value: row.type },
{ label: 'مقدار', value: row.amount },
{ label: 'شرح', value: row.desc },
{ label: 'وضعیت', value: tahesabStatusLabel(row) },
{ label: 'کد ته‌حساب', value: row.tahesab_id }
]
})
function tahesabStatusLabel(row?: TahesabRecord | null) {
if (!row) return '-'
if (row.tahesab_id) return 'موفق'
if (row.sync_status === 'failed') return 'ناموفق'
if (row.sync_status === 'success') return 'موفق'
return 'در انتظار'
}
const tblProps = ref({
columns: getAccountingColumns(props.showTahesab),
columns: getAccountingColumns(),
api: (params: TableInput) => getTotalAccountingTable(params)
})
watch(
() => props.showTahesab,
(enabled) => {
tblProps.value.columns = getAccountingColumns(enabled)
}
)
async function fetch() {
if (isMobile.value) {
await mobileListRef.value?.fetch?.()
@@ -145,22 +35,5 @@ async function fetch() {
await reload()
}
function openTahesabRetry(record: { tahesab_failed_id?: number }) {
if (!record.tahesab_failed_id) return
selectedTahesabId.value = record.tahesab_failed_id
tahesabRetryVisible.value = true
}
async function openTahesabDetail(record: { tahesab_record_id?: number }) {
if (!record.tahesab_record_id) return
selectedTahesabId.value = record.tahesab_record_id
try {
tahesabDetail.value = await getTahesabDetail(record.tahesab_record_id)
tahesabDetailVisible.value = true
} catch (e) {
console.error(e)
}
}
defineExpose({ fetch })
</script>
@@ -5,43 +5,79 @@
<BaseTable v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }">
<div class="flex justify-center gap-2">
<a-tooltip placement="bottom" class="cursor-pointer" @click="openDetail(record)">
<template #title><span>جزئیات</span></template>
<!-- دکمه ۱: اطلاعات کلی (همیشه نمایان) -->
<a-tooltip placement="bottom" class="cursor-pointer" @click="openInfo(record)">
<template #title><span>اطلاعات سند</span></template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
<!-- دکمه ۲: ثبت مشتری در ته‌حساب (فقط اگر سند هنوز ثبت نشده و مشتری کد ندارد) -->
<a-tooltip
v-if="canRetry(record)"
v-if="!record.tahesab_id && !record.customer?.moshtari_code"
placement="bottom"
class="cursor-pointer"
@click="openRetry(record)"
@click="openRegister(record)"
>
<template #title><span>ثبت مشتری در ته‌حساب</span></template>
<Icon icon="vuesax-linear:profile-add" color="#7C3AED" />
</a-tooltip>
<!-- دکمه ۳: ارسال به ته‌حساب (فقط اگر هنوز در ته‌حساب ثبت نشده) -->
<a-tooltip
v-if="!record.tahesab_id"
placement="bottom"
class="cursor-pointer"
@click="openSend(record)"
>
<template #title><span>ارسال به ته‌حساب</span></template>
<Icon icon="vuesax-linear:refresh-circle" color="#CA8A04" />
<Icon icon="vuesax-linear:send-2" color="#CA8A04" />
</a-tooltip>
</div>
</template>
</BaseTable>
</div>
<TahesabMobileCardList v-else ref="mobileListRef" />
</BasicShowContext>
<BasicModal v-model:visible="detailVisible" title="جزئیات سند ته‌حساب" width="36rem" :footer="null">
<!-- مودال اطلاعات کلی -->
<BasicModal v-model:visible="infoVisible" title="اطلاعات سند" width="36rem" :footer="null">
<div class="space-y-2 text-sm">
<div v-for="item in detailItems" :key="item.label" class="flex justify-between gap-3 border-b border-gray-100 py-2">
<div
v-for="item in infoItems"
:key="item.label"
class="flex justify-between gap-3 border-b border-gray-100 py-2"
>
<span class="text-gray-500">{{ item.label }}</span>
<span class="break-all text-left">{{ item.value ?? '-' }}</span>
</div>
<div v-if="selected?.res" class="rounded-lg bg-gray-100 p-3 text-xs font-mono text-gray-700 whitespace-pre-wrap">
{{ selected.res }}
</div>
<div v-if="selected?.sync_log" class="rounded-lg bg-red-50 p-3 text-red-700 whitespace-pre-wrap">
{{ selected.sync_log }}
</div>
</div>
</BasicModal>
<!-- مودال ارسال به ته‌حساب -->
<TahesabRetryModal
v-model="retryVisible"
v-model="sendVisible"
mode="retry-sanad"
:tahesab-id="selected?.id"
@success="fetch"
/>
<!-- مودال ثبت مشتری در ته‌حساب -->
<TahesabRetryModal
v-model="registerVisible"
mode="register-customer"
:customer-id="selected?.customer_id"
:customer-name="selected?.customer?.name"
@success="fetch"
/>
</div>
</template>
@@ -54,15 +90,26 @@ import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { isMobile } from '@core/utils/is'
import BasicModal from '@/components/BasicModal.vue'
import TahesabRetryModal from '@/components/TahesabRetryModal.vue'
import TahesabMobileCardList from '@/pages/menuPages/accounting/components/TahesabMobileCardList.vue'
import { tahesabStatusLabel } from '@/pages/menuPages/accounting/utils/tahesabStatus'
import { toJalali } from '@core/utils/useDataTime'
const [submit, reload] = useBaseTable()
const detailVisible = ref(false)
const retryVisible = ref(false)
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
const infoVisible = ref(false)
const sendVisible = ref(false)
const registerVisible = ref(false)
const selected = ref<TahesabRecord | null>(null)
const columns = [
{ title: 'ردیف', dataIndex: 'id', key: 'id', filterable: false, sortable: false, customRender: (r: any) => r.index + 1 },
{
title: 'ردیف',
dataIndex: 'id',
key: 'id',
filterable: false,
sortable: false,
customRender: (r: any) => r.index + 1
},
{ title: 'مشتری', dataIndex: 'customer.name', key: 'customer.name', type: 'Text' },
{ title: 'نوع', dataIndex: 'type', key: 'type', type: 'Text' },
{ title: 'مقدار', dataIndex: 'amount', key: 'amount', type: 'Number' },
@@ -72,7 +119,8 @@ const columns = [
dataIndex: 'sync_status',
key: 'sync_status',
type: 'Text',
customRender: (r: any) => statusLabel(r.record.sync_status, r.record.tahesab_id)
filterable: false,
customRender: (r: any) => tahesabStatusLabel(r.record.sync_status, r.record.tahesab_id)
},
{
title: 'زمان',
@@ -81,7 +129,13 @@ const columns = [
type: 'Text',
customRender: (r: any) => toJalali(r.record.created_at)
},
{ title: 'عملیات', dataIndex: 'action', key: 'action', slot: 'action', filterable: false }
{
title: 'عملیات',
dataIndex: 'action',
key: 'action',
slot: 'action',
filterable: false
}
]
const tblProps = ref({
@@ -89,44 +143,43 @@ const tblProps = ref({
api: (params: TableInput) => getTahesabTable(params)
})
const detailItems = computed(() => {
/** اطلاعاتی که برای هر سند حسابداری نیاز است (بدون جزئیات ته‌حساب‌محور) */
const infoItems = computed(() => {
const row = selected.value
if (!row) return []
return [
{ label: 'شناسه', value: row.id },
{ label: 'شناسه سند', value: row.id },
{ label: 'مشتری', value: row.customer?.name },
{ label: 'موبایل', value: row.customer?.mobile },
{ label: 'نوع', value: row.type },
{ label: 'مقدار', value: row.amount },
{ label: 'شرح', value: row.desc },
{ label: 'وضعیت', value: statusLabel(row.sync_status, row.tahesab_id) },
{ label: 'کد ته‌حساب', value: row.tahesab_id },
{ label: 'پاسخ API', value: row.res }
{ label: 'زمان ثبت', value: row.created_at ? toJalali(row.created_at) : '-' },
{ label: 'وضعیت', value: tahesabStatusLabel(row.sync_status, row.tahesab_id) },
{ label: 'کد ته‌حساب', value: row.tahesab_id || 'ثبت نشده' }
]
})
function statusLabel(status?: string, tahesabId?: string | null) {
if (tahesabId) return 'موفق'
if (status === 'failed') return 'ناموفق'
if (status === 'success') return 'موفق'
return 'در انتظار'
}
function canRetry(record: TahesabRecord) {
return !record.tahesab_id || record.sync_status === 'failed'
}
function openDetail(record: TahesabRecord) {
function openInfo(record: TahesabRecord) {
selected.value = record
detailVisible.value = true
infoVisible.value = true
}
function openRetry(record: TahesabRecord) {
function openSend(record: TahesabRecord) {
selected.value = record
retryVisible.value = true
sendVisible.value = true
}
function openRegister(record: TahesabRecord) {
selected.value = record
registerVisible.value = true
}
async function fetch() {
if (isMobile.value) {
await mobileListRef.value?.fetch?.()
return
}
await reload()
}