Files
tehran_tala/apps/user-app-v2/src/views/user/DashboardView.vue
T
s.esmaeiliandClaude Opus 5 f71fd5bfe1 feat(apps): sales-expert screens in both apps + v2 admin home slider (D21, APP-02)
Both expert screens are built in user-app and user-app-v2, with byte-identical
template and script and a per-app style block only — verified by diffing
everything above <style>:

  ChildOrdersView  "سفارش مشتریان"  — legacy ChildOrders.vue. Keeps the
    reference's title, search, infinite paging, and per-card item count, total
    weight, customer name and the fixed «تحویل مسئول فروش» chip (the endpoint
    only returns that status, ApiMobileController:766).
  CustomerChoiceView "لیست مشتریان" — legacy customerChoice.vue. Keeps its
    title, per-search re-query and the reference's choice to LIST inactive
    customers (its active() scope is commented out at :187).

Reachable from inside the app, not just by URL: routes carry the reference's own
paths and names, the dashboard grows the two entries only for an expert (v1 as
tiles, v2 as its own rows), and a router guard sends an ordinary customer back
to their dashboard. None of that is relied on for safety — the server scopes
both endpoints on the AdminUser id in the token and answers 403 otherwise.

Not ported, both recorded in D21: the act-as api_token minting (:197-201) and
the child-order edit drawer, which recomputed an order's money from client-sent
values. The card opens a read-only summary instead.

APP-02 fixed: user-app-v2 gained getHomeSlider and now prefers admin-configured
slides, keeping its category-derived hero as the fallback and its own design.

Session stores stay byte-identical across the apps.
build 5/5, jest 45/45, expert login 36/36, apps smoke 43/43, typecheck clean.

[ref: henza_pwa/src/views/user/src/components/ChildOrders.vue, customerChoice.vue]
[ref: henza_pwa/src/router/index.ts:171-188]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:12:53 +03:30

602 lines
18 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="#8A909C" />
</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="#8A909C" />
</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="#8A909C" />
</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="#8A909C" />
</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="#8A909C" />
</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="#8A909C" />
</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>
<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-[#1C2D56]" @click="logOut">بله</div>
<div class="text-center text-[#6b7280]" @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-[#1C2D56]" @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, nextTick, 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 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 { 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 })
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' })
}
function logOut() {
session.logout()
data.notify = false
router.push({ name: 'home' })
}
async function loadDashboard() {
if (session.isLoggedIn) {
customer.value = await api.getMe()
}
try {
goldPrice.value = await api.getLatestGoldPrice()
} catch {
goldPrice.value = null
}
}
async function onRefresh() {
await loadDashboard()
refreshing.value = false
}
// 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: @canvas;
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: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(18px) saturate(140%);
border-bottom: 1px solid fade(@primary, 5%);
box-shadow: 0 8px 20px -18px fade(@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, @ink 0%, @ink 55%, @wineDeep 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: @ink;
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: @ink;
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: @ink;
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: fade(@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>