feat(convert): show history and let customers edit pending requests

Desktop history sits beside the form. Pending requests can be updated or deleted, and the shared wallet surface drops its extra border and padding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-03 13:04:09 +03:30
co-authored by Cursor
parent 47d4fb44df
commit c21738950d
8 changed files with 320 additions and 87 deletions
@@ -62,3 +62,7 @@
- 2026-11-02 | سه زبانه تبدیل از اول | همان `CopperConvertCalculator.vue` | زبانه ۱.۵ و ۲.۵ دیگر پشت `remainderAmount > 0` نیستند؛ زیر زبانه مقدار، قبل از نتیجه
- 2026-11-02 | زبانه سیم مکمل | `useCopperConvertPage.ts` | بودجه = ارزش مقدار انتخاب‌شده؛ نصف به ۱.۵ و بقیه به ۲.۵؛ جابه‌جایی یکی دیگری را پر می‌کند
- 2026-11-01 | تاریخچه برداشت کیف پول | `wallet/mobile/MobileWalletView.vue` + `desktop/DesktopWalletView.vue` + `useWalletPageData.ts` | `walletWithdrawItems` از قبل لود می‌شد؛ زیر راهنمای برداشت و زیر حساب‌های دسکتاپ نشان داده می‌شود
- 2026-11-03 | تاریخچه تبدیل کاربر | `convert/components/CopperConvertHistory.vue` + `useCopperConvertPage.ts` + سرور `Customer/CopperConvertRequestController@index` | GET `/customer/copper-convert-request` فقط ردیف‌های همان مشتری؛ زیر فرم موبایل و دسکتاپ
- 2026-11-03 | ویرایش/حذف تبدیل در انتظار | همان تاریخچه + `CopperConvertRequestService::update/remove` | فقط status=pending؛ ویرایش فرم را با همان حلقه‌ها پر می‌کند و PUT می‌زند
- 2026-11-03 | چیدمان دسکتاپ تبدیل | `convert/desktop/DesktopConvertView.vue` | مثل کیف پول: `dwallet-grid`؛ فرم راست، تاریخچه چپ با `variant=side`
- 2026-11-03 | مداد ویرایش تبدیل | `convert/components/CopperConvertHistory.vue` | فقط pending؛ آیکون `vuesax-linear:edit` کنار وضعیت، همان `beginEdit`
+31
View File
@@ -1,6 +1,18 @@
import useAxios from '@/utils/useAxios'
import type { CopperConvertSource } from '@/utils/copperWireConvert'
export interface CopperConvertHistoryRow {
id: number
status: string
source: string
asset_unit: string
consumed_amount: number
leftover_amount: number
wire15_rings: number
wire25_rings: number
created_at?: string
}
export function createCopperConvertRequest(payload: {
source: CopperConvertSource
rings15: number
@@ -8,3 +20,22 @@ export function createCopperConvertRequest(payload: {
}) {
return useAxios.post('/customer/copper-convert-request', payload)
}
export function getCopperConvertRequests() {
return useAxios.get<CopperConvertHistoryRow[]>('/customer/copper-convert-request')
}
export function updateCopperConvertRequest(
id: number,
payload: {
source: CopperConvertSource
rings15: number
rings25: number
},
) {
return useAxios.put(`/customer/copper-convert-request/${id}`, payload)
}
export function deleteCopperConvertRequest(id: number) {
return useAxios.delete(`/customer/copper-convert-request/${id}`)
}
@@ -1,11 +1,11 @@
<template>
<div class="copper-convert" dir="rtl">
<p class="copper-convert__lead">
مقدار کاتد، مفتول یا شمش از دارایی شما خوانده می‌شود. با زبانه از صفر تا همان موجودی انتخاب کنید. معادل مفتول و حلقه سیم ۱.۵ و ۲.۵ با قیمت روز حساب می‌شود.
</p>
<div class="copper-convert" data-convert-form dir="rtl">
<a-spin :spinning="loading">
<section class="copper-convert__panel" aria-label="قیمت‌های مبنا">
<p v-if="!result && amountAttempted && !loading" class="copper-convert__warn">
مقدار معتبر وارد کنید و از فعال بودن قیمت کاتد/مفتول در سامانه مطمئن شوید.
</p>
<div class="copper-convert__panel-head">
<span class="copper-convert__panel-kicker">قیمت لحظه‌ای</span>
<span class="copper-convert__panel-title">مبنای محاسبه</span>
@@ -186,23 +186,28 @@
<p class="copper-convert__asset-hint">
از مقدار انتخاب‌شده، <span class="copper-convert__measure"><bdi>{{ assetUnit }}</bdi><bdi>{{ formatAssetAmount(leftoverAmount) }}</bdi></span> به حلقهٔ کامل نمی‌رسد و در دارایی می‌ماند. باقی دارایی که روی زبانه انتخاب نشده هم دست‌نخورده می‌ماند.
</p>
<BasicButton
type="primary"
class="!w-full"
:loading="submitting"
:disabledB="wire15Rings + wire25Rings < 1"
@click="submitRequest"
>
ثبت درخواست تبدیل
</BasicButton>
<p v-if="editingId" class="copper-convert__asset-hint">
در حال ویرایش درخواست در انتظار تایید. ذخیره، همان درخواست را به‌روز می‌کند.
</p>
<div :class="editingId ? 'grid grid-cols-2 gap-2' : ''">
<BasicButton
type="primary"
class="!w-full"
html-type="button"
:loading="submitting"
:disabledB="wire15Rings + wire25Rings < 1"
@click="submitRequest"
>
{{ editingId ? 'ذخیره ویرایش' : 'ثبت درخواست تبدیل' }}
</BasicButton>
<BasicButton v-if="editingId" class="!w-full" html-type="button" :disabledB="submitting" @click="cancelEdit">
انصراف
</BasicButton>
</div>
<p class="copper-convert__asset-hint">
درخواست برای ادمین می‌رود. بعد از تأیید، همان مقدار دارایی فروخته و حلقه‌های انتخاب‌شده خریده می‌شود.
</p>
</section>
<p v-if="!result && amountAttempted && !loading" class="copper-convert__warn">
مقدار معتبر وارد کنید و از فعال بودن قیمت کاتد/مفتول در سامانه مطمئن شوید.
</p>
</a-spin>
</div>
</template>
@@ -227,6 +232,8 @@ const {
leftoverAmount,
submitting,
submitRequest,
editingId,
cancelEdit,
result,
priceRows,
} = useCopperConvertPageContext()
@@ -0,0 +1,126 @@
<template>
<section
:class="side
? 'flex min-h-0 min-w-0 flex-1 flex-col'
: 'mt-3 flex flex-col gap-2 rounded-xl border border-gray-200 bg-white p-2.5'"
aria-label="تاریخچه تبدیل"
>
<div :class="side ? 'dwallet-side__header' : ''">
<div>
<h2 :class="side ? 'dwallet-side__title' : 'm-0 text-sm font-bold text-slate-900'">تاریخچه تبدیل</h2>
</div>
</div>
<div :class="side ? 'dwallet-side__scroll' : ''">
<a-spin :spinning="historyLoading && !history.length">
<div v-if="historyLoading && !history.length" class="min-h-16" />
<p
v-else-if="!history.length"
class="m-0 rounded-lg bg-slate-50 px-3 py-4 text-center text-xs text-slate-500"
>
درخواست تبدیلی ثبت نشده است.
</p>
<ul v-else class="m-0 flex list-none flex-col gap-2 p-0">
<li
v-for="item in history"
:key="item.id"
class="rounded-lg border p-2.5"
:class="editingId === item.id ? 'border-amber-400' : 'border-gray-200'"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-xs text-slate-500">{{ item.created_at ? toJalali(item.created_at) : '—' }}</span>
<span class="inline-flex items-center gap-1 text-xs text-slate-600">
<button
v-if="item.status === 'pending'"
type="button"
class="inline-flex h-7 w-7 items-center justify-center rounded-md border border-gray-200 bg-white"
aria-label="ویرایش درخواست"
title="ویرایش"
@click="beginEdit(item)"
>
<BasicIcon icon="vuesax-linear:edit" size="15" />
</button>
وضعیت
<a-tag class="!m-0" :color="statusColor(item.status)">{{ statusLabel(item.status) }}</a-tag>
</span>
</div>
<div class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1 text-xs text-slate-600">
<span>دارایی: <b class="text-slate-900">{{ sourceLabel(item.source) }}</b></span>
<span>
مقدار:
<b class="text-slate-900">
<bdi>{{ item.asset_unit }}</bdi>
<bdi> {{ formatAmount(item.consumed_amount) }}</bdi>
</b>
</span>
<span>حلقه ۱.۵: <b class="text-slate-900">{{ item.wire15_rings }}</b></span>
<span>حلقه ۲.۵: <b class="text-slate-900">{{ item.wire25_rings }}</b></span>
<span v-if="Number(item.leftover_amount) > 0" class="col-span-2">
مانده در دارایی:
<b class="text-slate-900">
<bdi>{{ item.asset_unit }}</bdi>
<bdi> {{ formatAmount(item.leftover_amount) }}</bdi>
</b>
</span>
</div>
<div v-if="item.status === 'pending'" class="mt-2 flex">
<a-button
size="small"
danger
:loading="deletingId === item.id"
@click="deleteRequest(item.id)"
>
حذف
</a-button>
</div>
</li>
</ul>
</a-spin>
</div>
</section>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { toJalali } from '@/utils/useDateTime'
import { useCopperConvertPageContext } from '../composables/useCopperConvertPage'
const props = withDefaults(defineProps<{ variant?: 'stack' | 'side' }>(), {
variant: 'stack',
})
const side = computed(() => props.variant === 'side')
const { history, historyLoading, editingId, beginEdit, deleteRequest, deletingId } =
useCopperConvertPageContext()
function sourceLabel(source: string) {
if (source === 'katod') return 'کاتد'
if (source === 'maftoul') return 'مفتول'
if (source === 'shamsh') return 'شمش'
return source
}
function statusLabel(status: string) {
if (status === 'pending') return 'در انتظار تایید'
if (status === 'approved') return 'تایید شد'
if (status === 'rejected') return 'رد شد'
return status
}
function statusColor(status: string) {
if (status === 'approved') return 'green'
if (status === 'rejected') return 'red'
return 'orange'
}
function formatAmount(value: number) {
const amount = Number(value)
if (!Number.isFinite(amount)) return '—'
return amount.toLocaleString('en-US', { maximumFractionDigits: 3 })
}
</script>
@@ -1,6 +1,7 @@
import {
computed,
inject,
nextTick,
onMounted,
onUnmounted,
provide,
@@ -10,9 +11,15 @@ import {
type InjectionKey,
type Ref,
} from 'vue'
import { createCopperConvertRequest } from '@/api/copperConvert'
import {
createCopperConvertRequest,
deleteCopperConvertRequest,
getCopperConvertRequests,
updateCopperConvertRequest,
type CopperConvertHistoryRow,
} from '@/api/copperConvert'
import { getProducts } from '@/api/products'
import { message } from 'ant-design-vue'
import { message, Modal } from 'ant-design-vue'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { Product } from '@/models/products'
import type { WalletInventoryCard } from '@/models/walletAssets'
@@ -47,6 +54,13 @@ export interface CopperConvertPageContext {
leftoverAmount: ComputedRef<number>
submitting: Ref<boolean>
submitRequest: () => Promise<void>
editingId: Ref<number | null>
beginEdit: (row: CopperConvertHistoryRow) => Promise<void>
cancelEdit: () => void
deleteRequest: (id: number) => void
deletingId: Ref<number>
history: Ref<CopperConvertHistoryRow[]>
historyLoading: Ref<boolean>
prices: ComputedRef<CopperConvertPrices>
result: ComputedRef<CopperConvertResult | null>
priceRows: ComputedRef<{ label: string; value: string; missing?: boolean }[]>
@@ -142,6 +156,10 @@ export function useCopperConvertPage() {
const wire25Rings = ref(0)
const submitting = ref(false)
const catalog = ref<Product[]>([])
const history = ref<CopperConvertHistoryRow[]>([])
const historyLoading = ref(true)
const editingId = ref<number | null>(null)
const deletingId = ref(0)
const { assetsSummary, loadWalletAssets } = useWalletAssets()
const assetCards = computed(() => {
@@ -310,24 +328,97 @@ export function useCopperConvertPage() {
writeRings(rings15For(next25), next25)
}, { flush: 'sync' })
function hasPendingRequest() {
return history.value.some((row) => row.status === 'pending')
}
async function beginEdit(row: CopperConvertHistoryRow) {
if (row.status !== 'pending' || submitting.value) return
editingId.value = row.id
if (row.source === 'katod' || row.source === 'maftoul' || row.source === 'shamsh') {
source.value = row.source
}
await nextTick()
const rings15 = Math.max(0, Math.min(maxRings15.value, Math.floor(Number(row.wire15_rings) || 0)))
const rings25 = Math.min(Math.floor(Number(row.wire25_rings) || 0), rings25For(rings15))
writeRings(rings15, Math.max(0, rings25))
const forms = document.querySelectorAll('[data-convert-form]')
const visible = [...forms].find((node) => node instanceof HTMLElement && node.getClientRects().length > 0)
visible?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function cancelEdit() {
if (submitting.value) return
editingId.value = null
applyDefaultSplit()
}
function deleteRequest(id: number) {
if (deletingId.value || submitting.value) return
const row = history.value.find((item) => item.id === id)
if (!row || row.status !== 'pending') return
Modal.confirm({
title: 'حذف درخواست',
content: 'این درخواست در انتظار تایید حذف شود؟',
okText: 'حذف',
cancelText: 'انصراف',
okType: 'danger',
async onOk() {
deletingId.value = id
try {
await deleteCopperConvertRequest(id)
if (editingId.value === id) editingId.value = null
message.success('درخواست تبدیل حذف شد')
await loadHistory()
} finally {
deletingId.value = 0
}
},
})
}
async function submitRequest() {
if (submitting.value) return
if (wire15Rings.value + wire25Rings.value < 1) return
if (!editingId.value && hasPendingRequest()) {
message.warning('یک درخواست در انتظار تایید دارید. همان را ویرایش یا حذف کنید.')
return
}
submitting.value = true
try {
await createCopperConvertRequest({
const payload = {
source: source.value,
rings15: wire15Rings.value,
rings25: wire25Rings.value,
})
}
if (editingId.value) {
await updateCopperConvertRequest(editingId.value, payload)
editingId.value = null
message.success('درخواست تبدیل به‌روز شد')
} else {
await createCopperConvertRequest(payload)
message.success('درخواست تبدیل برای تأیید ادمین ثبت شد')
}
wire15Rings.value = 0
wire25Rings.value = 0
message.success('درخواست تبدیل برای تأیید ادمین ثبت شد')
void loadHistory()
} finally {
submitting.value = false
}
}
async function loadHistory() {
historyLoading.value = true
try {
const rows = await getCopperConvertRequests()
history.value = Array.isArray(rows) ? rows : []
} catch {
// خطای شبکه را اینترسپتور نشان می‌دهد؛ لیست قبلی می‌ماند
} finally {
historyLoading.value = false
}
}
const priceRows = computed(() => {
const p = prices.value
const row = (label: string, amount: number) => ({
@@ -352,6 +443,7 @@ export function useCopperConvertPage() {
getLastPrices(),
getProducts(),
loadWalletAssets(),
loadHistory(),
])
catalog.value = Array.isArray(list) ? list : []
} catch {
@@ -385,6 +477,13 @@ export function useCopperConvertPage() {
leftoverAmount,
submitting,
submitRequest,
editingId,
beginEdit,
cancelEdit,
deleteRequest,
deletingId,
history,
historyLoading,
prices,
result,
priceRows,
@@ -1,77 +1,43 @@
<template>
<div class="dconvert">
<div class="dconvert__container">
<header class="dconvert-topbar">
<button type="button" class="dconvert-topbar__back" @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
<span>بازگشت</span>
</button>
<div class="dconvert-topbar__meta">
<p class="dconvert-topbar__eyebrow">محصول</p>
<h1 class="dconvert-topbar__title">تبدیل محصول</h1>
</div>
</header>
<div class="dwallet" dir="rtl">
<div class="dwallet__container">
<DesktopPageTopbar
eyebrow="محصول"
title="تبدیل محصول"
subtitle="تبدیل کاتد، مفتول یا شمش به حلقه سیم"
show-back
/>
<section class="dconvert-surface">
<CopperConvertCalculator />
</section>
<div class="dwallet-grid dconvert-grid">
<section class="dwallet-surface dwallet-form">
<CopperConvertCalculator />
</section>
<section class="dwallet-surface dwallet-side dconvert-side">
<CopperConvertHistory variant="side" />
</section>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
import CopperConvertCalculator from '../components/CopperConvertCalculator.vue'
import CopperConvertHistory from '../components/CopperConvertHistory.vue'
</script>
<style scoped lang="less">
.dconvert {
.dconvert-grid {
align-items: stretch;
}
.dconvert-side {
min-height: 100%;
padding: 1.25rem 1.5rem 2rem;
}
.dconvert__container {
max-width: 720px;
margin: 0 auto;
}
.dconvert-topbar {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.dconvert-topbar__back {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: none;
background: transparent;
color: var(--colorPrimary, #2563eb);
font-family: inherit;
font-weight: 700;
cursor: pointer;
}
.dconvert-topbar__eyebrow {
margin: 0;
font-size: 0.75rem;
color: #64748b;
}
.dconvert-topbar__title {
margin: 0;
font-size: 1.25rem;
font-weight: 800;
color: #0f172a;
}
.dconvert-surface {
border: 1px solid #e5e7eb;
border-radius: 0.875rem;
background: #fff;
padding: 1.25rem;
:deep(.dwallet-side__scroll) {
max-height: none;
flex: 1;
}
}
</style>
@@ -1,10 +1,11 @@
<template>
<div>
<MobilePageHeader title="تبدیل محصول" show-back />
<div class="px-5 mpage-content pb-2">
<div class="px-5 mpage-content pb-[calc(var(--app-bottom-chrome-height)+1rem)]">
<div class="border-gray-200 border-1 rounded-xl p-4 relative bg-white">
<CopperConvertCalculator />
</div>
<CopperConvertHistory />
</div>
</div>
</template>
@@ -12,4 +13,5 @@
<script lang="ts" setup>
import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue'
import CopperConvertCalculator from '../components/CopperConvertCalculator.vue'
import CopperConvertHistory from '../components/CopperConvertHistory.vue'
</script>
@@ -120,13 +120,11 @@
.dwallet-surface {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.dwallet-form {
padding: 24px;
&__title {
font-size: 16px;