feat: move brand name to one config and redesign the accounting card

اپ: نام برند در همه‌ی صفحه‌ها و پیام‌ها یکسان شده و از یک تنظیم مرکزی خوانده می‌شود.
اپ: کارت حسابداری در صفحه‌ی مشتری بازطراحی شد؛ مانده‌ها و وضعیت لینک واضح‌تر نمایش داده می‌شوند.
پنل: کارت حسابداری در پرونده‌ی مشتری بازطراحی شد؛ مانده‌ها، آخرین ارسال و فرم لینک دستی واضح‌تر شده‌اند.
پنل: ستون «کد» در جدول‌ها به‌طور پیش‌فرض پنهان است و از انتخاب ستون‌ها قابل نمایش است.
پنل: تغییر نمایش ستون‌ها در جدول‌ها بلافاصله اعمال می‌شود.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-14 13:31:20 +03:30
co-authored by Claude Haiku 5.5
parent 5d2a1efb0c
commit 532ceee189
100 changed files with 1870 additions and 579 deletions
+2 -1
View File
@@ -37,6 +37,7 @@ import { onMounted, onUnmounted, ref, watch } from 'vue';
import { notification } from 'ant-design-vue';
import faIR from 'ant-design-vue/es/locale/fa_IR';
import themeConfig from './themeConfig';
import { brand } from './brand';
import { useLayoutStore } from './stores/layout';
import AppUpdateGate from './components/common/AppUpdateGate.vue';
import { userName } from './utils';
@@ -96,7 +97,7 @@ onMounted(() => {
notification.open({
message: `${user && user.name ? user.name : ' کاربر '} عزیز `,
placement: 'topLeft',
description: 'به منتزر خوش آمدید',
description: `به ${brand.name} خوش آمدید`,
style: {
color: 'var(--primary-color)',
marginLeft: `20px`,

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -29,7 +29,7 @@
// ant-design-pro boilerplate in Vue's own palette, inherited verbatim from
// legacy and used as `@border-color-base`. That file is deleted (ADV4 has no
// LESS build to feed); nothing in the app should reference `--primary`/
// `--accent` any more — use the Montzar tokens below instead.
// `--accent` any more — use the brand tokens below instead.
:root {
--primary-color: #1c2d56;
--secondary-color: #836447;
+2
View File
@@ -0,0 +1,2 @@
// The brand lives in the repo-root brand.ts, shared by all three frontends.
export { brand } from '../../../brand';
@@ -4,50 +4,86 @@
linked to, and both balances side by side (the customer sees the
accounting one; the credit limit is checked against the app's own).
-->
<a-card v-if="visible" size="small" title="حسابداری" class="mt-4">
<section v-if="visible" class="acc-card" :style="themeVars">
<header class="acc-head">
<h2>حسابداری</h2>
<a-tag v-if="info?.link" color="success">لینک شده</a-tag>
<a-tag v-else color="warning">لینک نشده</a-tag>
</header>
<a-spin :spinning="loading">
<a-descriptions bordered size="small" :column="3">
<a-descriptions-item label="کد حساب">
<div class="acc-body">
<div class="acc-code">
<span class="label">کد حساب</span>
<template v-if="info?.link">
{{ info.link.externalCode }}
<a-tag class="ms-2">{{ ORIGIN[info.link.origin] ?? info.link.origin }}</a-tag>
</template>
<span v-else class="muted">لینک نشده</span>
</a-descriptions-item>
<a-descriptions-item label="مانده‌ی حسابداری (گرم / ریال)">
<template v-if="info?.accounting">
<span :class="sign(info.accounting.gold)">{{ num(info.accounting.gold, 3) }}</span> /
<span :class="sign(info.accounting.rial)">{{ num(info.accounting.rial, 0) }}</span>
<div class="muted small">{{ jdt(info.accounting.at) }}</div>
<span class="code" dir="ltr">{{ info.link.externalCode }}</span>
<span class="origin">{{ ORIGIN[info.link.origin] ?? info.link.origin }}</span>
</template>
<span v-else class="muted">—</span>
</a-descriptions-item>
<a-descriptions-item label="مانده‌ی دفتر اپ (گرم / ریال)">
<span :class="sign(info?.internal?.gold)">{{ num(info?.internal?.gold, 3) }}</span> /
<span :class="sign(info?.internal?.rial)">{{ num(info?.internal?.rial, 0) }}</span>
<div class="muted small">مبنای سقف اعتبار</div>
</a-descriptions-item>
</a-descriptions>
</div>
<div v-if="lastJob" class="muted small mt-2">
آخرین ارسال: {{ STATUS[lastJob.status] ?? lastJob.status }} — {{ jdt(lastJob.updatedAt) }}
<span v-if="lastJob.lastError" class="warn"> — {{ lastJob.lastError }}</span>
<div class="balances">
<div class="balance">
<div class="label">مانده‌ی حسابداری</div>
<template v-if="info?.accounting">
<div class="line">
<span :class="sign(info.accounting.gold)">{{ num(info.accounting.gold, 3) }}</span>
<small>گرم</small>
</div>
<div class="line">
<span :class="sign(info.accounting.rial)">{{ num(info.accounting.rial, 0) }}</span>
<small>ریال</small>
</div>
<div class="hint">{{ jdt(info.accounting.at) }}</div>
</template>
<template v-else>
<div class="line muted">—</div>
<div class="hint">هنوز دریافت نشده</div>
</template>
</div>
<div class="balance">
<div class="label">مانده‌ی دفتر اپ</div>
<div class="line">
<span :class="sign(info?.internal?.gold)">{{ num(info?.internal?.gold, 3) }}</span>
<small>گرم</small>
</div>
<div class="line">
<span :class="sign(info?.internal?.rial)">{{ num(info?.internal?.rial, 0) }}</span>
<small>ریال</small>
</div>
<div class="hint">مبنای سقف اعتبار</div>
</div>
</div>
<div v-if="lastJob" class="last-job">
<a-badge :status="JOB_BADGE[lastJob.status] ?? 'default'" />
آخرین ارسال: {{ STATUS[lastJob.status] ?? lastJob.status }} — {{ jdt(lastJob.updatedAt) }}
<div v-if="lastJob.lastError" class="warn">{{ lastJob.lastError }}</div>
</div>
<div class="link-form">
<div class="label">{{ info?.link ? 'تغییر لینک' : 'لینک دستی' }}</div>
<div class="link-row">
<a-input
v-model:value="code"
dir="ltr"
placeholder="کد حساب در حسابداری"
@press-enter="code.trim() && link()"
/>
<a-button type="primary" ghost :disabled="!code.trim()" @click="link">لینک</a-button>
</div>
<a-popconfirm v-if="info?.link" title="لینک حذف شود؟" @confirm="unlink">
<a-button type="link" danger size="small" class="unlink">حذف لینک فعلی</a-button>
</a-popconfirm>
</div>
</div>
<a-space class="mt-2">
<a-input v-model:value="code" dir="ltr" placeholder="کد حساب در حسابداری" style="width: 200px" />
<a-button size="small" :disabled="!code.trim()" @click="link">لینک دستی</a-button>
<a-popconfirm v-if="info?.link" title="لینک حذف شود؟" @confirm="unlink">
<a-button size="small" danger>حذف لینک</a-button>
</a-popconfirm>
</a-space>
</a-spin>
</a-card>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import { message, theme } from 'ant-design-vue';
import moment from 'moment-jalaali';
import api from '../../api/client';
import { useUsersStore } from '../../stores/users';
@@ -68,12 +104,32 @@ const STATUS: Record<string, string> = {
NEEDS_REVIEW: 'نیاز به بررسی',
RESOLVED: 'حل‌شده',
};
const JOB_BADGE: Record<string, 'success' | 'processing' | 'error' | 'warning' | 'default'> = {
PENDING: 'default',
SENDING: 'processing',
SENT: 'success',
FAILED: 'error',
NEEDS_REVIEW: 'warning',
RESOLVED: 'success',
};
const users = useUsersStore();
const { token } = theme.useToken();
const info = ref<any>(null);
const loading = ref(false);
const code = ref('');
const themeVars = computed(() => ({
'--acc-bg': token.value.colorBgContainer,
'--acc-border': token.value.colorBorderSecondary,
'--acc-fill': token.value.colorFillQuaternary,
'--acc-text': token.value.colorText,
'--acc-text-2': token.value.colorTextSecondary,
'--acc-text-3': token.value.colorTextTertiary,
'--acc-error': token.value.colorError,
'--acc-success': token.value.colorSuccess,
}));
const visible = computed(() => Boolean(users.hasPermission('config.accounting')) && info.value && info.value.provider !== 'none');
const lastJob = computed(() => info.value?.jobs?.[0]);
@@ -115,28 +171,116 @@ onMounted(load);
</script>
<style scoped>
.mt-4 {
margin-top: 1rem;
.acc-card {
background: var(--acc-bg);
border: 1px solid var(--acc-border);
border-radius: 12px;
color: var(--acc-text);
}
.mt-2 {
margin-top: 0.5rem;
.acc-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--acc-border);
}
.ms-2 {
margin-inline-start: 0.5rem;
.acc-head h2 {
margin: 0;
font-size: 14px;
font-weight: 700;
}
.muted {
color: #888;
.acc-head :deep(.ant-tag) {
margin: 0;
}
.small {
.acc-body {
display: flex;
flex-direction: column;
gap: 14px;
padding: 14px 18px 16px;
}
.label {
font-size: 13px;
color: var(--acc-text-2);
}
.acc-code {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
}
.acc-code .label {
margin-inline-end: auto;
}
.code {
font-weight: 700;
letter-spacing: 0.5px;
}
.origin {
font-size: 12px;
color: var(--acc-text-3);
}
.balances {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.balance {
padding: 10px 12px;
border-radius: 10px;
background: var(--acc-fill);
}
.balance .label {
margin-bottom: 4px;
font-size: 12px;
}
.line {
font-size: 15px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.7;
}
.line small {
margin-inline-start: 4px;
font-size: 11px;
font-weight: 500;
color: var(--acc-text-3);
}
.hint {
margin-top: 2px;
font-size: 11px;
color: var(--acc-text-3);
}
.last-job {
font-size: 12px;
color: var(--acc-text-2);
}
.link-form {
padding-top: 12px;
border-top: 1px dashed var(--acc-border);
}
.link-form .label {
margin-bottom: 6px;
font-size: 12px;
}
.link-row {
display: flex;
gap: 8px;
}
.unlink {
margin-top: 4px;
padding-inline: 0;
}
.muted {
color: var(--acc-text-3);
}
.warn {
color: #d4380d;
margin-top: 2px;
color: var(--acc-error);
}
.debt {
color: #cf1322;
color: var(--acc-error);
}
.credit {
color: #389e0d;
color: var(--acc-success);
}
</style>
@@ -293,7 +293,10 @@ const opOptions = [
{ value: 'between', label: 'بین' },
];
const cols = computed(() => props.model?.cols ?? []);
// The model is a plain class instance (see model.ts), so its columns are not
// reactive on their own. Wrapping it here lets the column picker's `col.show`
// checkbox toggles re-run visibleCols / antColumns.
const cols = computed<Xtc[]>(() => (props.model ? (reactive(props.model) as XTbl).cols : []));
const visibleCols = computed(() => cols.value.filter((c) => c.show));
const filterableOptions = computed(() =>
cols.value.filter((c) => c.filtered).map((c) => ({ value: c.__fkey ?? c.key, label: c.title })),
@@ -42,6 +42,9 @@ export class Xtc {
this.key = key;
this.title = title;
this.dataIndex = key;
// The record id ('کد') is kept out of every table by default. It can still be
// turned on from the column picker.
if (key === 'id') this.show = false;
}
private __filter() {
@@ -19,7 +19,7 @@
>
<div class="nav-header" :class="{ 'is-collapsed': collapsed }">
<span class="nav-logo">
<img src="../../assets/images/logo/montzar-icon.png" alt="" />
<img src="../../assets/images/logo/brand-icon.png" alt="" />
</span>
<span v-if="!collapsed" class="nav-brand">
<span class="nav-title">{{ title }}</span>
@@ -66,9 +66,10 @@ import { getMenu, type NavMenuNode as NavMenuNodeData } from '../navMenuItems';
import { useUsersStore } from '../../stores/users';
import { resolveIcon } from '../../components/icons';
import NavMenuNode from './NavMenuNode.vue';
import { brand } from '../../brand';
withDefaults(defineProps<{ title?: string; collapsed?: boolean }>(), {
title: 'منتزر',
title: brand.name,
collapsed: false,
});
+6
View File
@@ -150,6 +150,12 @@ router.beforeEach(async (to) => {
const token = localStorage.getItem(tokenName);
if (token) {
const roles = await getPermission();
// The stored token could not be confirmed (API down, still booting, or any
// non-401 failure). Letting the route through here showed the whole panel to
// whoever held a stale token, so fail closed and send them to sign in.
if (!roles) {
return { path: '/auth/login' };
}
if (to.meta?.superAdminOnly && !useUsersStore().isSuperAdmin) {
return { name: 'page-not-authorized' };
}
+5 -4
View File
@@ -20,9 +20,9 @@
<section class="hero" aria-hidden="true">
<div class="brand-row">
<div class="logo-chip">
<img src="../../assets/images/logo/montzar-icon.png" alt="" />
<img src="../../assets/images/logo/brand-icon.png" alt="" />
</div>
<span class="brand-name">Montzar</span>
<span class="brand-name">{{ brand.nameEn }}</span>
</div>
<h2 class="hero-title">
@@ -83,7 +83,7 @@
<div class="glass-card">
<div class="card-head">
<div class="logo-chip logo-chip--sm">
<img src="../../assets/images/logo/montzar-icon.png" alt="Montzar" />
<img src="../../assets/images/logo/brand-icon.png" :alt="brand.nameEn" />
</div>
<span class="eyebrow"><span class="dot"></span>پنل مدیریت</span>
</div>
@@ -146,12 +146,13 @@
</section>
</main>
<footer class="page-footer">© Montzar</footer>
<footer class="page-footer">© {{ brand.nameEn }}</footer>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { brand } from '../../brand';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
+1 -1
View File
@@ -9,7 +9,7 @@
<div class="flex gap-6 w-full justify-center h-screen branch-login-page">
<div class="flex flex-col h-screen items-center justify-center">
<img
src="../../assets/images/logo/montzar-icon.png"
src="../../assets/images/logo/brand-icon.png"
width="150"
alt="login"
class="mx-auto mb-5"
@@ -121,7 +121,7 @@
<ul>
<li class="comment-item">
<img src="../../../assets/images/logo/montzar-icon.png" alt="comment author" class="img border" />
<img src="../../../assets/images/logo/brand-icon.png" alt="comment author" class="img border" />
<div class="comment-body">
<h5>ادمین</h5>
<span v-if="tempRow.reply && tempRow.reply[0]" class="time">
@@ -166,6 +166,7 @@ import TooltipBtn from '../../../components/tooltip-btn/TooltipBtn.vue';
import { buildCommentListModel } from './CommentListModel';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
import { brand } from '../../../brand';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
@@ -185,12 +186,12 @@ const showChangeReplyBtn = computed(
/**
* legacy built a storefront link from the product title:
* https://bnk.montzar.ir/product/<title-with-dashes>/<id>
* The host is kept because it points at the live public site, not the API.
* <site>/product/<title-with-dashes>/<id>
* The host is the live public site (brand.siteUrl), not the API.
*/
function productLink(record: Record<string, any>) {
const slug = String(record.product?.title ?? '').split(' ').join('-');
return `https://bnk.montzar.ir/product/${slug}/${record.product?.id}`;
return `${brand.siteUrl}/product/${slug}/${record.product?.id}`;
}
async function changeSts(id: string, sts: number) {
@@ -3,123 +3,279 @@
Shared implementation of legacy ViewOmdeCustomer.vue (376 lines) and
ViewSingleCustomer.vue (425 lines) — the customer detail screens.
Both show the same four panels (profile, orders, financial documents, wallet)
Both show the same panels (profile, orders, financial documents, wallet)
against the same customer record; legacy duplicated the whole page per
customer kind. The differing bits are parameterised:
- the wholesale screen adds an activity-log panel (legacy log/ago/{id})
- the retail screen adds a wallet panel (legacy single/wallet/{id})
Layout: profile header → financial KPI tiles → main column (record tabs)
beside a side column (profile details, accounting).
-->
<a-spin :spinning="loading">
<a-card :title="title" class="page-card">
<a-descriptions bordered size="small" :column="{ xxl: 3, xl: 3, lg: 3, md: 2, sm: 2, xs: 1 }">
<a-descriptions-item label="نام">{{ customer?.name ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="موبایل">{{ customer?.mobile ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="کد ملی">
{{ customer?.national_code ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="شهر">{{ customer?.city?.title ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="کارشناس فروش">
{{ customer?.reagent?.name ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="وضعیت">
<a-tag :color="customer?.active === 1 ? 'green' : 'red'">
{{ customer?.active === 1 ? 'فعال' : 'غیر فعال' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="سطح اعتبار">
{{ customer?.gram_level ?? '-' }} گرم
</a-descriptions-item>
<a-descriptions-item label="بدهی گرمی">{{ customer?.remain ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="بدهی ریالی">
{{ customer?.rial_remain ?? '-' }}
</a-descriptions-item>
<!-- legacy ViewOmdeCustomer.vue:28-30 -->
<a-descriptions-item label="میزان فروش">{{ dashboard?.grams_sell ?? '-' }}</a-descriptions-item>
</a-descriptions>
<div v-if="dashboard" class="stats mt-4">
<a-tag color="blue">کل سفارش‌ها: {{ dashboard.total_order }}</a-tag>
<a-tag color="orange">سفارش باز: {{ dashboard.open_order }}</a-tag>
<a-tag color="green">اسناد: {{ dashboard.ledger_entries }}</a-tag>
</div>
</a-card>
<!-- D27 — link and balance in the accounting software. -->
<CustomerAccountingCard :customer-id="id" />
<a-tabs class="mt-4 detail-tabs" :default-active-key="defaultTab">
<a-tab-pane key="orders" tab="سفارش ها">
<a-table
size="small"
:data-source="orders"
:columns="orderColumns"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="documents" tab="سند های مالی">
<a-table
size="small"
:data-source="documents"
:columns="documentColumns"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'confirmed'">
<a-tag :color="record.confirmed === 1 ? 'green' : ''">
{{ record.confirmed === 1 ? 'تایید شده' : 'تایید نشده' }}
<div class="customer-detail" :style="themeVars">
<!-- Profile header -->
<section class="panel hero">
<div class="hero-main">
<a-avatar :size="64" :src="customer?.image?.image" class="hero-avatar">
{{ initials }}
</a-avatar>
<div class="hero-text">
<div class="hero-eyebrow">{{ title }}</div>
<h1 class="hero-name">{{ customer?.name || '—' }}</h1>
<div class="hero-tags">
<a-tag v-if="customer" :color="customer.active === 1 ? 'success' : 'error'">
{{ customer.active === 1 ? 'فعال' : 'غیرفعال' }}
</a-tag>
</template>
<template v-else-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
<a-tag v-if="customer" :color="customer.sts === 1 ? 'processing' : 'warning'">
{{ customer.sts === 1 ? 'مجاز به سفارش' : 'عدم سفارش' }}
</a-tag>
<a-tag v-if="customer?.type_str">{{ customer.type_str }}</a-tag>
<a-tag v-if="customer?.is_archive === 1" color="default">بایگانی‌شده</a-tag>
</div>
<div class="hero-meta">
<span v-if="customer?.mobile" class="meta-item">
<PhoneOutlined />
<span dir="ltr">{{ customer.mobile }}</span>
</span>
<span v-if="customer?.city?.title" class="meta-item">
<EnvironmentOutlined />
{{ customer.city.title }}
</span>
<span v-if="customer?.reagent?.name" class="meta-item">
<UserOutlined />
کارشناس فروش: {{ customer.reagent.name }}
</span>
<span v-if="customer?.caj" class="meta-item">
<CalendarOutlined />
عضویت از {{ jdate(customer.caj) }}
</span>
</div>
</div>
</div>
<div class="hero-actions">
<a-button @click="router.back()">
<template #icon><ArrowRightOutlined /></template>
بازگشت
</a-button>
<a-button v-if="canEdit" type="primary" @click="router.push({ name: editRoute, params: { id } })">
<template #icon><EditOutlined /></template>
ویرایش مشتری
</a-button>
</div>
</section>
<a-tab-pane v-if="showWallet" key="wallet" tab="کیف پول">
<a-table
size="small"
:data-source="wallet"
:columns="walletColumns"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
<template v-else-if="column.dataIndex === 'expires_at'">
{{ jdate(record.expires_at) }}
</template>
<!-- Financial KPIs -->
<section class="kpis">
<div class="panel kpi">
<div class="kpi-label">بدهی طلایی</div>
<div class="kpi-value" :class="sign(customer?.remain)">
{{ num(customer?.remain, 3) }}<small>گرم</small>
</div>
<div class="kpi-foot">{{ balanceWord(customer?.remain) }}</div>
</div>
<div class="panel kpi">
<div class="kpi-label">بدهی ریالی</div>
<div class="kpi-value" :class="sign(customer?.rial_remain)">
{{ num(customer?.rial_remain, 0) }}<small>ریال</small>
</div>
<div class="kpi-foot">{{ balanceWord(customer?.rial_remain) }}</div>
</div>
<div class="panel kpi">
<div class="kpi-label">سقف اعتبار</div>
<div class="kpi-value">{{ num(customer?.gram_level, 3) }}<small>گرم</small></div>
<template v-if="creditUsage !== null">
<a-progress
:percent="creditUsage"
:stroke-color="creditColor"
:show-info="false"
size="small"
class="kpi-progress"
/>
<div class="kpi-foot">{{ num(creditUsage, 0) }}٪ از سقف استفاده شده</div>
</template>
</a-table>
</a-tab-pane>
<div v-else class="kpi-foot">سقفی تعیین نشده</div>
</div>
<div class="panel kpi">
<div class="kpi-label">میزان فروش</div>
<div class="kpi-value">{{ num(dashboard?.grams_sell, 3) }}<small>گرم</small></div>
<div class="kpi-foot">مجموع وزن سفارش‌ها</div>
</div>
<div class="panel kpi">
<div class="kpi-label">سفارش‌ها</div>
<div class="kpi-value">{{ num(dashboard?.total_order ?? orders.length, 0) }}</div>
<div class="kpi-foot">
<span :class="{ 'text-warning': Number(dashboard?.open_order) > 0 }">
{{ num(dashboard?.open_order, 0) }} باز
</span>
· {{ num(dashboard?.ledger_entries ?? documents.length, 0) }} سند
</div>
</div>
</section>
<a-tab-pane v-if="showActivity" key="activity" tab="فعالیت ها">
<a-table
size="small"
:data-source="activity"
:columns="activityColumns"
:pagination="{ pageSize: 15 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">{{ jdateTime(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
<div class="layout">
<!-- Main column: the customer's records -->
<section class="panel layout-main">
<a-tabs v-model:active-key="activeTab" class="detail-tabs">
<a-tab-pane key="orders">
<template #tab>
سفارش‌ها <span class="tab-count">{{ num(orders.length, 0) }}</span>
</template>
<a-table
size="middle"
:data-source="orders"
:columns="orderColumns"
:pagination="pagination(10, orders.length)"
:locale="{ emptyText: 'سفارشی برای این مشتری ثبت نشده است' }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'name'">
<span class="mono" dir="ltr">{{ record.name }}</span>
</template>
<template v-else-if="column.dataIndex === 'w'">
{{ num(record.w, 3) }} <span class="unit">گرم</span>
</template>
<template v-else-if="column.dataIndex === 'status'">
<a-tag :color="ORDER_STATUS[record.status]?.color">
{{ ORDER_STATUS[record.status]?.label ?? record.status ?? '—' }}
</a-tag>
</template>
<template v-else-if="column.dataIndex === 'branch'">{{ record.branch ?? '—' }}</template>
<template v-else-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="documents">
<template #tab>
سندهای مالی <span class="tab-count">{{ num(documents.length, 0) }}</span>
</template>
<a-table
size="middle"
:data-source="documents"
:columns="documentColumns"
:pagination="pagination(10, documents.length)"
:locale="{ emptyText: 'سندی برای این مشتری ثبت نشده است' }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'type'">
{{ SOURCE_TYPE[record.type] ?? record.type }}
</template>
<template v-else-if="column.dataIndex === 'value'">
<span :class="record.direction === 'DEBIT' ? 'debt' : record.direction === 'CREDIT' ? 'credit' : ''">
{{ record.direction === 'CREDIT' ? '−' : record.direction === 'DEBIT' ? '+' : '' }}{{
num(record.value, record.currency === 'RIAL' ? 0 : 3)
}}
</span>
<span class="unit">{{ record.currency === 'RIAL' ? 'ریال' : 'گرم' }}</span>
</template>
<template v-else-if="column.dataIndex === 'confirmed'">
<a-badge
:status="record.confirmed === 1 ? 'success' : 'default'"
:text="record.confirmed === 1 ? 'تایید شده' : 'تایید نشده'"
/>
</template>
<template v-else-if="column.dataIndex === 'note'">
<span class="note">{{ record.note || '—' }}</span>
</template>
<template v-else-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane v-if="showWallet" key="wallet">
<template #tab>
کیف پول <span class="tab-count">{{ num(wallet.length, 0) }}</span>
</template>
<a-table
size="middle"
:data-source="wallet"
:columns="walletColumns"
:pagination="pagination(10, wallet.length)"
:locale="{ emptyText: 'اعتباری در کیف پول ثبت نشده است' }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'value'">
{{ num(record.value, 3) }} <span class="unit">گرم</span>
</template>
<template v-else-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
<template v-else-if="column.dataIndex === 'expires_at'">
{{ record.expires_at ? jdate(record.expires_at) : '—' }}
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane v-if="showActivity" key="activity">
<template #tab>
فعالیت‌ها <span class="tab-count">{{ num(activity.length, 0) }}</span>
</template>
<a-table
size="middle"
:data-source="activity"
:columns="activityColumns"
:pagination="pagination(15, activity.length)"
:locale="{ emptyText: 'فعالیتی ثبت نشده است' }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">
<span dir="ltr">{{ jdateTime(record.caj) }}</span>
</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</section>
<!-- Side column: who the customer is -->
<aside class="layout-side">
<section class="panel">
<h2 class="panel-title">اطلاعات هویتی و تماس</h2>
<dl class="facts">
<div v-for="f in identityFacts" :key="f.label" class="fact">
<dt>{{ f.label }}</dt>
<dd :dir="f.ltr ? 'ltr' : undefined">{{ f.value || '—' }}</dd>
</div>
</dl>
</section>
<section v-if="businessFacts.length" class="panel">
<h2 class="panel-title">اطلاعات کسب‌وکار</h2>
<dl class="facts">
<div v-for="f in businessFacts" :key="f.label" class="fact">
<dt>{{ f.label }}</dt>
<dd>{{ f.value }}</dd>
</div>
</dl>
</section>
<!-- D27 — link and balance in the accounting software. -->
<CustomerAccountingCard :customer-id="id" />
</aside>
</div>
</div>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { theme } from 'ant-design-vue';
import {
ArrowRightOutlined,
CalendarOutlined,
EditOutlined,
EnvironmentOutlined,
PhoneOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { jdate, jdateTime } from '../../utils/filters';
import api from '../../api/client';
import { useUsersStore } from '../../stores/users';
import CustomerAccountingCard from '../../components/accounting/CustomerAccountingCard.vue';
const props = withDefaults(
@@ -130,14 +286,26 @@ const props = withDefaults(
/** The wholesale screen shows the activity log. */
showActivity?: boolean;
defaultTab?: string;
/** Named route of this customer kind's edit form; the button hides if absent or not permitted. */
editRoute?: string;
}>(),
{ title: 'مشاهده اطلاعات مشتری', showWallet: false, showActivity: false, defaultTab: 'orders' },
{
title: 'مشاهده اطلاعات مشتری',
showWallet: false,
showActivity: false,
defaultTab: 'orders',
editRoute: undefined,
},
);
const route = useRoute();
const router = useRouter();
const users = useUsersStore();
const { token } = theme.useToken();
const id = route.params.id as string;
const loading = ref(false);
const activeTab = ref(props.defaultTab);
const customer = ref<Record<string, any> | undefined>(undefined);
const dashboard = ref<Record<string, any> | undefined>(undefined);
const orders = ref<Record<string, any>[]>([]);
@@ -145,10 +313,50 @@ const documents = ref<Record<string, any>[]>([]);
const wallet = ref<Record<string, any>[]>([]);
const activity = ref<Record<string, any>[]>([]);
// Colours come from the antd theme so the page follows the panel's palette.
const themeVars = computed(() => ({
'--cd-bg': token.value.colorBgContainer,
'--cd-border': token.value.colorBorderSecondary,
'--cd-text': token.value.colorText,
'--cd-text-2': token.value.colorTextSecondary,
'--cd-text-3': token.value.colorTextTertiary,
'--cd-fill': token.value.colorFillQuaternary,
'--cd-primary': token.value.colorPrimary,
'--cd-primary-bg': token.value.colorPrimaryBg,
'--cd-error': token.value.colorError,
'--cd-success': token.value.colorSuccess,
'--cd-warning': token.value.colorWarning,
}));
// OrderStatus enum (schema.prisma); labels as on the accounting settings page.
const ORDER_STATUS: Record<string, { label: string; color?: string }> = {
SUSPENDED: { label: 'معلق (عبور از سقف)', color: 'error' },
PLACED: { label: 'ثبت سفارش', color: 'default' },
ADMIN_APPROVED: { label: 'تأیید مدیر', color: 'processing' },
SOURCING: { label: 'در حال تأمین', color: 'processing' },
PREPARING: { label: 'در حال آماده‌سازی', color: 'processing' },
DELIVERED_TO_AGENT: { label: 'تحویل مسئول فروش', color: 'cyan' },
DELIVERED_TO_CUSTOMER: { label: 'تحویل مشتری', color: 'success' },
CANCELLED: { label: 'لغو شده', color: 'default' },
};
// LedgerSourceType enum (schema.prisma).
const SOURCE_TYPE: Record<string, string> = {
ORDER_STOCK: 'سفارش کالای موجود',
ORDER_PRE_ORDER: 'پیش‌سفارش',
REPAYMENT_MELTED_GOLD: 'تسویه با آبشده',
REPAYMENT_TRANSFER: 'تسویه با واریز',
REPAYMENT_CHEQUE: 'تسویه با چک',
REPAYMENT_CASH: 'تسویه نقدی',
WALLET_BONUS: 'پاداش کیف پول',
ADJUSTMENT: 'اصلاحیه',
};
const orderColumns = [
{ title: 'شماره سفارش', dataIndex: 'name' },
{ title: 'وضعیت', dataIndex: 'status' },
{ title: 'وزن', dataIndex: 'w' },
{ title: 'تعداد آیتم', dataIndex: 'items_count' },
{ title: 'تعداد آیتم', dataIndex: 'items_count', align: 'center' as const },
{ title: 'شعبه', dataIndex: 'branch' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
@@ -157,12 +365,12 @@ const documentColumns = [
{ title: 'نوع', dataIndex: 'type' },
{ title: 'مقدار', dataIndex: 'value' },
{ title: 'وضعیت', dataIndex: 'confirmed' },
{ title: 'توضیحات', dataIndex: 'note' },
{ title: 'توضیحات', dataIndex: 'note', ellipsis: true },
{ title: 'تاریخ', dataIndex: 'caj' },
];
const walletColumns = [
{ title: 'مقدار (گرم)', dataIndex: 'value' },
{ title: 'مقدار', dataIndex: 'value' },
{ title: 'تاریخ اعطا', dataIndex: 'caj' },
{ title: 'انقضا', dataIndex: 'expires_at' },
];
@@ -173,6 +381,65 @@ const activityColumns = [
{ title: 'تاریخ', dataIndex: 'caj' },
];
const num = (v: unknown, places: number) =>
v === null || v === undefined || v === '' || Number.isNaN(Number(v))
? '—'
: Number(v).toLocaleString('fa-IR', { maximumFractionDigits: places });
// Positive balance = the customer owes (same convention as the accounting card).
const sign = (v: unknown) => (Number(v) > 0 ? 'debt' : Number(v) < 0 ? 'credit' : '');
const balanceWord = (v: unknown) =>
v === null || v === undefined ? '' : Number(v) > 0 ? 'بدهکار' : Number(v) < 0 ? 'بستانکار' : 'تسویه';
const pagination = (pageSize: number, total: number) =>
total > pageSize ? { pageSize, size: 'small' as const, showSizeChanger: false } : false;
const initials = computed(() => (customer.value?.name ?? '').trim().charAt(0) || '؟');
const creditUsage = computed<number | null>(() => {
const limit = Number(customer.value?.gram_level);
if (!limit || limit <= 0) return null;
const used = Math.max(Number(customer.value?.remain) || 0, 0);
return Math.min(Math.round((used / limit) * 100), 100);
});
const creditColor = computed(() => {
const u = creditUsage.value ?? 0;
return u >= 90 ? token.value.colorError : u >= 70 ? token.value.colorWarning : token.value.colorPrimary;
});
const canEdit = computed(() => {
if (!props.editRoute || !router.hasRoute(props.editRoute)) return false;
const meta = router.resolve({ name: props.editRoute, params: { id } }).meta as Record<string, any>;
if (meta.superAdminOnly && !users.isSuperAdmin) return false;
return !meta.role || Boolean(users.hasPermission(meta.role));
});
const identityFacts = computed(() => {
const c = customer.value;
return [
{ label: 'موبایل', value: c?.mobile, ltr: true },
{ label: 'تلفن ثابت', value: c?.phone, ltr: true },
{ label: 'کد ملی', value: c?.national_code, ltr: true },
{ label: 'تاریخ تولد', value: c?.birth_date ? jdate(c.birth_date) : null },
{ label: 'شهر', value: c?.city?.title },
{ label: 'آدرس', value: c?.address },
];
});
// Only the fields this customer actually has — retail customers usually have none.
const businessFacts = computed(() => {
const c = customer.value;
return [
{ label: 'نام فروشگاه', value: c?.store_name },
{ label: 'پاساژ', value: c?.pasazh_name },
{ label: 'منطقه', value: c?.zone },
{ label: 'کد عضویت', value: c?.mcode },
{ label: 'کارشناس فروش', value: c?.reagent?.name },
{
label: 'درصد اجرت',
value: c?.percent !== null && c?.percent !== undefined && c?.percent !== '' ? `${num(c.percent, 2)}٪` : null,
},
].filter((f) => f.value);
});
onMounted(async () => {
try {
loading.value = true;
@@ -206,24 +473,282 @@ onMounted(async () => {
</script>
<style scoped>
.stats {
.customer-detail {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.mt-4 {
margin-top: 1rem;
flex-direction: column;
gap: 16px;
color: var(--cd-text);
}
.panel {
background: var(--cd-bg);
border: 1px solid var(--cd-border);
border-radius: 12px;
}
.panel-title {
margin: 0;
padding: 14px 18px;
font-size: 14px;
font-weight: 700;
border-bottom: 1px solid var(--cd-border);
}
/* ── Header ─────────────────────────────────────────── */
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px 24px;
}
.hero-main {
display: flex;
align-items: flex-start;
gap: 16px;
min-width: 0;
}
.hero-avatar {
flex-shrink: 0;
background: var(--cd-primary);
font-size: 26px;
font-weight: 700;
}
.hero-text {
min-width: 0;
}
.hero-eyebrow {
font-size: 12px;
color: var(--cd-text-3);
}
.hero-name {
margin: 2px 0 8px;
font-size: 22px;
font-weight: 800;
line-height: 1.4;
color: var(--cd-text);
}
.hero-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.hero-tags :deep(.ant-tag) {
margin: 0;
}
.hero-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 20px;
margin-top: 12px;
font-size: 13px;
color: var(--cd-text-2);
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.meta-item :deep(.anticon) {
color: var(--cd-text-3);
}
.hero-actions {
display: flex;
flex-shrink: 0;
gap: 8px;
}
/* ── KPI tiles ──────────────────────────────────────── */
.kpis {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 16px;
}
.kpi {
display: flex;
flex-direction: column;
padding: 16px 18px;
}
.kpi-label {
font-size: 13px;
color: var(--cd-text-2);
}
.kpi-value {
margin-top: 6px;
font-size: 22px;
font-weight: 800;
line-height: 1.3;
font-variant-numeric: tabular-nums;
color: var(--cd-text);
}
.kpi-value small {
margin-inline-start: 4px;
font-size: 12px;
font-weight: 500;
color: var(--cd-text-3);
}
.kpi-progress {
margin: 6px 0 0;
}
.kpi-foot {
margin-top: auto;
padding-top: 6px;
font-size: 12px;
color: var(--cd-text-3);
}
/* ── Two-column body ────────────────────────────────── */
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 16px;
align-items: start;
}
.layout-main {
padding: 4px 20px 16px;
}
.layout-side {
display: flex;
flex-direction: column;
gap: 16px;
}
.detail-tabs :deep(.ant-tabs-nav) {
margin-bottom: 12px;
}
.detail-tabs :deep(.ant-tabs-tab) {
padding: 14px 0;
font-weight: 600;
}
.tab-count {
display: inline-block;
min-width: 22px;
margin-inline-start: 6px;
padding: 0 7px;
border-radius: 10px;
background: var(--cd-fill);
color: var(--cd-text-2);
font-size: 12px;
font-weight: 500;
line-height: 20px;
text-align: center;
}
.detail-tabs :deep(.ant-tabs-tab-active) .tab-count {
background: var(--cd-primary-bg);
color: var(--cd-primary);
}
.detail-tabs :deep(.ant-table-thead > tr > th) {
font-weight: 600;
color: var(--cd-text-2);
}
.facts {
margin: 0;
padding: 6px 18px 10px;
}
.fact {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
padding: 9px 0;
border-bottom: 1px dashed var(--cd-border);
}
.fact:last-child {
border-bottom: 0;
}
.fact dt {
flex-shrink: 0;
font-size: 13px;
color: var(--cd-text-2);
}
.fact dd {
margin: 0;
font-weight: 600;
text-align: end;
word-break: break-word;
}
/* ── Shared bits ────────────────────────────────────── */
.debt {
color: var(--cd-error);
}
.credit {
color: var(--cd-success);
}
.text-warning {
color: var(--cd-warning);
font-weight: 600;
}
.unit {
font-size: 12px;
color: var(--cd-text-3);
}
.mono {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.note {
color: var(--cd-text-2);
}
/* ── Responsive ─────────────────────────────────────── */
@media (max-width: 1399px) {
.kpis {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1199px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
/* details first on narrow screens — they frame the records below */
.layout-side {
order: -1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
align-items: start;
}
}
@media (max-width: 767px) {
.hero {
flex-direction: column;
padding: 16px;
}
.hero-actions {
width: 100%;
}
.hero-actions :deep(.ant-btn) {
flex: 1;
}
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
}
@media (max-width: 575px) {
.mt-4 {
margin-top: 12px;
.customer-detail {
gap: 12px;
}
.stats :deep(.ant-tag) {
margin: 0;
.hero-avatar {
width: 48px !important;
height: 48px !important;
line-height: 48px !important;
font-size: 20px;
}
.detail-tabs :deep(.ant-tabs-nav) {
margin-bottom: 8px;
.hero-name {
font-size: 18px;
}
.kpi {
padding: 12px 14px;
}
.kpi-value {
font-size: 18px;
}
.layout-main {
padding: 0 12px 12px;
}
.layout-side {
grid-template-columns: minmax(0, 1fr);
}
.detail-tabs :deep(.ant-table-content) {
overflow-x: auto;
@@ -42,6 +42,14 @@
</a-tag>
</template>
<!-- D27 — balance in the accounting software; positive = the customer owes -->
<template v-for="cell in ACC_CELLS" :key="cell.slot" #[cell.slot]="{ record }">
<span v-if="record[cell.field] === null || record[cell.field] === undefined" class="acc-none">—</span>
<a-tooltip v-else :title="record.acc_at ? `به‌روزرسانی: ${jdt(record.acc_at)}` : undefined">
<span :class="accClass(record[cell.field])">{{ accText(record[cell.field], cell.places) }}</span>
</a-tooltip>
</template>
<template #opt="{ record }">
<div class="flex">
<a-dropdown>
@@ -253,7 +261,8 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onMounted, reactive, ref } from 'vue';
import moment from 'moment-jalaali';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
@@ -275,6 +284,7 @@ import BulkCustomer from '../../apps/customer/bulkCustomer.vue';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
import type { XTbl } from '../../../components/x-table/model';
import { addAccountingColumns } from './omdeListModel';
const props = withDefaults(
defineProps<{
@@ -282,19 +292,12 @@ const props = withDefaults(
title: string;
/** The table definition (differs only in its url). */
model: XTbl;
/**
* OmdeNonVerifiedList's quirk: its popconfirm calls rejectStepOne() in BOTH
* the accept and reject branches, so the first step can never be approved
* from that screen. Faithfully reproduced behind this flag rather than
* silently "fixed" — see docs/ADMIN-PORT-PLAN.md.
*/
rejectOnlyStepOne?: boolean;
/** Only OmdeList.vue passes this — see the comment on its call site. */
showAddButton?: boolean;
/** D27 §12 — «سینک با حسابداری» for the whole list; only OmdeList.vue. */
accountingSync?: boolean;
}>(),
{ rejectOnlyStepOne: false, showAddButton: false, accountingSync: false },
{ showAddButton: false, accountingSync: false },
);
const router = useRouter();
@@ -315,6 +318,31 @@ function setCount(raw: unknown) {
};
}
// ── D27: accounting balance columns, only while a software is connected ──
const ACC_CELLS = [
{ slot: 'acc_gold', field: 'acc_remain', places: 3 },
{ slot: 'acc_rial', field: 'acc_rial_remain', places: 0 },
] as const;
const accClass = (v: unknown) => (Number(v) > 0 ? 'acc-debt' : Number(v) < 0 ? 'acc-credit' : '');
function accText(v: unknown, places: number) {
const n = Number(v);
if (!n) return '۰';
const abs = Math.abs(n).toLocaleString('fa-IR', { maximumFractionDigits: places });
return `${abs} ${n > 0 ? 'بدهکار' : 'بستانکار'}`;
}
const jdt = (v: string) => moment(v).format('jYYYY/jMM/jDD HH:mm');
onMounted(async () => {
try {
const { data } = await api.get('admin/accounting-status');
// The proxy, not the raw model: XTable reads the columns through reactive().
if (data?.connected && data.provider) addAccountingColumns(reactive(props.model) as XTbl, data.provider);
} catch {
// No status, no columns — the list works as before.
}
});
/**
* legacy handleMenuClick(): closes the dropdown for the keys that open a
* popconfirm, so the menu does not stay open behind the confirmation.
@@ -424,11 +452,13 @@ async function submitReject() {
d.open = false;
}
/** legacy inline ternary on @confirm, preserved including the NonVerified quirk */
/**
* legacy inline ternary on @confirm. Legacy's OmdeNonVerifiedList rejected on
* BOTH buttons, so a pending customer could never be approved there; that
* quirk was dropped 2026-10-14 — every list now approves on «تایید».
*/
function onConfirmStepOne(record: Record<string, any>) {
if (record.sts_verify_step_one === 1) {
return props.rejectOnlyStepOne ? rejectStepOne(record.id) : acceptStepOne(record.id);
}
if (record.sts_verify_step_one === 1) return acceptStepOne(record.id);
if (record.sts_verify_step_one === 2) return rejectStepOne(record.id);
return acceptStepOne(record.id);
}
@@ -504,6 +534,15 @@ function viewCash(record: Record<string, any>) {
</script>
<style scoped>
.acc-debt {
color: #cf1322;
}
.acc-credit {
color: #389e0d;
}
.acc-none {
color: #bfbfbf;
}
@media (max-width: 575px) {
.list-count {
font-size: 12px;
@@ -1,6 +1,6 @@
<template>
<!-- Ported from legacy views/customer/omde/OmdeNonVerifiedList.vue. Carries the legacy quirk where step-one confirm rejects instead of accepting. -->
<OmdeCustomerList :title="title" :model="model" reject-only-step-one />
<!-- Ported from legacy views/customer/omde/OmdeNonVerifiedList.vue. Legacy's step-one confirm rejected instead of accepting; fixed 2026-10-14. -->
<OmdeCustomerList :title="title" :model="model" />
</template>
<script setup lang="ts">
@@ -9,6 +9,7 @@
title="مشاهده اطلاعات مشتری عمده"
:show-activity="true"
:show-wallet="false"
edit-route="bulkCustomer-edit"
/>
</template>
@@ -67,4 +67,26 @@ export function buildOmdeListModel(source: OmdeSourceKey): XTbl {
return tbl;
}
/**
* D27 — while an accounting software is connected, its balance gets two
* columns named after it («مانده‌ی زرین» / «مانده‌ی ته‌حساب»), and the app's
* own debt columns are renamed so the two are not mistaken for each other
* (docs/ACCOUNTING-BALANCE-COLUMNS-PLAN.md). Not connected: nothing changes.
* Pass the reactive proxy of the model so XTable picks the change up.
*/
export function addAccountingColumns(tbl: XTbl, provider: string) {
if (tbl.cols.some((c) => c.key === 'acc_remain')) return;
const at = tbl.cols.findIndex((c) => c.key === 'rial_remain');
for (const c of tbl.cols) {
if (c.key === 'remain') c.title = 'بدهی داخل اپ (گرم)';
if (c.key === 'rial_remain') c.title = 'بدهی داخل اپ (ریال)';
}
tbl.cols.splice(
at + 1,
0,
new Xtc('acc_remain', `مانده‌ی ${provider} (گرم)`).filterNumber().renderSlot('acc_gold'),
new Xtc('acc_rial_remain', `مانده‌ی ${provider} (ریال)`).filterNumber().renderSlot('acc_rial'),
);
}
export default buildOmdeListModel;
@@ -10,6 +10,7 @@
title="مشاهده اطلاعات مشتری"
:show-wallet="true"
:show-activity="false"
edit-route="singleCustomer-edit"
/>
</template>
+93 -22
View File
@@ -101,18 +101,39 @@
key="slider"
tab="اسلایدر موبایل"
>
<div v-for="(s, index) in sliders" :key="index" class="slide">
<h6 class="mobile-c">اسلاید {{ index + 1 }}</h6>
<div class="slider-toolbar">
<span class="slider-count">{{ sliders.length }} اسلاید</span>
<div class="row">
<ImageUploadInput v-model="s.image" purpose="slider" placeholder="تصویر اسلاید" />
<a-input v-model:value="s.link" placeholder="لینک" />
<a-button danger @click="sliders.splice(index, 1)">حذف</a-button>
<a-button @click="addSlide">
<template #icon><PlusOutlined /></template>
افزودن اسلاید
</a-button>
<a-button type="primary" @click="saveSliders">ذخیره اسلایدر</a-button>
</div>
</div>
<div class="row mt-4">
<a-button @click="sliders.push({ image: '', link: '' })">افزودن اسلاید</a-button>
<a-button type="primary" @click="saveSliders">ذخیره اسلایدر</a-button>
<a-empty v-if="sliders.length === 0" description="اسلایدی ثبت نشده است" />
<div v-else class="slider-grid">
<div v-for="(s, index) in sliders" :key="index" class="slide-card">
<div class="slide-card__head">
<span class="slide-card__title">اسلاید {{ index + 1 }}</span>
<a-popconfirm
ok-text="بله"
cancel-text="خیر"
title="این اسلاید حذف شود؟"
@confirm="removeSlide(index)"
>
<a-button type="text" danger size="small" title="حذف">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
</div>
<div class="slide-card__media">
<ImageUploadInput v-model="s.image" purpose="slider" placeholder="تصویر اسلاید" />
</div>
<a-input v-model:value="s.link" placeholder="لینک" />
</div>
</div>
</a-tab-pane>
@@ -173,7 +194,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons-vue';
import { useUsersStore } from '../../stores/users';
import { useDeleteHandler } from '../../composables/useDeleteHandler';
import api from '../../api/client';
@@ -280,6 +301,14 @@ async function saveTags() {
}
// ── slider
function addSlide() {
sliders.value.push({ image: '', link: '' });
}
function removeSlide(index: number) {
sliders.value.splice(index, 1);
}
// Gated server-side by its own `custom.config.statics.slider` permission (not
// the broad `config.statics` that guards the rest of this screen) — see
// admin-misc.controller.ts. Uses the dedicated `const/slider` route rather
@@ -368,14 +397,62 @@ onMounted(async () => {
gap: 0.5rem;
flex-wrap: wrap;
}
.slide {
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 0.75rem;
.slider-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.mobile-c {
margin: 0 0 0.5rem;
.slider-count {
color: rgba(0, 0, 0, 0.45);
}
/* one card per slide; min() keeps a card no wider than a narrow phone column */
.slider-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr));
gap: 16px;
}
.slide-card {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
padding: 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fff;
}
.slide-card__head {
display: flex;
justify-content: space-between;
align-items: center;
}
.slide-card__title {
font-weight: 600;
}
/* Fixed-height media box: the empty upload tile, the uploading spinner and the
saved thumbnail all fill it, so the card keeps the same height when a photo
is added or removed (no jump). The shared upload tile is 104px square by
default; here it becomes a full-width banner preview. */
.slide-card__media {
height: 140px;
}
.slide-card__media :deep(.image-upload),
.slide-card__media :deep(.ant-upload-wrapper),
.slide-card__media :deep(.ant-upload-list),
.slide-card__media :deep(.ant-upload-list-item-container),
.slide-card__media :deep(.ant-upload.ant-upload-select) {
width: 100% !important;
height: 100% !important;
margin: 0 !important;
}
.slide-card__media :deep(.ant-upload-wrapper) {
display: block;
}
.slide-card__media :deep(.ant-upload-list-item-thumbnail img) {
object-fit: cover;
}
.mt-4 {
margin-top: 1rem;
@@ -387,12 +464,6 @@ onMounted(async () => {
flex: 1 1 140px;
max-width: none !important;
}
.slide {
padding: 10px;
}
.slide .row > * {
flex: 1 1 100%;
}
.mt-4 {
margin-top: 12px;
}
@@ -119,7 +119,7 @@
</div>
<div class="hint mb-4">
اسناد سفارش همیشه «موقت» ثبت می‌شوند و تا حسابدار نهایی نکند روی مانده‌ی حسابداری اثر ندارند.
سقف اعتبار سفارش همچنان با دفتر داخلی منتزر چک می‌شود.
سقف اعتبار سفارش همچنان با دفتر داخلی {{ brand.name }} چک می‌شود.
</div>
<a-button type="primary" @click="save">ذخیره</a-button>
</a-tab-pane>
@@ -129,17 +129,17 @@
<a-collapse ghost class="mb-4">
<a-collapse-panel key="guide" header="راهنمای این تب: نگاشت چیست و چطور پر می‌شود؟">
<p>
در این صفحه «منتزر» یعنی همین سامانه: <b>اپ مشتری</b> (جایی که مشتری ثبت‌نام، احراز هویت و سفارش می‌کند و مانده‌اش را می‌بیند) و
در این صفحه «{{ brand.name }}» یعنی همین سامانه: <b>اپ مشتری</b> (جایی که مشتری ثبت‌نام، احراز هویت و سفارش می‌کند و مانده‌اش را می‌بیند) و
<b>پنل مدیریت</b> (همین پنل، جایی که محصول و دسته‌بندی تعریف می‌شود). «حسابداری» یعنی نرم‌افزار حسابداری مغازه (ته‌حساب یا زرین).
</p>
<p>
منتزر و حسابداری هر کدام گروه‌بندی خودشان را دارند. این تب می‌گوید هر چیز در منتزر با کدام گروه در حسابداری متناظر است؛
{{ brand.name }} و حسابداری هر کدام گروه‌بندی خودشان را دارند. این تب می‌گوید هر چیز در {{ brand.name }} با کدام گروه در حسابداری متناظر است؛
خودِ مشتری‌ها و محصول‌ها اینجا وصل نمی‌شوند (آن‌ها در تب «لینک‌ها» دیده می‌شوند).
</p>
<ol>
<li>تنظیمات تب «اتصال» را ذخیره کنید، بعد «دریافت گروه‌ها از حسابداری» را بزنید تا فهرست‌ها از حسابداری پر شوند.</li>
<li>
<b>گروه‌های حسابی که مشتری منتزر حساب می‌شوند:</b> در حسابداری صندوق، کارمند و سرمایه‌گذار هم «حساب» هستند. فقط گروه‌هایی را انتخاب کنید که
<b>گروه‌های حسابی که مشتری {{ brand.name }} حساب می‌شوند:</b> در حسابداری صندوق، کارمند و سرمایه‌گذار هم «حساب» هستند. فقط گروه‌هایی را انتخاب کنید که
واقعاً مشتری‌اند؛ سینک مشتری فقط حساب‌های این گروه‌ها را با شماره موبایل به کاربران اپ مشتری وصل می‌کند. اگر خالی بماند سینک مشتری اجرا نمی‌شود.
</li>
<li>
@@ -147,12 +147,12 @@
اگر تعیین نشود، ساخت حساب در «لاگ ارسال» با وضعیت «نیاز به بررسی» می‌ماند.
</li>
<li>
<b>عیار پایه:</b> عیار وزن‌هایی که در منتزر ثبت می‌شود (وزن محصول‌ها و سفارش‌ها؛ معمولاً ۷۵۰ = ۱۸ عیار). در سند سفارش، وزن با همین عیار به
<b>عیار پایه:</b> عیار وزن‌هایی که در {{ brand.name }} ثبت می‌شود (وزن محصول‌ها و سفارش‌ها؛ معمولاً ۷۵۰ = ۱۸ عیار). در سند سفارش، وزن با همین عیار به
حسابداری می‌رود؛ در ته‌حساب اجرت هم روی آن اعمال می‌شود (مثلاً ۷۵۰ با اجرت ۱۰٪ می‌شود ۸۲۵).
</li>
<li v-if="form.provider === 'zarrin'">
<b>کارساخته (فقط زرین):</b> وقتی در پنل مدیریت منتزر محصول جدیدی تعریف شود، برایش یک «کارساخته» در زرین ساخته می‌شود. «نوع» تعیین می‌کند
گرمی است یا تعدادی، و «گروه ساخته» جای آن در زرین است. در جدول پایین برای هر دسته‌بندی محصولِ منتزر می‌توانید گروه جدا بدهید؛ دسته‌ای که
<b>کارساخته (فقط زرین):</b> وقتی در پنل مدیریت {{ brand.name }} محصول جدیدی تعریف شود، برایش یک «کارساخته» در زرین ساخته می‌شود. «نوع» تعیین می‌کند
گرمی است یا تعدادی، و «گروه ساخته» جای آن در زرین است. در جدول پایین برای هر دسته‌بندی محصولِ {{ brand.name }} می‌توانید گروه جدا بدهید؛ دسته‌ای که
«پیش‌فرض» بماند در «گروه ساخته‌ی پیش‌فرض» می‌رود. اگر نوع یا گروه خالی باشد، ساخت کارساخته در «لاگ ارسال» منتظر بررسی می‌ماند.
</li>
</ol>
@@ -167,10 +167,10 @@
</a-space>
<a-form layout="vertical">
<a-divider orientation="right">حساب مشتری‌ها</a-divider>
<a-alert type="info" show-icon class="mb-4" message="این بخش تعیین می‌کند کدام حساب‌های حسابداری به کاربران اپ مشتری منتزر وصل شوند">
<a-alert type="info" show-icon class="mb-4" :message="`این بخش تعیین می‌کند کدام حساب‌های حسابداری به کاربران اپ مشتری ${brand.name} وصل شوند`">
<template #description>
<div>
هر حسابی که در گروه‌های انتخاب‌شده باشد و موبایلش با یک کاربر اپ مشتری منتزر یکی باشد، به آن کاربر وصل می‌شود و مانده‌اش در اپ مشتری
هر حسابی که در گروه‌های انتخاب‌شده باشد و موبایلش با یک کاربر اپ مشتری {{ brand.name }} یکی باشد، به آن کاربر وصل می‌شود و مانده‌اش در اپ مشتری
نمایش داده می‌شود. صندوق، کارمند و سرمایه‌گذار را انتخاب نکنید.
</div>
<div>
@@ -180,7 +180,7 @@
</template>
</a-alert>
<div class="grid-2">
<a-form-item label="گروه‌های حسابی که مشتری منتزر حساب می‌شوند" extra="فقط گروه‌هایی که مشتری‌اند؛ خالی = سینک مشتری اجرا نمی‌شود">
<a-form-item :label="`گروه‌های حسابی که مشتری ${brand.name} حساب می‌شوند`" extra="فقط گروه‌هایی که مشتری‌اند؛ خالی = سینک مشتری اجرا نمی‌شود">
<a-select v-model:value="importGroupKeys" mode="multiple" :options="customerGroupOptions" allow-clear placeholder="هیچ گروهی انتخاب نشده" />
</a-form-item>
<a-form-item
@@ -189,32 +189,32 @@
>
<a-select v-model:value="newCustomerGroupKey" :options="customerGroupOptions" allow-clear />
</a-form-item>
<a-form-item label="عیار پایه‌ی وزن‌های منتزر" extra="عیار وزن محصول‌ها و سفارش‌ها در منتزر؛ ۷۵۰ یعنی ۱۸ عیار">
<a-form-item :label="`عیار پایه‌ی وزن‌های ${brand.name}`" :extra="`عیار وزن محصول‌ها و سفارش‌ها در ${brand.name}؛ ۷۵۰ یعنی ۱۸ عیار`">
<a-input-number v-model:value="form.mapping.karat" :min="1" :max="1000" class="w-full" />
</a-form-item>
</div>
<template v-if="form.provider === 'zarrin'">
<a-divider orientation="right">کارساخته (زرین)</a-divider>
<a-alert type="info" show-icon class="mb-4" message="هر محصولی که در پنل مدیریت منتزر (بخش محصولات) تعریف می‌کنید، در زرین یک «کارساخته» می‌شود">
<a-alert type="info" show-icon class="mb-4" :message="`هر محصولی که در پنل مدیریت ${brand.name} (بخش محصولات) تعریف می‌کنید، در زرین یک «کارساخته» می‌شود`">
<template #description>
<div>
زرین برای ساختن کارساخته دو چیز لازم دارد: <b>نوع</b> (گرمی یا تعدادی) و <b>گروه ساخته</b> (کارساخته در کدام پوشه‌ی زرین قرار بگیرد).
این بخش این دو را به‌جای شما انتخاب می‌کند تا لازم نباشد برای هر محصول جداگانه در زرین کاری انجام دهید.
</div>
<div>
ستون «دسته‌بندی محصول در منتزر» همان دسته‌بندی‌هایی است که در پنل مدیریت برای محصولات تعریف کرده‌اید. مثال: اگر روبه‌روی «انگشتر»
گروه «انگشتر» زرین را بگذارید، هر محصول تازه در دسته‌ی انگشتر منتزر، در گروه انگشترِ زرین ساخته می‌شود.
ستون «دسته‌بندی محصول در {{ brand.name }}» همان دسته‌بندی‌هایی است که در پنل مدیریت برای محصولات تعریف کرده‌اید. مثال: اگر روبه‌روی «انگشتر»
گروه «انگشتر» زرین را بگذارید، هر محصول تازه در دسته‌ی انگشتر {{ brand.name }}، در گروه انگشترِ زرین ساخته می‌شود.
دسته‌هایی که «پیش‌فرض» بمانند در «گروه ساخته‌ی پیش‌فرض» می‌روند.
</div>
<div>
اگر نوع یا گروه خالی باشد، محصول در منتزر ساخته می‌شود ولی ساخت کارساخته در «لاگ ارسال» منتظر می‌ماند تا آن را تعیین کنید و «تلاش دوباره» بزنید.
اگر نوع یا گروه خالی باشد، محصول در {{ brand.name }} ساخته می‌شود ولی ساخت کارساخته در «لاگ ارسال» منتظر می‌ماند تا آن را تعیین کنید و «تلاش دوباره» بزنید.
محصول‌هایی که از قبل به کارساخته وصل شده‌اند تغییری نمی‌کنند.
</div>
</template>
</a-alert>
<div class="grid-2">
<a-form-item label="نوع کارساخته‌ی جدید (گرمی/تعدادی…)" extra="برای محصول تازه‌ای که در پنل مدیریت منتزر تعریف می‌شود">
<a-form-item label="نوع کارساخته‌ی جدید (گرمی/تعدادی…)" :extra="`برای محصول تازه‌ای که در پنل مدیریت ${brand.name} تعریف می‌شود`">
<a-select v-model:value="newTypeKey" :options="typeOptions" allow-clear />
</a-form-item>
<a-form-item label="گروه ساخته‌ی پیش‌فرض" extra="برای دسته‌هایی که در جدول زیر «پیش‌فرض» مانده‌اند">
@@ -222,7 +222,7 @@
</a-form-item>
</div>
<a-table :data-source="categories" :pagination="false" row-key="id" size="small" class="mb-4">
<a-table-column title="دسته‌بندی محصول در منتزر" data-index="title" />
<a-table-column :title="`دسته‌بندی محصول در ${brand.name}`" data-index="title" />
<a-table-column title="گروه ساخته در زرین">
<template #default="{ record }">
<a-select v-model:value="categoryGroupKeys[record.id]" :options="itemGroupOptions" allow-clear class="w-full" placeholder="پیش‌فرض" />
@@ -343,7 +343,7 @@
>
<a-table-column title="کد حسابداری" data-index="externalCode" />
<a-table-column title="نام در حسابداری" data-index="externalName" />
<a-table-column title="شناسه در منتزر" data-index="entityId" />
<a-table-column :title="`شناسه در ${brand.name}`" data-index="entityId" />
<a-table-column title="منشأ">
<template #default="{ record }">{{ ORIGIN[record.origin] ?? record.origin }}</template>
</a-table-column>
@@ -396,6 +396,7 @@ import moment from 'moment-jalaali';
import { useUsersStore } from '../../stores/users';
import api from '../../api/client';
import AccountingConnection from '../../components/accounting/AccountingConnection.vue';
import { brand } from '../../brand';
type ProviderName = 'tahesab' | 'zarrin';
interface Group {
@@ -438,7 +439,7 @@ const ISSUE: Record<string, string> = {
const ORIGIN: Record<string, string> = {
LINKED: 'جفت با موبایل/نام',
IMPORTED: 'وارد شده از حسابداری',
PUSHED: 'ساخته شده از منتزر',
PUSHED: `ساخته شده از ${brand.name}`,
MANUAL: 'دستی',
};