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
+1
View File
@@ -53,6 +53,7 @@ declare module '@vue/runtime-core' {
ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimePicker: typeof import('ant-design-vue/es')['TimePicker']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
AUpload: typeof import('ant-design-vue/es')['Upload']
BaseButton: typeof import('./src/core/components/BaseButton/index.ts')['default']
+1 -1
View File
@@ -5,6 +5,6 @@ import type { Accounting, TotalAccounting } from '@/model/accounting'
export const getAccountingTable = async (start: string,end: string) =>
await useAxios.post<Accounting>('/admin/dashboard/finance', {startDate: start, endDate: end});
//Accounting Tab
//Accounting Tab — دفترکل داخلی، مستقل از هر سیستم حسابداری بیرونی
export const getTotalAccountingTable = async (params: TableInput) =>
await useAxios.post<TotalAccounting[]>('/admin/finance', params)
+56
View File
@@ -34,3 +34,59 @@ export const registerCustomerInTahesab = async (customerId: number) =>
await useAxios.post<{ ok: boolean; message: string; moshtari_code?: string | number }>(
`/admin/customer/${customerId}/tahesab/register`
)
export interface PanelOnlyCustomer {
id: number
name: string
mobile: string
created_at: string
}
export interface TahesabOnlyCustomer {
code: number
name: string
tel: string
gid: number | null
group: string
}
export interface CustomerSyncStatus {
panel_total: number
panel_registered: number
panel_only: number
tahesab_total: number
tahesab_only_count: number
panel_only_list: {
data: PanelOnlyCustomer[]
current_page: number
last_page: number
total: number
}
tahesab_only_list: {
data: TahesabOnlyCustomer[]
current_page: number
last_page: number
total: number
}
}
export const getCustomerSyncStatus = async (params?: {
search?: string
page?: number
tahesab_page?: number
}) => await useAxios.get<CustomerSyncStatus>('/admin/tahesab/customer-sync/status', { params })
export interface SyncPanelResult {
ok: boolean
success: number
failed: number
errors: { name: string; mobile: string; error: string }[]
}
export const syncPanelToTahesab = async () =>
await useAxios.post<SyncPanelResult>('/admin/tahesab/customer-sync/panel-to-tahesab')
export const importFromTahesab = async (customers: TahesabOnlyCustomer[]) =>
await useAxios.post<SyncPanelResult>('/admin/tahesab/customer-sync/import-from-tahesab', {
customers,
})
+6 -8
View File
@@ -13,6 +13,7 @@
<span class="text-gray-500">{{ item.label }}</span>
<span class="text-left break-all">{{ item.value ?? '-' }}</span>
</div>
<div v-if="detail?.res" class="rounded-lg bg-gray-100 p-3 text-xs font-mono text-gray-700 whitespace-pre-wrap">{{ detail.res }}</div>
<div v-if="logText" class="rounded-lg bg-red-50 p-3 text-red-700 whitespace-pre-wrap">{{ logText }}</div>
</div>
</BasicModal>
@@ -23,6 +24,7 @@ import { computed, ref, watch } from 'vue'
import BasicModal from '@/components/BasicModal.vue'
import { message } from 'ant-design-vue/es'
import { getTahesabDetail, retryTahesabSanad, registerCustomerInTahesab, type TahesabRecord } from '@/api/tahesab'
import { tahesabStatusLabel } from '@/pages/menuPages/accounting/utils/tahesabStatus'
const props = defineProps<{
modelValue: boolean
@@ -49,7 +51,9 @@ const title = computed(() =>
props.mode === 'register-customer' ? 'ثبت مشتری در ته‌حساب' : 'ارسال مجدد سند به ته‌حساب'
)
const confirmText = computed(() => (props.mode === 'register-customer' ? 'ثبت در ته‌حساب' : 'ارسال به ته‌حساب'))
const confirmText = computed(() =>
props.mode === 'register-customer' ? 'ثبت در ته‌حساب' : 'ارسال مجدد'
)
const logText = computed(() => detail.value?.sync_log || '')
@@ -69,17 +73,11 @@ const items = computed(() => {
{ label: 'نوع', value: detail.value?.type },
{ label: 'مقدار', value: detail.value?.amount },
{ label: 'شرح', value: detail.value?.desc },
{ label: 'وضعیت', value: statusLabel(detail.value?.sync_status) },
{ label: 'وضعیت', value: tahesabStatusLabel(detail.value?.sync_status, detail.value?.tahesab_id) },
{ label: 'کد ته‌حساب', value: detail.value?.tahesab_id }
]
})
function statusLabel(status?: string) {
if (status === 'success') return 'موفق'
if (status === 'failed') return 'ناموفق'
return 'در انتظار'
}
watch(
() => [props.modelValue, props.tahesabId] as const,
async ([open, id]) => {
+1 -1
View File
@@ -23,7 +23,7 @@ export interface AccountingCustomerRef {
export interface TotalAccounting {
id: number
customer_id: number
order_id: null
order_id: number | null
customer_to_id: number
user_id: number
wallet: null
+1
View File
@@ -25,6 +25,7 @@ export interface Product{
show: boolean
show_in_assets?: boolean
trade_by_quantity?: boolean
override_trading_hours?: boolean
use_last_price_if_zero?: boolean
title: string
updated_at: string
@@ -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()
}
@@ -1,106 +1,51 @@
import type { BaseTableColumn } from '@core/type/baseTable'
import { toJalali, toJalaliNoTime, toTime } from '@core/utils/useDataTime'
import CustomTag from '@/components/CustomTag.vue'
import { usePrice } from '@core/utils/usePrice'
import { toJalali } from '@core/utils/useDataTime'
import { metalLabel } from '@/utils/adminMetalLabels'
/*import BudgetSelect from '@/components/BudgetSelect.vue'
import { useStaticApiListsStore } from '@/store/staticApisStore'
import type { FilterSelect } from '@/model/filterSelect'
import type { DocItem } from '@/model/docType'*/
//define store
//const store = useStaticApiListsStore()
//document types
/*let docTypes: DocItem[]*/
/*onMounted(async () => {
docTypes = await store.readyDocTypeList()
})
const filteredDocTypes: FilterSelect = docTypes?.map((x: DocItem) => {
return {
label: x.title,
value: x.id
}
})*/
export function getAccountingColumns(showTahesab = false): BaseTableColumn[] {
/**
* ستون‌های دفترکل (گزارشات) — مستقل از هر سیستم حسابداری بیرونی.
* هیچ ستون مربوط به ته‌حساب یا سایر حسابداری‌ها اینجا نیست.
*/
export function getAccountingColumns(): BaseTableColumn[] {
return [
{
title: 'ردیف',
dataIndex: 'id',
key: 'id',
filterable: false,
sortable: false,
customRender: (record: any) => {
return record.index + 1
{
title: 'ردیف',
dataIndex: 'id',
key: 'id',
filterable: false,
sortable: false,
customRender: (record: any) => record.index + 1
},
{
title: 'نام مشتری',
dataIndex: 'customer_to.name',
key: 'customer_id',
type: 'Text'
},
{
title: 'زمان ثبت',
dataIndex: 'created_at',
key: 'created_at',
type: 'Text',
customRender: (record: any) => toJalali(record.record.created_at)
},
{
title: 'نقد (ریال)',
dataIndex: 'cash',
key: 'cash',
type: 'Number'
},
{
title: metalLabel.goldUnit(),
dataIndex: 'gold',
key: 'gold',
type: 'Number'
},
{
title: 'توضیحات',
dataIndex: 'note',
key: 'note',
type: 'Text'
}
},
{
title: 'نام مشتری',
dataIndex: 'customer_to.name',
key: 'customer_id',
type: 'Number'
},
{
title: 'نام مشتری دعوت شده',
dataIndex: 'customer.name',
key: 'customer_id',
type: 'Number'
},
{
title: 'زمان ثبت',
dataIndex: 'created_at',
key: 'created_at',
type: 'Text',
customRender: (record: any) => {
return toJalali(record.record.created_at)
}
},
{
title: 'نقد',
dataIndex: 'cash',
key: 'cash',
type: 'Number',
// customRender(data) {
// return usePrice(data.record?.price ?? 0, true, '')
// }
},
{
title: metalLabel.goldUnit(),
dataIndex: 'gold',
key: 'gold',
type: 'Number',
},
{
title: 'توضیحات',
dataIndex: 'note',
key: 'note',
type: 'Text',
},
...(showTahesab
? [
{
title: 'ته‌حساب',
dataIndex: 'tahesab_sync_status',
key: 'tahesab_sync_status',
type: 'Text',
filterable: false,
customRender: (record: any) => {
const row = record.record
if (!row.tahesab_record_id && !row.tahesab_sync_status) return '-'
if (row.tahesab_failed_id) return 'ناموفق'
if (row.tahesab_sync_status === 'success') return 'موفق'
if (row.tahesab_sync_status === 'pending') return 'در انتظار'
return row.tahesab_sync_status ?? '-'
}
} as BaseTableColumn
]
: []),
{
title: 'عملیات',
dataIndex: 'action',
key: 'action',
slot: 'action',
filterable: false
}
]
}
+12 -22
View File
@@ -1,20 +1,18 @@
<template>
<div>
<BasicMainCard>
<div class="flex h-full w-full min-w-0 max-w-full flex-col overflow-x-hidden">
<ATabs v-if="tahesabEnabled" v-model:activeKey="activeTab" class="mb-3 shrink-0">
<div
class="flex h-full w-full min-w-0 max-w-full flex-col overflow-x-hidden"
:class="activeTab === 'customer-sync' ? 'overflow-y-auto' : 'overflow-hidden'"
>
<ATabs v-model:activeKey="activeTab" class="mb-3 shrink-0">
<ATabPane key="reports" tab="گزارشات" />
<ATabPane key="tahesab" tab="اسناد ته‌حساب" />
<ATabPane v-if="tahesabEnabled" key="tahesab" tab="اسناد ته‌حساب" />
<ATabPane v-if="tahesabEnabled" key="customer-sync" tab="همگام سازی مشتریان در ته‌حساب" />
</ATabs>
<tbl
v-if="!tahesabEnabled || activeTab === 'reports'"
ref="table"
class="min-h-0 flex-1"
:show-tahesab="tahesabEnabled"
@info="onAccountingWithdraw"
@orders="onAccountingOrders"
/>
<tahesabTbl v-else-if="tahesabEnabled" ref="tahesabTable" class="min-h-0 flex-1" />
<tbl v-if="activeTab === 'reports'" ref="table" class="min-h-0 flex-1" />
<tahesabTbl v-else-if="tahesabEnabled && activeTab === 'tahesab'" ref="tahesabTable" class="min-h-0 flex-1" />
<customerSync v-else-if="tahesabEnabled && activeTab === 'customer-sync'" class="shrink-0" />
</div>
</BasicMainCard>
<BasicModal
@@ -135,6 +133,7 @@ import { useStaticApiListsStore } from '@/store/staticApisStore'
import { ref } from 'vue'
import tbl from './components/table.vue'
import tahesabTbl from './components/tahesabTable.vue'
import customerSync from './components/TahesabCustomerSync.vue'
import type { FormInstance } from 'ant-design-vue'
import BasicInfoModal from '@/components/BasicInfoModal/index.vue'
import { message } from 'ant-design-vue/es'
@@ -145,7 +144,6 @@ import { cloneDeep } from 'lodash'
import type { Item } from '@/model/Item'
import type { Customer } from '@/model/customer'
import { createCustomer, deleteCustomer, getCustomersList, updateCustomer } from '@/api/customer'
import type { TotalAccounting } from '@/model/accounting'
import type { Order } from '@/model/order'
import { usePrice } from '@core/utils/usePrice'
import { getLastPrice } from '@/api/price'
@@ -157,7 +155,7 @@ import { initTahesabSettingFromApi, tahesabEnabledSetting } from '@/utils/config
//loading
const loading = ref<boolean>(false)
const activeTab = ref<'reports' | 'tahesab'>('reports')
const activeTab = ref<'reports' | 'tahesab' | 'customer-sync'>('reports')
const tahesabEnabled = tahesabEnabledSetting
//define store
@@ -242,14 +240,6 @@ function stopGetPrice() {
//info
let infoParams = reactive<Info>({})
function onAccountingWithdraw(_record: TotalAccounting) {
message.info('برداشت و واریز به‌زودی در دسترس است')
}
function onAccountingOrders(_record: TotalAccounting) {
message.info('لیست سفارشات به‌زودی در دسترس است')
}
function info(val: Order) {
infoParams.name = val.customer.name
infoParams.type = val.customer.mobile
@@ -0,0 +1,12 @@
export function tahesabStatusLabel(status?: string, tahesabId?: string | null): string {
if (tahesabId) return 'موفق'
if (status === 'failed') return 'ناموفق'
if (status === 'success') return 'موفق'
// pending = ردیف محلی ساخته شده ولی هنوز در نرم‌افزار ته‌حساب ثبت نشده (نه «در صف»)
return 'ارسال نشده'
}
export function canRetryTahesab(record: { tahesab_id?: string | null; sync_status?: string }): boolean {
return !record.tahesab_id || record.sync_status === 'failed'
}
@@ -39,7 +39,12 @@
<script setup lang="ts">
import SettingValueField from './SettingValueField.vue'
import { helpText, resolveSettingFieldType, type SettingFieldType } from './settingTypes'
import {
helpText,
KEY_LABEL_FALLBACK,
resolveSettingFieldType,
type SettingFieldType
} from './settingTypes'
import type { Const } from '@/model/configuration'
const props = defineProps<{
@@ -58,7 +63,9 @@ const emit = defineEmits<{
const settingKey = computed(() => props.row.key ?? '')
const settingType = computed(() => props.row.type)
const description = computed(() => props.row.description)
const title = computed(() => props.row.title_fa || props.row.key || '—')
const title = computed(
() => props.row.title_fa || (props.row.key && KEY_LABEL_FALLBACK[props.row.key]) || props.row.key || '—'
)
const fieldType = computed<SettingFieldType>(() =>
resolveSettingFieldType({ key: props.row.key, type: props.row.type })
@@ -31,6 +31,18 @@
</a-select-option>
</a-select>
<a-time-picker
v-else-if="fieldType === 'time'"
:value="modelValue || undefined"
:disabled="disabled"
class="w-full"
format="HH:mm"
value-format="HH:mm:ss"
:minute-step="5"
placeholder="انتخاب ساعت"
@update:value="onTimeChange"
/>
<a-input-number
v-else-if="fieldType === 'price'"
:value="priceValue"
@@ -130,6 +142,12 @@ function onSelectChange(val: string | undefined) {
emit('update:value', val ?? '')
}
function onTimeChange(val: string | string[] | undefined) {
const v = Array.isArray(val) ? val[0] : val
emit('update:value', v ?? '')
emit('blur')
}
const priceValue = computed(() => {
const n = parseFloat(String(props.modelValue || '').replace(/,/g, ''))
return Number.isFinite(n) ? n : undefined
@@ -16,12 +16,18 @@ export const EDITABLE_SETTING_GROUPS: SettingGroupDef[] = [
k
)
},
{
id: 'trading_hours',
title: 'ساعت کاری و نمایش قیمت',
icon: 'vuesax-linear:clock',
match: (k) => /^(close_price|startDay|endDay|show_last_price)$/.test(k)
},
{
id: 'pricing',
title: 'کارمزد و محدودیت‌ها',
icon: 'vuesax-linear:dollar-circle',
match: (k) =>
/^(fee_|buy_fee|sell_fee|min_|minimum_|show_last_price|close_price|startDay|endDay|up_price|down_price|price_extreme_date|balance_report_exclude)/.test(
/^(fee_|buy_fee|sell_fee|min_|minimum_|up_price|down_price|price_extreme_date|balance_report_exclude)/.test(
k
) || k.includes('charge_percentage')
},
@@ -1,7 +1,7 @@
import type { Const } from '@/model/configuration'
import { isStoreApp } from '@/utils/config'
export type SettingFieldType = 'text' | 'number' | 'price' | 'switch' | 'select' | 'longtext'
export type SettingFieldType = 'text' | 'number' | 'price' | 'switch' | 'select' | 'longtext' | 'time'
/** کلیدهای legacy — در پنل نمایش داده نمی‌شوند */
export const HIDDEN_LEGACY_SETTING_KEYS = new Set([
@@ -42,7 +42,8 @@ export const SETTING_TYPE_OPTIONS = [
{ key: 'price', value: 'مبلغ (ریال)' },
{ key: 'switch', value: 'روشن/خاموش' },
{ key: 'select', value: 'انتخابی' },
{ key: 'longtext', value: 'متن بلند' }
{ key: 'longtext', value: 'متن بلند' },
{ key: 'time', value: 'ساعت' }
]
const KEY_TYPE_FALLBACK: Record<string, SettingFieldType> = {
@@ -73,6 +74,8 @@ const KEY_TYPE_FALLBACK: Record<string, SettingFieldType> = {
open_register: 'switch',
auto_active_customer: 'switch',
close_price: 'switch',
startDay: 'time',
endDay: 'time',
show_last_price: 'switch',
send_to_tahesab: 'switch',
profit_gold_api_enabled: 'switch',
@@ -136,7 +139,7 @@ export function referralRequiredToString(checked: boolean): string {
export function resolveSettingFieldType(row: Pick<Const, 'key' | 'type'>): SettingFieldType {
const raw = (row.type || '').trim().toLowerCase()
if (['text', 'number', 'price', 'switch', 'select', 'longtext'].includes(raw)) {
if (['text', 'number', 'price', 'switch', 'select', 'longtext', 'time'].includes(raw)) {
return raw as SettingFieldType
}
if (row.key && KEY_TYPE_FALLBACK[row.key]) {
@@ -205,6 +208,9 @@ export function formatValueForSave(
if (type === 'number') {
return String(value ?? '').replace(/,/g, '')
}
if (type === 'time') {
return normalizeTimeValue(value)
}
return value ?? ''
}
@@ -218,12 +224,45 @@ export function switchLabels(key: string | undefined): { on: string; off: string
return { on: 'فعال', off: 'غیرفعال' }
}
/** توضیحات کامل و ساده برای کلیدهای مهم — اگر description در دیتابیس خالی بود استفاده می‌شود */
const KEY_HELP_FALLBACK: Record<string, string> = {
close_price:
'فیلتر اصلی فروشگاه. وقتی روشن باشد، خرید و فروش و نمایش قیمت فقط در بازهٔ «ساعت شروع» تا «ساعت پایان» انجام می‌شود و خارج از این بازه به مشتری پیام «فروشگاه بسته است» نشان داده می‌شود. اگر خاموش باشد، فروشگاه همیشه باز است (مگر اینکه برای محصولی به‌صورت اختصاصی ساعت تعریف کرده باشید).',
startDay:
'ساعت شروع فعالیت فروشگاه. فقط وقتی «بستن خودکار خارج از ساعت کاری» روشن باشد اعمال می‌شود. ساعت را از روی ساعت‌شمار انتخاب کنید (مثلاً ۰۹:۰۰).',
endDay:
'ساعت پایان فعالیت فروشگاه. فقط وقتی «بستن خودکار خارج از ساعت کاری» روشن باشد اعمال می‌شود. بعد از این ساعت فروشگاه بسته می‌شود (مثلاً ۱۷:۰۰).',
show_last_price:
'وقتی قیمت زندهٔ لحظه‌ای در دسترس نباشد: اگر روشن باشد، آخرین قیمت ذخیره‌شده در دیتابیس به مشتری نشان داده می‌شود؛ اگر خاموش باشد، به‌جای قیمت صفر پیام «مظنه در دسترس نیست» نمایش داده می‌شود.'
}
/** برچسب فارسی ساده برای کلیدهای مهم — اگر سرور برچسبی نفرستد */
export const KEY_LABEL_FALLBACK: Record<string, string> = {
close_price: 'بستن خودکار خارج از ساعت کاری',
startDay: 'ساعت شروع',
endDay: 'ساعت پایان',
show_last_price: 'نمایش آخرین قیمت ذخیره‌شده'
}
export function helpText(row: Const): string {
const desc = (row.description || '').trim()
if (!desc) return ''
if (!desc) {
return (row.key && KEY_HELP_FALLBACK[row.key]) || ''
}
if (resolveSettingFieldType(row) === 'select' && desc.includes(',')) {
const parts = desc.split('|')
return parts.length > 1 ? parts[0].trim() : ''
}
return desc
}
/** نرمال‌سازی ساعت به فرمت HH:mm:ss برای ذخیره و سازگاری با بک‌اند */
export function normalizeTimeValue(value: string | undefined | null): string {
const raw = String(value ?? '').trim()
if (!raw) return ''
const parts = raw.split(':')
const pad = (n: string) => n.padStart(2, '0')
if (parts.length >= 3) return `${pad(parts[0])}:${pad(parts[1])}:${pad(parts[2])}`
if (parts.length === 2) return `${pad(parts[0])}:${pad(parts[1])}:00`
return raw
}
@@ -63,8 +63,8 @@
class="cursor-pointer"
@click="openTahesabRegister(record)"
>
<template #title><span>ثبت در ته‌حساب</span></template>
<Icon icon="vuesax-linear:refresh-circle" color="#CA8A04" />
<template #title><span>در ته‌حساب ثبت نشده — کلیک برای ثبت</span></template>
<Icon icon="vuesax-linear:profile-add" color="#CA8A04" />
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<template #title><span>مشخصات</span></template>
@@ -225,11 +225,30 @@
<a-form-item label="هزینه نقدی موقع فروش" name="cash_fee_sell" :rules="requiredPriceRules('هزینه نقدی موقع فروش')">
<BasicInput v-model:value="form.cash_fee_sell" addon-before="ریال" type="price" />
</a-form-item>
<a-form-item label="ساعت کاری اختصاصی این محصول" name="override_trading_hours">
<a-switch v-model:checked="form.override_trading_hours" />
<div class="text-xs text-gray-500 mt-1 leading-6">
روشن: این محصول ساعت شروع و پایان مخصوص خودش را دارد و ساعت کاری سراسری فروشگاه را نادیده می‌گیرد (override).
خاموش: همان ساعت کاری سراسری فروشگاه برای این محصول اعمال می‌شود و زمان‌های زیر نادیده گرفته می‌شوند.
</div>
</a-form-item>
<a-form-item label="زمان شروع فعال سازی" name="auto_open_at">
<TimePicker v-model:value="form.auto_open_at" :locale="customLocale" placeholder="انتخاب زمان" :defaultValue="dayjs('00:00:00', 'HH:mm:ss')" />
<TimePicker
v-model:value="form.auto_open_at"
:locale="customLocale"
placeholder="انتخاب زمان"
:disabled="!form.override_trading_hours"
:defaultValue="dayjs('00:00:00', 'HH:mm:ss')"
/>
</a-form-item>
<a-form-item label="زمان پایان فعال سازی" name="auto_close_at">
<TimePicker v-model:value="form.auto_close_at" :locale="customLocale" placeholder="انتخاب زمان" :defaultValue="dayjs('00:00:00', 'HH:mm:ss')" />
<TimePicker
v-model:value="form.auto_close_at"
:locale="customLocale"
placeholder="انتخاب زمان"
:disabled="!form.override_trading_hours"
:defaultValue="dayjs('00:00:00', 'HH:mm:ss')"
/>
</a-form-item>
<a-form-item label="محدودیت مبلغ سفارش" name="order_limit_enabled">
<a-switch v-model:checked="form.order_limit_enabled" @change="onOrderLimitChange" />
@@ -337,6 +337,7 @@ async function edit(record: Product) {
...productData,
show_in_assets: productData.show_in_assets ?? false,
trade_by_quantity: productData.trade_by_quantity ?? false,
override_trading_hours: productData.override_trading_hours ?? false,
price_offset_percent: productData.price_offset_percent ?? 0,
direct_price: productData.direct_price ?? 0,
auto_open_at: start.isValid() ? start : dayjs('00:00:00', 'HH:mm:ss'),
@@ -382,6 +383,7 @@ function resetData() {
order_limit_enabled: false,
show_in_assets: false,
trade_by_quantity: false,
override_trading_hours: false,
use_last_price_if_zero: false,
inventory: 0,
min_order_cash: undefined,