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:
co-authored by
Claude Haiku 5.5
parent
5d2a1efb0c
commit
532ceee189
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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: 'دستی',
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user