feat(convert): show convert requests under hesab ketab

Admins review pending cathode-to-wire requests in the shared table, and on mobile they see cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-01 13:13:09 +03:30
co-authored by Cursor
parent dfc6f8dc64
commit 3df3164e0b
9 changed files with 470 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
import useAxios from '@core/utils/useAxios'
export interface CopperConvertRequestRow {
id: number
status: 'pending' | 'approved' | 'rejected' | string
source: string
customer_name: string
customer_mobile: string
asset_unit: string
consumed_amount: number
leftover_amount: number
source_unit_price: number
wire15_rings: number
wire15_unit_price: number
wire25_rings: number
wire25_unit_price: number
created_at?: string
}
export const getCopperConvertRequests = () =>
useAxios.get<CopperConvertRequestRow[]>('/admin/copper-convert-request')
export const approveCopperConvertRequest = (id: number) =>
useAxios.post(`/admin/copper-convert-request/${id}/approve`)
export const rejectCopperConvertRequest = (id: number) =>
useAxios.post(`/admin/copper-convert-request/${id}/reject`)
@@ -0,0 +1,45 @@
<template>
<MobileCardList
v-model:search="search"
:loading="loading"
:has-more="hasMore"
:show-empty="!loading && items.length === 0"
empty-text="درخواستی یافت نشد"
search-placeholder="جستجو مشتری، موبایل یا وضعیت"
show-refresh
@search="reload"
@refresh="reload"
@load-more="loadMore"
>
<CopperConvertMobileListItem
v-for="item in items"
:key="item.id"
:item="item"
:busy="busyId === item.id"
:can-edit="can('edit')"
@approve="approve(item)"
@reject="reject(item)"
/>
</MobileCardList>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import MobileCardList from '@/components/mobile/MobileCardList.vue'
import { useMobileTableList } from '@/components/mobile/composables/useMobileTableList'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
import { fetchCopperConvertTable, useCopperConvertActions } from '../copperConvertTable'
import CopperConvertMobileListItem from './CopperConvertMobileListItem.vue'
const { can } = usePageActionAccess()
const { loading, search, items, hasMore, reload, loadMore, fetch } = useMobileTableList({
fetchApi: fetchCopperConvertTable
})
const { busyId, approve, reject } = useCopperConvertActions(reload)
onMounted(() => {
void reload()
})
defineExpose({ fetch })
</script>
@@ -0,0 +1,51 @@
<template>
<MobileCard>
<div class="flex flex-col gap-2">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="truncate text-sm font-700 text-gray-900">{{ item.customer_name }}</div>
<div class="text-xs text-gray-500">{{ item.customer_mobile || '-' }}</div>
</div>
<CustomTag class="shrink-0" :text-color="colors.text" :bg-color="colors.bg">
{{ item.status_label }}
</CustomTag>
</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-1 text-xs">
<span class="text-gray-600">دارایی: <span class="font-600 text-gray-900">{{ item.source_label }}</span></span>
<span class="text-gray-600">واحد: <span class="font-600 text-gray-900">{{ item.asset_unit }}</span></span>
<span class="text-gray-600">حلقه ۱.۵: <span class="font-600 text-gray-900">{{ item.wire15_rings }}</span></span>
<span class="text-gray-600">حلقه ۲.۵: <span class="font-600 text-gray-900">{{ item.wire25_rings }}</span></span>
<span class="text-gray-600">مقدار فروش: <span class="font-600 text-gray-900">{{ item.consumed_amount }}</span></span>
<span class="text-gray-600">مانده: <span class="font-600 text-gray-900">{{ item.leftover_amount }}</span></span>
</div>
<div class="text-xs text-gray-400">{{ item.created_label }}</div>
<div v-if="item.status === 'pending' && canEdit" class="grid grid-cols-2 gap-2">
<BaseButton type="primary" size="small" :loading="busy" @click="$emit('approve')">تأیید</BaseButton>
<BaseButton size="small" :loading="busy" @click="$emit('reject')">رد</BaseButton>
</div>
</div>
</MobileCard>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import CustomTag from '@/components/CustomTag.vue'
import MobileCard from '@/components/mobile/MobileCard.vue'
import { statusColors, type CopperConvertTableRow } from '../copperConvertTable'
const props = defineProps<{
item: CopperConvertTableRow
busy?: boolean
canEdit?: boolean
}>()
defineEmits<{
(e: 'approve'): void
(e: 'reject'): void
}>()
const colors = computed(() => statusColors(props.item.status))
</script>
@@ -0,0 +1,149 @@
<template>
<div class="min-w-0 w-full max-w-full overflow-x-hidden">
<BasicShowContext title="درخواست تبدیل" :help="copperConvertPageIntro">
<div v-if="!isMobile" class="min-w-0 w-full max-w-full overflow-x-auto">
<BaseTable v-bind="tblProps" @register="submit">
<template #status="{ record }">
<div class="flex justify-center">
<CustomTag :text-color="statusColors(record.status).text" :bg-color="statusColors(record.status).bg">
{{ record.status_label }}
</CustomTag>
</div>
</template>
<template #action="{ record }">
<div v-if="record.status === 'pending' && can('edit')" class="flex justify-center gap-2">
<BaseButton type="primary" size="small" :loading="busyId === record.id" @click="approve(record)">
تأیید
</BaseButton>
<BaseButton size="small" :loading="busyId === record.id" @click="reject(record)">رد</BaseButton>
</div>
<span v-else>-</span>
</template>
</BaseTable>
</div>
<CopperConvertMobileCardList v-else />
</BasicShowContext>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import type { BaseTableColumn } from '@core/type/baseTable'
import { isMobile } from '@core/utils/is'
import CustomTag from '@/components/CustomTag.vue'
import { copperConvertPageIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
import CopperConvertMobileCardList from './CopperConvertMobileCardList.vue'
import { fetchCopperConvertTable, statusColors, useCopperConvertActions } from '../copperConvertTable'
const { can } = usePageActionAccess()
const [submit, reload] = useBaseTable()
const { busyId, approve, reject } = useCopperConvertActions(reload)
const columns: BaseTableColumn[] = [
{
title: 'شناسه',
dataIndex: 'id',
key: 'id',
type: 'Number',
width: 80,
sortField: 'id',
filterField: 'id'
},
{
title: 'مشتری',
dataIndex: 'customer_name',
key: 'customer_name',
type: 'Text',
sortField: 'customer_name',
filterField: 'customer_name'
},
{
title: 'موبایل',
dataIndex: 'customer_mobile',
key: 'customer_mobile',
type: 'Text',
sortField: 'customer_mobile',
filterField: 'customer_mobile'
},
{
title: 'دارایی',
dataIndex: 'source_label',
key: 'source_label',
type: 'Text',
sortField: 'source_label',
filterField: 'source_label'
},
{
title: 'حلقه ۱.۵',
dataIndex: 'wire15_rings',
key: 'wire15_rings',
type: 'Number',
sortField: 'wire15_rings',
filterField: 'wire15_rings'
},
{
title: 'حلقه ۲.۵',
dataIndex: 'wire25_rings',
key: 'wire25_rings',
type: 'Number',
sortField: 'wire25_rings',
filterField: 'wire25_rings'
},
{
title: 'مقدار فروش',
dataIndex: 'consumed_amount',
key: 'consumed_amount',
type: 'Number',
sortField: 'consumed_amount',
filterField: 'consumed_amount'
},
{
title: 'مانده',
dataIndex: 'leftover_amount',
key: 'leftover_amount',
type: 'Number',
sortField: 'leftover_amount',
filterField: 'leftover_amount'
},
{
title: 'واحد',
dataIndex: 'asset_unit',
key: 'asset_unit',
type: 'Text',
sortField: 'asset_unit',
filterField: 'asset_unit'
},
{
title: 'وضعیت',
dataIndex: 'status_label',
key: 'status',
type: 'Text',
sortField: 'status_label',
filterField: 'status_label',
slot: 'status'
},
{
title: 'تاریخ',
dataIndex: 'created_label',
key: 'created_label',
type: 'Text',
sortField: 'created_at',
filterField: 'created_label'
},
{
title: 'عملیات',
dataIndex: 'action',
key: 'action',
slot: 'action',
filterable: false,
sortable: false
}
]
const tblProps = ref({
columns,
api: fetchCopperConvertTable
})
</script>
@@ -0,0 +1,170 @@
import { ref } from 'vue'
import { Modal, message } from 'ant-design-vue'
import type { Filter, TableInput, TableOutput } from '@core/type/baseTable'
import { toJalali } from '@core/utils/useDataTime'
import {
approveCopperConvertRequest,
getCopperConvertRequests,
rejectCopperConvertRequest,
type CopperConvertRequestRow
} from '@/api/copperConvert'
export type CopperConvertTableRow = CopperConvertRequestRow & {
source_label: string
status_label: string
created_label: string
}
const numericKeys = new Set(['id', 'wire15_rings', 'wire25_rings', 'consumed_amount', 'leftover_amount'])
export function sourceLabel(source: string) {
if (source === 'katod') return 'کاتد'
if (source === 'maftoul') return 'مفتول'
if (source === 'shamsh') return 'شمش'
return source
}
export function statusLabel(status: string) {
if (status === 'pending') return 'در انتظار'
if (status === 'approved') return 'تأیید شده'
if (status === 'rejected') return 'رد شده'
return status
}
export function statusColors(status: string) {
if (status === 'approved') return { text: '#25855A', bg: '#E3F4E9' }
if (status === 'rejected') return { text: '#C53030', bg: '#FECACA' }
return { text: '#B45309', bg: '#FEF3C7' }
}
function decorate(row: CopperConvertRequestRow): CopperConvertTableRow {
return {
...row,
source_label: sourceLabel(row.source),
status_label: statusLabel(row.status),
created_label: row.created_at ? toJalali(row.created_at) : '-'
}
}
function asText(value: unknown) {
return value == null ? '' : String(value).toLowerCase()
}
function matchFilter(row: Record<string, unknown>, filter: Filter) {
const raw = row[filter.field]
const op = filter.op
const value = filter.value
if (op === 'null') return raw == null || raw === ''
if (op === 'nn') return raw != null && raw !== ''
if (value === '' || value == null) return true
if (op === 'bet' && Array.isArray(value)) {
const n = Number(raw)
return n >= Number(value[0]) && n <= Number(value[1])
}
if (op === 'in' && Array.isArray(value)) {
return value.map(String).includes(String(raw ?? ''))
}
const text = asText(raw)
const needle = asText(value)
if (op === 'eq') return text === needle
if (op === 'not') return text !== needle
if (op === 'sw') return text.startsWith(needle)
if (op === 'ew') return text.endsWith(needle)
if (op === 'has' || op == null) return text.includes(needle)
const left = Number(raw)
const right = Number(value)
if (!Number.isFinite(left) || !Number.isFinite(right)) return false
if (op === 'gt') return left > right
if (op === 'lt') return left < right
if (op === 'gte') return left >= right
if (op === 'lte') return left <= right
return true
}
function queryRows(rows: CopperConvertTableRow[], params: TableInput): TableOutput<CopperConvertTableRow> {
const search = (params.search ?? '').trim().toLowerCase()
let list = rows.filter((row) => {
if (!search) return true
return [row.customer_name, row.customer_mobile, row.source_label, row.status_label, row.asset_unit, row.created_label]
.join(' ')
.toLowerCase()
.includes(search)
})
for (const filter of params.filters ?? []) {
if (!filter.field) continue
list = list.filter((row) => matchFilter(row as unknown as Record<string, unknown>, filter))
}
const sortKey = params.sort?.key
if (sortKey) {
const dir = params.sort?.order === 'desc' ? -1 : 1
list = [...list].sort((a, b) => {
const av = (a as Record<string, unknown>)[sortKey]
const bv = (b as Record<string, unknown>)[sortKey]
if (numericKeys.has(sortKey)) return (Number(av) - Number(bv)) * dir
return String(av ?? '').localeCompare(String(bv ?? ''), 'fa') * dir
})
}
const page = params.page || 1
const limit = params.limit || 10
return {
rows: list.slice((page - 1) * limit, page * limit),
query: {
...params,
page,
limit,
total: list.length,
search: params.search ?? null,
filters: params.filters ?? []
}
}
}
export async function fetchCopperConvertTable(params: TableInput) {
const data = await getCopperConvertRequests()
const rows = (Array.isArray(data) ? data : []).map(decorate)
return queryRows(rows, params)
}
export function useCopperConvertActions(reload: () => unknown) {
const busyId = ref(0)
function approve(row: CopperConvertRequestRow) {
Modal.confirm({
title: 'تأیید تبدیل',
content: 'دارایی مشتری فروخته و حلقه‌های سیم برای او خریداری شود؟',
okText: 'تأیید',
cancelText: 'انصراف',
async onOk() {
busyId.value = row.id
try {
await approveCopperConvertRequest(row.id)
message.success('تبدیل ثبت شد')
await reload()
} finally {
busyId.value = 0
}
}
})
}
function reject(row: CopperConvertRequestRow) {
Modal.confirm({
title: 'رد درخواست',
content: 'این درخواست تبدیل رد شود؟',
okText: 'رد',
cancelText: 'انصراف',
async onOk() {
busyId.value = row.id
try {
await rejectCopperConvertRequest(row.id)
message.success('درخواست رد شد')
await reload()
} finally {
busyId.value = 0
}
}
})
}
return { busyId, approve, reject }
}
@@ -0,0 +1,13 @@
<template>
<div class="h-full min-h-0">
<BasicMainCard>
<div class="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden">
<tbl class="min-h-0 flex-1" />
</div>
</BasicMainCard>
</div>
</template>
<script setup lang="ts">
import tbl from './components/table.vue'
</script>
+11
View File
@@ -130,6 +130,17 @@ const HesabKetab: RouteRecordRaw = {
}
}
},
{
name: 'copperConvertMain',
path: 'copper-convert',
component: () => import('@/pages/menuPages/copperConvert/index.vue'),
meta: {
permissionsKey: 'copperConvertMain',
icon: 'vuesax-linear:empty-wallet-time',
title: 'درخواست تبدیل',
order: 6.9
}
},
{
name: 'accountingMain',
path: 'accounting',
+3
View File
@@ -210,6 +210,9 @@ export const conditionalOrdersPageIntro =
export const colleagueOrdersPageIntro =
'تراکنش‌های پوشش‌شده با اپ همکار. وضعیت هر ردیف بعد از ارسال به همکار به‌روز می‌شود (در انتظار، موفق، ناموفق). برای ثبت درست سند حسابداری، کد حساب همکار را هم در همین صفحه تنظیم کنید.'
export const copperConvertPageIntro =
'درخواست فروش دارایی مشتری و خرید حلقه سیم ۱.۵ و ۲.۵. تأیید، دارایی را می‌فروشد و حلقه‌ها را با قیمت همان درخواست می‌خرد.'
export const colleagueAccountingCodeHelp =
'کدی که سند خرید/فروش پوشش‌شدهٔ همکار روی آن در سیستم حسابداری فعال ثبت می‌شود. فقط فیلد مربوط به حسابداری فعال را پر کنید.'
+1
View File
@@ -129,6 +129,7 @@ export const ADMIN_PAGE_TREE: AccessNode[] = [
route: 'colleagueOrdersMain',
enabled: () => priceProviderSetting.value === 'montzar'
},
{ key: 'copperConvertMain', title: 'درخواست تبدیل', route: 'copperConvertMain' },
{ key: 'accountingMain', title: 'حسابداری', route: 'accountingMain' }
]
},