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 | سه زبانه تبدیل از اول | همان `CopperConvertCalculator.vue` | زبانه ۱.۵ و ۲.۵ دیگر پشت `remainderAmount > 0` نیستند؛ زیر زبانه مقدار، قبل از نتیجه
- 2026-11-02 | زبانه سیم مکمل | `useCopperConvertPage.ts` | بودجه = ارزش مقدار انتخاب‌شده؛ نصف به ۱.۵ و بقیه به ۲.۵؛ جابه‌جایی یکی دیگری را پر می‌کند - 2026-11-02 | زبانه سیم مکمل | `useCopperConvertPage.ts` | بودجه = ارزش مقدار انتخاب‌شده؛ نصف به ۱.۵ و بقیه به ۲.۵؛ جابه‌جایی یکی دیگری را پر می‌کند
- 2026-11-01 | تاریخچه برداشت کیف پول | `wallet/mobile/MobileWalletView.vue` + `desktop/DesktopWalletView.vue` + `useWalletPageData.ts` | `walletWithdrawItems` از قبل لود می‌شد؛ زیر راهنمای برداشت و زیر حساب‌های دسکتاپ نشان داده می‌شود - 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 useAxios from '@/utils/useAxios'
import type { CopperConvertSource } from '@/utils/copperWireConvert' 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: { export function createCopperConvertRequest(payload: {
source: CopperConvertSource source: CopperConvertSource
rings15: number rings15: number
@@ -8,3 +20,22 @@ export function createCopperConvertRequest(payload: {
}) { }) {
return useAxios.post('/customer/copper-convert-request', 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> <template>
<div class="copper-convert" dir="rtl"> <div class="copper-convert" data-convert-form dir="rtl">
<p class="copper-convert__lead">
مقدار کاتد، مفتول یا شمش از دارایی شما خوانده می‌شود. با زبانه از صفر تا همان موجودی انتخاب کنید. معادل مفتول و حلقه سیم ۱.۵ و ۲.۵ با قیمت روز حساب می‌شود.
</p>
<a-spin :spinning="loading"> <a-spin :spinning="loading">
<section class="copper-convert__panel" aria-label="قیمت‌های مبنا"> <section class="copper-convert__panel" aria-label="قیمت‌های مبنا">
<p v-if="!result && amountAttempted && !loading" class="copper-convert__warn">
مقدار معتبر وارد کنید و از فعال بودن قیمت کاتد/مفتول در سامانه مطمئن شوید.
</p>
<div class="copper-convert__panel-head"> <div class="copper-convert__panel-head">
<span class="copper-convert__panel-kicker">قیمت لحظه‌ای</span> <span class="copper-convert__panel-kicker">قیمت لحظه‌ای</span>
<span class="copper-convert__panel-title">مبنای محاسبه</span> <span class="copper-convert__panel-title">مبنای محاسبه</span>
@@ -186,23 +186,28 @@
<p class="copper-convert__asset-hint"> <p class="copper-convert__asset-hint">
از مقدار انتخاب‌شده، <span class="copper-convert__measure"><bdi>{{ assetUnit }}</bdi><bdi>{{ formatAssetAmount(leftoverAmount) }}</bdi></span> به حلقهٔ کامل نمی‌رسد و در دارایی می‌ماند. باقی دارایی که روی زبانه انتخاب نشده هم دست‌نخورده می‌ماند. از مقدار انتخاب‌شده، <span class="copper-convert__measure"><bdi>{{ assetUnit }}</bdi><bdi>{{ formatAssetAmount(leftoverAmount) }}</bdi></span> به حلقهٔ کامل نمی‌رسد و در دارایی می‌ماند. باقی دارایی که روی زبانه انتخاب نشده هم دست‌نخورده می‌ماند.
</p> </p>
<BasicButton <p v-if="editingId" class="copper-convert__asset-hint">
type="primary" در حال ویرایش درخواست در انتظار تایید. ذخیره، همان درخواست را به‌روز می‌کند.
class="!w-full" </p>
:loading="submitting" <div :class="editingId ? 'grid grid-cols-2 gap-2' : ''">
:disabledB="wire15Rings + wire25Rings < 1" <BasicButton
@click="submitRequest" type="primary"
> class="!w-full"
ثبت درخواست تبدیل html-type="button"
</BasicButton> :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 class="copper-convert__asset-hint">
درخواست برای ادمین می‌رود. بعد از تأیید، همان مقدار دارایی فروخته و حلقه‌های انتخاب‌شده خریده می‌شود. درخواست برای ادمین می‌رود. بعد از تأیید، همان مقدار دارایی فروخته و حلقه‌های انتخاب‌شده خریده می‌شود.
</p> </p>
</section> </section>
<p v-if="!result && amountAttempted && !loading" class="copper-convert__warn">
مقدار معتبر وارد کنید و از فعال بودن قیمت کاتد/مفتول در سامانه مطمئن شوید.
</p>
</a-spin> </a-spin>
</div> </div>
</template> </template>
@@ -227,6 +232,8 @@ const {
leftoverAmount, leftoverAmount,
submitting, submitting,
submitRequest, submitRequest,
editingId,
cancelEdit,
result, result,
priceRows, priceRows,
} = useCopperConvertPageContext() } = 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 { import {
computed, computed,
inject, inject,
nextTick,
onMounted, onMounted,
onUnmounted, onUnmounted,
provide, provide,
@@ -10,9 +11,15 @@ import {
type InjectionKey, type InjectionKey,
type Ref, type Ref,
} from 'vue' } from 'vue'
import { createCopperConvertRequest } from '@/api/copperConvert' import {
createCopperConvertRequest,
deleteCopperConvertRequest,
getCopperConvertRequests,
updateCopperConvertRequest,
type CopperConvertHistoryRow,
} from '@/api/copperConvert'
import { getProducts } from '@/api/products' import { getProducts } from '@/api/products'
import { message } from 'ant-design-vue' import { message, Modal } from 'ant-design-vue'
import { useWalletAssets } from '@/composables/useWalletAssets' import { useWalletAssets } from '@/composables/useWalletAssets'
import type { Product } from '@/models/products' import type { Product } from '@/models/products'
import type { WalletInventoryCard } from '@/models/walletAssets' import type { WalletInventoryCard } from '@/models/walletAssets'
@@ -47,6 +54,13 @@ export interface CopperConvertPageContext {
leftoverAmount: ComputedRef<number> leftoverAmount: ComputedRef<number>
submitting: Ref<boolean> submitting: Ref<boolean>
submitRequest: () => Promise<void> 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> prices: ComputedRef<CopperConvertPrices>
result: ComputedRef<CopperConvertResult | null> result: ComputedRef<CopperConvertResult | null>
priceRows: ComputedRef<{ label: string; value: string; missing?: boolean }[]> priceRows: ComputedRef<{ label: string; value: string; missing?: boolean }[]>
@@ -142,6 +156,10 @@ export function useCopperConvertPage() {
const wire25Rings = ref(0) const wire25Rings = ref(0)
const submitting = ref(false) const submitting = ref(false)
const catalog = ref<Product[]>([]) 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 { assetsSummary, loadWalletAssets } = useWalletAssets()
const assetCards = computed(() => { const assetCards = computed(() => {
@@ -310,24 +328,97 @@ export function useCopperConvertPage() {
writeRings(rings15For(next25), next25) writeRings(rings15For(next25), next25)
}, { flush: 'sync' }) }, { 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() { async function submitRequest() {
if (submitting.value) return if (submitting.value) return
if (wire15Rings.value + wire25Rings.value < 1) return if (wire15Rings.value + wire25Rings.value < 1) return
if (!editingId.value && hasPendingRequest()) {
message.warning('یک درخواست در انتظار تایید دارید. همان را ویرایش یا حذف کنید.')
return
}
submitting.value = true submitting.value = true
try { try {
await createCopperConvertRequest({ const payload = {
source: source.value, source: source.value,
rings15: wire15Rings.value, rings15: wire15Rings.value,
rings25: wire25Rings.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 wire15Rings.value = 0
wire25Rings.value = 0 wire25Rings.value = 0
message.success('درخواست تبدیل برای تأیید ادمین ثبت شد') void loadHistory()
} finally { } finally {
submitting.value = false 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 priceRows = computed(() => {
const p = prices.value const p = prices.value
const row = (label: string, amount: number) => ({ const row = (label: string, amount: number) => ({
@@ -352,6 +443,7 @@ export function useCopperConvertPage() {
getLastPrices(), getLastPrices(),
getProducts(), getProducts(),
loadWalletAssets(), loadWalletAssets(),
loadHistory(),
]) ])
catalog.value = Array.isArray(list) ? list : [] catalog.value = Array.isArray(list) ? list : []
} catch { } catch {
@@ -385,6 +477,13 @@ export function useCopperConvertPage() {
leftoverAmount, leftoverAmount,
submitting, submitting,
submitRequest, submitRequest,
editingId,
beginEdit,
cancelEdit,
deleteRequest,
deletingId,
history,
historyLoading,
prices, prices,
result, result,
priceRows, priceRows,
@@ -1,77 +1,43 @@
<template> <template>
<div class="dconvert"> <div class="dwallet" dir="rtl">
<div class="dconvert__container"> <div class="dwallet__container">
<header class="dconvert-topbar"> <DesktopPageTopbar
<button type="button" class="dconvert-topbar__back" @click="router.back()"> eyebrow="محصول"
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" /> title="تبدیل محصول"
<span>بازگشت</span> subtitle="تبدیل کاتد، مفتول یا شمش به حلقه سیم"
</button> show-back
<div class="dconvert-topbar__meta"> />
<p class="dconvert-topbar__eyebrow">محصول</p>
<h1 class="dconvert-topbar__title">تبدیل محصول</h1>
</div>
</header>
<section class="dconvert-surface"> <div class="dwallet-grid dconvert-grid">
<CopperConvertCalculator /> <section class="dwallet-surface dwallet-form">
</section> <CopperConvertCalculator />
</section>
<section class="dwallet-surface dwallet-side dconvert-side">
<CopperConvertHistory variant="side" />
</section>
</div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue' import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
import router from '@/router'
import CopperConvertCalculator from '../components/CopperConvertCalculator.vue' import CopperConvertCalculator from '../components/CopperConvertCalculator.vue'
import CopperConvertHistory from '../components/CopperConvertHistory.vue'
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.dconvert { .dconvert-grid {
align-items: stretch;
}
.dconvert-side {
min-height: 100%; min-height: 100%;
padding: 1.25rem 1.5rem 2rem;
}
.dconvert__container { :deep(.dwallet-side__scroll) {
max-width: 720px; max-height: none;
margin: 0 auto; flex: 1;
} }
.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;
} }
</style> </style>
@@ -1,10 +1,11 @@
<template> <template>
<div> <div>
<MobilePageHeader title="تبدیل محصول" show-back /> <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"> <div class="border-gray-200 border-1 rounded-xl p-4 relative bg-white">
<CopperConvertCalculator /> <CopperConvertCalculator />
</div> </div>
<CopperConvertHistory />
</div> </div>
</div> </div>
</template> </template>
@@ -12,4 +13,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue' import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue'
import CopperConvertCalculator from '../components/CopperConvertCalculator.vue' import CopperConvertCalculator from '../components/CopperConvertCalculator.vue'
import CopperConvertHistory from '../components/CopperConvertHistory.vue'
</script> </script>
@@ -120,13 +120,11 @@
.dwallet-surface { .dwallet-surface {
background: #fff; background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px; border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
} }
.dwallet-form { .dwallet-form {
padding: 24px;
&__title { &__title {
font-size: 16px; font-size: 16px;