Files
tehran_tala/apps/user-app/src/views/report/ReportView.vue
T
s.esmaeili f441ca8289 feat(user-apps): appearance settings (dark mode + reduced motion), product morph, filter sheet
Applies to both user-app and user-app-v2. Logic and structure are shared;
each app keeps its own look.

Appearance settings
- New "تنظیمات ظاهری" page (/settings/appearance), opened from the
  dashboard and available without login. It offers a light/dark/system
  theme and a "fast & light" motion switch.
- appearanceStore saves the choice to localStorage. A small inline boot
  script in index.html applies it before first paint so there is no
  white flash. The "system" option follows OS changes live.
- New ToggleSwitch component.

Dark mode
- Theme LESS tokens now compile to CSS custom properties. Light/dark
  values come from a generated virtual:palette.css (themeConfig/
  appearance.ts, with colors.json + new colors.dark.json).
- New LESS alpha() plugin replaces fade() on theme tokens.
- New semantic tokens: page, onInk, heroSurface, hairlineStrong,
  photoMat, photoStage.
- The dark palette is layered (page < card < panel < border) so
  surfaces separate without shadows.
- Hard-coded colors were replaced by tokens according to their role.
- antd inputs/text get dark overrides; vant uses its own
  .van-theme-dark.
- Light mode looks the same as before.

Reduced motion
- With html.motion-reduced, transitions are capped at 120ms, keyframe
  loops stop, and backdrop blurs and permanent will-change are dropped.
- Spinners and the toast progress bar (which closes the toast) are
  exempt via .keep-motion.
- Carousel autoplay, the v1 Lottie and the product morph honor the
  setting.

Product detail
- Opening a product is now a container-transform morph from the tapped
  card (ProductMorph). It replaces the bottom Drawer and uses composited
  transform/opacity only.
- The detail view is seeded with the card's data, so it no longer
  opens blank and jumps.

Filters and sheets
- Filters are now a full-width bottom sheet. Selections stay a draft
  until applied, so the result list no longer re-renders while
  dragging the slider.
- DropDown collapse animates grid-template-rows instead of a capped
  max-height.
- v2 IslandSheet is restyled to the v2 design language (API unchanged).

Fixes
- filtersStore.reset() shallow-copied shared arrays, so clearing the
  filters never cleared the selected brands.
2026-10-04 16:18:56 +03:30

438 lines
12 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
"گزارش" — 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: @primary;
border-color: @primary;
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: @green;
}
.is-out {
color: @red;
}
.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: @primary;
color: #fff;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
</style>