Files
tehran_tala/apps/user-app/src/views/user/DashboardView.vue
T
s.esmaeiliandClaude Opus 5.5 1bc3739e78 Merge release/delivery (IAM single sign-on) into the triage work
Brings the four release/delivery commits (IAM SSO for the admin panel,
both apps and the API; IAM env in docker-compose) onto fix/issues-triage.

Conflicts:
- customer-auth.service.ts imports: both kept.
- both apps' api/client.ts: both kept (AccountingBalance/WageQuote, and
  iamAppUrl).
- all build output (admin-panel, user-app, user-app-v2 dist, and the api
  release bundle): rebuilt from the merged source instead of picking a
  side.

Fixed while verifying:
- The IAM change calls iam.browserBootstrapUrl after every login, but
  the identity-service stand-ins in three test files lacked it. 15 tests
  failed on release/delivery itself.
- SMS relay: SMS_RELAY_PLATFORM was read but never sent, so the gateway
  never got `platform` (documented body: phone, host, text, platform).
  relayConfig() now returns it ("default" → null) and the request carries
  it. sms.service.spec now passes.

All 249 API tests pass; the three front-ends type-check and build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:03:53 +03:30

551 lines
16 KiB
Vue

<!--
Ported from legacy henza_pwa/src/views/user/src/components/Dashboard.vue,
restyled into the redesigned design language (see components/home/* and
HomeView). The live rate block now reuses components/home/RateStrip.vue
rather than re-implementing the same strip a second time.
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, shown as one strip
rather than fabricating rows that don't exist.
-->
<template>
<div class="dash">
<header class="dash__bar">
<div class="dash__barInner">
<span class="dash__barSpacer" aria-hidden="true" />
<h1 class="dash__barTitle">میز کار</h1>
<button
v-if="session.isLoggedIn"
type="button"
class="dash__barBtn"
aria-label="خروج"
@click="data.notify = true"
>
<Icon icon="vuesax-linear:logout" size="19" />
</button>
<span v-else class="dash__barSpacer" aria-hidden="true" />
</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 -->
<template v-if="customer">
<section class="dash__balance reveal" :style="revealDelay(0)">
<div class="dash__balanceHead">
<div>
<div class="dash__eyebrow">{{ remainText }}</div>
<div class="dash__figure">
{{ grams }}
<span class="dash__unit">گرم</span>
</div>
</div>
<span class="dash__seal" :class="isDebtor ? 'is-debtor' : 'is-creditor'">
{{ isDebtor ? 'بدهکار' : 'بستانکار' }}
</span>
</div>
<div class="dash__balanceRow">
<span class="dash__rowLabel">بدهی ریالی</span>
<span class="dash__rowValue">{{ price(customer.fiatDebtRial) }} <i>ریال</i></span>
</div>
<div class="dash__identity">
<span class="dash__name">{{ customer.fullName }}</span>
<span class="dash__mobile">{{ session.mobile }}</span>
</div>
</section>
</template>
<!-- Signed out: a quiet invitation, no fabricated numbers -->
<section v-else class="dash__invite reveal" :style="revealDelay(0)">
<div class="dash__inviteIcon" aria-hidden="true">
<Icon icon="vuesax-linear:security-user" size="20" />
</div>
<div class="dash__inviteCopy">
<h3>حساب گرمی شما</h3>
<p>برای دیدن موجودی، سفارش‌ها و اسناد خود وارد شوید.</p>
</div>
<button type="button" class="dash__inviteCta" @click="router.push({ name: 'login' })">
ورود / ثبت‌نام
</button>
</section>
<!-- Nav grid -->
<section class="dash__section reveal" :style="revealDelay(1)">
<SectionHead title="دسترسی‌ها" subtitle="بخش‌های حساب شما" />
<div class="dash__grid">
<button
v-for="item in list"
:key="item.id"
type="button"
class="dash__tile"
:class="{ 'is-disabled': item.disabled }"
@click="goToDetails(item.id)"
>
<span class="dash__tileIcon" aria-hidden="true">
<Icon :icon="item.icon" size="20" />
</span>
<span class="dash__tileTitle">{{ item.title }}</span>
</button>
</div>
</section>
<!-- Live 18k rate — same strip the home screen uses -->
<section class="dash__section reveal" :style="revealDelay(2)">
<SectionHead title="نرخ روز" subtitle="آخرین نرخ اعلام‌شده" />
<RateStrip :quote="goldPrice" />
</section>
</container>
</PullRefresh>
<DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="exitImage" alt="" class="opacity-70 mb-8" />
</template>
<template v-slot:title>آیا می خواهید از برنامه خارج شوید؟</template>
<template v-slot:button>
<div class="text-center text-[color:var(--c-primary)]" @click="logOut">بله</div>
<div class="text-center text-[color:var(--c-inkMuted)]" @click="data.notify = false">بی خیال</div>
</template>
</DialogVant>
<DialogVant :timeOut="-1" v-model:show="data.loginNotify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" />
</template>
<template v-slot:title>
<div class="p-8 text-justify">
برای دسترسی به تمامی امکانات نرم افزار منتزر، ابتدا گزینه ورود/ثبت نام را لمس کنید و در
صورت نداشتن حساب کاربری، ثبت نام نمایید.
</div>
</template>
<template v-slot:button>
<div class="text-center text-[color:var(--c-primary)]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center text-danger" @click="data.loginNotify = false">بیخیال</div>
</template>
</DialogVant>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import RateStrip from '../../components/home/RateStrip.vue'
import DialogVant from '../../components/DialogVant.vue'
import exitImage from '../../assets/img/empty/dialogEmpty5.svg'
import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg'
import { price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { useReveal } from '../../composables/useReveal'
import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
const router = useRouter()
const session = useSessionStore()
const customer = ref<CustomerDetail | null>(null)
const goldPrice = ref<GoldPriceQuote | null>(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 })
const BASE_TILES = [
{ id: 0, icon: 'vuesax-linear:box', title: 'سفارشات', disabled: false },
{ id: 1, icon: 'vuesax-linear:receipt-text', title: 'اسناد', disabled: false },
{ id: 2, icon: 'vuesax-linear:user', title: 'پروفایل', disabled: false },
{ id: 3, icon: 'vuesax-linear:heart', title: 'علاقمندی', disabled: false },
// 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.
{ id: 6, icon: 'vuesax-linear:chart-2', title: 'گزارش', disabled: false },
]
/**
* D21 — the two sales-expert entries, appended only for an expert. 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.
*/
const EXPERT_TILES = [
{ id: 4, icon: 'vuesax-linear:box-tick', title: 'سفارش مشتریان', disabled: false },
{ id: 5, icon: 'vuesax-linear:profile-2user', title: 'مشتریان من', disabled: false },
]
// Appearance (theme + reduced motion) — always last, and open to everyone:
// device preferences, not account data. See goToDetails.
const APPEARANCE_TILE = { id: 7, icon: 'vuesax-linear:brush', title: 'ظاهر', disabled: false }
const list = computed(() => [
...BASE_TILES,
...(session.isSalesExpert ? EXPERT_TILES : []),
APPEARANCE_TILE,
])
function goToDetails(id: number) {
// The only tile that needs no login.
if (id === 7) {
router.push({ name: 'appearanceSettings' })
return
}
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() {
if (session.isLoggedIn) {
// 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.
try {
customer.value = await api.getMe()
} catch (e) {
console.log(e)
}
}
try {
goldPrice.value = await api.getLatestGoldPrice()
} catch {
goldPrice.value = null
}
}
async function onRefresh() {
await loadDashboard()
refreshing.value = false
}
// First-paint entrance, the same choreography HomeView uses: played once,
// after the first load; pull-to-refresh and tab returns don't replay it.
const { revealed, revealDelay, reveal } = useReveal('dashboard')
onMounted(async () => {
await loadDashboard()
reveal()
})
</script>
<style scoped lang="less">
.dash {
background: @page;
min-height: 100vh;
}
.dash__bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: alpha(@canvas, 88%);
backdrop-filter: blur(14px);
border-bottom: 1px solid @hairline;
}
.dash__barInner {
max-width: 768px;
margin: 0 auto;
padding: 11px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dash__barTitle {
margin: 0;
font-size: 14.5px;
font-weight: 800;
color: @ink;
}
.dash__barBtn {
flex: 0 0 auto;
width: 38px;
height: 38px;
border: 0;
cursor: pointer;
border-radius: 12px;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
}
.dash__barBtn:active {
transform: scale(0.94);
}
.dash__barSpacer {
display: block;
width: 38px;
height: 38px;
}
.dash__body {
padding-top: 76px;
padding-bottom: 90px;
}
.dash__balance {
padding: 16px;
border-radius: 20px;
background: @primary;
color: #fff;
box-shadow: 0 14px 30px -22px rgba(20, 22, 26, 0.95);
}
.dash__balanceHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.dash__eyebrow {
font-size: 11.5px;
color: rgba(255, 255, 255, 0.62);
}
.dash__figure {
padding-top: 5px;
font-size: 25px;
font-weight: 800;
letter-spacing: -0.3px;
}
.dash__unit {
font-size: 11.5px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
padding-inline-start: 4px;
}
.dash__seal {
flex: 0 0 auto;
padding: 5px 12px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
}
.dash__seal.is-debtor {
color: #fff;
background: @wineSoft;
}
.dash__seal.is-creditor {
color: #14161A;
background: rgba(255, 255, 255, 0.9);
}
.dash__balanceRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 16px;
padding-top: 13px;
border-top: 1px solid rgba(255, 255, 255, 0.13);
}
.dash__rowLabel {
font-size: 11.5px;
color: rgba(255, 255, 255, 0.62);
}
.dash__rowValue {
font-size: 13.5px;
font-weight: 700;
}
.dash__rowValue i {
font-style: normal;
font-size: 10.5px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
padding-inline-start: 3px;
}
.dash__identity {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 13px;
padding-top: 13px;
border-top: 1px solid rgba(255, 255, 255, 0.13);
}
.dash__name {
font-size: 12.5px;
font-weight: 700;
}
.dash__mobile {
font-size: 11.5px;
color: rgba(255, 255, 255, 0.62);
direction: ltr;
}
.dash__invite {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid @hairline;
border-radius: 20px;
background: @canvasSoft;
}
.dash__inviteIcon {
flex: 0 0 42px;
width: 42px;
height: 42px;
border-radius: 50%;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
}
.dash__inviteCopy {
flex: 1;
min-width: 0;
}
.dash__inviteCopy h3 {
margin: 0;
font-size: 13px;
font-weight: 800;
color: @ink;
}
.dash__inviteCopy p {
margin: 4px 0 0;
font-size: 11.5px;
color: @inkFaint;
}
.dash__inviteCta {
flex: 0 0 auto;
padding: 9px 14px;
border: 0;
cursor: pointer;
border-radius: 999px;
font-family: inherit;
font-size: 11.5px;
font-weight: 700;
color: #fff;
background: @primary;
}
.dash__section {
margin-top: 26px;
}
.dash__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 9px;
}
.dash__tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 14px 6px;
border: 1px solid @hairline;
border-radius: 16px;
cursor: pointer;
background: @canvas;
font-family: inherit;
transition: transform 0.18s ease;
}
.dash__tile:active {
transform: scale(0.97);
}
.dash__tile.is-disabled {
opacity: 0.4;
}
.dash__tileIcon {
width: 38px;
height: 38px;
border-radius: 12px;
display: grid;
place-items: center;
color: @inkSoft;
background: @hairlineSoft;
}
.dash__tileTitle {
font-size: 11px;
font-weight: 700;
color: @inkSoft;
}
</style>