feat: initial commit

This commit is contained in:
s.esmaeili
2026-09-09 06:46:27 +03:30
commit 0ed6bed028
214 changed files with 20177 additions and 0 deletions
@@ -0,0 +1,192 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/Dashboard.vue —
the most heavily adapted view in this port so far. 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. In the redesigned schema (see the original migration
audit), sales agents are AdminUser records with no customer-facing session
at all — there is no "type" field on our Customer model, so there is
nothing to adapt here; the underlying 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. Shown as
a disabled tile rather than a broken link.
REPLACED, not dropped: legacy's "گزارش حساب" van-action-sheet called out to
the external Tahesab accounting service (hesabMe()) that the very first
migration audit explicitly decided to retire in favor of this system's own
internal ledger — this dashboard's balance card already shows that real,
internal balance directly, so there's no separate popup needed.
DROPPED: the multi-row world gold/currency price ticker (legacy's GET
/price, ~14 rows). The new API has one single-karat quote endpoint
(GET /pricing/latest) — shown as one line instead of fabricating rows that
don't exist.
-->
<template>
<div class="fixed top-0 z-10 w-full">
<AppHeader>
<div class="flex justify-between items-center">
<div></div>
<h3 class="text-white bold mb-0 text-center w-full">میز کار</h3>
<Icon v-if="session.customerId" icon="vuesax-linear:logout" @click="data.notify = true" class="cursor-pointer text-white absolute left-5" size="25" />
</div>
</AppHeader>
</div>
<div class="h-16"></div>
<container class="overflow-y-scroll pt-4">
<div v-if="session.customerId">
<div class="rounded-[10px] bg-[#F9F9F9] p-[16px] w-full mb-[16px] border-[#E5E7EB] border border-solid">
<div class="grid grid-cols-5 items-center pb-2 mb-2 border-b-2 border-solid border-[#E5E7EB]">
<h5 class="font-bold col-span-2 mb-0">{{ customer?.fullName }}</h5>
</div>
<div class="flex justify-between">
<div>شماره تلفن همراه</div>
<div>{{ session.mobile }}</div>
</div>
</div>
</div>
<div v-else>
<div class="rounded-[10px] bg-[#F9F9F9] p-[16px] w-full mb-[16px] border-[#E5E7EB] border border-solid">
<div class="text-center font-bold">برای استفاده از امکانات نرم افزار ابتدا وارد شوید</div>
<div class="flex w-full justify-center mt-2" @click="router.push({ name: 'login' })">
<Button type="primary" class="w-[17rem]">ورود/ثبت نام</Button>
</div>
</div>
</div>
<div class="rounded-[10px] bg-light p-[16px] w-full mb-[16px] bg-[#F9F9F9]" v-if="customer">
<div class="flex justify-between">
<div>
<div class="text-[12px] mb-2">{{ remainText }}</div>
<div class="font-bold">{{ Math.abs(parseFloat(customer.goldDebtGrams)) }} گرم</div>
</div>
<div>
<div class="text-[12px] mb-2">بدهی ریالی</div>
<div class="font-bold">{{ priceUnit(customer.fiatDebtRial) }}</div>
</div>
</div>
</div>
<div class="grid grid-cols-4 gap-6 my-4">
<div v-for="item in list" :key="item.id" class="grid col-span-1 cursor-pointer" @click="goToDetails(item.id)">
<div class="border border-solid border-[#E5E7EB] bg-[#F9F9F9] rounded-[5px] flex justify-center items-center py-4" :class="{ 'opacity-40': item.disabled }">
<Icon :icon="item.icon" />
</div>
<div class="text-center text-[14px] font-medium pt-2">{{ item.title }}</div>
</div>
</div>
<div class="rounded-[10px] text-secondary col-span-6 mb-24" v-if="goldPrice">
<div class="flex items-center justify-between bg-[#1C2D56] text-[#ffffff] p-3 rounded">
<div class="font-bold text-[16px]">نرخ طلای ۱۸ عیار</div>
<div>{{ jdate(goldPrice.quotedAt) }}</div>
</div>
<div class="grid grid-cols-1 mt-2">
<div class="bg-[#F9F9F9] rounded flex justify-between items-center p-2">
<div class="flex items-center">
<div class="bg-white rounded w-9 h-9 flex justify-center items-center">
<Icon class="text-primary" icon="vuesax-linear:number-18" />
</div>
<div class="text-[#1C2D56] text-[15px] font-semibold mr-3">هر گرم طلای ۱۸ عیار</div>
</div>
<div class="text-[#1C2D56] text-[18px] font-semibold flex items-center">
{{ price(goldPrice.pricePerGramRial) }}
<div class="text-xs text-[#8D6A46] pr-0.5 font-medium">ریال</div>
</div>
</div>
</div>
</div>
<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>
</container>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.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, priceUnit } from '../../utils/filters'
import { jdate } 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 remainText = computed(() => (customer.value && parseFloat(customer.value.goldDebtGrams) > 0 ? 'بدهکار گرمی' : 'بستانکار گرمی'))
const data = reactive({ notify: false, loginNotify: false })
const list = [
{ 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 },
]
function goToDetails(id: number) {
if (!session.customerId) {
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' })
}
function logOut() {
session.logout()
data.notify = false
router.push({ name: 'home' })
}
onMounted(async () => {
if (session.customerId) {
customer.value = await api.getCustomer(session.customerId)
}
try {
goldPrice.value = await api.getLatestGoldPrice()
} catch {
goldPrice.value = null
}
})
</script>
@@ -0,0 +1,50 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/favorits.vue.
Real, not stubbed: favorites are the local favoritesStore (per-device, no
backend persistence — see favoritesStore.ts from an earlier batch), filtered
against the real product catalog client-side. No pagination — the whole
catalog is fetched once (same "small catalog" approach as everywhere else).
-->
<template>
<div class="fixed top-0 z-10 w-full">
<AppHeader>
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer text-white" size="25" @click="router.push({ name: 'userPage::hasSubRoute' })" />
<h3 class="text-white bold mb-0">علاقمندی</h3>
<div></div>
</div>
</AppHeader>
</div>
<div class="h-16"></div>
<Spinner :spinning="loading" tip="در حال دریافت اطلاعات">
<ProductList class="px-[16px] pt-8" padding="px" :flag="favoriteProducts.length === 0" :value="favoriteProducts" :loading="false" is-fav @pull="() => {}" />
</Spinner>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import Spinner from '../../components/Spinner.vue'
import ProductList from '../../components/product/ProductList.vue'
import { useFavoritesStore } from '../../stores/favoritesStore'
import { api, type ProductSummary } from '../../api/client'
const router = useRouter()
const favorites = useFavoritesStore()
const loading = ref(false)
const allProducts = ref<ProductSummary[]>([])
const favoriteProducts = computed(() => allProducts.value.filter((p) => favorites.isFavorite(p.id)))
onMounted(async () => {
loading.value = true
try {
allProducts.value = await api.listProducts()
} finally {
loading.value = false
}
})
</script>
@@ -0,0 +1,14 @@
<!-- Ported from legacy henza_pwa/src/views/user/LoginRegister/index.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import LoginView from './LoginView.vue'
import RegisterView from './RegisterView.vue'
const showRegister = ref(false)
const register = (state: boolean) => (showRegister.value = state)
</script>
<template>
<LoginView @register="register" v-if="!showRegister" />
<RegisterView @register="register" v-if="showRegister" />
</template>
+197
View File
@@ -0,0 +1,197 @@
<!--
Ported from legacy henza_pwa/src/views/user/LoginRegister/components/Login.vue.
Per your decision: OTP-based flow ported structurally, but the SMS-send step
is stubbed — no SMS provider exists. What's real vs. stubbed:
- Step 1 (mobile number) is checked against the REAL, already-existing
GET /api/customers endpoint (client-side match on `mobile`) — no fake
endpoint invented. If it matches a real seeded customer, that customer's
exact legacy-notify message path is preserved: a non-match shows
legacy's own "اطلاعات کاربری در سیستم وجود ندارد" text (legacy's actual
404 handling), not a new message.
- Step 2 (OTP code) is NOT verified — any 4-digit code is accepted, since
there is no real code to check against. On "verify," the session store
logs in as the matched real customer (session.login), so downstream
features (balance, ledger, favorites-as-logged-in) work against real
seeded data. This is the "session wiring" — apps/api itself is untouched.
- `token.fireBaseToken` (Firebase push token) doesn't exist in this port
(Firebase was already deferred) — omitted from the stub verify call.
- `useOtpToken()` (WebOTP browser autofill) was already dead in legacy
itself (its call was commented out in App.vue, and even active, nothing
ever read its output into the OTP input) — not ported.
-->
<template>
<div>
<NotifyVant :timeOut="2000" v-model="notify.show" :text="notify.text" :color="'background-color: #DC2626'" />
<div class="fixed w-full">
<img class="w-full absolute left-0 right-0" :src="pattern1" alt="" />
<img class="absolute w-full left-0 right-0" :src="pattern2" alt="" />
</div>
<div class="fixed top-0 z-10 w-full">
<AppHeader class="bg-white shadowHeader">
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer" size="25" @click="router.push({ name: 'home' })" />
<h3 class="t bold mb-0">ورود</h3>
<div></div>
</div>
</AppHeader>
<div class="flex justify-center mb-8 mt-5">
<div class="h-[150px] w-[150px] bg-[#F8F7F6] rounded-full flex justify-center items-center bg-opacity-50">
<div class="bg-[#F9F5F0] h-[120px] w-[120px] flex justify-center items-center rounded-full">
<div>
<div class="rounded-full w-[90px] h-[90px] flex justify-center bg-[#F1E8DE]">
<img :src="editImg" class="p-6" alt="henza" />
</div>
</div>
</div>
</div>
</div>
<div class="w-full text-center font-bold text-black text-lg">ورود به اپلیکیشن هنزا گلد</div>
<div class="px-8 custom-form">
<OtpInput
class="w-full mb-[10px] text-black mt-5"
v-show="body.step == 2 && timer > 0"
input-classes="otp-input"
inputType="tel"
:num-inputs="4"
:should-auto-focus="true"
:defaultFocus="body.defaultFocus"
@on-complete="sendToken"
/>
<div v-show="body.step == 2 && timer === 0" class="text-center mb-[10px] mt-5">برای دریافت رمز عبور مجدد تلاش کنید</div>
<div v-show="body.step == 2 && timer !== 0" class="text-center text-xl mb-[10px] mt-5">
امکان دریافت مجدد رمز در &nbsp;{{ ' ' + counterZeroShow(timer) + ' ' }} ثانیه دیگر
</div>
<Form :model="formModel" layout="vertical" @finish="sendPhone" class="w-full">
<FormItem name="username" label="تلفن همراه" v-show="body.step == 1" :rules="[required('تلفن همراه'), mobileRule('تلفن همراه')]">
<Input type="number" v-model:value="formModel.username" />
</FormItem>
<Button class="btn-px-double" type="primary" html-type="submit" size="large" block :loading="body.loading" :disabled="timer > 0 && body.step === 2">
{{ btnText }}
</Button>
</Form>
<div class="flex justify-center mt-5">
<div>اگر ثبت نام نکرده اید از این قسمت</div>
<div class="pr-1 text-primary font-bold" @click="emit('register', true)">ثبت نام کنید.</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button, Form, FormItem, Input } from 'ant-design-vue'
import pattern1 from '../../assets/img/pattern2.svg'
import pattern2 from '../../assets/img/pattern3.svg'
import editImg from '../../assets/img/henza.svg'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import NotifyVant from '../../components/NotifyVant.vue'
import OtpInput from '../../components/input/OtpInput.vue'
import { required, mobile as mobileRule } from '../../utils/validator'
import { useSessionStore } from '../../stores/session'
import { api, type CustomerSummary } from '../../api/client'
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
const router = useRouter()
const session = useSessionStore()
const formModel = reactive({ username: undefined as string | undefined })
const notify = reactive({ show: false, text: 'اطلاعات کاربری در سیستم وجود ندارد.' })
const body = reactive({
step: 1,
loading: false,
defaultFocus: false,
})
let matchedCustomer: CustomerSummary | null = null
const timer = ref(0)
const countDownTimer = () => {
if (timer.value > 0) {
setTimeout(() => {
if (timer.value > 0) {
timer.value -= 1
countDownTimer()
}
}, 1000)
}
}
const sendPhone = async () => {
try {
body.loading = true
const customers = await api.listCustomers()
matchedCustomer = customers.find((c) => c.mobile === formModel.username) ?? null
if (!matchedCustomer) {
notify.text = 'اطلاعات کاربری در سیستم وجود ندارد.'
notify.show = true
return
}
body.step = 2
timer.value = 120
countDownTimer()
await nextTick(() => {
body.defaultFocus = true
})
} catch (e) {
console.log(e)
} finally {
body.loading = false
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const sendToken = async (_otpCode: string) => {
// Stub: the OTP code itself isn't verified (no SMS was actually sent to
// check it against). `matchedCustomer` was already confirmed real in
// sendPhone(), so any 4-digit code here logs in as that customer.
try {
body.loading = true
if (!matchedCustomer) return
session.login(matchedCustomer.id, matchedCustomer.mobile)
await router.push({ name: 'home' })
notify.text = 'با موفقیت وارد شدید'
notify.show = true
} finally {
body.loading = false
}
}
const counterZeroShow = (value: number) => (value < 10 ? '0' + value : value)
const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'))
</script>
<style lang="less">
.shadowHeader {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
}
.custom-form {
.otp-input {
width: 50px;
height: 50px;
padding: 5px;
margin: 0;
font-size: 20px;
background: transparent;
border: 0 solid @black;
border-bottom-width: 2px;
text-align: center;
&.error {
border: 1px solid @danger !important;
}
}
.otp-input::-webkit-inner-spin-button,
.otp-input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
</style>
+282
View File
@@ -0,0 +1,282 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/Orders.vue.
Adapted:
- Status filter tabs use our real OrderStatus enum values directly (see
utils/orderStatus.ts) rather than legacy's own inconsistent tab labels.
- Fetches real order history via the already-existing GET
/customers/:id/orders endpoint, filtered client-side by tab (that
endpoint has no server-side status filter — small enough dataset that
client-side filtering is reasonable, and apps/api stays untouched).
- Search matches against `orderNumber` (our real user-facing identifier)
instead of legacy's numeric `id`.
DROPPED: "دریافت فاکتور" (get invoice) button — legacy's handler opened a
hardcoded link to the real legacy production domain
(https://henzagold.com/oapi/...). Not just unimplemented — an actual
external production URL that must never be carried into this port. No
invoice-generation endpoint exists in the new API either.
-->
<template>
<div class="fixed top-0 z-10 w-full bg-white">
<AppHeader>
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer text-white" size="25" @click="router.push({ name: 'home' })" />
<h3 class="text-white bold mb-0">سفارشات</h3>
<div></div>
</div>
</AppHeader>
<div class="pt-3 px-2 pb-3 mb-3 border-b-solid border-[#E5E7EB] w-full">
<SearchInput @search="readSearch" place-holder="جستجو" />
</div>
</div>
<div class="h-36"></div>
<container class="bg-[#F9FAFB] px-2 orders">
<Tabs v-model:activeKey="activeTab" style="padding-right: 0.2rem !important; font-weight: bold">
<TabPane v-for="item in tabs" :key="item.key" :tab="item.title" force-render>
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات" cls="overflow-y-scroll no-scrollbar">
<OrderCard @view="openModal" v-for="item in filteredOrders" :key="item.id" :value="item" />
<EmptyState v-if="filteredOrders.length === 0" :image="ordersEmpty" />
</Spinner>
</TabPane>
</Tabs>
<Modal v-model:visible="viewData.model" :mask="false" :footer="false" :closable="false" class="henza-custom-modal" v-if="viewData.item">
<div class="flex justify-between items-center p-4">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer" size="25" @click="viewData.model = false" />
<h3 class="font-semibold mb-0">جزئیات سفارش</h3>
<div></div>
</div>
<container>
<div class="overflow-y-auto h-screen">
<div class="flex justify-between items-center pt-4">
<span class="font-semibold text-[14px]">اطلاعات سفارش</span>
</div>
<div class="responsive-steps">
<div class="steps-container">
<div v-for="(step, index) in ORDER_STEPS" :key="index" class="step" :class="{ active: currentStep === index }">
<div class="step-circle">{{ index + 1 }}</div>
<div class="step-title">{{ step }}</div>
<div v-if="index !== 0" class="step-line"></div>
</div>
</div>
</div>
<div class="grid grid-cols-2 border-[1.5px] border-[#E5E7EB] border-solid mt-4 rounded-[6px]">
<div class="grid col-span-1 p-4 text-[#9CA3AF] text-[12px] font-medium">
<div>شماره سفارش :</div>
<div class="pt-4">وضعیت سفارش :</div>
<div class="pt-4">تعداد کالاها :</div>
<div class="pt-4">وزن سفارش :</div>
<div class="pt-4">تاریخ سفارش :</div>
</div>
<div class="grid col-span-1 text-left p-4">
<div class="font-bold text-[18px] text-[#23335B]">{{ viewData.item.orderNumber }}</div>
<div class="pt-4 text-center flex justify-end">
<div class="rounded-[5px] py-[4px] px-[12px] text-[14px] font-semibold w-fit" :style="{ background: modalColor?.background, color: modalColor?.font }">
{{ modalStatusLabel }}
</div>
</div>
<div class="pt-4">
<span class="text-[16px] font-bold pl-2 text-black">{{ viewData.item.items.length }}</span>
<span class="text-[14px] font-medium">آیتم</span>
</div>
<div class="pt-4 font-medium">
<span class="text-[16px] font-bold pl-1 text-black">{{ parseFloat(viewData.item.totalGoldWeightGrams).toFixed(2) }}</span>
<span class="pr-2">گرم</span>
</div>
<div class="pt-4 font-semibold text-[#1E2229]">{{ jdate(viewData.item.createdAt) }}</div>
</div>
</div>
<div>
<div class="font-semibold mt-4">آیتم های سفارش</div>
<div class="w-full max-w-screen-md m-auto h-full flex flex-col">
<div class="flex-1 py-[10px] pb-[70px]">
<OrderItemCard v-for="item in viewData.item.items" :key="item.id" :value="item" class="border-[#E5E7EB] border-[1.5px] border-solid text-primary bg-[#F9F9F9] rounded-[6px] mb-4" />
</div>
</div>
</div>
</div>
</container>
</Modal>
</container>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Modal, TabPane, Tabs } from 'ant-design-vue'
import ordersEmpty from '../../assets/img/empty/ordersEmpty.svg'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import Spinner from '../../components/Spinner.vue'
import EmptyState from '../../components/EmptyState.vue'
import SearchInput from '../../components/search/SearchInput.vue'
import OrderCard from '../../components/product/OrderCard.vue'
import OrderItemCard from '../../components/product/OrderItemCard.vue'
import { ORDER_STATUS_COLORS, ORDER_STATUS_LABELS, ORDER_STEPS, orderStepIndex } from '../../utils/orderStatus'
import { jdate } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type OrderDetail } from '../../api/client'
const router = useRouter()
const session = useSessionStore()
const tabs = [
{ title: 'همه', key: 'ALL' },
{ title: ORDER_STATUS_LABELS.SUSPENDED, key: 'SUSPENDED' },
{ title: ORDER_STATUS_LABELS.PLACED, key: 'PLACED' },
{ title: ORDER_STATUS_LABELS.ADMIN_APPROVED, key: 'ADMIN_APPROVED' },
{ title: ORDER_STATUS_LABELS.SOURCING, key: 'SOURCING' },
{ title: ORDER_STATUS_LABELS.PREPARING, key: 'PREPARING' },
{ title: ORDER_STATUS_LABELS.DELIVERED_TO_AGENT, key: 'DELIVERED_TO_AGENT' },
{ title: ORDER_STATUS_LABELS.DELIVERED_TO_CUSTOMER, key: 'DELIVERED_TO_CUSTOMER' },
{ title: ORDER_STATUS_LABELS.CANCELLED, key: 'CANCELLED' },
]
const activeTab = ref('ALL')
const loading = ref(false)
const allOrders = ref<OrderDetail[]>([])
const searchTerm = ref<string | undefined>(undefined)
const filteredOrders = computed(() => {
let list = allOrders.value
if (activeTab.value !== 'ALL') list = list.filter((o) => o.status === activeTab.value)
if (searchTerm.value) list = list.filter((o) => o.orderNumber.includes(searchTerm.value!))
return list
})
function readSearch(value: string | undefined) {
searchTerm.value = value
}
async function loadOrders() {
if (!session.customerId) return
loading.value = true
try {
allOrders.value = await api.getCustomerOrders(session.customerId)
} finally {
loading.value = false
}
}
const viewData = reactive<{ model: boolean; item: OrderDetail | null }>({ model: false, item: null })
const modalStatusLabel = computed(() => (viewData.item ? ORDER_STATUS_LABELS[viewData.item.status] ?? viewData.item.status : ''))
const modalColor = computed(() => (viewData.item ? ORDER_STATUS_COLORS[viewData.item.status] : undefined))
const currentStep = computed(() => (viewData.item ? orderStepIndex(viewData.item.status) : 0))
function openModal(item: OrderDetail) {
viewData.item = item
viewData.model = true
}
onMounted(loadOrders)
</script>
<style lang="less">
.henza-custom-modal,
.henza-custom-modal .ant-modal-body,
.henza-custom-modal .ant-modal-content {
border-radius: 0;
width: 100vw !important;
max-width: 100vw;
margin: 0;
height: 100vh;
top: 0;
padding: 0;
direction: rtl;
}
.orders {
min-height: calc(100vh - 150px);
height: fit-content;
}
.orders .ant-tabs > .ant-tabs-nav,
.ant-tabs > div > .ant-tabs-nav {
background-color: white;
position: fixed;
z-index: 10;
overflow-x: auto;
width: 100%;
max-width: 768px;
margin: auto;
left: 0;
right: 0;
padding-right: 10px;
padding-left: 10px;
border-bottom-style: solid;
border-bottom-color: #e5e7eb;
border-bottom-width: 1px;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.responsive-steps {
width: 100%;
padding: 10px;
.steps-container {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
text-align: center;
position: relative;
.step-circle {
width: 30px;
height: 30px;
border-radius: 50%;
background-color: #e5e7eb;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: #9ca3af;
z-index: 1;
}
.step-title {
margin-top: 6px;
font-size: 12px;
white-space: normal;
word-break: break-word;
}
.step-line {
position: absolute;
top: 15px;
left: 50%;
width: 100%;
height: 2px;
background-color: #e5e7eb;
z-index: 0;
transform: translateX(0);
}
&.active .step-circle {
background-color: @primary-color;
color: white;
}
}
}
@media (max-width: 600px) {
.step-title {
font-size: 11px;
}
.step-circle {
width: 24px;
height: 24px;
font-size: 10px;
}
}
</style>
@@ -0,0 +1,93 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/profile.vue,
heavily simplified — legacy's original (971 lines, the largest view in the
app) is a full edit form (bank info, KYC document uploads, sub-user
management for banker accounts). None of that has a backend to back it:
there's no update-customer endpoint, no document-upload endpoint, and (as
in DashboardView) no banker/sub-account concept in the new Customer model
at all. Kept, faithfully styled: the avatar circle and the real read-only
info card (name/mobile/national code/verification status/city), all backed
by real fields the API already returns.
DROPPED: "تکمیل پروفایل" (complete profile) button, "افزودن کاربر" (add
sub-user) FAB, the "کارشناس فروش" (sales agent) contact card — that last
one needs the referring AdminUser's name/mobile, which the current
GET /customers/:id response doesn't expose (only referredByAgentId, a bare
id) — not resolved here to keep apps/api untouched this batch.
-->
<template>
<div class="fixed top-0 z-10 w-full">
<AppHeader class="bg-white shadowHeader">
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer" size="25" @click="router.push({ name: 'userPage::hasSubRoute' })" />
<h3 class="t bold mb-0">اطلاعات کاربری</h3>
<div></div>
</div>
</AppHeader>
</div>
<div class="h-20"></div>
<container>
<div class="flex justify-center mb-8">
<div class="h-[150px] w-[150px] bg-[#F8F7F6] rounded-full flex justify-center items-center bg-opacity-50">
<div class="bg-[#F9F5F0] h-[120px] w-[120px] flex justify-center items-center rounded-full">
<div class="rounded-full w-[90px] h-[90px] flex justify-center bg-[#F1E8DE]">
<img :src="editImg" class="p-6" alt="henza" />
</div>
</div>
</div>
</div>
<div v-if="customer" class="border-solid border-[1px] border-[#F3F4F6] rounded-[6px] p-8 mt-2 bg-[#FEFEFE]">
<div class="flex justify-between">
<div class="text-[#9CA3AF] font-medium">نام و نام خانوادگی :</div>
<div class="text-[#374151] text-[16px] font-bold text-left">{{ customer.fullName }}</div>
</div>
<div class="flex justify-between mt-8">
<div class="text-[#9CA3AF] font-medium">تلفن همراه :</div>
<div class="text-[#374151] text-[16px] font-bold text-left">{{ customer.mobile }}</div>
</div>
<div class="flex justify-between mt-8">
<div class="text-[#9CA3AF] font-medium">کد ملی :</div>
<div class="text-[#374151] text-[16px] font-bold text-left">{{ customer.nationalCode }}</div>
</div>
<div class="flex justify-between mt-8">
<div class="text-[#9CA3AF] font-medium">وضعیت احراز هویت :</div>
<div class="text-[#374151] text-[16px] font-bold text-left">{{ VERIFICATION_LABELS[customer.verificationStep] }}</div>
</div>
</div>
</container>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import editImg from '../../assets/img/henza.svg'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import { useSessionStore } from '../../stores/session'
import { api, type CustomerDetail } from '../../api/client'
const VERIFICATION_LABELS: Record<string, string> = {
UNVERIFIED: 'احراز نشده',
STEP_ONE_SUBMITTED: 'مرحله اول ثبت شده',
STEP_TWO_SUBMITTED: 'مرحله دوم ثبت شده',
VERIFIED: 'تایید شده',
}
const router = useRouter()
const session = useSessionStore()
const customer = ref<CustomerDetail | null>(null)
onMounted(async () => {
if (session.customerId) {
customer.value = await api.getCustomer(session.customerId)
}
})
</script>
<style scoped>
.shadowHeader {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
}
</style>
@@ -0,0 +1,128 @@
<!--
Ported from legacy henza_pwa/src/views/user/LoginRegister/components/Register.vue.
DROPPED as confirmed-dead code (not "possibly dead" — verified unreachable):
legacy rendered a whole cascading country->province->city picker
(WorkActionSheet + selectCityArrays + getCities() against POST /cities/list)
alongside a plain "شهر" text <Input>. The only element that ever opened the
picker (`#citybtn`) was itself commented out in legacy's own template, so
the picker was unreachable UI even in the original app; the visible form
uses the plain text input. That whole complex, and the parallel unused
`userFormModel`/`defaultUserFormModel` objects (the template binds
`formModel.*`, a *different*, separately-declared empty reactive object —
`userFormModel` is never referenced anywhere), are not ported.
STUBBED, not real: POST /major/register — apps/api has no create-customer
endpoint. Submitting shows legacy's exact success dialog without actually
persisting anything.
-->
<template>
<div>
<div class="fixed w-full">
<img class="w-full absolute left-0 right-0" :src="pattern1" alt="" />
<img class="absolute w-full left-0 right-0" :src="pattern2" alt="" />
</div>
<div class="fixed top-0 z-10 w-full">
<AppHeader class="bg-white shadowHeader">
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer" size="25" @click="router.push({ name: 'home' })" />
<h3 class="t bold mb-0">ثبت نام</h3>
<div></div>
</div>
</AppHeader>
</div>
<div class="relative w-full py-16 px-5">
<div class="mt-14">
<Spinner :spinning="profileData.loading" tip="در حال ثبت اطلاعات">
<Form :model="formModel" layout="vertical" @finish="register" class="w-full">
<FormItem style="font-size: 16px" name="fname" label="نام" :rules="required('نام')">
<Input v-model:value="formModel.fname" />
</FormItem>
<FormItem style="font-size: 16px" name="lname" label="نام خانوادگی" :rules="required('نام خانوادگی')">
<Input v-model:value="formModel.lname" />
</FormItem>
<FormItem style="font-size: 16px" name="mobile" label="شماره موبایل" :rules="[mobileRule('شماره موبایل'), required('شماره موبایل')]">
<Input v-model:value="formModel.mobile" type="number" />
</FormItem>
<FormItem style="font-size: 16px" name="store_name" label="نام فروشگاه" :rules="required('نام فروشگاه')">
<Input v-model:value="formModel.store_name" />
</FormItem>
<FormItem style="font-size: 16px" name="phone" label="تلفن فروشگاه" :rules="[required('تلفن فروشگاه')]">
<Input v-model:value="formModel.phone" type="number" />
</FormItem>
<FormItem style="font-size: 16px" name="address" label="آدرس پستی" :rules="required('آدرس')">
<Textarea v-model:value="formModel.address" />
</FormItem>
<FormItem name="city" label="شهر" :rules="required('شهر')">
<Input v-model:value="formModel.city" />
</FormItem>
<Button type="primary" class="w-full max-w-[768px] m-auto" style="height: 42px" html-type="submit">
<span style="font-size: 18px">ثبت اطلاعات</span>
</Button>
</Form>
</Spinner>
<div class="flex justify-center mt-10">
<div>اگر حساب کاربری دارید از این قسمت</div>
<div class="pr-1 text-primary font-bold" @click="emit('register', false)">وارد شوید.</div>
</div>
</div>
</div>
<DialogVant :timeOut="-1" v-model:show="registeredInfo" :closeOnClickOverlay="false">
<template v-slot:img>
<img :src="registerDelayImage" alt="" class="opacity-80" />
</template>
<template v-slot:title>
<div class="p-5">ثبت نام شما با موفقیت انجام شد. منتظر تایید اطلاعات ثبت نامی شما توسط کارشناسان بمانید.</div>
</template>
<template v-slot:button>
<div class="text-center" @click="router.push({ name: 'home' })">متوجه شدم</div>
</template>
</DialogVant>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button, Form, FormItem, Input, Textarea } from 'ant-design-vue'
import pattern1 from '../../assets/img/pattern2.svg'
import pattern2 from '../../assets/img/pattern3.svg'
import registerDelayImage from '../../assets/img/registerDelay.svg'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import Spinner from '../../components/Spinner.vue'
import DialogVant from '../../components/DialogVant.vue'
import { required, mobile as mobileRule } from '../../utils/validator'
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
const router = useRouter()
const profileData = reactive({ loading: false })
const registeredInfo = ref(false)
const formModel = reactive({
fname: undefined as string | undefined,
lname: undefined as string | undefined,
mobile: undefined as string | undefined,
store_name: undefined as string | undefined,
phone: undefined as string | undefined,
address: undefined as string | undefined,
city: undefined as string | undefined,
})
const register = async () => {
try {
profileData.loading = true
// Stubbed — no create-customer endpoint exists in apps/api yet.
registeredInfo.value = true
} finally {
profileData.loading = false
}
}
</script>
<style scoped>
.shadowHeader {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
}
</style>
@@ -0,0 +1,168 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/transactions.vue
("اسناد" — financial documents). Replaces the English AccountView scaffold
that had no legacy counterpart.
Data source: legacy listed `Cash` rows from POST /get-my-findoc. The new
system's equivalent is LedgerEntry, already exposed by
GET /customers/:id/ledger-entries, so the list is real data.
Legacy's colour/icon logic is preserved exactly: `hasFactor(item)` was
`item.type != 9` (type 9 = سفارش, the debt-creating order entry) → green
#F0FDF4/#22C55E + add-square for everything else, red #FEF2F2/#F44336 +
minus-square for orders. That maps 1:1 onto our CREDIT/DEBIT direction,
since an ORDER_STOCK entry is exactly the DEBIT case.
DROPPED: the "دریافت فایل" per-document download — legacy's getFile() hit the
old Laravel host; there is no document-file endpoint in the new API.
-->
<template>
<div class="fixed top-0 z-10 w-full">
<AppHeader>
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer text-white" size="25" @click="router.push({ name: 'userPage::hasSubRoute' })" />
<h3 class="text-white bold mb-0">اسناد</h3>
<div></div>
</div>
</AppHeader>
</div>
<div class="h-24 bg-[#F9FAFB]"></div>
<container class="overflow-y-auto bg-[#F9FAFB]">
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات" cls="no-scrollbar overflow-auto">
<div
v-if="entries.length"
v-for="item in entries"
:key="item.id"
@click="openEntry(item)"
class="rounded-[10px] border-[#E8EAEE] py-[16px] px-[14px] w-full mb-[16px] grid grid-cols-8 items-center cursor-pointer bg-white"
>
<div class="col-span-1 items-end h-full">
<Icon
:icon="getIcon(item)"
:style="{ background: getColor(item), color: getColor2(item) }"
class="rounded-full p-[10px] ml-2 h-[40px] w-[40px]"
size="20"
/>
</div>
<div class="col-span-7 pr-1 text-primary mt-2">
<div class="flex justify-between items-center">
<div class="font-bold mb-2">{{ item.note || '- -' }}</div>
<div
class="rounded-[5px] px-[20px] py-[4px] w-fit text-[16px] mb-2 font-semibold"
:style="{ background: getColor(item), color: getColor2(item) }"
>
{{ SOURCE_LABELS[item.sourceType] ?? item.sourceType }}
</div>
</div>
<div class="flex justify-between items-center">
<div class="text-[14px] font-semibold mb-2">{{ formatAmount(item) }}</div>
<div class="h-10"></div>
</div>
<div class="mb-2 text-[14px] text-grey font-semibold">{{ jdate(item.createdAt) }}</div>
<div class="flex justify-between pt-1">
<div class="text-[12px] font-bold">{{ item.confirmed ? 'تایید شده' : 'در انتظار تایید' }}</div>
<Icon icon="vuesax-linear:arrow-left-1" class="cursor-pointer text-primary" size="20" />
</div>
</div>
</div>
<EmptyState v-else :image="transactionEmpty" />
</Spinner>
<Drawer
v-model:visible="showPopUp"
placement="bottom"
v-if="activeEntry"
:closable="false"
height="450px"
:class="['henza-rounded-drawer rtl', 'bg-white', 'text-primary']"
:bodyStyle="{ display: 'flex', flexDirection: 'column' }"
>
<div class="w-full h-full flex flex-col">
<div class="pb-[10px] border-b border-solid border-[#E5E7EB] flex text-[16px] font-bold">جزئیات سند</div>
<div class="flex-1 py-[10px] overflow-auto no-scrollbar">
<div class="flex justify-between mb-4">
<div class="text-grey">نوع سند :</div>
<div class="font-semibold">{{ SOURCE_LABELS[activeEntry.sourceType] ?? activeEntry.sourceType }}</div>
</div>
<div class="flex justify-between mb-4">
<div class="text-grey">مبلغ :</div>
<div class="font-semibold">{{ formatAmount(activeEntry) }}</div>
</div>
<div class="flex justify-between mb-4">
<div class="text-grey">وضعیت :</div>
<div class="font-semibold">{{ activeEntry.confirmed ? 'تایید شده' : 'در انتظار تایید' }}</div>
</div>
<div class="flex justify-between mb-4">
<div class="text-grey">تاریخ :</div>
<div class="font-semibold">{{ jdate(activeEntry.createdAt) }}</div>
</div>
<div class="flex justify-between mb-4">
<div class="text-grey">توضیحات :</div>
<div class="font-semibold">{{ activeEntry.note || '- -' }}</div>
</div>
</div>
</div>
</Drawer>
</container>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import Spinner from '../../components/Spinner.vue'
import EmptyState from '../../components/EmptyState.vue'
import transactionEmpty from '../../assets/img/empty/transactionEmpty.svg'
import { jdate, price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type LedgerEntrySummary } from '../../api/client'
const SOURCE_LABELS: Record<string, string> = {
ORDER_STOCK: 'سفارش',
ORDER_PRE_ORDER: 'سفارش ساخت',
REPAYMENT_MELTED_GOLD: 'طلای آب‌شده',
REPAYMENT_TRANSFER: 'حواله',
REPAYMENT_CHEQUE: 'چک',
REPAYMENT_CASH: 'پولی',
WALLET_BONUS: 'پاداش',
ADJUSTMENT: 'اصلاحیه',
}
const router = useRouter()
const session = useSessionStore()
const loading = ref(false)
const entries = ref<LedgerEntrySummary[]>([])
const showPopUp = ref(false)
const activeEntry = ref<LedgerEntrySummary | null>(null)
// legacy hasFactor(item) === item.type != 9 (9 = سفارش/order). Our DEBIT
// entries are exactly that case.
const isCredit = (item: LedgerEntrySummary) => item.direction === 'CREDIT'
const getIcon = (item: LedgerEntrySummary) => (isCredit(item) ? 'vuesax-linear:add-square' : 'vuesax-linear:minus-square')
const getColor = (item: LedgerEntrySummary) => (isCredit(item) ? '#F0FDF4' : '#FEF2F2')
const getColor2 = (item: LedgerEntrySummary) => (isCredit(item) ? '#22C55E' : '#F44336')
const formatAmount = (item: LedgerEntrySummary) =>
item.currency === 'GOLD_GRAM' ? `${item.amount} گرم` : `${price(item.amount)} ریال`
const openEntry = (item: LedgerEntrySummary) => {
activeEntry.value = item
showPopUp.value = true
}
onMounted(async () => {
if (!session.customerId) return
loading.value = true
try {
entries.value = await api.getLedgerEntries(session.customerId)
} finally {
loading.value = false
}
})
</script>