438 lines
12 KiB
Vue
438 lines
12 KiB
Vue
<!--
|
||||
|
|
"گزارش" — the customer's account statement.
|
|||
|
|
|
|||
|
|
Ported from henza_pwa/src/views/report/index.vue (route /report, name
|
|||
|
|
report) plus its two card components (components/Accounting.vue,
|
|||
|
|
components/AccountingDetail.vue), restyled into this app's design language.
|
|||
|
|
The LOGIC is the reference's and is identical in user-app-v2; only the
|
|||
|
|
styling differs.
|
|||
|
|
|
|||
|
|
Kept from the reference:
|
|||
|
|
- the title "گزارش" and the back arrow to the dashboard (index.vue:7-10)
|
|||
|
|
- the two tabs «کلیات حساب» / «جزئیات حساب» (:15-16), switching clears the
|
|||
|
|
list and re-queries from page 1 (:178-181)
|
|||
|
|
- the summary card's rows, in the reference's order: شماره سند + تاریخ,
|
|||
|
|
طلا (in/out), مانده طلا, ریال (in/out), مانده ریال (Accounting.vue:5-48)
|
|||
|
|
- balances coloured by sign — red when negative (Accounting.vue:29, :46)
|
|||
|
|
- the «جزئیات» link opening the document in a modal (:66, index.vue:44-52)
|
|||
|
|
- paging by scroll on the detail tab (:231-241)
|
|||
|
|
|
|||
|
|
Deliberately NOT ported: the ارز (foreign-currency) rows. The reference read
|
|||
|
|
them from an external accounting system; the target has no foreign-currency
|
|||
|
|
ledger, so showing a hardcoded "0 $" would assert something untrue. Recorded
|
|||
|
|
as REP-01.
|
|||
|
|
|
|||
|
|
The reference gated this page on `myStore.hasMCode()` (index.vue:174-177) —
|
|||
|
|
the customer's id in that external system. The target has no mcode, so the
|
|||
|
|
gate is simply a session, and the server scopes every row to the token.
|
|||
|
|
-->
|
|||
|
|
<template>
|
|||
|
|
<div class="rep">
|
|||
|
|
<PageBar title="گزارش" back-to="userPage::hasSubRoute">
|
|||
|
|
<template #below>
|
|||
|
|
<div class="rep__tabs">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
class="rep__tab"
|
|||
|
|
:class="{ 'is-on': activeKey === '1' }"
|
|||
|
|
@click="changeTab('1')"
|
|||
|
|
>
|
|||
|
|
کلیات حساب
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
class="rep__tab"
|
|||
|
|
:class="{ 'is-on': activeKey === '2' }"
|
|||
|
|
@click="changeTab('2')"
|
|||
|
|
>
|
|||
|
|
جزئیات حساب
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
</PageBar>
|
|||
|
|
|
|||
|
|
<Spinner :spinning="loading && !rows.length && !details.length" tip="درحال دریافت اطلاعات">
|
|||
|
|
<PullRefresh v-model="refreshing" @refresh="onRefresh">
|
|||
|
|
<container class="rep__body">
|
|||
|
|
<!-- ── کلیات حساب ─────────────────────────────────────────── -->
|
|||
|
|
<template v-if="activeKey === '1'">
|
|||
|
|
<div v-if="rows.length" class="rep__list">
|
|||
|
|
<article v-for="item in rows" :key="item.factor_Code" class="rep__card">
|
|||
|
|
<header class="rep__head">
|
|||
|
|
<span><span class="rep__muted">شماره سند:</span> {{ item.sh_factor }}</span>
|
|||
|
|
<span><span class="rep__muted">تاریخ:</span> {{ jdate(item.tarikh) }}</span>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<div class="rep__pair">
|
|||
|
|
<span>طلا</span>
|
|||
|
|
<div class="rep__stack">
|
|||
|
|
<span dir="ltr" class="is-in">{{ item.geram_v }}</span>
|
|||
|
|
<span dir="ltr" class="is-out">{{ item.geram_kh }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="rep__pair is-balance">
|
|||
|
|
<span>مانده طلا:</span>
|
|||
|
|
<span dir="ltr" :class="sign(item.tahesab_T)">{{ item.tahesab_T }}</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="rep__pair">
|
|||
|
|
<span>ریال</span>
|
|||
|
|
<div class="rep__stack">
|
|||
|
|
<span dir="ltr" class="is-in">{{ rial(item.rial_v) }}</span>
|
|||
|
|
<span dir="ltr" class="is-out">{{ rial(item.rial_kh) }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="rep__pair is-balance">
|
|||
|
|
<span>مانده ریال:</span>
|
|||
|
|
<span dir="ltr" :class="sign(item.tahesab_P)">{{ rial(item.tahesab_P) }}</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<button type="button" class="rep__more" @click="openDetail(item)">جزئیات</button>
|
|||
|
|
</article>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<section v-else-if="!loading && !failed" class="rep__empty">
|
|||
|
|
<Icon icon="vuesax-linear:receipt-text" size="26" />
|
|||
|
|
<p>هنوز سندی برای حساب شما ثبت نشده است.</p>
|
|||
|
|
</section>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<!-- ── جزئیات حساب ────────────────────────────────────────── -->
|
|||
|
|
<template v-else>
|
|||
|
|
<div v-if="details.length" class="rep__list">
|
|||
|
|
<ReportDocument v-for="d in details" :key="d.factor_Code" :item="d" />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<section v-else-if="!loading && !failed" class="rep__empty">
|
|||
|
|
<Icon icon="vuesax-linear:receipt-text" size="26" />
|
|||
|
|
<p>هنوز سندی برای حساب شما ثبت نشده است.</p>
|
|||
|
|
</section>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<section v-if="failed" class="rep__empty">
|
|||
|
|
<Icon icon="vuesax-linear:danger" size="26" />
|
|||
|
|
<p>دریافت اطلاعات با خطا روبهرو شد.</p>
|
|||
|
|
<button type="button" class="rep__retry" @click="reload">تلاش دوباره</button>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<div v-if="loading && (rows.length || details.length)" class="rep__more">در حال دریافت…</div>
|
|||
|
|
<div ref="sentinel" class="rep__sentinel" />
|
|||
|
|
</container>
|
|||
|
|
</PullRefresh>
|
|||
|
|
</Spinner>
|
|||
|
|
|
|||
|
|
<Drawer
|
|||
|
|
v-model:open="modalOpen"
|
|||
|
|
placement="bottom"
|
|||
|
|
height="auto"
|
|||
|
|
:closable="false"
|
|||
|
|
:body-style="{ padding: '0' }"
|
|||
|
|
>
|
|||
|
|
<div class="rep__modal">
|
|||
|
|
<Spinner :spinning="modalLoading" tip="درحال دریافت اطلاعات">
|
|||
|
|
<ReportDocument v-for="d in modalRows" :key="d.factor_Code" :item="d" />
|
|||
|
|
<p v-if="!modalLoading && !modalRows.length" class="rep__modalEmpty">
|
|||
|
|
جزئیاتی برای این سند ثبت نشده است.
|
|||
|
|
</p>
|
|||
|
|
</Spinner>
|
|||
|
|
<button type="button" class="rep__close" @click="modalOpen = false">بازگشت</button>
|
|||
|
|
</div>
|
|||
|
|
</Drawer>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
|||
|
|
import { useRoute } from 'vue-router'
|
|||
|
|
import { Drawer } from 'ant-design-vue'
|
|||
|
|
import { PullRefresh } from 'vant'
|
|||
|
|
import Icon from '../../components/icon/Icon.vue'
|
|||
|
|
import PageBar from '../../components/ui/PageBar.vue'
|
|||
|
|
import Spinner from '../../components/Spinner.vue'
|
|||
|
|
import ReportDocument from './ReportDocument.vue'
|
|||
|
|
import { jdate, price } from '../../utils/filters'
|
|||
|
|
import { api, type ReportRow, type ReportDetailRow } from '../../api/client'
|
|||
|
|
|
|||
|
|
const route = useRoute()
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* The reference had /report and /report/detail as two routes. The detail one
|
|||
|
|
* lands on the same screen with the second tab already selected, which is what
|
|||
|
|
* its 'جزئیات حساب' tab showed.
|
|||
|
|
*/
|
|||
|
|
const activeKey = ref<'1' | '2'>(route.name === 'reportDetail' ? '2' : '1')
|
|||
|
|
|
|||
|
|
const rows = ref<ReportRow[]>([])
|
|||
|
|
const details = ref<ReportDetailRow[]>([])
|
|||
|
|
const page = ref(1)
|
|||
|
|
const hasMore = ref(true)
|
|||
|
|
|
|||
|
|
const loading = ref(false)
|
|||
|
|
const refreshing = ref(false)
|
|||
|
|
const failed = ref(false)
|
|||
|
|
|
|||
|
|
const modalOpen = ref(false)
|
|||
|
|
const modalLoading = ref(false)
|
|||
|
|
const modalRows = ref<ReportDetailRow[]>([])
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* price() groups digits from the right, so a decimal tail would be grouped
|
|||
|
|
* too. Rial figures are whole here, so the .00 is dropped before grouping and
|
|||
|
|
* a negative sign is preserved outside the grouping.
|
|||
|
|
*/
|
|||
|
|
function rial(value: string): string {
|
|||
|
|
const n = parseFloat(value)
|
|||
|
|
if (!Number.isFinite(n)) return value
|
|||
|
|
const whole = Math.trunc(Math.abs(n)).toString()
|
|||
|
|
return (n < 0 ? '-' : '') + price(whole)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** The reference coloured a balance by its sign (Accounting.vue:29). */
|
|||
|
|
function sign(value: string): string {
|
|||
|
|
return parseFloat(value) < 0 ? 'is-out' : 'is-in'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function fetchPage() {
|
|||
|
|
if (loading.value || !hasMore.value) return
|
|||
|
|
loading.value = true
|
|||
|
|
failed.value = false
|
|||
|
|
try {
|
|||
|
|
if (activeKey.value === '1') {
|
|||
|
|
const data = await api.getReport(page.value)
|
|||
|
|
rows.value = [...rows.value, ...(data.result ?? [])]
|
|||
|
|
if ((data.result?.length ?? 0) < (data.limit ?? 50)) hasMore.value = false
|
|||
|
|
else page.value += 1
|
|||
|
|
} else {
|
|||
|
|
const data = await api.getReportDetailList(page.value)
|
|||
|
|
details.value = [...details.value, ...(data.result ?? [])]
|
|||
|
|
if ((data.result?.length ?? 0) < (data.limit ?? 50)) hasMore.value = false
|
|||
|
|
else page.value += 1
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
failed.value = true
|
|||
|
|
hasMore.value = false
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function reset() {
|
|||
|
|
rows.value = []
|
|||
|
|
details.value = []
|
|||
|
|
page.value = 1
|
|||
|
|
hasMore.value = true
|
|||
|
|
failed.value = false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function reload() {
|
|||
|
|
reset()
|
|||
|
|
await fetchPage()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** The reference cleared the list and re-queried on tab change (index.vue:178). */
|
|||
|
|
async function changeTab(key: '1' | '2') {
|
|||
|
|
if (activeKey.value === key) return
|
|||
|
|
activeKey.value = key
|
|||
|
|
await reload()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function onRefresh() {
|
|||
|
|
await reload()
|
|||
|
|
refreshing.value = false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** The «جزئیات» link — the reference opened the same card in a modal. */
|
|||
|
|
async function openDetail(item: ReportRow) {
|
|||
|
|
modalOpen.value = true
|
|||
|
|
modalLoading.value = true
|
|||
|
|
modalRows.value = []
|
|||
|
|
try {
|
|||
|
|
const data = await api.getReportDocument(item.factor_Code)
|
|||
|
|
modalRows.value = data.result ?? []
|
|||
|
|
} catch {
|
|||
|
|
modalRows.value = []
|
|||
|
|
} finally {
|
|||
|
|
modalLoading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// The reference paged by scroll on the detail tab (index.vue:231-241).
|
|||
|
|
const sentinel = ref<HTMLElement | null>(null)
|
|||
|
|
let observer: IntersectionObserver | null = null
|
|||
|
|
|
|||
|
|
onMounted(async () => {
|
|||
|
|
await fetchPage()
|
|||
|
|
if (typeof IntersectionObserver !== 'undefined') {
|
|||
|
|
observer = new IntersectionObserver((entries) => {
|
|||
|
|
if (entries.some((e) => e.isIntersecting)) void fetchPage()
|
|||
|
|
})
|
|||
|
|
if (sentinel.value) observer.observe(sentinel.value)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
onBeforeUnmount(() => observer?.disconnect())
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped lang="less">
|
|||
|
|
.rep__tabs {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 0 16px 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rep__tab {
|
|||
|
|
flex: 1;
|
|||
|
|
padding: 10px;
|
|||
|
|
border: 1px solid @hairline;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
background: @canvas;
|
|||
|
|
font-family: inherit;
|
|||
|
|
font-size: 13px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: @inkMuted;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rep__tab.is-on {
|
|||
|
|
background: @ink;
|
|||
|
|
border-color: @ink;
|
|||
|
|
color: #fff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rep__body {
|
|||
|
|
padding-top: 132px;
|
|||
|
|
padding-bottom: 90px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rep__list {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
padding-top: 16px;
|
|||
|
|
gap: 11px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rep__card {
|
|||
|
|
padding: 16px;
|
|||
|
|
background: @canvas;
|
|||
|
|
border: 1px solid @hairline;
|
|||
|
|
border-radius: 18px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rep__head {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 10px;
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
font-size: 13px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rep__muted {
|
|||
|
|
color: @inkMuted;
|
|||
|
|
font-weight: 500;
|
|||
|
|
margin-left: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rep__pair {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
padding: 9px 12px;
|
|||
|
|
margin-bottom: 6px;
|
|||
|
|
background: @canvasSoft;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rep__pair.is-balance {
|
|||
|
|
background: @hairlineSoft;
|
|||
|
|
font-weight: 700;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rep__stack {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: flex-end;
|
|||
|
|
gap: 2px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.is-in {
|
|||
|
|
color: #16a34a;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.is-out {
|
|||
|
|
color: #dc2626;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rep__more,
|
|||
|
|
.rep__sentinel {
|
|||
|
|
padding: 12px 0;
|
|||
|
|
text-align: center;
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: @inkMuted;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rep__more {
|
|||
|
|
width: 100%;
|
|||
|
|
margin-top: 4px;
|
|||
|
|
border: none;
|
|||
|
|
background: transparent;
|
|||
|
|
font-family: inherit;
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: @wine;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rep__empty {
|
|||
|
|
margin-top: 8px;
|
|||
|
|
padding: 34px 20px;
|
|||
|
|
border: 1px dashed @hairline;
|
|||
|
|
border-radius: 18px;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 10px;
|
|||
|
|
color: @inkMuted;
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rep__retry {
|
|||
|
|
padding: 8px 18px;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
border: 1px solid @hairline;
|
|||
|
|
background: @canvas;
|
|||
|
|
font-family: inherit;
|
|||
|
|
font-size: 12px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rep__modal {
|
|||
|
|
padding: 20px 16px 24px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rep__modalEmpty {
|
|||
|
|
padding: 24px 0;
|
|||
|
|
text-align: center;
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: @inkMuted;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rep__close {
|
|||
|
|
margin-top: 18px;
|
|||
|
|
width: 100%;
|
|||
|
|
padding: 13px;
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 14px;
|
|||
|
|
background: @ink;
|
|||
|
|
color: #fff;
|
|||
|
|
font-family: inherit;
|
|||
|
|
font-size: 14px;
|
|||
|
|
font-weight: 700;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
</style>
|