feat(admin): «اجرت» screen — edit the making-charge formula, check a sample, approve

The formula shown as a sentence with coloured terms; each term can be
switched off or given a coefficient; promotion as largest or sum; a
constant, floor, cap and rounding. A sample order line is computed by
the server for the formula in force and the draft side by side, changes
highlighted. Saving needs a change, a confirmed sample and a final
confirm; the approved sample is kept and shown as the formula's
reference, with earlier versions below.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-08 11:26:56 +03:30
co-authored by Claude Opus 5.5
parent 699657e483
commit fbe7b98ada
4 changed files with 535 additions and 0 deletions
@@ -471,6 +471,14 @@ export const adminMenu: NavMenuNode[] = [
i18n: 'اتصال حسابداری',
permissionId:"config.accounting"
},
// MON-08 — the making-charge formula.
{
url: '/config/manage/wage',
name: 'wage Formula',
slug: 'manage-wage',
i18n: 'اجرت',
permissionId:"config.wage"
},
// Moved under "پیکر بندی" per request — same node shape as before
// (own submenu / plain leaf), just relocated in the tree.
{
+22
View File
@@ -122,6 +122,28 @@ export const adminRoute: RouteRecordRaw[] = [
no_scroll: true
}
},
// MON-08 — the making-charge formula (owner ruling 2026-10-08).
{
path: '/config/manage/wage',
component: () => import('../views/manage/wage.vue'),
meta: {
breadcrumb: [
{
title: 'خانه',
url: '/'
},
{
title: 'اجرت',
active: true
}
],
pageTitle: 'اجرت',
rule: 'editor',
role: 'config.wage',
parent: 'manage',
no_scroll: true
}
},
{
path: '/config/manage/report',
component: () => import('../views/manage/report.vue'),
+501
View File
@@ -0,0 +1,501 @@
<template>
<!--
«اجرت» — the making-charge formula every app order is priced with
(owner ruling 2026-10-08, MON-08). The server computes everything shown
here (POST admin/pricing/wage-formula/preview), so the sample is exactly
what an order would be charged — this page never does the arithmetic.
Flow: change the formula → the sample shows the formula in force and the
draft side by side → the admin confirms the sample is right → «تأیید و
ثبت». The approved sample is kept with the formula as its reference.
-->
<div v-if="hasPermission('config.wage')" class="wage">
<a-spin :spinning="loading">
<!-- ── status ─────────────────────────────────────────── -->
<a-card class="mb-4" :bordered="false">
<div class="status">
<div>
<div class="status__title">فرمول اجرت سفارش‌های اپلیکیشن</div>
<div class="hint">
اجرتی که مشتری در اپ می‌بیند و اجرتی که روی فاکتور می‌آید، هر دو با همین فرمول در سرور حساب می‌شوند.
اجرتی که اپراتور در پنل برای یک سفارش دستی وارد می‌کند، دست نمی‌خورد.
</div>
</div>
<a-tag v-if="config?.isLegacyDefault" color="blue">فرمول سیستم قدیم (پیش‌فرض)</a-tag>
<a-tag v-else-if="config" color="green">
تأییدشده در {{ jdate(config.approvedAt) }} — {{ config.approvedBy?.name }}
</a-tag>
</div>
</a-card>
<a-row :gutter="[16, 16]">
<!-- ── the formula ──────────────────────────────────── -->
<a-col :xs="24" :xl="12">
<a-card title="ساختار فرمول">
<template #extra>
<a-button size="small" :disabled="isLegacy(draft)" @click="resetToLegacy">بازگشت به فرمول سیستم قدیم</a-button>
</template>
<div class="formula" aria-live="polite">
<span class="formula__lhs">اجرت نهایی =</span>
<template v-for="(t, i) in formulaTerms" :key="t.key">
<span v-if="i > 0 || t.sign === '−'" class="formula__op">{{ t.sign }}</span>
<span class="chip" :class="`chip--${t.key}`">{{ t.text }}</span>
</template>
<span v-if="!formulaTerms.length" class="chip chip--zero">۰</span>
<div v-if="limitsText" class="formula__limits">{{ limitsText }}</div>
</div>
<div class="term" v-for="t in termRows" :key="t.key">
<div class="term__head">
<a-switch v-model:checked="t.enabled.value" size="small" />
<span class="term__label">{{ t.label }}</span>
<span class="chip chip--sm" :class="`chip--${t.key}`">{{ t.sign }}</span>
</div>
<div class="hint">{{ t.help }}</div>
<div v-if="t.enabled.value" class="term__body">
<span>ضریب</span>
<a-input-number v-model:value="t.coef.value" :step="0.1" :min="-10" :max="10" class="num" />
<template v-if="t.key === 'promotion'">
<a-radio-group v-model:value="draft.promotionPick" size="small" button-style="solid">
<a-radio-button value="max">بیشترین تخفیف</a-radio-button>
<a-radio-button value="sum">جمع تخفیف‌ها</a-radio-button>
</a-radio-group>
</template>
</div>
</div>
<div class="term">
<div class="term__head">
<span class="term__label">مقدار ثابت (درصد)</span>
<span class="chip chip--sm chip--constant">±</span>
</div>
<div class="hint">به همه‌ی اجرت‌ها اضافه می‌شود؛ عدد منفی کم می‌کند. صفر یعنی بی‌اثر.</div>
<div class="term__body">
<a-input-number v-model:value="constantNum" :step="0.1" :min="-100" :max="100" class="num" addon-after="٪" />
</div>
</div>
<a-divider orientation="right" class="divider">محدودیت و گرد کردن</a-divider>
<div class="limits">
<div class="limit">
<a-checkbox v-model:checked="minOn">حداقل اجرت</a-checkbox>
<a-input-number v-model:value="minNum" :disabled="!minOn" :step="0.5" class="num" addon-after="٪" />
</div>
<div class="limit">
<a-checkbox v-model:checked="maxOn">حداکثر اجرت</a-checkbox>
<a-input-number v-model:value="maxNum" :disabled="!maxOn" :step="0.5" class="num" addon-after="٪" />
</div>
<div class="limit">
<a-checkbox v-model:checked="roundOn">گرد کردن</a-checkbox>
<a-input-number v-model:value="decimalsNum" :disabled="!roundOn" :min="0" :max="4" :precision="0" class="num" addon-after="رقم اعشار" />
</div>
</div>
</a-card>
</a-col>
<!-- ── the sample ───────────────────────────────────── -->
<a-col :xs="24" :xl="12">
<a-card title="نمونه — نتیجه را قبل از ثبت ببینید">
<template #extra>
<a-button size="small" type="link" :disabled="!config" @click="loadSavedSample">نمونه‌ی ثبت‌شده</a-button>
</template>
<div class="sample">
<label>وزن خام<a-input-number v-model:value="sample.rashWeightGrams" :min="0" :step="0.1" class="num" addon-after="گرم" /></label>
<label>تعداد<a-input-number v-model:value="sample.quantity" :min="1" :max="1000" :precision="0" class="num" /></label>
<label>اجرت مدل<a-input-number v-model:value="sample.variantPercent" :step="0.5" class="num" addon-after="٪" /></label>
<label>درصد مشتری<a-input-number v-model:value="sample.customerPercent" :step="0.5" class="num" addon-after="٪" /></label>
<label>تخفیف فعال<a-input-number v-model:value="sample.promotionPercent" :min="0" :max="100" :step="0.5" class="num" addon-after="٪" /></label>
</div>
<a-alert v-if="previewError" type="error" show-icon class="mb-4" :message="previewError" />
<table class="cmp" v-if="preview">
<thead>
<tr>
<th></th>
<th>فرمول فعلی</th>
<th :class="{ changed: dirty }">{{ dirty ? 'پیش‌نویس شما' : 'پیش‌نویس (بدون تغییر)' }}</th>
</tr>
</thead>
<tbody>
<tr v-for="r in cmpRows" :key="r.key" :class="{ strong: r.strong }">
<td>{{ r.label }}</td>
<td>{{ r.fmt(preview.current) }}</td>
<td :class="{ diff: r.fmt(preview.current) !== r.fmt(preview.draft) }">{{ r.fmt(preview.draft) }}</td>
</tr>
</tbody>
</table>
<div v-if="preview?.draft.limitedBy" class="hint warn">
در پیش‌نویس، {{ preview.draft.limitedBy === 'min' ? 'حداقل' : 'حداکثر' }} اجرت روی نتیجه اثر گذاشت.
</div>
</a-card>
<!-- ── approve ──────────────────────────────────────── -->
<a-card class="mt-4" title="تأیید و ثبت">
<a-alert
v-if="!dirty"
type="info"
show-icon
message="تغییری نسبت به فرمول فعلی نداده‌اید."
class="mb-4"
/>
<template v-else>
<a-alert
type="warning"
show-icon
class="mb-4"
message="تا «تأیید و ثبت» نزنید هیچ چیزی عوض نمی‌شود."
description="بعد از ثبت، همه‌ی سفارش‌های جدید اپ و اجرتی که اپ نشان می‌دهد با فرمول جدید حساب می‌شوند. سفارش‌های ثبت‌شده‌ی قبلی تغییری نمی‌کنند."
/>
<a-checkbox v-model:checked="sampleChecked" class="mb-4">
نتیجه‌ی نمونه را بررسی کردم و درست است.
</a-checkbox>
<div>
<a-popconfirm
title="فرمول جدید از همین حالا برای سفارش‌های اپ اعمال شود؟"
ok-text="بله، ثبت شود"
cancel-text="انصراف"
:disabled="!canApprove"
@confirm="approve"
>
<a-button type="primary" :disabled="!canApprove" :loading="saving">تأیید و ثبت فرمول</a-button>
</a-popconfirm>
<a-button class="ms" @click="discard">لغو تغییرات</a-button>
</div>
</template>
</a-card>
</a-col>
</a-row>
<!-- ── the approved reference sample + history ─────────── -->
<a-card class="mt-4" title="نمونه‌ی مرجعِ فرمول فعلی" v-if="config">
<div class="hint mb-4">
این نمونه همراه فرمول فعلی ثبت شده است؛ هر کس فرمول را ببیند می‌داند با چه عددی بررسی و تأیید شده.
</div>
<a-descriptions size="small" bordered :column="{ xs: 1, md: 3 }">
<a-descriptions-item label="فرمول">{{ formulaText(config.formula) }}</a-descriptions-item>
<a-descriptions-item label="نمونه">{{ sampleText(config.sample) }}</a-descriptions-item>
<a-descriptions-item label="نتیجه">
اجرت {{ pct(config.result.finalPercent) }} — {{ g(config.result.wageGrams) }} اجرت، {{ g(config.result.totalGrams) }} وزن کل
</a-descriptions-item>
</a-descriptions>
<a-collapse v-if="config.history.length" ghost class="mt-4">
<a-collapse-panel key="h" :header="`نسخه‌های قبلی (${config.history.length})`">
<div v-for="(h, i) in config.history" :key="i" class="hist">
<div class="hist__meta">{{ jdate(h.approvedAt) }} — {{ h.approvedBy?.name }}</div>
<div>{{ formulaText(h.formula) }}</div>
<div class="hint">{{ sampleText(h.sample) }} ← اجرت {{ pct(h.result.finalPercent) }}، وزن کل {{ g(h.result.totalGrams) }}</div>
</div>
</a-collapse-panel>
</a-collapse>
</a-card>
</a-spin>
</div>
<a-result v-else status="403" title="دسترسی ندارید" sub-title="این صفحه دسترسی «اجرت» می‌خواهد." />
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import moment from 'moment-jalaali';
import { useUsersStore } from '../../stores/users';
import api from '../../api/client';
type Pick = 'max' | 'sum';
interface Formula {
version: 1;
variantCoef: string;
customerCoef: string;
promotionCoef: string;
promotionPick: Pick;
constant: string;
min: string | null;
max: string | null;
decimals: number | null;
}
interface Sample {
rashWeightGrams: number;
quantity: number;
variantPercent: number;
customerPercent: number;
promotionPercent: number;
}
interface Result {
variantTerm: string;
customerTerm: string;
promotionPercent: string;
promotionTerm: string;
constant: string;
beforeLimits: string;
limitedBy: 'min' | 'max' | null;
finalPercent: string;
wageGrams: string;
totalGrams: string;
rashTotalGrams: string;
}
interface Approval { formula: Formula; sample: Sample; approvedAt: string; approvedBy: { id: string; name: string }; result: Result }
interface Config extends Approval {
approvedAt: string;
isLegacyDefault: boolean;
legacy: Formula;
history: Approval[];
}
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const loading = ref(false);
const saving = ref(false);
const config = ref<Config | null>(null);
const draft = reactive<Formula>({
version: 1, variantCoef: '1', customerCoef: '1', promotionCoef: '1', promotionPick: 'max', constant: '0', min: null, max: null, decimals: null,
});
const sample = reactive<Sample>({ rashWeightGrams: 10, quantity: 1, variantPercent: 7, customerPercent: 3, promotionPercent: 0 });
const preview = ref<{ current: Result; draft: Result } | null>(null);
const previewError = ref('');
const sampleChecked = ref(false);
const errMsg = (e: any, fallback: string) => {
const m = e?.response?.data?.message;
return Array.isArray(m) ? m.join('، ') : (m ?? fallback);
};
/* ───────── numbers ⇄ the string-valued formula ───────── */
const n = (s: string | null) => (s === null ? null : Number(s));
const s = (v: number | null | undefined) => (v === null || v === undefined || Number.isNaN(v) ? '0' : String(v));
// Each term remembers its coefficient while switched off, so turning it back
// on restores the number instead of resetting it to 1.
const remembered = reactive({ variant: 1, customer: 1, promotion: 1 });
function termModel(key: 'variant' | 'customer' | 'promotion') {
const field = `${key}Coef` as const;
const enabled = computed({
get: () => Number(draft[field]) !== 0,
set: (on: boolean) => {
if (!on) remembered[key] = Number(draft[field]) || 1;
draft[field] = on ? String(remembered[key] || 1) : '0';
},
});
const coef = computed({
get: () => Number(draft[field]),
set: (v: number | null) => (draft[field] = s(v)),
});
return { enabled, coef };
}
const termRows = [
{ key: 'variant', label: 'اجرت مدل', sign: '+', help: 'اجرتی که روی هر مدل (واریانت) کالا ثبت شده است.', ...termModel('variant') },
{ key: 'customer', label: 'درصد اختصاصی مشتری', sign: '+', help: 'درصدی که روی پرونده‌ی هر مشتری ثبت شده (معمولاً ۳).', ...termModel('customer') },
{ key: 'promotion', label: 'تخفیف فعال', sign: '−', help: 'تخفیف‌های فعالِ مربوط به همان کالا، دسته‌بندی یا برند. سیستم قدیم فقط بیشترین را کم می‌کرد.', ...termModel('promotion') },
] as const;
const constantNum = computed({ get: () => Number(draft.constant), set: (v: number | null) => (draft.constant = s(v)) });
const minOn = computed({ get: () => draft.min !== null, set: (on: boolean) => (draft.min = on ? draft.min ?? '0' : null) });
const maxOn = computed({ get: () => draft.max !== null, set: (on: boolean) => (draft.max = on ? draft.max ?? '20' : null) });
const roundOn = computed({ get: () => draft.decimals !== null, set: (on: boolean) => (draft.decimals = on ? draft.decimals ?? 2 : null) });
const minNum = computed({ get: () => n(draft.min), set: (v: number | null) => (draft.min = s(v)) });
const maxNum = computed({ get: () => n(draft.max), set: (v: number | null) => (draft.max = s(v)) });
const decimalsNum = computed({ get: () => draft.decimals, set: (v: number | null) => (draft.decimals = v ?? 0) });
/* ───────── readable formula ───────── */
const fa = (v: string | number) => String(v).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]).replace('.', '٫');
const coefText = (c: string) => (Number(c) === 1 ? '' : `${fa(c)} × `);
function termsOf(f: Formula) {
const out: { key: string; sign: '+' | '−'; text: string }[] = [];
if (Number(f.variantCoef)) out.push({ key: 'variant', sign: '+', text: `${coefText(f.variantCoef)}اجرت مدل` });
if (Number(f.customerCoef)) out.push({ key: 'customer', sign: '+', text: `${coefText(f.customerCoef)}درصد مشتری` });
if (Number(f.promotionCoef))
out.push({ key: 'promotion', sign: '−', text: `${coefText(f.promotionCoef)}${f.promotionPick === 'max' ? 'بیشترین تخفیف' : 'جمع تخفیف‌ها'}` });
if (Number(f.constant)) out.push({ key: 'constant', sign: Number(f.constant) < 0 ? '−' : '+', text: `${fa(Math.abs(Number(f.constant)))}٪` });
return out;
}
const formulaTerms = computed(() => termsOf(draft));
function limitsOf(f: Formula) {
const parts: string[] = [];
if (f.min !== null) parts.push(`حداقل ${fa(f.min)}٪`);
if (f.max !== null) parts.push(`حداکثر ${fa(f.max)}٪`);
if (f.decimals !== null) parts.push(`گرد تا ${fa(f.decimals)} رقم اعشار`);
return parts.join(' · ');
}
const limitsText = computed(() => limitsOf(draft));
function formulaText(f: Formula) {
const t = termsOf(f);
const body = t.map((x, i) => `${i > 0 || x.sign === '−' ? ` ${x.sign} ` : ''}${x.text}`).join('') || '۰';
const lim = limitsOf(f);
return `اجرت نهایی = ${body}${lim ? ` (${lim})` : ''}`;
}
const sampleText = (x: Sample) =>
`${fa(x.rashWeightGrams)} گرم × ${fa(x.quantity)}، اجرت مدل ${fa(x.variantPercent)}٪، مشتری ${fa(x.customerPercent)}٪، تخفیف ${fa(x.promotionPercent)}٪`;
const pct = (v: string) => `${fa(Number(v))}٪`;
const g = (v: string) => `${fa(Number(v))} گرم`;
const jdate = (v?: string | null) => (v ? fa(moment(v).format('jYYYY/jM/jD HH:mm')) : '');
const cmpRows = [
{ key: 'v', label: 'سهم اجرت مدل', fmt: (r: Result) => pct(r.variantTerm) },
{ key: 'c', label: 'سهم درصد مشتری', fmt: (r: Result) => pct(r.customerTerm) },
{ key: 'p', label: 'سهم تخفیف (کم می‌شود)', fmt: (r: Result) => pct(r.promotionTerm) },
{ key: 'k', label: 'مقدار ثابت', fmt: (r: Result) => pct(r.constant) },
{ key: 'b', label: 'قبل از محدودیت', fmt: (r: Result) => pct(r.beforeLimits) },
{ key: 'f', label: 'اجرت نهایی', fmt: (r: Result) => pct(r.finalPercent), strong: true },
{ key: 'w', label: 'اجرت به گرم', fmt: (r: Result) => g(r.wageGrams) },
{ key: 't', label: 'وزن کل فاکتور', fmt: (r: Result) => g(r.totalGrams), strong: true },
];
/* ───────── state ───────── */
const sameFormula = (a: Formula, b: Formula) =>
(['variantCoef', 'customerCoef', 'promotionCoef', 'constant', 'min', 'max'] as const).every(
(k) => (a[k] === null ? null : Number(a[k])) === (b[k] === null ? null : Number(b[k])),
) && a.promotionPick === b.promotionPick && a.decimals === b.decimals;
const dirty = computed(() => !!config.value && !sameFormula(draft, config.value.formula));
const isLegacy = (f: Formula) => !!config.value && sameFormula(f, config.value.legacy);
const canApprove = computed(() => dirty.value && sampleChecked.value && !previewError.value && !!preview.value);
function setDraft(f: Formula) {
Object.assign(draft, { ...f });
}
function setSample(x: Sample) {
Object.assign(sample, {
rashWeightGrams: Number(x.rashWeightGrams),
quantity: Number(x.quantity),
variantPercent: Number(x.variantPercent),
customerPercent: Number(x.customerPercent),
promotionPercent: Number(x.promotionPercent),
});
}
async function load() {
loading.value = true;
try {
const { data } = await api.get('admin/pricing/wage-formula');
config.value = data;
setDraft(data.formula);
setSample(data.sample);
sampleChecked.value = false;
} catch (e) {
message.error(errMsg(e, 'خطا در دریافت فرمول اجرت'));
} finally {
loading.value = false;
}
}
let seq = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
async function runPreview() {
const mine = ++seq;
try {
const { data } = await api.post('admin/pricing/wage-formula/preview', { formula: { ...draft }, sample: { ...sample } });
if (mine !== seq) return;
preview.value = data;
previewError.value = '';
} catch (e) {
if (mine !== seq) return;
previewError.value = errMsg(e, 'محاسبه‌ی نمونه انجام نشد');
}
}
// Any change: recompute on the server, and ask for the sample to be checked again.
watch(
[() => ({ ...draft }), () => ({ ...sample })],
() => {
sampleChecked.value = false;
if (timer) clearTimeout(timer);
timer = setTimeout(runPreview, 250);
},
{ deep: true },
);
function resetToLegacy() {
if (config.value) setDraft(config.value.legacy);
}
function discard() {
if (config.value) setDraft(config.value.formula);
}
function loadSavedSample() {
if (config.value) setSample(config.value.sample);
}
async function approve() {
saving.value = true;
try {
const { data } = await api.put('admin/pricing/wage-formula', { formula: { ...draft }, sample: { ...sample } });
config.value = data;
setDraft(data.formula);
setSample(data.sample);
sampleChecked.value = false;
message.success('فرمول اجرت ثبت شد و از همین حالا اعمال می‌شود');
} catch (e) {
message.error(errMsg(e, 'خطا در ثبت فرمول'));
} finally {
saving.value = false;
}
}
onMounted(async () => {
await load();
await runPreview();
});
</script>
<style scoped>
.mb-4 { margin-bottom: 1rem; }
.mt-4 { margin-top: 1rem; }
.ms { margin-inline-start: 8px; }
.hint { font-size: 12px; color: #888; margin-top: 4px; }
.warn { color: #d4380d; }
.status { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.status__title { font-size: 16px; font-weight: 600; }
.formula {
background: linear-gradient(135deg, rgba(212, 167, 44, 0.08), rgba(212, 167, 44, 0.02));
border: 1px solid rgba(212, 167, 44, 0.35);
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 16px;
font-size: 15px;
line-height: 2.2;
}
.formula__lhs { font-weight: 700; margin-inline-end: 8px; }
.formula__op { margin: 0 6px; font-weight: 700; color: #555; }
.formula__limits { font-size: 12px; color: #666; line-height: 1.6; }
.chip { display: inline-block; padding: 0 10px; border-radius: 999px; line-height: 26px; font-weight: 600; white-space: nowrap; }
.chip--sm { line-height: 20px; padding: 0 8px; font-size: 12px; }
.chip--variant { background: #e6f4ff; color: #0958d9; }
.chip--customer { background: #f9f0ff; color: #531dab; }
.chip--promotion { background: #fff1f0; color: #cf1322; }
.chip--constant { background: #f6ffed; color: #389e0d; }
.chip--zero { background: #f5f5f5; color: #888; }
.term { padding: 10px 0; border-top: 1px dashed #eee; }
.term:first-of-type { border-top: none; }
.term__head { display: flex; align-items: center; gap: 8px; }
.term__label { font-weight: 600; flex: 1; }
.term__body { display: flex; align-items: center; gap: 10px; margin-top: 8px; flex-wrap: wrap; }
.num { width: 150px; }
.divider { font-size: 13px; }
.limits { display: flex; flex-direction: column; gap: 10px; }
.limit { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
.sample { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; margin-bottom: 16px; }
.sample label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #555; }
.sample .num { width: 100%; }
.cmp { width: 100%; border-collapse: collapse; font-size: 13px; }
.cmp th, .cmp td { padding: 7px 10px; border-bottom: 1px solid #f0f0f0; text-align: start; }
.cmp th { background: #fafafa; font-weight: 600; }
.cmp th.changed { color: #d48806; }
.cmp tr.strong td { font-weight: 700; font-size: 14px; }
.cmp td.diff { background: #fffbe6; color: #ad6800; }
.hist { padding: 8px 0; border-bottom: 1px solid #f5f5f5; }
.hist__meta { font-size: 12px; color: #888; }
</style>
@@ -842,6 +842,10 @@ export const treeData: PermissionNode[] = [
"title": "اتصال حسابداری",
"key": "config.accounting",
},
{
"title": "اجرت",
"key": "config.wage",
},
]
},
{