Initial commit: Hedena Gold monorepo.
First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
This commit is contained in:
@@ -0,0 +1,742 @@
|
||||
<template>
|
||||
<!--
|
||||
«اتصال» tab of the accounting screen (D27), redesigned 2026-10-06.
|
||||
|
||||
One question per step, only what the chosen software needs:
|
||||
status → 1. which software → 2. how to reach it → 3. its certificate
|
||||
(only for https, shown as a fact, never as raw PEM) → test and save.
|
||||
The form object is the page's own (save sends the whole configuration);
|
||||
this component only edits the connection part of it.
|
||||
-->
|
||||
<div class="conn">
|
||||
<!-- ── status ─────────────────────────────────────────────── -->
|
||||
<div class="status" :class="`status--${status.tone}`">
|
||||
<component :is="status.icon" class="status__icon" />
|
||||
<div class="status__body">
|
||||
<div class="status__title">
|
||||
{{ status.title }}
|
||||
<a-tag v-if="dirty" color="orange" class="status__tag">تغییرات ذخیره نشده</a-tag>
|
||||
</div>
|
||||
<div v-if="status.text" class="status__text">{{ status.text }}</div>
|
||||
<dl v-if="status.rows?.length" class="kv status__rows">
|
||||
<template v-for="row in status.rows" :key="row.label">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd :dir="row.ltr ? 'ltr' : undefined" :class="{ latin: row.ltr }">{{ row.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 1. software ────────────────────────────────────────── -->
|
||||
<section class="step">
|
||||
<h3 class="step__title"><span class="step__no">۱</span>نرمافزار حسابداری</h3>
|
||||
<div class="choices" role="radiogroup">
|
||||
<button
|
||||
v-for="c in CHOICES"
|
||||
:key="c.value"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="form.provider === c.value"
|
||||
class="choice"
|
||||
:class="{ 'choice--on': form.provider === c.value }"
|
||||
@click="form.provider = c.value"
|
||||
>
|
||||
<span class="choice__dot" />
|
||||
<span class="choice__name">
|
||||
{{ c.label }}
|
||||
</span>
|
||||
<span class="choice__desc">{{ c.desc }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<a-alert
|
||||
v-if="providerChanged"
|
||||
class="mt"
|
||||
type="info"
|
||||
show-icon
|
||||
message="با ذخیره، نرمافزار عوض میشود"
|
||||
description="کارهای در صف نرمافزار قبلی متوقف میمانند (پاک نمیشوند) و لینکهای هر نرمافزار جدا نگه داشته میشوند؛ برگشت بیخطر است."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<template v-if="p">
|
||||
<!-- ── 2. connection details ──────────────────────────────── -->
|
||||
<section class="step">
|
||||
<h3 class="step__title"><span class="step__no">۲</span>اطلاعات اتصال به {{ LABEL[p] }}</h3>
|
||||
<a-form layout="vertical" class="grid">
|
||||
<a-form-item label="آدرس وبسرویس" required>
|
||||
<a-input v-model:value="form[p].baseUrl" dir="ltr" class="ltr" :placeholder="p === 'tahesab' ? 'https://192.168.1.10:8081' : 'http://192.168.1.10:8000/ZarrinService/accounting'" />
|
||||
<div class="help">آدرسی که وبسرویس {{ LABEL[p] }} روی آن در دسترس است، با پورت.</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="p === 'tahesab'" label="نام دیتابیس (DBname)" required>
|
||||
<a-input v-model:value="form.tahesab.dbName" dir="ltr" class="ltr" placeholder="TahesabDB" />
|
||||
<div class="help">نام دقیق دیتابیس همین شرکت. یک سرور ممکن است چند دیتابیس داشته باشد؛ بعد از تست، نام را در نتیجه چک کنید.</div>
|
||||
</a-form-item>
|
||||
<a-form-item v-else label="علامت ماندهی بدهکار">
|
||||
<a-select v-model:value="form.zarrin.balanceOwesWhen">
|
||||
<a-select-option value="negative">منفی یعنی بدهکار</a-select-option>
|
||||
<a-select-option value="positive">مثبت یعنی بدهکار</a-select-option>
|
||||
</a-select>
|
||||
<div class="help">در زرین منفی یعنی بدهکار (روی نصب واقعی بررسی شد)؛ تغییرش ندهید مگر نصب شما خلاف این باشد.</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="توکن وبسرویس" required class="grid__full">
|
||||
<div v-if="tokenSaved && !editingToken[p]" class="token">
|
||||
<LockOutlined class="token__icon" />
|
||||
<span v-if="pub(p)?.tokenReadable">توکن ذخیره شده است و نمایش داده نمیشود.</span>
|
||||
<span v-else class="text-danger">توکن ذخیرهشده قابلخواندن نیست؛ دوباره وارد کنید.</span>
|
||||
<a-space class="token__actions">
|
||||
<a-button size="small" @click="editingToken[p] = true">تغییر توکن</a-button>
|
||||
<a-popconfirm title="توکن حذف شود؟ تا توکن جدید وارد نشود اتصال کار نمیکند." ok-text="حذف" cancel-text="انصراف" @confirm="emit('clearToken', p)">
|
||||
<a-button size="small" danger>حذف</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</div>
|
||||
<template v-else>
|
||||
<a-input-password v-model:value="tokens[p]" dir="ltr" class="ltr" autocomplete="new-password" placeholder="توکن را اینجا وارد کنید" />
|
||||
<div class="help">
|
||||
بعد از ذخیره، توکن رمز میشود و دیگر نمایش داده نمیشود.
|
||||
<a v-if="tokenSaved" class="link" @click="cancelTokenEdit">انصراف و نگهداشتن توکن فعلی</a>
|
||||
</div>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-collapse ghost class="advanced">
|
||||
<a-collapse-panel key="adv" header="تنظیمات پیشرفته">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="حداکثر زمان انتظار برای پاسخ">
|
||||
<a-input-number v-model:value="timeoutSec" :min="3" :max="120" addon-after="ثانیه" />
|
||||
<div class="help">اگر سرور حسابداری کند است بیشترش کنید. پیشفرض ۲۰ ثانیه.</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</section>
|
||||
|
||||
<!-- ── 3. certificate (https only) ────────────────────────── -->
|
||||
<section v-if="isHttps" class="step">
|
||||
<h3 class="step__title"><span class="step__no">۳</span>گواهی امنیتی سرور</h3>
|
||||
<p class="step__lead">
|
||||
اتصال همیشه رمزنگاریشده و بررسیشده است. نصبهای معمول {{ LABEL[p] }} گواهی خودساخته دارند؛ در این حالت یک بار گواهی سرور را بررسی و تأیید کنید.
|
||||
</p>
|
||||
|
||||
<!-- trusted (pinned) -->
|
||||
<div v-if="form[p].caCert" class="cert cert--ok">
|
||||
<SafetyCertificateOutlined class="cert__icon" />
|
||||
<div class="cert__body">
|
||||
<div class="cert__title">
|
||||
گواهی سرور تأیید شده است
|
||||
<a-tag v-if="certUnsaved" color="orange">ذخیره نشده</a-tag>
|
||||
<a-tag v-if="certInfo?.expired" color="red">منقضی شده</a-tag>
|
||||
</div>
|
||||
<dl v-if="certInfo" class="kv">
|
||||
<dt>صادرشده برای</dt>
|
||||
<dd dir="ltr" class="latin">{{ cn(certInfo.subject) }}</dd>
|
||||
<dt>معتبر تا</dt>
|
||||
<dd>{{ jdate(certInfo.validTo) }}</dd>
|
||||
<dt>اثرانگشت</dt>
|
||||
<dd dir="ltr" class="mono">{{ certInfo.fingerprint256 }}</dd>
|
||||
</dl>
|
||||
<div v-else class="help">مشخصات گواهی بعد از ذخیره نمایش داده میشود.</div>
|
||||
</div>
|
||||
<a-space direction="vertical" class="cert__actions">
|
||||
<a-button size="small" :loading="checking" @click="checkCert">بررسی دوباره</a-button>
|
||||
<a-button size="small" danger @click="form[p].caCert = ''">برداشتن</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- just checked, waiting for a decision -->
|
||||
<div v-else-if="checked && !checked.trustedBySystem" class="cert cert--ask">
|
||||
<ExclamationCircleOutlined class="cert__icon" />
|
||||
<div class="cert__body">
|
||||
<div class="cert__title">این گواهی را تأیید میکنید؟</div>
|
||||
<div class="help">اثرانگشت زیر را با گواهی نصبشده روی سرور حسابداری مقایسه کنید. اگر یکی است تأیید کنید.</div>
|
||||
<dl class="kv">
|
||||
<dt>صادرشده برای</dt>
|
||||
<dd dir="ltr" class="latin">{{ cn(checked.subject) }}</dd>
|
||||
<dt>معتبر تا</dt>
|
||||
<dd>{{ jdate(checked.validTo) }}</dd>
|
||||
<dt>اثرانگشت</dt>
|
||||
<dd dir="ltr" class="mono">{{ checked.fingerprint256 }}</dd>
|
||||
</dl>
|
||||
<a-alert v-if="checked.expired" type="error" show-icon message="این گواهی منقضی شده است." class="mt" />
|
||||
</div>
|
||||
<a-space direction="vertical" class="cert__actions">
|
||||
<a-button type="primary" size="small" @click="trustChecked">تأیید گواهی</a-button>
|
||||
<a-button size="small" @click="checked = null">انصراف</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- public certificate: nothing to do -->
|
||||
<div v-else-if="checked?.trustedBySystem" class="cert cert--ok">
|
||||
<SafetyCertificateOutlined class="cert__icon" />
|
||||
<div class="cert__body">
|
||||
<div class="cert__title">گواهی سرور معتبر است</div>
|
||||
<div class="help">این سرور گواهی معتبر عمومی دارد؛ کاری لازم نیست.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- nothing yet -->
|
||||
<div v-else class="cert">
|
||||
<SafetyCertificateOutlined class="cert__icon cert__icon--muted" />
|
||||
<div class="cert__body">
|
||||
<div class="cert__title">گواهیای تأیید نشده</div>
|
||||
<div class="help">اگر «تست اتصال» خطای گواهی داد، اینجا گواهی سرور را بررسی کنید.</div>
|
||||
</div>
|
||||
<a-button :loading="checking" :disabled="!form[p].baseUrl" @click="checkCert">بررسی گواهی سرور</a-button>
|
||||
</div>
|
||||
|
||||
<a-collapse ghost class="advanced">
|
||||
<a-collapse-panel key="pem" header="وارد کردن دستی گواهی (PEM)">
|
||||
<a-textarea v-model:value="form[p].caCert" dir="ltr" class="ltr mono" :rows="5" placeholder="-----BEGIN CERTIFICATE-----" />
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- ── test result ────────────────────────────────────────── -->
|
||||
<section v-if="p && result" class="step">
|
||||
<a-alert v-if="result.ok" type="success" show-icon :message="`اتصال به ${LABEL[p]} برقرار است`">
|
||||
<template #description>
|
||||
<dl class="kv kv--inline">
|
||||
<template v-for="row in resultRows" :key="row.label">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd :dir="row.ltr ? 'ltr' : undefined" :class="{ latin: row.ltr }">{{ row.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<div v-if="dbMismatch" class="text-danger mt">
|
||||
دیتابیسی که وصل شد <bdi class="latin">{{ result.info.dbName }}</bdi> است، نه <bdi class="latin">{{ form.tahesab.dbName }}</bdi>. نام دیتابیس را چک کنید.
|
||||
</div>
|
||||
<div v-if="resultStale" class="help">این نتیجه برای مقادیر قبلی است؛ دوباره تست کنید.</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
<a-alert v-else type="error" show-icon message="اتصال برقرار نشد">
|
||||
<template #description>
|
||||
<div>{{ result.error }}</div>
|
||||
<div v-if="resultStale" class="help">این نتیجه برای مقادیر قبلی است؛ دوباره تست کنید.</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
</section>
|
||||
|
||||
<!-- ── actions ────────────────────────────────────────────── -->
|
||||
<div class="bar">
|
||||
<span class="bar__note">
|
||||
<template v-if="dirty">تغییرات هنوز ذخیره نشدهاند.</template>
|
||||
<template v-else-if="p">«تست اتصال» مقادیر همین فرم را امتحان میکند، حتی پیش از ذخیره.</template>
|
||||
</span>
|
||||
<a-space>
|
||||
<a-button v-if="p" :loading="testing" :disabled="!canTest" @click="test">تست اتصال</a-button>
|
||||
<a-button type="primary" :loading="saving" :disabled="!dirty" @click="emit('save')">ذخیره</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
CloseCircleFilled,
|
||||
ExclamationCircleOutlined,
|
||||
LockOutlined,
|
||||
MinusCircleFilled,
|
||||
SafetyCertificateOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import moment from 'moment-jalaali';
|
||||
import api from '../../api/client';
|
||||
|
||||
type ProviderName = 'tahesab' | 'zarrin';
|
||||
|
||||
const props = defineProps<{
|
||||
overview: any;
|
||||
form: any;
|
||||
tokens: Record<ProviderName, string>;
|
||||
saving: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{ (e: 'save'): void; (e: 'clearToken', p: ProviderName): void }>();
|
||||
|
||||
const LABEL: Record<ProviderName, string> = { tahesab: 'تهحساب', zarrin: 'زرین' };
|
||||
const CHOICES: { value: 'none' | ProviderName; label: string; desc: string }[] = [
|
||||
{ value: 'none', label: 'بدون اتصال', desc: 'هیچ اطلاعاتی با حسابداری رد و بدل نمیشود.' },
|
||||
{ value: 'tahesab', label: 'تهحساب', desc: 'اتصال به وبسرویس نرمافزار تهحساب.' },
|
||||
{ value: 'zarrin', label: 'زرین', desc: 'اتصال به وبسرویس نرمافزار زرین.' },
|
||||
];
|
||||
|
||||
const p = computed<ProviderName | null>(() => (props.form.provider === 'none' ? null : props.form.provider));
|
||||
const saved = computed(() => props.overview?.config ?? null);
|
||||
const pub = (name: ProviderName) => saved.value?.[name];
|
||||
const tokenSaved = computed(() => !!(p.value && pub(p.value)?.tokenSet));
|
||||
const isHttps = computed(() => !!p.value && /^https:\/\//i.test(props.form[p.value].baseUrl || ''));
|
||||
|
||||
// ── dirty: the connection part of the form against what is saved ──
|
||||
const snapshot = (src: any, provider: string) =>
|
||||
JSON.stringify(
|
||||
provider === 'none'
|
||||
? { provider }
|
||||
: {
|
||||
provider,
|
||||
baseUrl: src[provider]?.baseUrl ?? '',
|
||||
caCert: src[provider]?.caCert ?? '',
|
||||
timeoutMs: src[provider]?.timeoutMs,
|
||||
dbName: provider === 'tahesab' ? src.tahesab?.dbName : undefined,
|
||||
balanceOwesWhen: provider === 'zarrin' ? src.zarrin?.balanceOwesWhen : undefined,
|
||||
},
|
||||
);
|
||||
const dirty = computed(() => {
|
||||
if (!saved.value) return false;
|
||||
if (props.form.provider !== saved.value.provider) return true;
|
||||
if (!p.value) return false;
|
||||
return snapshot(props.form, p.value) !== snapshot(saved.value, p.value) || !!props.tokens[p.value]?.trim();
|
||||
});
|
||||
const providerChanged = computed(() => !!saved.value && saved.value.provider !== 'none' && saved.value.provider !== props.form.provider);
|
||||
|
||||
// ── status card ──
|
||||
interface StatusRow {
|
||||
label: string;
|
||||
value: string;
|
||||
ltr?: boolean;
|
||||
}
|
||||
const jdt = (v?: string | null) => (v ? moment(v).format('jYYYY/jMM/jDD ساعت HH:mm') : '');
|
||||
const status = computed(() => {
|
||||
const ov = props.overview;
|
||||
if (!ov) return { tone: 'off', icon: MinusCircleFilled, title: '…', text: '', rows: [] as StatusRow[] };
|
||||
const c = ov.config;
|
||||
if (c.provider === 'none') {
|
||||
return { tone: 'off', icon: MinusCircleFilled, title: 'اتصال خاموش است', text: 'هیچ اطلاعاتی با نرمافزار حسابداری رد و بدل نمیشود.' };
|
||||
}
|
||||
const label = LABEL[c.provider as ProviderName];
|
||||
if (ov.inactiveReason) {
|
||||
return { tone: 'bad', icon: CloseCircleFilled, title: `اتصال به ${label} کار نمیکند`, text: ov.inactiveReason };
|
||||
}
|
||||
const rt = ov.realtime;
|
||||
if (rt?.lastError) {
|
||||
return { tone: 'bad', icon: CloseCircleFilled, title: `${label}: ارتباط برقرار نیست`, text: rt.lastError };
|
||||
}
|
||||
const conn = c[c.provider];
|
||||
const rows: StatusRow[] = [{ label: 'آدرس', value: conn.baseUrl, ltr: true }];
|
||||
if (c.provider === 'tahesab') rows.push({ label: 'دیتابیس', value: c.tahesab.dbName, ltr: true });
|
||||
if (rt?.lastOkAt) rows.push({ label: 'آخرین ارتباط موفق', value: jdt(rt.lastOkAt) });
|
||||
return { tone: 'ok', icon: CheckCircleFilled, title: `متصل به ${label}`, text: '', rows };
|
||||
});
|
||||
|
||||
// ── token ──
|
||||
const editingToken = reactive<Record<ProviderName, boolean>>({ tahesab: false, zarrin: false });
|
||||
function cancelTokenEdit() {
|
||||
if (!p.value) return;
|
||||
props.tokens[p.value] = '';
|
||||
editingToken[p.value] = false;
|
||||
}
|
||||
watch(
|
||||
() => props.overview,
|
||||
() => {
|
||||
editingToken.tahesab = false;
|
||||
editingToken.zarrin = false;
|
||||
checked.value = null;
|
||||
},
|
||||
);
|
||||
|
||||
// ── timeout shown in seconds ──
|
||||
const timeoutSec = computed({
|
||||
get: () => (p.value ? Math.round((props.form[p.value].timeoutMs ?? 20000) / 1000) : 20),
|
||||
set: (v: number) => {
|
||||
if (p.value) props.form[p.value].timeoutMs = Math.max(3, Math.min(120, Number(v) || 20)) * 1000;
|
||||
},
|
||||
});
|
||||
|
||||
// ── certificate ──
|
||||
const checking = ref(false);
|
||||
const checked = ref<any>(null);
|
||||
const cn = (subject: string) => (subject.match(/CN=([^\n,]+)/)?.[1] ?? subject.replace(/\n/g, ', '));
|
||||
const jdate = (v: string) => (v ? moment(new Date(v)).format('jYYYY/jMM/jDD') : '—');
|
||||
const certUnsaved = computed(() => !!p.value && (props.form[p.value].caCert || '') !== (pub(p.value)?.caCert || ''));
|
||||
const certInfo = computed(() => {
|
||||
if (!p.value) return null;
|
||||
if (!certUnsaved.value) return pub(p.value)?.caInfo ?? null;
|
||||
return checked.value && checked.value.pem === props.form[p.value].caCert ? checked.value : null;
|
||||
});
|
||||
|
||||
async function checkCert() {
|
||||
if (!p.value) return;
|
||||
checking.value = true;
|
||||
try {
|
||||
const { data } = await api.post('admin/accounting/certificate', { baseUrl: props.form[p.value].baseUrl });
|
||||
checked.value = data;
|
||||
if (props.form[p.value].caCert && data.pem.trim() === props.form[p.value].caCert.trim()) {
|
||||
message.success('گواهی سرور همان گواهی تأییدشده است');
|
||||
} else if (props.form[p.value].caCert) {
|
||||
// A different certificate than the trusted one: ask again.
|
||||
props.form[p.value].caCert = '';
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'گواهی دریافت نشد');
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
}
|
||||
function trustChecked() {
|
||||
if (!p.value || !checked.value) return;
|
||||
props.form[p.value].caCert = checked.value.pem;
|
||||
}
|
||||
|
||||
// ── test ──
|
||||
const testing = ref(false);
|
||||
const result = ref<any>(null);
|
||||
const testedWith = ref('');
|
||||
const fingerprint = () => (p.value ? JSON.stringify([props.form.provider, props.form[p.value], props.tokens[p.value]]) : '');
|
||||
const resultStale = computed(() => !!result.value && testedWith.value !== fingerprint());
|
||||
const canTest = computed(() => !!p.value && !!props.form[p.value].baseUrl && (tokenSaved.value || !!props.tokens[p.value]?.trim()));
|
||||
watch(p, () => (result.value = null));
|
||||
|
||||
async function test() {
|
||||
if (!p.value) return;
|
||||
const name = p.value;
|
||||
testing.value = true;
|
||||
try {
|
||||
const connection: Record<string, unknown> = {
|
||||
baseUrl: props.form[name].baseUrl,
|
||||
timeoutMs: props.form[name].timeoutMs,
|
||||
caCert: props.form[name].caCert,
|
||||
token: props.tokens[name] || undefined,
|
||||
};
|
||||
if (name === 'tahesab') connection.dbName = props.form.tahesab.dbName;
|
||||
else connection.balanceOwesWhen = props.form.zarrin.balanceOwesWhen;
|
||||
testedWith.value = fingerprint();
|
||||
const { data } = await api.post('admin/accounting/test', { provider: name, connection });
|
||||
result.value = data;
|
||||
} catch (e: any) {
|
||||
const m = e?.response?.data?.message;
|
||||
result.value = { ok: false, error: Array.isArray(m) ? m.join('، ') : (m ?? 'خطا در تست اتصال') };
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const INFO_LABELS: Record<string, { label: string; ltr?: boolean }> = {
|
||||
version: { label: 'نسخه', ltr: true },
|
||||
dbType: { label: 'نوع دیتابیس', ltr: true },
|
||||
dbName: { label: 'دیتابیس', ltr: true },
|
||||
customerGroups: { label: 'گروههای حساب' },
|
||||
message: { label: 'پیام سرور' },
|
||||
};
|
||||
const resultRows = computed(() => {
|
||||
const info = result.value?.info ?? {};
|
||||
const rows = Object.entries(INFO_LABELS)
|
||||
.filter(([k]) => info[k] !== undefined && info[k] !== null && info[k] !== '')
|
||||
.map(([k, v]) => ({ label: v.label, value: String(info[k]), ltr: v.ltr }));
|
||||
rows.push({ label: 'زمان پاسخ', value: `${(result.value?.ms ?? 0).toLocaleString('fa-IR')} میلیثانیه`, ltr: false });
|
||||
return rows;
|
||||
});
|
||||
const dbMismatch = computed(
|
||||
() =>
|
||||
p.value === 'tahesab' &&
|
||||
!!result.value?.info?.dbName &&
|
||||
String(result.value.info.dbName).toLowerCase() !== String(props.form.tahesab.dbName || '').toLowerCase(),
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.conn {
|
||||
max-width: 920px;
|
||||
}
|
||||
.mt {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.help {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
margin-top: 4px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.link {
|
||||
margin-inline-start: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.text-danger {
|
||||
color: #cf1322;
|
||||
}
|
||||
.ltr {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
.ltr :deep(input),
|
||||
.ltr :deep(textarea) {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* status */
|
||||
.status {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
padding: 16px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.status__icon {
|
||||
font-size: 22px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.status__title {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
.status__tag {
|
||||
margin-inline-start: 8px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.status__text {
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.status__rows {
|
||||
margin-top: 6px;
|
||||
}
|
||||
/* URLs, database names, fingerprints: Latin digits, left-to-right */
|
||||
.latin,
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
.status--ok {
|
||||
background: #f6ffed;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
.status--ok .status__icon {
|
||||
color: #52c41a;
|
||||
}
|
||||
.status--bad {
|
||||
background: #fff2f0;
|
||||
border-color: #ffccc7;
|
||||
}
|
||||
.status--bad .status__icon {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
.status--off {
|
||||
background: #fafafa;
|
||||
border-color: #f0f0f0;
|
||||
}
|
||||
.status--off .status__icon {
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
/* steps */
|
||||
.step {
|
||||
padding: 4px 0 24px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.step__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.step__no {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #1f2a5c;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
.step__lead {
|
||||
margin: -4px 0 14px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
/* provider choice */
|
||||
.choices {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.choice {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 10px;
|
||||
text-align: start;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.choice:hover {
|
||||
border-color: #8c9bd1;
|
||||
}
|
||||
.choice:focus-visible {
|
||||
outline: 2px solid #1f2a5c;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.choice--on {
|
||||
border-color: #1f2a5c;
|
||||
box-shadow: 0 0 0 1px #1f2a5c;
|
||||
background: #f5f7ff;
|
||||
}
|
||||
.choice__dot {
|
||||
grid-row: span 2;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 3px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #bfbfbf;
|
||||
}
|
||||
.choice--on .choice__dot {
|
||||
border: 5px solid #1f2a5c;
|
||||
}
|
||||
.choice__name {
|
||||
font-weight: 700;
|
||||
}
|
||||
.choice__tag {
|
||||
margin-inline-start: 6px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.choice__desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* form grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 16px;
|
||||
}
|
||||
.grid__full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.advanced {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.advanced :deep(.ant-collapse-header) {
|
||||
padding-inline: 0 !important;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* token */
|
||||
.token {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.token__icon {
|
||||
color: #52c41a;
|
||||
}
|
||||
.token__actions {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
/* certificate */
|
||||
.cert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.cert--ok {
|
||||
background: #f6ffed;
|
||||
border-color: #d9f7be;
|
||||
}
|
||||
.cert--ask {
|
||||
background: #fffbe6;
|
||||
border-color: #ffe58f;
|
||||
}
|
||||
.cert__icon {
|
||||
font-size: 22px;
|
||||
color: #52c41a;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.cert--ask .cert__icon {
|
||||
color: #faad14;
|
||||
}
|
||||
.cert__icon--muted {
|
||||
color: #bfbfbf;
|
||||
}
|
||||
.cert__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.cert__title {
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cert__actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* key/value lists */
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 4px 14px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.kv dt {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
text-align: start;
|
||||
}
|
||||
.kv dd[dir='ltr'] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* action bar */
|
||||
.bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.bar__note {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.choices,
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.cert {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<!--
|
||||
D27, plan §12 — «سینک با حسابداری» on a list page (wholesale customers,
|
||||
products). Shown only while an accounting software is connected; runs the
|
||||
sync on the server, waits for it and reports what happened.
|
||||
-->
|
||||
<a-button v-if="connected" :loading="busy" @click="run">
|
||||
<template #icon><SyncOutlined /></template>
|
||||
سینک با {{ provider || 'حسابداری' }}
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, onMounted, ref } from 'vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import { SyncOutlined } from '@ant-design/icons-vue';
|
||||
import api from '../../api/client';
|
||||
|
||||
const props = defineProps<{ endpoint: string }>();
|
||||
const emit = defineEmits<{ (e: 'done'): void }>();
|
||||
|
||||
const connected = ref(false);
|
||||
const provider = ref<string | null>(null);
|
||||
const busy = ref(false);
|
||||
|
||||
const JOB_LABELS: Record<string, string> = { customers: 'مشتریان', balances: 'ماندهها', products: 'کارساختهها و موجودی' };
|
||||
const KEY_LABELS: Record<string, string> = {
|
||||
scanned: 'بررسیشده',
|
||||
inScope: 'در گروههای انتخابی',
|
||||
refreshed: 'بهروز',
|
||||
linked: 'لینکشده',
|
||||
imported: 'وارد شده',
|
||||
issues: 'مغایرت',
|
||||
updated: 'بهروز',
|
||||
items: 'کارساخته',
|
||||
stockErrors: 'خطای موجودی',
|
||||
};
|
||||
|
||||
const describe = (summary: unknown) =>
|
||||
summary && typeof summary === 'object'
|
||||
? Object.entries(summary as Record<string, unknown>)
|
||||
.map(([k, v]) => `${KEY_LABELS[k] ?? k}: ${v}`)
|
||||
.join('، ')
|
||||
: '';
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get('admin/accounting-status');
|
||||
connected.value = !!data?.connected;
|
||||
provider.value = data?.provider ?? null;
|
||||
} catch {
|
||||
connected.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function run() {
|
||||
busy.value = true;
|
||||
try {
|
||||
const { data } = await api.post(props.endpoint);
|
||||
const rows = Object.entries(data.results as Record<string, { ok: boolean; summary?: unknown; error?: string }>).map(
|
||||
([job, r]) =>
|
||||
h('p', { style: r.ok ? '' : 'color:#d4380d' }, `${JOB_LABELS[job] ?? job}: ${r.ok ? describe(r.summary) || 'انجام شد' : `خطا — ${r.error}`}`),
|
||||
);
|
||||
(data.ok ? Modal.success : Modal.warning)({
|
||||
title: data.ok ? 'سینک با حسابداری انجام شد' : 'سینک با حسابداری کامل انجام نشد',
|
||||
content: h('div', rows),
|
||||
okText: 'باشه',
|
||||
});
|
||||
emit('done');
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'سینک با حسابداری انجام نشد');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<!--
|
||||
D27 — the customer's side in the accounting software: which account it is
|
||||
linked to, and both balances side by side (the customer sees the
|
||||
accounting one; the credit limit is checked against the app's own).
|
||||
-->
|
||||
<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">
|
||||
<div class="acc-body">
|
||||
<div class="acc-code">
|
||||
<span class="label">کد حساب</span>
|
||||
<template v-if="info?.link">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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-spin>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { message, theme } from 'ant-design-vue';
|
||||
import moment from 'moment-jalaali';
|
||||
import api from '../../api/client';
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
|
||||
const props = defineProps<{ customerId: string }>();
|
||||
|
||||
const ORIGIN: Record<string, string> = {
|
||||
LINKED: 'جفت با موبایل',
|
||||
IMPORTED: 'وارد شده از حسابداری',
|
||||
PUSHED: 'ساخته شده از اپ',
|
||||
MANUAL: 'دستی',
|
||||
};
|
||||
const STATUS: Record<string, string> = {
|
||||
PENDING: 'در صف',
|
||||
SENDING: 'در حال ارسال',
|
||||
SENT: 'ارسال شد',
|
||||
FAILED: 'ناموفق',
|
||||
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]);
|
||||
|
||||
const num = (v: unknown, places: number) => (v === null || v === undefined ? '—' : Number(v).toLocaleString('fa-IR', { maximumFractionDigits: places }));
|
||||
const sign = (v: unknown) => (Number(v) > 0 ? 'debt' : Number(v) < 0 ? 'credit' : '');
|
||||
const jdt = (v?: string | null) => (v ? moment(v).format('jYYYY/jMM/jDD HH:mm') : '');
|
||||
|
||||
async function load() {
|
||||
if (!users.hasPermission('config.accounting')) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`admin/accounting/customers/${props.customerId}`);
|
||||
info.value = data;
|
||||
} catch {
|
||||
info.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function link() {
|
||||
try {
|
||||
await api.post('admin/accounting/links', { entityType: 'CUSTOMER', entityId: props.customerId, externalCode: code.value.trim() });
|
||||
message.success('لینک شد');
|
||||
code.value = '';
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا');
|
||||
}
|
||||
}
|
||||
|
||||
async function unlink() {
|
||||
if (!info.value?.link) return;
|
||||
await api.delete(`admin/accounting/links/${info.value.link.id}`);
|
||||
load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.acc-card {
|
||||
background: var(--acc-bg);
|
||||
border: 1px solid var(--acc-border);
|
||||
border-radius: 12px;
|
||||
color: var(--acc-text);
|
||||
}
|
||||
.acc-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--acc-border);
|
||||
}
|
||||
.acc-head h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.acc-head :deep(.ant-tag) {
|
||||
margin: 0;
|
||||
}
|
||||
.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 {
|
||||
margin-top: 2px;
|
||||
color: var(--acc-error);
|
||||
}
|
||||
.debt {
|
||||
color: var(--acc-error);
|
||||
}
|
||||
.credit {
|
||||
color: var(--acc-success);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<!--
|
||||
The update modal (composables/useAppUpdate.ts). It cannot be dismissed:
|
||||
the only way out is the update, so every operator runs the live release
|
||||
(owner ruling 2026-10-10).
|
||||
-->
|
||||
<a-modal
|
||||
:open="Boolean(available)"
|
||||
:closable="false"
|
||||
:mask-closable="false"
|
||||
:keyboard="false"
|
||||
:z-index="2000"
|
||||
width="420px"
|
||||
centered
|
||||
>
|
||||
<template #title>
|
||||
نسخهی تازهی پنل آماده است
|
||||
<a-tag v-if="available" class="ms-2" color="blue">نسخهی {{ available.version }}</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="changes.length">
|
||||
<p class="lead">در این نسخه:</p>
|
||||
<ul class="list">
|
||||
<template v-for="c in changes" :key="c.build">
|
||||
<li v-for="(n, i) in c.notes" :key="c.build + '-' + i">{{ n }}</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
<p v-else class="lead">بهبودها و رفع اشکالها.</p>
|
||||
<p class="hint">کار ذخیرهنشدهای اگر در صفحه دارید، پیش از بهروزرسانی ثبتش کنید.</p>
|
||||
|
||||
<template #footer>
|
||||
<a-button type="primary" block :loading="updating" @click="applyUpdate">بهروزرسانی</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppUpdate } from '../../composables/useAppUpdate';
|
||||
|
||||
const { available, changes, updating, applyUpdate } = useAppUpdate();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lead {
|
||||
margin: 0 0 6px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.list {
|
||||
margin: 0 0 10px;
|
||||
padding-inline-start: 18px;
|
||||
line-height: 1.9;
|
||||
max-height: 50vh;
|
||||
list-style: disc;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<!--
|
||||
Standard shell for every "add/edit" modal in the panel: header = title,
|
||||
footer = action buttons, body = the form (passed in via the default slot).
|
||||
The 16 forms converted from full pages to modals all use this instead of
|
||||
hand-rolling <a-modal> + <a-card> + a trailing button, which used to be
|
||||
duplicated 16 times with inconsistent widths (60%/70%/80%/default).
|
||||
-->
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="id ? editTitle : addTitle"
|
||||
:width="WIDTH[width]"
|
||||
:confirm-loading="submitting"
|
||||
:ok-text="okText"
|
||||
cancel-text="انصراف"
|
||||
:mask-closable="false"
|
||||
wrap-class-name="base-form-modal"
|
||||
@update:open="onUpdateOpen"
|
||||
@ok="$emit('submit')"
|
||||
@cancel="onCancel"
|
||||
>
|
||||
<a-spin :spinning="loading">
|
||||
<a-form layout="vertical">
|
||||
<slot />
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
/** Presence of `id` switches the modal into edit mode (title only — the
|
||||
* form body decides its own create/edit behaviour from the same prop). */
|
||||
id?: string;
|
||||
addTitle: string;
|
||||
editTitle: string;
|
||||
/** Drives the inner a-spin, e.g. while loading the record being edited. */
|
||||
loading?: boolean;
|
||||
/** Drives the OK button's loading state while a submit is in flight. */
|
||||
submitting?: boolean;
|
||||
/** 520 / 720 / 900 / 1140 — replaces the old ad-hoc 60%/70%/80% widths. */
|
||||
width?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
okText?: string;
|
||||
}>(),
|
||||
{
|
||||
id: undefined,
|
||||
loading: false,
|
||||
submitting: false,
|
||||
width: 'md',
|
||||
okText: 'ثبت اطلاعات',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void;
|
||||
(e: 'submit'): void;
|
||||
(e: 'cancel'): void;
|
||||
}>();
|
||||
|
||||
const WIDTH: Record<'sm' | 'md' | 'lg' | 'xl', number> = {
|
||||
sm: 520,
|
||||
md: 720,
|
||||
lg: 900,
|
||||
xl: 1140,
|
||||
};
|
||||
|
||||
function onUpdateOpen(value: boolean) {
|
||||
emit('update:open', value);
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
emit('update:open', false);
|
||||
emit('cancel');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Unscoped: <a-modal> teleports to <body>; contained by wrap-class-name. -->
|
||||
<style>
|
||||
@media (max-width: 575px) {
|
||||
.base-form-modal .ant-modal {
|
||||
/* ant sets the width inline from :width */
|
||||
width: 100vw !important;
|
||||
max-width: 100vw;
|
||||
top: 0;
|
||||
margin: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.base-form-modal .ant-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(var(--vh, 1vh) * 100);
|
||||
border-radius: 0;
|
||||
padding: 14px 16px 0;
|
||||
}
|
||||
.base-form-modal .ant-modal-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.base-form-modal .ant-modal-body {
|
||||
flex: 1;
|
||||
}
|
||||
.base-form-modal .ant-modal-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 0 -16px;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.base-form-modal .ant-modal-footer > .ant-btn {
|
||||
flex: 1;
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<!--
|
||||
Legacy's city picker (henza_admin mixins/citiesModel.js): the cities as a
|
||||
tree — country > province > city > district — with the top level a heading
|
||||
that cannot be picked. A top-level row with nothing under it stays pickable,
|
||||
so a city entered without a parent is never unreachable.
|
||||
`parentPicker` is the «والد» field of the city form: every row is pickable
|
||||
there except the city being edited and its own descendants.
|
||||
-->
|
||||
<a-tree-select
|
||||
:value="value ?? undefined"
|
||||
:tree-data="treeData"
|
||||
show-search
|
||||
allow-clear
|
||||
tree-default-expand-all
|
||||
tree-node-filter-prop="title"
|
||||
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }"
|
||||
:placeholder="placeholder ?? 'شهر را انتخاب کنید'"
|
||||
class="w-full"
|
||||
@update:value="onChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
export interface CityRow {
|
||||
id: string;
|
||||
title: string;
|
||||
parent?: string | null;
|
||||
}
|
||||
|
||||
interface Node {
|
||||
value: string;
|
||||
key: string;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
children?: Node[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
value?: string | null;
|
||||
cities: CityRow[];
|
||||
parentPicker?: boolean;
|
||||
/** parentPicker: the city being edited — it and its subtree are not offered. */
|
||||
excludeId?: string;
|
||||
placeholder?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:value', v: string | undefined): void;
|
||||
(e: 'change', v: string | undefined): void;
|
||||
}>();
|
||||
|
||||
const treeData = computed<Node[]>(() => {
|
||||
const ids = new Set(props.cities.map((c) => c.id));
|
||||
const kids = new Map<string | null, CityRow[]>();
|
||||
for (const c of props.cities) {
|
||||
const p = c.parent && ids.has(c.parent) && c.parent !== c.id ? c.parent : null;
|
||||
if (!kids.has(p)) kids.set(p, []);
|
||||
kids.get(p)!.push(c);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const build = (parent: string | null, depth: number): Node[] =>
|
||||
(kids.get(parent) ?? [])
|
||||
.filter((c) => !seen.has(c.id) && !(props.parentPicker && c.id === props.excludeId))
|
||||
.map((c) => {
|
||||
seen.add(c.id);
|
||||
const children = build(c.id, depth + 1);
|
||||
const node: Node = { value: c.id, key: c.id, title: c.title };
|
||||
if (children.length) node.children = children;
|
||||
if (!props.parentPicker && depth === 0 && children.length) node.disabled = true;
|
||||
return node;
|
||||
});
|
||||
return build(null, 0);
|
||||
});
|
||||
|
||||
function onChange(v: string | undefined) {
|
||||
emit('update:value', v ?? undefined);
|
||||
emit('change', v ?? undefined);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<!--
|
||||
Standard "add" button for every list page — same rid the create route
|
||||
used to gate on, so permissions are not re-derived by hand. Rendered
|
||||
inline inside XTable's toolbar (via its #add slot), aligned with the
|
||||
search bar, rather than in a row of its own above the table.
|
||||
-->
|
||||
<a-button v-if="!rid || hasPermission(rid)" type="primary" @click="$emit('add')">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
{{ addLabel }}
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** Permission id gating the button; omit if the action has no rid of its own. */
|
||||
rid?: string;
|
||||
addLabel?: string;
|
||||
}>(),
|
||||
{ rid: undefined, addLabel: 'افزودن' },
|
||||
);
|
||||
|
||||
defineEmits<{ (e: 'add'): void }>();
|
||||
|
||||
const users = useUsersStore();
|
||||
const hasPermission = (id: string) => Boolean(users.hasPermission(id));
|
||||
</script>
|
||||
@@ -0,0 +1,81 @@
|
||||
// Shared legacy-Feather-name -> ant-design-vue icon map.
|
||||
//
|
||||
// Originally lived only inside TooltipBtn.vue (row-action icons). Extracted
|
||||
// here so the sidebar (navMenuItems.ts icon: values) can resolve the SAME
|
||||
// names through the SAME map instead of duplicating it — one source of
|
||||
// truth for "legacy Feather icon name" -> "ant component" across the app.
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
BarChartOutlined,
|
||||
BellOutlined,
|
||||
BlockOutlined,
|
||||
BookOutlined,
|
||||
CameraOutlined,
|
||||
CheckOutlined,
|
||||
CloseOutlined,
|
||||
CreditCardOutlined,
|
||||
DeleteOutlined,
|
||||
DollarOutlined,
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
GiftOutlined,
|
||||
HomeOutlined,
|
||||
InboxOutlined,
|
||||
MailOutlined,
|
||||
MessageOutlined,
|
||||
PercentageOutlined,
|
||||
PlusOutlined,
|
||||
SettingOutlined,
|
||||
ShoppingOutlined,
|
||||
StopOutlined,
|
||||
TeamOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserAddOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
/** legacy Feather name -> ant component. */
|
||||
export const ICONS: Record<string, unknown> = {
|
||||
// row-action icons (TooltipBtn.vue's original set)
|
||||
ListIcon: UnorderedListOutlined,
|
||||
FileIcon: FileOutlined,
|
||||
TrashIcon: DeleteOutlined,
|
||||
EditIcon: EditOutlined,
|
||||
CheckIcon: CheckOutlined,
|
||||
CameraIcon: CameraOutlined,
|
||||
EyeIcon: EyeOutlined,
|
||||
PlusIcon: PlusOutlined,
|
||||
XIcon: CloseOutlined,
|
||||
FileTextIcon: FileTextOutlined,
|
||||
|
||||
// sidebar icons (navMenuItems.ts), added when the sidebar started
|
||||
// rendering them for the first time
|
||||
HomeIcon: HomeOutlined,
|
||||
PackageIcon: InboxOutlined,
|
||||
UserIcon: UserOutlined,
|
||||
UsersIcon: TeamOutlined,
|
||||
UserPlusIcon: UserAddOutlined,
|
||||
ShoppingBagIcon: ShoppingOutlined,
|
||||
BoxIcon: InboxOutlined,
|
||||
GridIcon: AppstoreOutlined,
|
||||
LayersIcon: BlockOutlined,
|
||||
CreditCardIcon: CreditCardOutlined,
|
||||
DollarSignIcon: DollarOutlined,
|
||||
PercentIcon: PercentageOutlined,
|
||||
GiftIcon: GiftOutlined,
|
||||
SlashIcon: StopOutlined,
|
||||
BookIcon: BookOutlined,
|
||||
MessageSquareIcon: MessageOutlined,
|
||||
MailIcon: MailOutlined,
|
||||
BellIcon: BellOutlined,
|
||||
BarChartIcon: BarChartOutlined,
|
||||
SettingsIcon: SettingOutlined,
|
||||
InboxIcon: InboxOutlined,
|
||||
};
|
||||
|
||||
export function resolveIcon(name: string | undefined): unknown | undefined {
|
||||
if (!name) return undefined;
|
||||
return ICONS[name];
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/components/input/DateInput.vue — the
|
||||
Jalali date picker used by the report filters.
|
||||
|
||||
Legacy wrapped vue-persian-datetime-picker, which has no Vue 3 build. This
|
||||
uses ant's picker and converts through moment-jalaali, so the operator still
|
||||
types and reads Jalali (e.g. ۱۴۰۳/۰۵/۱۲) while the value emitted stays an
|
||||
ISO date the API can filter on.
|
||||
-->
|
||||
<a-form-item :label="label">
|
||||
<a-input
|
||||
:value="displayValue"
|
||||
:placeholder="placeholder"
|
||||
allow-clear
|
||||
@update:value="onType"
|
||||
/>
|
||||
<small v-if="hint" class="hint">{{ hint }}</small>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import moment from 'moment-jalaali';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string;
|
||||
/** ISO date string, or undefined when empty. */
|
||||
modelValue?: string;
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{ placeholder: 'مثال: ۱۴۰۳/۰۵/۱۲' },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: string | undefined): void }>();
|
||||
|
||||
const typed = ref<string | undefined>(undefined);
|
||||
const hint = ref('');
|
||||
|
||||
/** ISO in -> Jalali out, for display. */
|
||||
const displayValue = computed(() => {
|
||||
if (typed.value !== undefined) return typed.value;
|
||||
if (!props.modelValue) return '';
|
||||
const m = moment(props.modelValue);
|
||||
return m.isValid() ? m.format('jYYYY/jMM/jDD') : '';
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
// A change from outside overrides whatever was half-typed.
|
||||
typed.value = undefined;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Accepts Jalali (۱۴۰۳/۰۵/۱۲ or 1403/05/12) and emits the ISO equivalent.
|
||||
* Persian digits are normalised first so either numeral set works.
|
||||
*/
|
||||
function onType(raw: string) {
|
||||
typed.value = raw;
|
||||
|
||||
if (!raw) {
|
||||
hint.value = '';
|
||||
emit('update:modelValue', undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const latin = raw.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)));
|
||||
const m = moment(latin, 'jYYYY/jMM/jDD');
|
||||
|
||||
if (m.isValid()) {
|
||||
hint.value = '';
|
||||
emit('update:modelValue', m.format('YYYY-MM-DD'));
|
||||
} else {
|
||||
hint.value = 'تاریخ معتبر نیست';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
color: #ea5455;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/components/statistics-cards/StatisticsCardLine.vue.
|
||||
Visual chrome unified with admin panel cards (2026-10-14). Props and call-sites
|
||||
unchanged. Sparkline branch still omitted (every live caller used hideChart).
|
||||
-->
|
||||
<a-card class="stat-card" :bordered="true">
|
||||
<div class="stat-row">
|
||||
<span class="stat-icon">
|
||||
<component :is="iconComponent" />
|
||||
</span>
|
||||
<div class="stat-copy">
|
||||
<span class="stat-title">{{ statisticTitle }}</span>
|
||||
<h2 class="stat-value">{{ statistic }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
ShoppingOutlined,
|
||||
TeamOutlined,
|
||||
DollarOutlined,
|
||||
BarChartOutlined,
|
||||
CreditCardOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
icon?: string;
|
||||
statistic?: string | number;
|
||||
statisticTitle?: string;
|
||||
/** Kept for call-site compatibility; chrome no longer varies by colour. */
|
||||
color?: string;
|
||||
/** Kept for call-site parity; the chart branch is not ported (see above). */
|
||||
hideChart?: boolean;
|
||||
}>(),
|
||||
{ color: 'primary', hideChart: true },
|
||||
);
|
||||
|
||||
/** feather name -> ant icon, matching the icons the live cards ask for */
|
||||
const ICONS: Record<string, unknown> = {
|
||||
UsersIcon: TeamOutlined,
|
||||
ShoppingBagIcon: ShoppingOutlined,
|
||||
DollarSignIcon: DollarOutlined,
|
||||
BarChartIcon: BarChartOutlined,
|
||||
CreditCardIcon: CreditCardOutlined,
|
||||
};
|
||||
|
||||
const iconComponent = computed(() => ICONS[props.icon ?? ''] ?? BarChartOutlined);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.stat-card :deep(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.stat-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(28, 45, 86, 0.06);
|
||||
color: #1c2d56;
|
||||
}
|
||||
|
||||
.stat-copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.stat-title {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: #8c8c8c;
|
||||
font-weight: 400;
|
||||
}
|
||||
.stat-value {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1c2d56;
|
||||
line-height: 1.25;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.stat-card :deep(.ant-card-body) {
|
||||
padding: 12px;
|
||||
}
|
||||
.stat-row {
|
||||
gap: 10px;
|
||||
}
|
||||
.stat-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 16px;
|
||||
}
|
||||
.stat-title {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/components/tooltip-btn/TooltipBtn.vue.
|
||||
Same API (title, popTitle, icon, iconClass) and same behaviour: with popTitle
|
||||
it confirms first, without it fires immediately.
|
||||
|
||||
Legacy rendered <feather-icon icon="ListIcon">. vue-feather has no Vue 3
|
||||
build, so the feather NAME is mapped to an ant-design icon via the shared
|
||||
map in components/icons.ts (also used by the sidebar). Ported views keep
|
||||
writing icon="ListIcon" unchanged.
|
||||
-->
|
||||
<div>
|
||||
<a-tooltip :title="title">
|
||||
<a-popconfirm
|
||||
v-if="popTitle"
|
||||
ok-text="بله"
|
||||
cancel-text="خیر"
|
||||
:title="popTitle"
|
||||
@confirm="onClick"
|
||||
>
|
||||
<component :is="iconComponent" class="cursor-pointer" :class="iconClass" />
|
||||
</a-popconfirm>
|
||||
<div v-else>
|
||||
<component
|
||||
:is="iconComponent"
|
||||
class="cursor-pointer"
|
||||
:class="iconClass"
|
||||
@click="onClick"
|
||||
/>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { UnorderedListOutlined } from '@ant-design/icons-vue';
|
||||
import { resolveIcon } from '../icons';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
popTitle?: string;
|
||||
icon?: string;
|
||||
iconClass?: string;
|
||||
}>(),
|
||||
{ iconClass: 'w-5 h-5' },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ (e: 'click'): void }>();
|
||||
|
||||
const iconComponent = computed(() => resolveIcon(props.icon) ?? UnorderedListOutlined);
|
||||
|
||||
function onClick() {
|
||||
emit('click');
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<!--
|
||||
Multi-file gallery uploader. Same `file/create` endpoint as
|
||||
ImageUploadInput (one file per request), but files can be picked or dropped
|
||||
in bulk; each uploads on its own with progress, and the bound value stays
|
||||
the ordered list of stored URLs.
|
||||
-->
|
||||
<div class="gallery-upload">
|
||||
<a-upload-dragger
|
||||
accept="image/*"
|
||||
multiple
|
||||
:show-upload-list="false"
|
||||
:custom-request="doUpload"
|
||||
:before-upload="beforeUpload"
|
||||
>
|
||||
<div class="gallery-upload__zone">
|
||||
<CloudUploadOutlined class="gallery-upload__icon" />
|
||||
<p class="gallery-upload__title">{{ placeholder }}</p>
|
||||
<p class="gallery-upload__hint">
|
||||
تصاویر را اینجا بکشید و رها کنید یا کلیک کنید — انتخاب چند تصویر بهصورت همزمان امکانپذیر است
|
||||
</p>
|
||||
</div>
|
||||
</a-upload-dragger>
|
||||
|
||||
<div v-if="items.length" class="gallery-upload__grid">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.uid"
|
||||
class="tile"
|
||||
:class="{ 'is-uploading': item.status === 'uploading' }"
|
||||
>
|
||||
<img :src="item.url ?? item.preview" alt="" />
|
||||
|
||||
<div v-if="item.status === 'uploading'" class="tile__progress">
|
||||
<a-progress type="circle" :percent="item.percent" :size="40" :stroke-width="8" stroke-color="#fff" trail-color="rgba(255,255,255,.3)" :show-info="false" />
|
||||
</div>
|
||||
|
||||
<div v-else class="tile__overlay">
|
||||
<button type="button" class="tile__btn" title="نمایش" @click="preview(item)">
|
||||
<EyeOutlined />
|
||||
</button>
|
||||
<button type="button" class="tile__btn tile__btn--danger" title="حذف" @click="remove(item.uid)">
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="items.length" class="gallery-upload__meta">
|
||||
<span>{{ doneCount }} تصویر</span>
|
||||
<span v-if="pendingCount" class="gallery-upload__pending">
|
||||
<LoadingOutlined /> در حال بارگذاری {{ pendingCount }} تصویر…
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<a-modal :open="previewVisible" :footer="null" @cancel="previewVisible = false">
|
||||
<img :src="previewSrc" style="width: 100%" alt="" />
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { message, Upload } from 'ant-design-vue';
|
||||
import { CloudUploadOutlined, DeleteOutlined, EyeOutlined, LoadingOutlined } from '@ant-design/icons-vue';
|
||||
import api from '../../api/client';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Ordered list of stored file URLs. */
|
||||
modelValue?: string[];
|
||||
/** Free-form tag recorded on each upload, e.g. 'product.gallery'. */
|
||||
purpose?: string;
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{ modelValue: () => [], purpose: undefined, placeholder: 'افزودن تصاویر گالری' },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: string[]): void }>();
|
||||
|
||||
const MAX_PARALLEL = 3;
|
||||
|
||||
interface Item {
|
||||
uid: string;
|
||||
status: 'uploading' | 'done';
|
||||
url?: string;
|
||||
/** Local object URL shown while the file is still uploading. */
|
||||
preview?: string;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
const items = ref<Item[]>([]);
|
||||
const previewVisible = ref(false);
|
||||
const previewSrc = ref('');
|
||||
|
||||
const doneUrls = () => items.value.filter((i) => i.status === 'done' && i.url).map((i) => i.url!);
|
||||
const doneCount = computed(() => items.value.filter((i) => i.status === 'done').length);
|
||||
const pendingCount = computed(() => items.value.filter((i) => i.status === 'uploading').length);
|
||||
|
||||
function sync() {
|
||||
emit('update:modelValue', doneUrls());
|
||||
}
|
||||
|
||||
/** Keeps the tiles in step when the parent loads an existing record. */
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(urls) => {
|
||||
if (JSON.stringify(doneUrls()) === JSON.stringify(urls ?? [])) return;
|
||||
const pending = items.value.filter((i) => i.status === 'uploading');
|
||||
items.value = [
|
||||
...(urls ?? []).map((url) => ({ uid: url, status: 'done' as const, url, percent: 100 })),
|
||||
...pending,
|
||||
];
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function beforeUpload(file: File) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
message.error(`«${file.name}» تصویر نیست`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// A small queue so a 30-file drop doesn't open 30 requests at once.
|
||||
let active = 0;
|
||||
const queue: Array<() => void> = [];
|
||||
function runQueued<T>(job: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const start = () => {
|
||||
active++;
|
||||
job()
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
active--;
|
||||
queue.shift()?.();
|
||||
});
|
||||
};
|
||||
if (active < MAX_PARALLEL) start();
|
||||
else queue.push(start);
|
||||
});
|
||||
}
|
||||
|
||||
const find = (uid: string) => items.value.find((i) => i.uid === uid);
|
||||
|
||||
function doUpload({ file }: { file: File & { uid?: string } }) {
|
||||
const uid = file.uid ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
items.value.push({ uid, status: 'uploading', preview: URL.createObjectURL(file), percent: 0 });
|
||||
|
||||
runQueued(async () => {
|
||||
// removed by the user while still waiting in the queue
|
||||
if (!find(uid)) return null;
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (props.purpose) form.append('purpose', props.purpose);
|
||||
// Let the browser set the multipart boundary.
|
||||
const { data } = await api.post('file/create', form, {
|
||||
onUploadProgress: (e) => {
|
||||
const item = find(uid);
|
||||
if (item && e.total) item.percent = Math.round((e.loaded / e.total) * 100);
|
||||
},
|
||||
});
|
||||
return data as { url?: string };
|
||||
})
|
||||
.then((data) => {
|
||||
const item = find(uid);
|
||||
if (!item || !data?.url) return;
|
||||
if (item.preview) URL.revokeObjectURL(item.preview);
|
||||
Object.assign(item, { status: 'done', url: data.url, preview: undefined, percent: 100 });
|
||||
sync();
|
||||
})
|
||||
.catch((e) => {
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
`بارگذاری «${file.name}» ناموفق بود`;
|
||||
message.error(msg);
|
||||
dropItem(uid);
|
||||
});
|
||||
}
|
||||
|
||||
function dropItem(uid: string) {
|
||||
const idx = items.value.findIndex((i) => i.uid === uid);
|
||||
if (idx === -1) return;
|
||||
const [gone] = items.value.splice(idx, 1);
|
||||
if (gone.preview) URL.revokeObjectURL(gone.preview);
|
||||
}
|
||||
|
||||
function remove(uid: string) {
|
||||
dropItem(uid);
|
||||
sync();
|
||||
}
|
||||
|
||||
function preview(item: Item) {
|
||||
previewSrc.value = item.url ?? item.preview ?? '';
|
||||
previewVisible.value = true;
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
items.value.forEach((i) => i.preview && URL.revokeObjectURL(i.preview));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gallery-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.gallery-upload__zone {
|
||||
padding: 18px 12px;
|
||||
}
|
||||
.gallery-upload__icon {
|
||||
font-size: 16px;
|
||||
color: #1c2d56;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.gallery-upload__title {
|
||||
margin: 8px 0 2px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #101828;
|
||||
}
|
||||
.gallery-upload__hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #8a94a6;
|
||||
}
|
||||
|
||||
/* ── square thumbnails ── */
|
||||
.gallery-upload__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.tile {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #eef0f5;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
.tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.tile.is-uploading img {
|
||||
filter: brightness(0.6);
|
||||
}
|
||||
.tile__progress {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.tile__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: rgba(16, 24, 40, 0.5);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.tile:hover .tile__overlay,
|
||||
.tile:focus-within .tile__overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
.tile__btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #1c2d56;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.tile__btn:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.tile__btn--danger {
|
||||
color: #ea5455;
|
||||
}
|
||||
/* touch screens have no hover: keep the actions visible */
|
||||
@media (hover: none) {
|
||||
.tile__overlay {
|
||||
opacity: 1;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
padding: 6px;
|
||||
gap: 4px;
|
||||
background: linear-gradient(to top, rgba(16, 24, 40, 0.55), transparent 55%);
|
||||
}
|
||||
.tile__btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-upload__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-size: 12px;
|
||||
color: #8a94a6;
|
||||
}
|
||||
.gallery-upload__pending {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #1c2d56;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<!--
|
||||
Ports legacy henza_admin/src/components/input/SingleUploadInput.vue (and the
|
||||
near-identical MyUpload.vue / UploadInput.vue), which posted multipart to
|
||||
`file/create` and emitted the response once it carried an `id`.
|
||||
|
||||
Legacy shipped FOUR of these wrappers differing only in list type and the
|
||||
emitted event; this is the single shared version, matching how the rest of
|
||||
this port deduplicates legacy's copy-paste components.
|
||||
|
||||
The bound value is the stored file's URL, which is what the schema keeps.
|
||||
-->
|
||||
<div class="image-upload">
|
||||
<a-upload
|
||||
:file-list="fileList"
|
||||
list-type="picture-card"
|
||||
:max-count="1"
|
||||
accept="image/*"
|
||||
:custom-request="doUpload"
|
||||
@remove="handleRemove"
|
||||
@preview="handlePreview"
|
||||
>
|
||||
<div v-if="fileList.length === 0">
|
||||
<a-spin v-if="uploading" size="small" />
|
||||
<div v-else>
|
||||
<plus-outlined />
|
||||
<div style="margin-top: 8px">{{ placeholder }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
|
||||
<a-modal :open="previewVisible" :footer="null" @cancel="previewVisible = false">
|
||||
<img :src="previewSrc" style="width: 100%" alt="" />
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import api from '../../api/client';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** The stored file URL. */
|
||||
modelValue?: string | null;
|
||||
/** Free-form tag recorded on the upload, e.g. 'category' or 'product'. */
|
||||
purpose?: string;
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{ modelValue: null, purpose: undefined, placeholder: 'بارگذاری' },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string | null): void;
|
||||
/** Legacy emitted the whole response object as `ok`. */
|
||||
(e: 'ok', file: Record<string, unknown>): void;
|
||||
}>();
|
||||
|
||||
const uploading = ref(false);
|
||||
const previewVisible = ref(false);
|
||||
const previewSrc = ref('');
|
||||
|
||||
interface UiFile {
|
||||
uid: string;
|
||||
name: string;
|
||||
status: 'done';
|
||||
url: string;
|
||||
}
|
||||
const fileList = ref<UiFile[]>([]);
|
||||
|
||||
/** Keeps the thumbnail in step when the parent loads an existing record. */
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(url) => {
|
||||
if (!url) {
|
||||
fileList.value = [];
|
||||
} else if (fileList.value[0]?.url !== url) {
|
||||
fileList.value = [{ uid: url, name: url.split('/').pop() ?? 'file', status: 'done', url }];
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const placeholder = computed(() => props.placeholder);
|
||||
|
||||
async function doUpload({ file }: { file: File }) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (props.purpose) form.append('purpose', props.purpose);
|
||||
|
||||
uploading.value = true;
|
||||
try {
|
||||
// Let the browser set the multipart boundary — overriding Content-Type here
|
||||
// is the classic way to break a multipart POST.
|
||||
const { data } = await api.post('file/create', form);
|
||||
emit('update:modelValue', data.url);
|
||||
emit('ok', data);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
'بارگذاری فایل ناموفق بود';
|
||||
message.error(msg);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
emit('update:modelValue', null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function handlePreview(file: UiFile) {
|
||||
previewSrc.value = file.url;
|
||||
previewVisible.value = true;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,757 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/components/x-table/XTable.vue (572 lines).
|
||||
|
||||
Preserved: the server contract (POST {search, filters, page, limit, select,
|
||||
titles, report_title}), the { rows, query } response shape, the filter bar,
|
||||
the column show/hide picker, search, sorting and the named cell slots.
|
||||
|
||||
Dropped: the fullscreen toggle and the excel/json/print export buttons —
|
||||
exports needed a legacy endpoint that returned a file url, which the new
|
||||
backend does not implement. The buttons are omitted rather than left as
|
||||
dead controls that silently fail.
|
||||
-->
|
||||
<div class="x-table" :class="{ 'x-table--cards': cardMode }">
|
||||
<div class="x-table__toolbar">
|
||||
<div class="x-table__search-group">
|
||||
<a-input-search
|
||||
v-if="!model._hideSearch"
|
||||
v-model:value="query.search"
|
||||
class="x-table__search"
|
||||
placeholder="جستجو"
|
||||
allow-clear
|
||||
@search="onSearch"
|
||||
/>
|
||||
|
||||
<div class="x-table__actions">
|
||||
<a-badge v-if="!model._noFilters" :count="query.filters.length">
|
||||
<a-button type="text" :class="{ 'x-table__action--active': activeFilter }" @click="toggleVisibleFilter">
|
||||
فیلتر
|
||||
<template #icon><FilterOutlined /></template>
|
||||
</a-button>
|
||||
</a-badge>
|
||||
|
||||
<a-tooltip title="چاپ">
|
||||
<a-button type="text" shape="circle">
|
||||
<template #icon><PrinterOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-tooltip title="ستونها">
|
||||
<a-button type="text" shape="circle">
|
||||
<template #icon><SettingOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item v-for="col in cols" :key="col.key">
|
||||
<a-checkbox v-model:checked="col.show">{{ col.title }}</a-checkbox>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<a-tooltip title="بارگذاری مجدد">
|
||||
<a-button class="refresh-btn" type="text" shape="circle" :loading="loading" @click="fetch()">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<!-- card view has no column headers to click, so sorting moves here -->
|
||||
<a-select
|
||||
v-if="cardMode && sortOptions.length"
|
||||
:value="sortValue"
|
||||
class="x-table__sort"
|
||||
size="small"
|
||||
placeholder="مرتبسازی"
|
||||
allow-clear
|
||||
:options="sortOptions"
|
||||
@change="onSortSelect"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.add" class="x-table__add">
|
||||
<slot name="add" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- filter bar: one row per active filter -->
|
||||
<div v-if="activeFilter" class="x-table__filters">
|
||||
<div v-for="(f, index) in query.filters" :key="f.id" class="x-table__filter-row">
|
||||
<a-select
|
||||
v-model:value="f.field"
|
||||
placeholder="ستون"
|
||||
style="width: 180px"
|
||||
:options="filterableOptions"
|
||||
/>
|
||||
<a-select v-model:value="f.op" placeholder="شرط" style="width: 130px" :options="opOptions" />
|
||||
<!--
|
||||
A column declared with filterSelect({...}) gets a dropdown of its
|
||||
value->label map; a date column gets a date picker; everything else
|
||||
gets a free-text box, as in legacy.
|
||||
-->
|
||||
<a-select
|
||||
v-if="selectItemsFor(f.field)"
|
||||
v-model:value="f.value"
|
||||
placeholder="مقدار"
|
||||
style="width: 200px"
|
||||
:options="selectItemsFor(f.field)"
|
||||
/>
|
||||
<a-date-picker
|
||||
v-else-if="isDateField(f.field)"
|
||||
v-model:value="f.value"
|
||||
placeholder="مقدار"
|
||||
style="width: 200px"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
<a-input v-else v-model:value="f.value" placeholder="مقدار" style="width: 200px" />
|
||||
<a-input
|
||||
v-if="f.op === 'between'"
|
||||
v-model:value="f.value2"
|
||||
placeholder="تا"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<a-button danger @click="removeFilter(index)">حذف</a-button>
|
||||
</div>
|
||||
<div class="x-table__filter-actions">
|
||||
<a-button @click="addFilterRow">افزودن فیلتر</a-button>
|
||||
<a-button type="primary" @click="doFilter">اعمال</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="cardMode" class="x-table__cards">
|
||||
<a-spin :spinning="loading">
|
||||
<a-empty v-if="!data.length && !loading" :description="locale.emptyText" />
|
||||
<a-checkbox
|
||||
v-if="hasSelection && data.length"
|
||||
class="x-table__select-all"
|
||||
:checked="allOnPageSelected"
|
||||
:indeterminate="someOnPageSelected && !allOnPageSelected"
|
||||
@change="(e: any) => selectPage(e.target.checked)"
|
||||
>
|
||||
انتخاب همه در این صفحه
|
||||
</a-checkbox>
|
||||
<div
|
||||
v-for="record in data"
|
||||
:key="String(record.id)"
|
||||
class="x-card"
|
||||
:class="{ 'x-card--selected': hasSelection && isSelected(record) }"
|
||||
>
|
||||
<div class="x-card__head">
|
||||
<div class="x-card__title">
|
||||
<a-checkbox
|
||||
v-if="hasSelection"
|
||||
:checked="isSelected(record)"
|
||||
@change="(e: any) => toggleRow(record, e.target.checked)"
|
||||
/>
|
||||
<span v-if="cardMediaCol" class="x-card__media">
|
||||
<slot :name="cardMediaCol.slot" :text="cellValue(record, cardMediaCol)" :record="record" />
|
||||
</span>
|
||||
<span v-if="cardIdCol" class="x-card__id">#{{ cellValue(record, cardIdCol) }}</span>
|
||||
<span class="x-card__name">
|
||||
<template v-for="col in cardTitleCols" :key="col.key">
|
||||
<slot v-if="col.slot" :name="col.slot" :text="cellValue(record, col)" :record="record" />
|
||||
<template v-else>{{ displayValue(record, col) }} </template>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="cardActionCol" class="x-card__actions">
|
||||
<slot :name="cardActionCol.slot" :text="cellValue(record, cardActionCol)" :record="record" />
|
||||
</div>
|
||||
</div>
|
||||
<dl v-if="cardBodyCols.length" class="x-card__body">
|
||||
<div v-for="col in cardBodyCols" :key="col.key" class="x-card__row">
|
||||
<dt>{{ col.title }}</dt>
|
||||
<dd>
|
||||
<slot v-if="col.slot" :name="col.slot" :text="cellValue(record, col)" :record="record" />
|
||||
<template v-else>{{ displayValue(record, col) }}</template>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<div v-if="!model._hidePagination && query.total > 0" class="x-table__card-pager">
|
||||
<span class="x-table__card-total">{{ query.total }} مورد</span>
|
||||
<a-pagination
|
||||
size="small"
|
||||
show-less-items
|
||||
:current="query.page"
|
||||
:page-size="query.limit"
|
||||
:total="query.total"
|
||||
:show-size-changer="false"
|
||||
@change="onCardPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
v-else
|
||||
:columns="antColumns"
|
||||
:data-source="data"
|
||||
:loading="loading"
|
||||
:pagination="paginationConfig"
|
||||
:locale="locale"
|
||||
:row-selection="rowSelection"
|
||||
row-key="id"
|
||||
:size="layout.isMobile ? 'small' : 'middle'"
|
||||
:scroll="layout.isSmallScreen ? { x: 'max-content' } : undefined"
|
||||
bordered
|
||||
@change="onTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record, text }">
|
||||
<!--
|
||||
Legacy used named scoped slots (renderSlot('opt') -> slot="opt").
|
||||
Vue 3 routes them through #bodyCell, and the slot name is forwarded to
|
||||
the parent under the same name, so ported views keep their markup.
|
||||
-->
|
||||
<template v-if="slotNameFor(column)">
|
||||
<slot :name="slotNameFor(column)" :text="text" :record="record" />
|
||||
</template>
|
||||
<template v-else-if="column.__date">{{ gridDate(text) }}</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { FilterOutlined, PrinterOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons-vue';
|
||||
import api from '../../api/client';
|
||||
import type { XTbl, Xtc } from './model';
|
||||
import { gridDate } from '../../utils/filters';
|
||||
import { useLayoutStore } from '../../stores/layout';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
model: XTbl;
|
||||
/** legacy prop: renders a checkbox column (ProductList uses this) */
|
||||
hasSelection?: boolean;
|
||||
/** legacy used v-model for the selected rows */
|
||||
modelValue?: Record<string, unknown>[];
|
||||
/** On phones, render each row as a card instead of a wide table. Opt-in per page. */
|
||||
mobileCards?: boolean;
|
||||
}>(),
|
||||
{ hasSelection: false, modelValue: () => [], mobileCards: false },
|
||||
);
|
||||
|
||||
const layout = useLayoutStore();
|
||||
const cardMode = computed(() => props.mobileCards && layout.isMobile);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'query', q: unknown): void;
|
||||
(e: 'update:modelValue', rows: Record<string, unknown>[]): void;
|
||||
}>();
|
||||
|
||||
/** Only present when hasSelection is set, so other tables are unaffected. */
|
||||
const rowSelection = computed(() =>
|
||||
props.hasSelection
|
||||
? {
|
||||
selectedRowKeys: props.modelValue.map((r) => r.id as string),
|
||||
onChange: (_keys: (string | number)[], rows: Record<string, unknown>[]) => {
|
||||
emit('update:modelValue', rows);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
interface FilterRow {
|
||||
id: number;
|
||||
field?: string;
|
||||
op?: string;
|
||||
value?: unknown;
|
||||
value2?: unknown;
|
||||
}
|
||||
|
||||
const data = ref<Record<string, unknown>[]>([]);
|
||||
const loading = ref(false);
|
||||
const activeFilter = ref(false);
|
||||
const sort = ref<string | undefined>(undefined);
|
||||
const sortDirection = ref<'asc' | 'desc'>('asc');
|
||||
|
||||
// legacy data.query
|
||||
const query = reactive({
|
||||
search: undefined as string | undefined,
|
||||
filters: [] as FilterRow[],
|
||||
total: 0,
|
||||
limit: 10,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const locale = { emptyText: 'هیج رکوردی یافت نشد.' };
|
||||
|
||||
const opOptions = [
|
||||
{ value: '=', label: 'برابر' },
|
||||
{ value: '!=', label: 'نامساوی' },
|
||||
{ value: 'like', label: 'شامل' },
|
||||
{ value: '>', label: 'بزرگتر' },
|
||||
{ value: '>=', label: 'بزرگتر مساوی' },
|
||||
{ value: '<', label: 'کوچکتر' },
|
||||
{ value: '<=', label: 'کوچکتر مساوی' },
|
||||
{ value: 'between', label: 'بین' },
|
||||
];
|
||||
|
||||
// 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 })),
|
||||
);
|
||||
|
||||
/** legacy `order` computed: '+field' / '-field' */
|
||||
const order = computed(() =>
|
||||
sort.value ? `${sortDirection.value === 'asc' ? '+' : '-'}${sort.value}` : undefined,
|
||||
);
|
||||
|
||||
const antColumns = computed(() =>
|
||||
visibleCols.value.map((c) => ({
|
||||
key: c.key,
|
||||
title: c.title,
|
||||
dataIndex: c.dataIndex.includes('.') ? c.dataIndex.split('.') : c.dataIndex,
|
||||
sorter: c.__sorter,
|
||||
width: c.width,
|
||||
__slot: c.scopedSlots?.customRender,
|
||||
__date: c.filterType === 'date',
|
||||
})),
|
||||
);
|
||||
|
||||
function slotNameFor(column: { __slot?: string }) {
|
||||
return column.__slot;
|
||||
}
|
||||
|
||||
// ── phone card view ──────────────────────────────────────────────────
|
||||
interface CardCol {
|
||||
key: string;
|
||||
title: string;
|
||||
path: string[];
|
||||
slot?: string;
|
||||
date: boolean;
|
||||
}
|
||||
|
||||
const ACTION_KEYS = ['opt', 'op', 'action'];
|
||||
|
||||
const cardCols = computed<CardCol[]>(() =>
|
||||
visibleCols.value.map((c: Xtc) => ({
|
||||
key: c.key,
|
||||
title: c.title,
|
||||
path: c.dataIndex.split('.'),
|
||||
slot: c.scopedSlots?.customRender,
|
||||
date: c.filterType === 'date',
|
||||
})),
|
||||
);
|
||||
const cardActionCol = computed(() => cardCols.value.find((c) => ACTION_KEYS.includes(c.key) && c.slot));
|
||||
const cardIdCol = computed(() => cardCols.value.find((c) => c.key === 'id'));
|
||||
const cardMediaCol = computed(() => cardCols.value.find((c) => c.slot === 'image'));
|
||||
const cardTitleCols = computed<CardCol[]>(() => {
|
||||
const rest = cardCols.value.filter(
|
||||
(c) => c !== cardActionCol.value && c !== cardIdCol.value && c !== cardMediaCol.value,
|
||||
);
|
||||
const named = rest.find((c) => c.key === 'name' || c.key === 'title');
|
||||
if (named) return [named];
|
||||
const fname = rest.find((c) => c.key === 'fname');
|
||||
if (fname) return [fname, ...rest.filter((c) => c.key === 'lname')];
|
||||
return rest.slice(0, 1);
|
||||
});
|
||||
const cardBodyCols = computed(() =>
|
||||
cardCols.value.filter(
|
||||
(c) =>
|
||||
c !== cardActionCol.value &&
|
||||
c !== cardIdCol.value &&
|
||||
c !== cardMediaCol.value &&
|
||||
!cardTitleCols.value.includes(c),
|
||||
),
|
||||
);
|
||||
|
||||
// card-mode selection, same v-model contract as the table's rowSelection
|
||||
function isSelected(record: Record<string, unknown>) {
|
||||
return props.modelValue.some((r) => r.id === record.id);
|
||||
}
|
||||
function toggleRow(record: Record<string, unknown>, checked: boolean) {
|
||||
const rest = props.modelValue.filter((r) => r.id !== record.id);
|
||||
emit('update:modelValue', checked ? [...rest, record] : rest);
|
||||
}
|
||||
const allOnPageSelected = computed(() => data.value.length > 0 && data.value.every(isSelected));
|
||||
const someOnPageSelected = computed(() => data.value.some(isSelected));
|
||||
function selectPage(checked: boolean) {
|
||||
const pageIds = new Set(data.value.map((r) => r.id));
|
||||
const rest = props.modelValue.filter((r) => !pageIds.has(r.id));
|
||||
emit('update:modelValue', checked ? [...rest, ...data.value] : rest);
|
||||
}
|
||||
|
||||
function cellValue(record: Record<string, unknown>, col: CardCol): unknown {
|
||||
return col.path.reduce<unknown>(
|
||||
(v, k) => (v && typeof v === 'object' ? (v as Record<string, unknown>)[k] : undefined),
|
||||
record,
|
||||
);
|
||||
}
|
||||
|
||||
function displayValue(record: Record<string, unknown>, col: CardCol) {
|
||||
const v = cellValue(record, col);
|
||||
if (v === null || v === undefined || v === '') return '-';
|
||||
return col.date ? gridDate(v) : v;
|
||||
}
|
||||
|
||||
const sortOptions = computed(() =>
|
||||
visibleCols.value
|
||||
.filter((c) => c.__sorter)
|
||||
.flatMap((c) => [
|
||||
{ value: `${c.key}|asc`, label: `${c.title} ↑` },
|
||||
{ value: `${c.key}|desc`, label: `${c.title} ↓` },
|
||||
]),
|
||||
);
|
||||
const sortValue = computed(() => (sort.value ? `${sort.value}|${sortDirection.value}` : undefined));
|
||||
|
||||
function onSortSelect(value?: string) {
|
||||
if (value) {
|
||||
const [key, dir] = value.split('|');
|
||||
sort.value = key;
|
||||
sortDirection.value = dir as 'asc' | 'desc';
|
||||
} else {
|
||||
sort.value = undefined;
|
||||
}
|
||||
query.page = 1;
|
||||
fetch();
|
||||
}
|
||||
|
||||
function onCardPage(page: number, pageSize: number) {
|
||||
query.page = page;
|
||||
query.limit = pageSize;
|
||||
fetch();
|
||||
document.getElementById('content-area')?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
/** Resolves a filter row's column back to its Xtc definition. */
|
||||
function colForField(field?: string) {
|
||||
if (!field) return undefined;
|
||||
return cols.value.find((c) => (c.__fkey ?? c.key) === field);
|
||||
}
|
||||
|
||||
/** Dropdown options for a filterSelect column, or undefined for other types. */
|
||||
function selectItemsFor(field?: string) {
|
||||
const col = colForField(field);
|
||||
if (col?.filterType !== 'select' || !col.filterItems) return undefined;
|
||||
return Object.entries(col.filterItems).map(([value, label]) => ({ value, label }));
|
||||
}
|
||||
|
||||
function isDateField(field?: string) {
|
||||
return colForField(field)?.filterType === 'date';
|
||||
}
|
||||
|
||||
const paginationConfig = computed(() =>
|
||||
props.model?._hidePagination
|
||||
? false
|
||||
: {
|
||||
current: query.page,
|
||||
pageSize: query.limit,
|
||||
total: query.total,
|
||||
showSizeChanger: true,
|
||||
},
|
||||
);
|
||||
|
||||
/** legacy requestData() */
|
||||
function requestData(op?: string) {
|
||||
const titles: Record<string, string> = {};
|
||||
visibleCols.value.forEach((c) => {
|
||||
titles[c.key] = c.title;
|
||||
});
|
||||
const select = visibleCols.value.map((c) => c.key);
|
||||
const base = {
|
||||
search: query.search,
|
||||
filters: query.filters.filter((f) => f.field && f.op),
|
||||
total: query.total,
|
||||
limit: query.limit,
|
||||
page: query.page,
|
||||
order: order.value,
|
||||
select,
|
||||
titles,
|
||||
report_title: props.model?.title,
|
||||
};
|
||||
return op ? { ...base, op } : base;
|
||||
}
|
||||
|
||||
/** legacy fetch() + getDataUsage() in 'laravel' mode */
|
||||
async function fetch(op?: string) {
|
||||
if (!props.model) return;
|
||||
try {
|
||||
loading.value = true;
|
||||
const { data: res } = await api.post(props.model.url, requestData(op));
|
||||
data.value = res.rows;
|
||||
query.total = res.query?.total ?? 0;
|
||||
query.page = res.query?.page ?? query.page;
|
||||
query.limit = res.query?.limit ?? query.limit;
|
||||
emit('query', res.query);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch(value: string) {
|
||||
query.search = value;
|
||||
query.page = 1;
|
||||
fetch();
|
||||
}
|
||||
|
||||
function toggleVisibleFilter() {
|
||||
activeFilter.value = !activeFilter.value;
|
||||
if (activeFilter.value && query.filters.length === 0) addFilterRow();
|
||||
}
|
||||
|
||||
/** legacy addFilterRow(): id is a random float used only as a list key */
|
||||
function addFilterRow() {
|
||||
query.filters.push({ id: parseFloat(`${query.filters.length}.${Math.floor(Math.random() * 1000)}`) });
|
||||
}
|
||||
|
||||
function removeFilter(index: number) {
|
||||
query.filters.splice(index, 1);
|
||||
}
|
||||
|
||||
function doFilter() {
|
||||
activeFilter.value = false;
|
||||
query.page = 1;
|
||||
fetch();
|
||||
}
|
||||
|
||||
function onTableChange(
|
||||
pagination: { current?: number; pageSize?: number },
|
||||
_filters: unknown,
|
||||
sorter: { columnKey?: string; order?: string },
|
||||
) {
|
||||
query.page = pagination.current ?? 1;
|
||||
query.limit = pagination.pageSize ?? query.limit;
|
||||
if (sorter?.columnKey && sorter.order) {
|
||||
sort.value = String(sorter.columnKey);
|
||||
sortDirection.value = sorter.order === 'ascend' ? 'asc' : 'desc';
|
||||
} else {
|
||||
sort.value = undefined;
|
||||
}
|
||||
fetch();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// legacy applied model.defaultFilters before the first fetch
|
||||
for (const df of props.model?.defaultFilters ?? []) {
|
||||
query.filters.push({ id: Math.random(), ...df });
|
||||
}
|
||||
fetch();
|
||||
});
|
||||
|
||||
defineExpose({ fetch });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.x-table__toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.x-table__search-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.x-table__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.x-table__action--active {
|
||||
color: #1677ff;
|
||||
}
|
||||
.x-table__add {
|
||||
display: flex;
|
||||
}
|
||||
.x-table__filters {
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.x-table__filter-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.x-table__filter-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.refresh-btn {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.x-table__search {
|
||||
max-width: 260px;
|
||||
}
|
||||
.x-table__sort {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
/* ── phone card view ── */
|
||||
.x-table__cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.x-table__cards :deep(.ant-spin-container) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 80px;
|
||||
}
|
||||
.x-card {
|
||||
border: 1px solid #eef0f3;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.x-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #fafbfc;
|
||||
border-bottom: 1px solid #eef0f3;
|
||||
}
|
||||
.x-card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.x-card__id {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: #8a909c;
|
||||
background: #eef0f3;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
direction: ltr;
|
||||
}
|
||||
.x-card__name {
|
||||
font-weight: 600;
|
||||
color: #1c2d56;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.x-card__actions {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.x-card--selected {
|
||||
border-color: #91caff;
|
||||
}
|
||||
.x-card--selected .x-card__head {
|
||||
background: #f0f7ff;
|
||||
}
|
||||
.x-card__media {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
}
|
||||
.x-card__media :deep(img) {
|
||||
width: 44px !important;
|
||||
height: 44px !important;
|
||||
object-fit: cover !important;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.x-table__select-all {
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
}
|
||||
.x-card__body {
|
||||
margin: 0;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
.x-card__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 7px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.x-card__row + .x-card__row {
|
||||
border-top: 1px dashed #f0f0f0;
|
||||
}
|
||||
.x-card__row dt {
|
||||
color: #8a909c;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.x-card__row dd {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
.x-card__row dd :deep(.ant-tag) {
|
||||
margin: 0;
|
||||
}
|
||||
.x-table__card-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.x-table__card-total {
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.x-table__toolbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
/* flatten so search takes row one and actions + add share row two */
|
||||
.x-table__search-group {
|
||||
display: contents;
|
||||
}
|
||||
.x-table__search {
|
||||
max-width: none;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.x-table__actions {
|
||||
gap: 2px;
|
||||
}
|
||||
.x-table__sort {
|
||||
width: 130px;
|
||||
}
|
||||
.x-table__add {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.x-table__filters {
|
||||
padding: 10px;
|
||||
}
|
||||
.x-table__filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed #e8e8e8;
|
||||
}
|
||||
/* the filter inputs carry fixed inline widths for desktop */
|
||||
.x-table__filter-row > * {
|
||||
width: 100% !important;
|
||||
}
|
||||
.x-table__filter-row > :nth-child(n + 3) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.x-table__filter-actions > * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Ported from legacy henza_admin/src/components/x-table/model/{XTbl,Xtc}.js
|
||||
* and src/table/index.js (ColCreator).
|
||||
*
|
||||
* The fluent column API is preserved exactly — `new Xtc(key, title).filterText()
|
||||
* .renderSlot('x').noSort()` builds the same shape the legacy table schemas used,
|
||||
* so the 18 schema files port over with only their import path changed.
|
||||
*
|
||||
* Legacy had FOUR near-duplicate XTable forks (x-table/, x-table_select/,
|
||||
* custom-xtable-select/, backup-table/, ~1,300 LOC). Per the agreed decision this
|
||||
* port keeps ONE — the x-table/ variant, which is the most complete (it is the
|
||||
* only one with QuerySelectTableFilter).
|
||||
*/
|
||||
|
||||
export type FilterType = 'default' | 'switch' | 'query' | 'date' | 'number' | 'select';
|
||||
|
||||
export interface ColumnSlots {
|
||||
customRender?: string;
|
||||
filterDropdown?: string;
|
||||
filterIcon?: string;
|
||||
}
|
||||
|
||||
/** Port of legacy Xtc — one column definition. */
|
||||
export class Xtc {
|
||||
key: string;
|
||||
title: string;
|
||||
dataIndex: string;
|
||||
show = true;
|
||||
/** legacy `__sorter`: sortable unless noSort() was called */
|
||||
__sorter = true;
|
||||
filtered = false;
|
||||
filterType?: FilterType;
|
||||
filterAction?: string;
|
||||
/** legacy `filterItems`: a value -> label map for select filters */
|
||||
filterItems?: Record<string | number, string>;
|
||||
scopedSlots?: ColumnSlots;
|
||||
/** legacy filterKey()/master — filter against a different field than dataIndex */
|
||||
__fkey?: string;
|
||||
width?: string | number;
|
||||
|
||||
constructor(key: string, title: string) {
|
||||
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() {
|
||||
this.filtered = true;
|
||||
if (!this.scopedSlots) this.scopedSlots = {};
|
||||
this.scopedSlots.filterDropdown = `${this.key}_filterDropdown`;
|
||||
this.scopedSlots.filterIcon = `${this.key}_filterIcon`;
|
||||
}
|
||||
|
||||
/** legacy filterKey(key) */
|
||||
filterKey(key: string) {
|
||||
this.__fkey = key;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** legacy renderSlot(name) — render this cell through a named slot */
|
||||
renderSlot(name: string) {
|
||||
if (!this.scopedSlots) this.scopedSlots = {};
|
||||
this.scopedSlots.customRender = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
filterText() {
|
||||
this.__filter();
|
||||
this.filterType = 'default';
|
||||
return this;
|
||||
}
|
||||
|
||||
filterSwitch() {
|
||||
this.__filter();
|
||||
this.filterType = 'switch';
|
||||
return this;
|
||||
}
|
||||
|
||||
filterQuery(action: string) {
|
||||
this.__filter();
|
||||
this.filterType = 'query';
|
||||
this.filterAction = action;
|
||||
return this;
|
||||
}
|
||||
|
||||
filterDate() {
|
||||
this.__filter();
|
||||
this.filterType = 'date';
|
||||
return this;
|
||||
}
|
||||
|
||||
filterNumber() {
|
||||
this.__filter();
|
||||
this.filterType = 'number';
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* legacy filterSelect(items) — `items` is an OBJECT MAP of value -> label,
|
||||
* e.g. { 0: 'معلق', 1: 'ثبت سفارش' }, stored on `filterItems`. The shape is
|
||||
* kept exactly so the ported table models pass their maps unchanged.
|
||||
*
|
||||
* Legacy also permitted calling it with no argument at all (TaminListModel does
|
||||
* this), which left filterItems undefined and rendered an empty dropdown.
|
||||
* Preserved: the parameter is optional here too.
|
||||
*/
|
||||
filterSelect(items?: Record<string | number, string>) {
|
||||
this.__filter();
|
||||
this.filterType = 'select';
|
||||
this.filterItems = items;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** legacy noSort() */
|
||||
noSort() {
|
||||
this.__sorter = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** legacy hidden() — column exists but starts unchecked in the column picker */
|
||||
hidden() {
|
||||
this.show = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
setWidth(w: string | number) {
|
||||
this.width = w;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DefaultFilter {
|
||||
field: string;
|
||||
op: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
/** Port of legacy XTbl — a table definition: endpoint + title + columns. */
|
||||
export class XTbl {
|
||||
url: string;
|
||||
title: string;
|
||||
cols: Xtc[] = [];
|
||||
defaultFilters?: DefaultFilter[];
|
||||
_hidePagination = false;
|
||||
_hideSearch = false;
|
||||
_noFilters = false;
|
||||
|
||||
constructor(url: string, title: string) {
|
||||
this.url = url;
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
add(col: Xtc) {
|
||||
this.cols.push(col);
|
||||
return this;
|
||||
}
|
||||
|
||||
addDefaultFilter(filter: DefaultFilter) {
|
||||
if (!this.defaultFilters) this.defaultFilters = [];
|
||||
this.defaultFilters.push(filter);
|
||||
return this;
|
||||
}
|
||||
|
||||
hidePagination() {
|
||||
this._hidePagination = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: legacy's hideSearch() set `_hidePageination` (sic) instead of a search
|
||||
* flag — a copy-paste bug, so calling it hid PAGINATION, not the search box.
|
||||
* Fixed here to set the intended flag; no ported schema calls it, so nothing
|
||||
* changes in behaviour. Recorded in docs/ADMIN-PORT-PLAN.md.
|
||||
*/
|
||||
hideSearch() {
|
||||
this._hideSearch = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** legacy noFilters(): hides the filter bar entirely (walletTbl uses this). */
|
||||
noFilters() {
|
||||
this._noFilters = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
getSortFields() {
|
||||
return this.cols.filter((c) => c.__sorter);
|
||||
}
|
||||
}
|
||||
|
||||
export default XTbl;
|
||||
Reference in New Issue
Block a user