Files
tehran_taj/apps/user-app-v2/src/views/user/DashboardView.vue
T

649 lines
21 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.
<!--
Ported from legacy henza_pwa/src/views/user/src/components/Dashboard.vue.
Legacy's original (820 lines) is a multi-role workbench mixing
plain-customer, sales-agent ("reagent") and "banker" (type=1, sub-account
switching) views. Kept: the real balance card, the nav grid to
Orders/Documents/Profile/Favorites, and the logout/login-nudge dialogs —
all backed by real data.
DROPPED as architecturally incompatible, not merely deferred: everything
tied to legacy's `Customer.type`/multi-account model — "انتخاب مشتری"
(toggleUser/hasActiveChild), "سفارش مشتریان" (ChildOrders) and
customerChoice. Sales agents are AdminUser records with no customer-facing
session at all, and there is no `type` field on our Customer model, so the
data model this UI assumed no longer exists.
DROPPED as no backend exists: "سفارش ساخت" (custom/pre-order manufacturing)
— PreOrder has a DB table but no controller anywhere in apps/api.
REPLACED, not dropped: legacy's "گزارش حساب" called out to the external
Tahesab accounting service, which the first migration audit retired in
favour of this system's own internal ledger — the balance card below shows
that real internal position directly.
DROPPED: the multi-row world gold/currency ticker (legacy's GET /price, ~14
rows). The new API has one single-karat quote endpoint, folded into the
bottom rate row rather than fabricating rows that don't exist.
Second visual pass — a deliberately different structure from the previous
"card + 4-cell grid" layout, while staying in the same visual language as
HomeView (same bar treatment, same reveal choreography, same @ink/@wine
palette — no gold/bronze; this app's identity is black + burgundy only):
1. a bare, logo-height bar (no title chrome), matching Home's bar rhythm
2. one full hero card: balance figure + identity, with quick actions
built INTO the card itself as an icon row on its own dark surface —
replacing the old "card, then a separate white 4-cell grid below it"
split
3. "اسناد" / "علاقمندی" as wide settings-style rows with a chevron,
replacing the grid tiles those two also occupied
4. the live rate folded into a single slim inline row at the foot of the
page instead of a titled section
Data, routes, dialogs and auth behaviour are all unchanged from before —
only markup structure and styling moved.
-->
<template>
<div class="dash">
<header class="dash__bar">
<div class="dash__barInner">
<span class="dash__brand">میز کار</span>
<button
v-if="session.isLoggedIn"
type="button"
class="dash__exit"
aria-label="خروج"
@click="data.notify = true"
>
<Icon icon="vuesax-linear:logout" size="18" />
</button>
</div>
</header>
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<container class="dash__body" :class="{ 'is-revealed': revealed }">
<!-- Signed in: real position from GET /customers/:id, quick actions
built into the same dark surface as the balance itself. -->
<section v-if="customer" class="dash__hero dash__reveal" :style="revealDelay(0)">
<div class="dash__heroTop">
<div class="dash__heroIdentity">
<span class="dash__heroName">{{ customer.fullName }}</span>
<span class="dash__heroMobile">{{ session.mobile }}</span>
</div>
<span class="dash__heroSeal" :class="isDebtor ? 'is-debtor' : 'is-creditor'">
{{ isDebtor ? 'بدهکار' : 'بستانکار' }}
</span>
</div>
<div class="dash__heroFigure">
<span class="dash__heroNum">{{ grams }}</span>
<span class="dash__heroUnit">گرم {{ remainText }}</span>
</div>
<div class="dash__heroDebt">
<span>بدهی ریالی</span>
<b>{{ price(customer.fiatDebtRial) }} <i>ریال</i></b>
</div>
</section>
<!-- Signed out: a quiet invitation, no fabricated numbers -->
<section v-else class="dash__invite dash__reveal" :style="revealDelay(0)">
<div class="dash__inviteIcon" aria-hidden="true">
<Icon icon="vuesax-linear:security-user" size="22" color="#fff" />
</div>
<h3>حساب گرمی شما</h3>
<p>برای دیدن موجودی، سفارش‌ها و اسناد خود وارد شوید.</p>
<button type="button" class="dash__inviteCta" @click="router.push({ name: 'login' })">
ورود / ثبت‌نام
</button>
</section>
<!-- Settings-style rows: every destination the hero no longer carries
as its own quick-action row. -->
<section class="dash__rows dash__reveal" :style="revealDelay(1)">
<button type="button" class="dash__row" @click="goToDetails(0)">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:box" size="18" />
</span>
<span class="dash__rowText">
<b>سفارشات</b>
<small>پیگیری سفارش‌های شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
</button>
<button type="button" class="dash__row" @click="goToDetails(1)">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:receipt-text" size="18" />
</span>
<span class="dash__rowText">
<b>اسناد</b>
<small>تراکنش‌ها و رسیدهای حساب</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
</button>
<button type="button" class="dash__row" @click="goToDetails(2)">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:user" size="18" />
</span>
<span class="dash__rowText">
<b>پروفایل</b>
<small>اطلاعات و تنظیمات حساب</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
</button>
<button type="button" class="dash__row" @click="goToDetails(3)">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:heart" size="18" />
</span>
<span class="dash__rowText">
<b>علاقمندی</b>
<small>محصولات ذخیره‌شده شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
</button>
<!--
D24 — the account statement. The reference put it on the dashboard
too, gated on hasMCode() (Dashboard.vue:179, :449). The target has no
mcode, so every signed-in customer sees it and the server scopes it
to their token.
-->
<button type="button" class="dash__row" @click="goToDetails(6)">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:chart-2" size="18" />
</span>
<span class="dash__rowText">
<b>گزارش</b>
<small>گردش حساب طلایی و ریالی شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
</button>
<!-- Appearance (theme + reduced motion). Device-level preferences, not
account data, so — unlike every row above — it needs no login. -->
<button type="button" class="dash__row" @click="router.push({ name: 'appearanceSettings' })">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:brush" size="18" />
</span>
<span class="dash__rowText">
<b>تنظیمات ظاهری</b>
<small>پوسته روشن و تیره، انیمیشن‌ها</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
</button>
<!--
D21 — the two sales-expert entries, rendered only for an expert, in
v2's own row style. The reference showed them the same way, gated on
the agent's own account (Dashboard.vue:117-125). An ordinary customer
never sees them, and the server refuses both routes for a non-expert
token regardless of what the app renders.
-->
<template v-if="session.isSalesExpert">
<button type="button" class="dash__row" @click="goToDetails(4)">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:box-tick" size="18" />
</span>
<span class="dash__rowText">
<b>سفارش مشتریان</b>
<small>سفارش‌های مشتریان معرفی‌شده شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
</button>
<button type="button" class="dash__row" @click="goToDetails(5)">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:profile-2user" size="18" />
</span>
<span class="dash__rowText">
<b>مشتریان من</b>
<small>فهرست مشتریان معرفی‌شده شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
</button>
</template>
</section>
<!-- Live 18k rate — a single inline row, not a titled section. -->
<RateStrip :quote="goldPrice" class="dash__rate dash__reveal" :style="revealDelay(2)" />
</container>
</PullRefresh>
<IslandSheet
v-model:show="data.notify"
art="logout"
title="آیا می خواهید از برنامه خارج شوید؟"
description="برای استفاده دوباره از امکانات منتزر، باید دوباره وارد حساب کاربری خود شوید."
confirm-text="بله"
cancel-text="بی خیال"
@confirm="logOut"
/>
<IslandSheet
v-model:show="data.loginNotify"
art="login"
title="ابتدا وارد حساب کاربری شوید"
description="برای دسترسی به تمامی امکانات نرم افزار منتزر، گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید."
confirm-text="ورود/ثبت نام"
cancel-text="بیخیال"
@confirm="router.push({ name: 'login' })"
/>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
import RateStrip from '../../components/home/RateStrip.vue'
import IslandSheet from '../../components/ui/IslandSheet.vue'
import { price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const router = useRouter()
const session = useSessionStore()
const reads = useCachedReads()
const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
const refreshing = ref(false)
// The ledger stores the gram position as a signed figure — positive means the
// customer owes gold. Labelled the same way AccountStrip does on the home
// screen so the two never disagree.
const isDebtor = computed(() => (customer.value ? parseFloat(customer.value.goldDebtGrams) > 0 : false))
const remainText = computed(() => (isDebtor.value ? 'بدهکار' : 'بستانکار'))
const grams = computed(() =>
customer.value ? Math.abs(parseFloat(customer.value.goldDebtGrams)) : 0,
)
const data = reactive({ notify: false, loginNotify: false })
function goToDetails(id: number) {
if (!session.isLoggedIn) {
data.loginNotify = true
return
}
if (id === 0) router.push({ name: 'ordersList' })
if (id === 1) router.push({ name: 'transactionsList' })
if (id === 2) router.push({ name: 'profileData' })
if (id === 3) router.push({ name: 'favoritesList' })
if (id === 4) router.push({ name: 'ChildOrder' })
if (id === 5) router.push({ name: 'customerChoice' })
if (id === 6) router.push({ name: 'report' })
}
async function logOut() {
data.notify = false
let central: string | null = null
try {
central = (await api.ssoLogoutUrl()).logout_url
} catch {
central = null
}
session.logout()
sessionStorage.removeItem('gb_iam_sso_checked')
if (central) {
window.location.assign(central)
return
}
router.push({ name: 'home' })
}
async function loadDashboard(force = false) {
// Not wrapped before: a failure here (expired token, network blip) left
// the exception uncaught, so onRefresh's refreshing.value = false never
// ran and PullRefresh stayed stuck in its loading state — a working pull
// gesture never dismissed it again until a full page reload.
const [me, price] = await Promise.allSettled([
// The balance on this tab is the one figure read fresh on every visit;
// the cached copy only fills the screen until it arrives.
session.isLoggedIn ? reads.me({ force: true }) : Promise.resolve(null),
reads.goldPrice({ force }),
])
if (me.status === 'fulfilled') customer.value = me.value
else console.log(me.reason)
goldPrice.value = price.status === 'fulfilled' ? price.value : null
}
async function onRefresh() {
await loadDashboard(true)
refreshing.value = false
}
let activatedOnce = false
onActivated(() => {
// Kept alive by MasterLayout, so every later visit lands here, not in onMounted.
if (activatedOnce) void loadDashboard()
activatedOnce = true
})
// First-paint entrance, mirroring HomeView's choreography exactly (module-scope
// flag so it only replays on a full app reload, not on every tab switch).
let hasRevealed = false
const revealed = ref(hasRevealed)
const REVEAL_STEP_MS = 70
function revealDelay(i: number) {
return { '--reveal-delay': `${i * REVEAL_STEP_MS}ms` }
}
onMounted(async () => {
await loadDashboard()
if (revealed.value) return
await nextTick()
requestAnimationFrame(() => {
revealed.value = true
hasRevealed = true
})
})
</script>
<style scoped lang="less">
.dash {
background: @page;
min-height: 100vh;
}
// A bare bar, sized like Home's, but with no border/title-bar chrome — the
// hero card below carries the page's identity instead of a header label.
.dash__bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: alpha(@canvas, 82%);
backdrop-filter: blur(18px) saturate(140%);
border-bottom: 1px solid alpha(@primary, 5%);
box-shadow: 0 8px 20px -18px alpha(@primary, 55%);
}
.dash__barInner {
max-width: 768px;
margin: 0 auto;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dash__brand {
font-size: 14.5px;
font-weight: 800;
color: @ink;
}
.dash__exit {
flex: 0 0 auto;
width: 34px;
height: 34px;
border: 0;
cursor: pointer;
border-radius: 11px;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
transition: transform 0.18s ease;
}
.dash__exit:active {
transform: scale(0.92);
}
.dash__body {
padding-top: 58px;
padding-bottom: 90px;
}
.dash__reveal {
opacity: 0;
transform: translate3d(0, 14px, 0) scale(0.98);
will-change: transform, opacity;
}
.is-revealed .dash__reveal {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
transition:
opacity 0.62s cubic-bezier(0.32, 0.72, 0, 1) var(--reveal-delay, 0ms),
transform 0.62s cubic-bezier(0.32, 0.72, 0, 1) var(--reveal-delay, 0ms);
}
@media (prefers-reduced-motion: reduce) {
.dash__reveal {
opacity: 1;
transform: none;
}
.is-revealed .dash__reveal {
transition: none;
}
}
// ── Hero ────────────────────────────────────────────────────────────────
// One full surface: identity, balance and quick actions all on the same
// ink → wine gradient, instead of a dark card handing off to a separate
// white grid underneath it.
.dash__hero {
margin-top: 16px;
margin-bottom: 28px;
padding: 20px 18px 16px;
border-radius: 26px;
background: linear-gradient(155deg, @heroSurface 0%, @heroSurface 55%, #5E1427 130%);
color: #fff;
box-shadow: 0 22px 40px -26px rgba(20, 22, 26, 0.32);
}
.dash__heroTop {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.dash__heroIdentity {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.dash__heroName {
font-size: 13.5px;
font-weight: 800;
}
.dash__heroMobile {
font-size: 11px;
color: rgba(255, 255, 255, 0.55);
direction: ltr;
text-align: right;
}
.dash__heroSeal {
flex: 0 0 auto;
padding: 5px 12px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
}
.dash__heroSeal.is-debtor {
color: #fff;
background: @wineSoft;
}
.dash__heroSeal.is-creditor {
color: #14161A;
background: rgba(255, 255, 255, 0.9);
}
.dash__heroFigure {
margin-top: 20px;
display: flex;
align-items: baseline;
gap: 8px;
}
.dash__heroNum {
font-size: 34px;
font-weight: 800;
letter-spacing: -0.5px;
}
.dash__heroUnit {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
}
.dash__heroDebt {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding-top: 13px;
border-top: 1px solid rgba(255, 255, 255, 0.14);
font-size: 11.5px;
color: rgba(255, 255, 255, 0.62);
}
.dash__heroDebt b {
font-size: 13px;
font-weight: 700;
color: #fff;
}
.dash__heroDebt i {
font-style: normal;
font-size: 10.5px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
padding-inline-start: 3px;
}
// ── Signed-out invite ──────────────────────────────────────────────────
.dash__invite {
margin-top: 16px;
padding: 26px 20px;
border-radius: 26px;
background: @heroSurface;
color: #fff;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 10px;
}
.dash__inviteIcon {
width: 46px;
height: 46px;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.1);
margin-bottom: 2px;
}
.dash__invite h3 {
margin: 0;
font-size: 14px;
font-weight: 800;
}
.dash__invite p {
margin: 0;
font-size: 11.5px;
color: rgba(255, 255, 255, 0.6);
max-width: 240px;
}
.dash__inviteCta {
margin-top: 6px;
padding: 10px 22px;
border: 0;
cursor: pointer;
border-radius: 999px;
font-family: inherit;
font-size: 12px;
font-weight: 700;
color: #14161A;
background: #fff;
transition: transform 0.18s ease;
}
.dash__inviteCta:active {
transform: scale(0.96);
}
// ── Settings-style rows ────────────────────────────────────────────────
.dash__rows {
margin-top: 0;
display: flex;
flex-direction: column;
gap: 9px;
}
.dash__row {
display: flex;
align-items: center;
gap: 12px;
padding: 13px 14px;
border: 1px solid @hairline;
border-radius: 17px;
cursor: pointer;
background: @canvas;
font-family: inherit;
text-align: right;
transition: transform 0.18s ease, border-color 0.18s ease;
}
.dash__row:active {
transform: scale(0.985);
border-color: alpha(@wine, 20%);
}
.dash__rowIcon {
flex: 0 0 auto;
width: 38px;
height: 38px;
border-radius: 12px;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
}
.dash__rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.dash__rowText b {
font-size: 12.5px;
font-weight: 700;
color: @ink;
}
.dash__rowText small {
font-size: 10.5px;
color: @inkFaint;
}
// ── Rate row ───────────────────────────────────────────────────────────
.dash__rate {
margin-top: 14px;
}
</style>