feat: complete admin panel and backend admin api migration

This commit is contained in:
s.esmaeili
2026-09-13 21:02:25 +03:30
parent 03119cd655
commit 267fc7b7d1
239 changed files with 25431 additions and 935 deletions
@@ -0,0 +1,64 @@
<template>
<!--
Ported from legacy henza_admin/src/views/DashboardAnalytics.vue — the retail
dashboard ("آمار تک فروشی").
Legacy's file is 342 lines but reads only ONE field from the server:
`model.lastOrder`. Everything else on the page is Vuexy demo data defined in
the component itself (analyticsData.js series, radial bars, order-type
colours) and renders nothing real — so only the live table is ported.
-->
<a-spin :spinning="loading">
<a-card title="سفارشات هفته جاری">
<a-table
size="small"
:data-source="model.lastOrder"
:columns="columns"
:pagination="{ pageSize: 15 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'sts_str'">
<a-tag :color="tagColor(record.statusColor)">{{ record.sts_str }}</a-tag>
</template>
<template v-else-if="column.dataIndex === 'caj'">
{{ jdate(record.caj) }}
</template>
</template>
</a-table>
</a-card>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { jdate } from '../utils/filters';
import api from '../api/client';
const loading = ref(false);
const model = reactive<Record<string, any>>({ lastOrder: [] });
const columns = [
{ title: 'شماره سفارش', dataIndex: 'name' },
{ title: 'مشتری', dataIndex: 'customer' },
{ title: 'وزن', dataIndex: 'w' },
{ title: 'وضعیت', dataIndex: 'sts_str' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
function tagColor(c: string) {
return { success: 'green', warning: 'orange', danger: 'red', primary: 'blue' }[c] ?? '';
}
onMounted(async () => {
try {
loading.value = true;
const { data } = await api.get('get-analytics-tak');
Object.assign(model, data);
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
});
</script>
@@ -0,0 +1,142 @@
<template>
<!--
Ported from legacy henza_admin/src/views/DashboardECommerce.vue — the
wholesale dashboard ("آمار عمده فروشی").
Legacy's file is a Vuexy template whose English demo cards are all COMMENTED
OUT (Congratulations, Support Tracker, Product Orders, Sales Stats, Activity
Timeline — verified line by line). The live content is the three 30-day stat
cards plus three Farsi tables, and that is what is ported.
-->
<a-spin :spinning="loading">
<div class="grid-3">
<StatisticsCardLine
icon="UsersIcon"
color="warning"
:statistic="`${omdeData.customerCount} نفر `"
statistic-title="تعداد مشتریان ثبت نامی در 30 روز گذشته"
/>
<StatisticsCardLine
icon="ShoppingBagIcon"
:statistic="`${omdeData.allgram} گرم `"
statistic-title="طلای فروش رفته در 30 روز گذشته"
/>
<StatisticsCardLine
icon="ShoppingBagIcon"
color="warning"
:statistic="`${omdeData.ordercount} سفارش `"
statistic-title="تعداد سفارشات 30 روز گذشته"
/>
</div>
<div class="grid-2 mt-4">
<a-card title="عملکرد کارشناسان فروش">
<a-table
size="small"
:data-source="omdeData.reagent"
:columns="agentColumns"
:pagination="false"
row-key="id"
/>
</a-card>
<a-card title="میزان فروش">
<a-descriptions bordered size="small" :column="1">
<a-descriptions-item label="تعداد سفارش (۳۰ روز)">
{{ omdeData.sumLastOrder }}
</a-descriptions-item>
<a-descriptions-item label="مجموع وزن (۳۰ روز)">
{{ omdeData.sumLastOrderMonth }} گرم
</a-descriptions-item>
</a-descriptions>
</a-card>
</div>
<a-card title="سفارشات هفته جاری" class="mt-4">
<a-table
size="small"
:data-source="omdeData.lastOrder"
:columns="orderColumns"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'sts_str'">
<a-tag :color="tagColor(record.statusColor)">{{ record.sts_str }}</a-tag>
</template>
<template v-else-if="column.dataIndex === 'caj'">
{{ jdate(record.caj) }}
</template>
</template>
</a-table>
</a-card>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import StatisticsCardLine from '../components/statistics-cards/StatisticsCardLine.vue';
import { jdate } from '../utils/filters';
import api from '../api/client';
const loading = ref(false);
const omdeData = reactive<Record<string, any>>({
customerCount: 0,
allgram: 0,
ordercount: 0,
reagent: [],
lastOrder: [],
sumLastOrder: 0,
sumLastOrderMonth: 0,
});
const agentColumns = [
{ title: 'کارشناس فروش', dataIndex: 'name' },
{ title: 'میزان فروش (گرم)', dataIndex: 'grams_sell' },
{ title: 'تعداد سفارش', dataIndex: 'order_count' },
];
const orderColumns = [
{ title: 'شماره سفارش', dataIndex: 'name' },
{ title: 'مشتری', dataIndex: 'customer' },
{ title: 'وزن', dataIndex: 'w' },
{ title: 'وضعیت', dataIndex: 'sts_str' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
function tagColor(c: string) {
return { success: 'green', warning: 'orange', danger: 'red', primary: 'blue' }[c] ?? '';
}
onMounted(async () => {
try {
loading.value = true;
const { data } = await api.get('get-analytics');
Object.assign(omdeData, data);
// legacy sorts both lists client-side after the fetch
omdeData.reagent.sort((a: any, b: any) => Number(b.grams_sell) - Number(a.grams_sell));
omdeData.lastOrder.sort((a: any, b: any) => Number(b.w) - Number(a.w));
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.grid-3 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
}
.mt-4 {
margin-top: 1rem;
}
</style>
@@ -1,73 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { api, type CustomerSummary } from '../api/client';
import { useSessionStore } from '../stores/session';
const customers = ref<CustomerSummary[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
const router = useRouter();
const session = useSessionStore();
onMounted(async () => {
try {
customers.value = await api.listCustomers();
} catch (e) {
error.value = (e as Error).message;
} finally {
loading.value = false;
}
});
function goToNewOrder(customerId: string) {
session.selectCustomer(customerId);
router.push({ name: 'order-new' });
}
</script>
<template>
<section>
<h1>Customers</h1>
<p v-if="loading">Loading…</p>
<p v-else-if="error" class="error">{{ error }}</p>
<table v-else>
<thead>
<tr>
<th>Name</th>
<th>Mobile</th>
<th>Gold debt (g)</th>
<th>Rial debt</th>
<th>Credit limit (g)</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="c in customers" :key="c.id">
<td>{{ c.fullName }}</td>
<td>{{ c.mobile }}</td>
<td>{{ c.goldDebtGrams }}</td>
<td>{{ c.fiatDebtRial }}</td>
<td>{{ c.goldCreditLimitGrams }}</td>
<td><button @click="goToNewOrder(c.id)">New order</button></td>
</tr>
</tbody>
</table>
</section>
</template>
<style scoped>
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 0.5rem;
border-bottom: 1px solid #ddd;
}
.error {
color: #b00020;
}
</style>
-2
View File
@@ -1,2 +0,0 @@
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
export default _default;
@@ -1,97 +0,0 @@
/// <reference types="../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { api } from '../api/client';
import { useSessionStore } from '../stores/session';
const customers = ref([]);
const loading = ref(true);
const error = ref(null);
const router = useRouter();
const session = useSessionStore();
onMounted(async () => {
try {
customers.value = await api.listCustomers();
}
catch (e) {
error.value = e.message;
}
finally {
loading.value = false;
}
});
function goToNewOrder(customerId) {
session.selectCustomer(customerId);
router.push({ name: 'order-new' });
}
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
const __VLS_ctx = {};
let __VLS_components;
let __VLS_directives;
// CSS variable injection
// CSS variable injection end
__VLS_asFunctionalElement(__VLS_intrinsicElements.section, __VLS_intrinsicElements.section)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.h1, __VLS_intrinsicElements.h1)({});
if (__VLS_ctx.loading) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({});
}
else if (__VLS_ctx.error) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({
...{ class: "error" },
});
(__VLS_ctx.error);
}
else {
__VLS_asFunctionalElement(__VLS_intrinsicElements.table, __VLS_intrinsicElements.table)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.thead, __VLS_intrinsicElements.thead)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.tr, __VLS_intrinsicElements.tr)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.tbody, __VLS_intrinsicElements.tbody)({});
for (const [c] of __VLS_getVForSourceType((__VLS_ctx.customers))) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.tr, __VLS_intrinsicElements.tr)({
key: (c.id),
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({});
(c.fullName);
__VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({});
(c.mobile);
__VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({});
(c.goldDebtGrams);
__VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({});
(c.fiatDebtRial);
__VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({});
(c.goldCreditLimitGrams);
__VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
if (!!(__VLS_ctx.loading))
return;
if (!!(__VLS_ctx.error))
return;
__VLS_ctx.goToNewOrder(c.id);
} },
});
}
}
/** @type {__VLS_StyleScopedClasses['error']} */ ;
var __VLS_dollars;
const __VLS_self = (await import('vue')).defineComponent({
setup() {
return {
customers: customers,
loading: loading,
error: error,
goToNewOrder: goToNewOrder,
};
},
});
export default (await import('vue')).defineComponent({
setup() {
return {};
},
});
; /* PartiallyEnd: #4569/main.vue */
@@ -1,63 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { api, type OrderDetail } from '../api/client';
const route = useRoute();
const order = ref<OrderDetail | null>(null);
const error = ref<string | null>(null);
const STATUS_FLOW = [
'PLACED',
'ADMIN_APPROVED',
'SOURCING',
'PREPARING',
'DELIVERED_TO_AGENT',
'DELIVERED_TO_CUSTOMER',
];
async function load() {
order.value = await api.getOrder(route.params.id as string);
}
async function advance() {
if (!order.value) return;
const currentIndex = STATUS_FLOW.indexOf(order.value.status);
const next = STATUS_FLOW[currentIndex + 1];
if (!next) return;
try {
order.value = await api.updateOrderStatus(order.value.id, next);
} catch (e) {
error.value = (e as Error).message;
}
}
onMounted(load);
</script>
<template>
<section v-if="order">
<h1>Order {{ order.orderNumber }}</h1>
<p>Status: <strong>{{ order.status }}</strong></p>
<p v-if="order.isSuspendedOverCreditLimit" class="error">Suspended — credit limit exceeded at placement.</p>
<p>Total gold weight: {{ order.totalGoldWeightGrams }} g (wage: {{ order.wageWeightGrams }} g)</p>
<p>Flat Rial charge: {{ order.flatRialCharge }}</p>
<p>Delivered at: {{ order.deliveredAt ?? '—' }}</p>
<button v-if="STATUS_FLOW.indexOf(order.status) < STATUS_FLOW.length - 1" @click="advance">
Advance to next status
</button>
<p v-if="error" class="error">{{ error }}</p>
<h2>Status history</h2>
<ul>
<li v-for="(e, i) in order.statusEvents" :key="i">{{ e.toStatus }} — {{ e.createdAt }}</li>
</ul>
</section>
</template>
<style scoped>
.error {
color: #b00020;
}
</style>
-2
View File
@@ -1,2 +0,0 @@
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
export default _default;
@@ -1,98 +0,0 @@
/// <reference types="../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../api/client';
const route = useRoute();
const order = ref(null);
const error = ref(null);
const STATUS_FLOW = [
'PLACED',
'ADMIN_APPROVED',
'SOURCING',
'PREPARING',
'DELIVERED_TO_AGENT',
'DELIVERED_TO_CUSTOMER',
];
async function load() {
order.value = await api.getOrder(route.params.id);
}
async function advance() {
if (!order.value)
return;
const currentIndex = STATUS_FLOW.indexOf(order.value.status);
const next = STATUS_FLOW[currentIndex + 1];
if (!next)
return;
try {
order.value = await api.updateOrderStatus(order.value.id, next);
}
catch (e) {
error.value = e.message;
}
}
onMounted(load);
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
const __VLS_ctx = {};
let __VLS_components;
let __VLS_directives;
// CSS variable injection
// CSS variable injection end
if (__VLS_ctx.order) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.section, __VLS_intrinsicElements.section)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.h1, __VLS_intrinsicElements.h1)({});
(__VLS_ctx.order.orderNumber);
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.strong, __VLS_intrinsicElements.strong)({});
(__VLS_ctx.order.status);
if (__VLS_ctx.order.isSuspendedOverCreditLimit) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({
...{ class: "error" },
});
}
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({});
(__VLS_ctx.order.totalGoldWeightGrams);
(__VLS_ctx.order.wageWeightGrams);
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({});
(__VLS_ctx.order.flatRialCharge);
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({});
(__VLS_ctx.order.deliveredAt ?? '—');
if (__VLS_ctx.STATUS_FLOW.indexOf(__VLS_ctx.order.status) < __VLS_ctx.STATUS_FLOW.length - 1) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.advance) },
});
}
if (__VLS_ctx.error) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({
...{ class: "error" },
});
(__VLS_ctx.error);
}
__VLS_asFunctionalElement(__VLS_intrinsicElements.h2, __VLS_intrinsicElements.h2)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.ul, __VLS_intrinsicElements.ul)({});
for (const [e, i] of __VLS_getVForSourceType((__VLS_ctx.order.statusEvents))) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.li, __VLS_intrinsicElements.li)({
key: (i),
});
(e.toStatus);
(e.createdAt);
}
}
/** @type {__VLS_StyleScopedClasses['error']} */ ;
/** @type {__VLS_StyleScopedClasses['error']} */ ;
var __VLS_dollars;
const __VLS_self = (await import('vue')).defineComponent({
setup() {
return {
order: order,
error: error,
STATUS_FLOW: STATUS_FLOW,
advance: advance,
};
},
});
export default (await import('vue')).defineComponent({
setup() {
return {};
},
});
; /* PartiallyEnd: #4569/main.vue */
-112
View File
@@ -1,112 +0,0 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import type { CreateOrderItemDto } from '@gold-bank/shared-types';
import { api, type ProductSummary } from '../api/client';
import { useSessionStore } from '../stores/session';
const session = useSessionStore();
const router = useRouter();
const products = ref<ProductSummary[]>([]);
const items = reactive<CreateOrderItemDto[]>([
{ productVariantId: '', quantity: 1, wagePercent: 7, discountPercent: 0, flatRialCharge: 0 },
]);
const submitting = ref(false);
const error = ref<string | null>(null);
onMounted(async () => {
products.value = await api.listProducts();
});
function addItem() {
items.push({ productVariantId: '', quantity: 1, wagePercent: 7, discountPercent: 0, flatRialCharge: 0 });
}
function removeItem(index: number) {
items.splice(index, 1);
}
async function submit() {
if (!session.selectedCustomerId) {
error.value = 'Select a customer from the dashboard first.';
return;
}
submitting.value = true;
error.value = null;
try {
const order = await api.createOrder({
customerId: session.selectedCustomerId,
items: items.map((i) => ({ ...i })),
});
router.push({ name: 'order-detail', params: { id: order.id } });
} catch (e) {
error.value = (e as Error).message;
} finally {
submitting.value = false;
}
}
</script>
<template>
<section>
<h1>New order</h1>
<p v-if="!session.selectedCustomerId" class="error">
No customer selected — go back to the dashboard and pick one.
</p>
<p v-else>Customer: {{ session.selectedCustomerId }}</p>
<table>
<thead>
<tr>
<th>Variant</th>
<th>Qty</th>
<th>Wage %</th>
<th>Discount %</th>
<th>Flat Rial</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in items" :key="idx">
<td>
<select v-model="item.productVariantId">
<option value="" disabled>Select…</option>
<template v-for="p in products" :key="p.id">
<option v-for="v in p.variants" :key="v.id" :value="v.id">
{{ p.title }} — {{ v.title }} ({{ v.rashWeightGrams }}g)
</option>
</template>
</select>
</td>
<td><input v-model.number="item.quantity" type="number" min="1" /></td>
<td><input v-model.number="item.wagePercent" type="number" step="0.1" /></td>
<td><input v-model.number="item.discountPercent" type="number" step="0.1" /></td>
<td><input v-model.number="item.flatRialCharge" type="number" step="1000" /></td>
<td><button @click="removeItem(idx)">Remove</button></td>
</tr>
</tbody>
</table>
<button @click="addItem">Add item</button>
<br /><br />
<button :disabled="submitting" @click="submit">Place order</button>
<p v-if="error" class="error">{{ error }}</p>
</section>
</template>
<style scoped>
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
th,
td {
text-align: left;
padding: 0.4rem;
}
.error {
color: #b00020;
}
</style>
-2
View File
@@ -1,2 +0,0 @@
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
export default _default;
@@ -1,163 +0,0 @@
/// <reference types="../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { api } from '../api/client';
import { useSessionStore } from '../stores/session';
const session = useSessionStore();
const router = useRouter();
const products = ref([]);
const items = reactive([
{ productVariantId: '', quantity: 1, wagePercent: 7, discountPercent: 0, flatRialCharge: 0 },
]);
const submitting = ref(false);
const error = ref(null);
onMounted(async () => {
products.value = await api.listProducts();
});
function addItem() {
items.push({ productVariantId: '', quantity: 1, wagePercent: 7, discountPercent: 0, flatRialCharge: 0 });
}
function removeItem(index) {
items.splice(index, 1);
}
async function submit() {
if (!session.selectedCustomerId) {
error.value = 'Select a customer from the dashboard first.';
return;
}
submitting.value = true;
error.value = null;
try {
const order = await api.createOrder({
customerId: session.selectedCustomerId,
items: items.map((i) => ({ ...i })),
});
router.push({ name: 'order-detail', params: { id: order.id } });
}
catch (e) {
error.value = e.message;
}
finally {
submitting.value = false;
}
}
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
const __VLS_ctx = {};
let __VLS_components;
let __VLS_directives;
// CSS variable injection
// CSS variable injection end
__VLS_asFunctionalElement(__VLS_intrinsicElements.section, __VLS_intrinsicElements.section)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.h1, __VLS_intrinsicElements.h1)({});
if (!__VLS_ctx.session.selectedCustomerId) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({
...{ class: "error" },
});
}
else {
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({});
(__VLS_ctx.session.selectedCustomerId);
}
__VLS_asFunctionalElement(__VLS_intrinsicElements.table, __VLS_intrinsicElements.table)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.thead, __VLS_intrinsicElements.thead)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.tr, __VLS_intrinsicElements.tr)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.tbody, __VLS_intrinsicElements.tbody)({});
for (const [item, idx] of __VLS_getVForSourceType((__VLS_ctx.items))) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.tr, __VLS_intrinsicElements.tr)({
key: (idx),
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.select, __VLS_intrinsicElements.select)({
value: (item.productVariantId),
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.option, __VLS_intrinsicElements.option)({
value: "",
disabled: true,
});
for (const [p] of __VLS_getVForSourceType((__VLS_ctx.products))) {
(p.id);
for (const [v] of __VLS_getVForSourceType((p.variants))) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.option, __VLS_intrinsicElements.option)({
key: (v.id),
value: (v.id),
});
(p.title);
(v.title);
(v.rashWeightGrams);
}
}
__VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
type: "number",
min: "1",
});
(item.quantity);
__VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
type: "number",
step: "0.1",
});
(item.wagePercent);
__VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
type: "number",
step: "0.1",
});
(item.discountPercent);
__VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
type: "number",
step: "1000",
});
(item.flatRialCharge);
__VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
__VLS_ctx.removeItem(idx);
} },
});
}
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.addItem) },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.br)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.br)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.submit) },
disabled: (__VLS_ctx.submitting),
});
if (__VLS_ctx.error) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({
...{ class: "error" },
});
(__VLS_ctx.error);
}
/** @type {__VLS_StyleScopedClasses['error']} */ ;
/** @type {__VLS_StyleScopedClasses['error']} */ ;
var __VLS_dollars;
const __VLS_self = (await import('vue')).defineComponent({
setup() {
return {
session: session,
products: products,
items: items,
submitting: submitting,
error: error,
addItem: addItem,
removeItem: removeItem,
submit: submit,
};
},
});
export default (await import('vue')).defineComponent({
setup() {
return {};
},
});
; /* PartiallyEnd: #4569/main.vue */
@@ -0,0 +1,185 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/category/Add.vue.
Serves BOTH create and edit — Edit.vue is only a wrapper around it.
Legacy's distinctive touch, preserved: once "نوع نمایش" is chosen every
subsequent label is suffixed with (سایت) or (موبایل), so the operator can see
which storefront they are editing.
The two image uploaders (تصویر شاخص / آیکون) are real file pickers, posting
to the ported `file/create` endpoint and storing the returned URL — the same
contract legacy's SingleUploadInput.vue used.
-->
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card :title="`دسته بندی ${typeSuffix}`" class="mb-base">
<a-form-item
label="نوع نمایش"
:validate-status="err.type ? 'error' : ''"
:help="err.type"
>
<a-radio-group v-model:value="form.type">
<a-radio :value="1">سایت</a-radio>
<a-radio :value="2">اپلیکشن موبایل</a-radio>
</a-radio-group>
</a-form-item>
<div class="grid-2">
<a-form-item
:label="`عنوان ${typeSuffix}`"
:validate-status="err.title ? 'error' : ''"
:help="err.title"
>
<a-input v-model:value="form.title" />
</a-form-item>
<a-form-item :label="`پدر دسته ${typeSuffix}`">
<a-select
v-model:value="form.parent_id_id"
allow-clear
show-search
placeholder="انتخاب کنید"
:filter-option="filterOption"
>
<a-select-option v-for="c in parentOptions" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="`تصویر شاخص ${typeSuffix}`">
<ImageUploadInput v-model="form.image" purpose="category.image" placeholder="تصویر" />
</a-form-item>
<a-form-item :label="`آیکون ${typeSuffix}`">
<ImageUploadInput v-model="form.icon" purpose="category.icon" placeholder="آیکون" />
</a-form-item>
<a-form-item :label="`متا تگ ${typeSuffix}`">
<a-input v-model:value="form.meta" />
</a-form-item>
</div>
<a-form-item :label="`شرح دسته ${typeSuffix}`">
<a-textarea v-model:value="form.body_html" :auto-size="{ minRows: 3, maxRows: 8 }" />
</a-form-item>
<a-button type="primary" @click="handleSubmit">ثبت اطلاعات</a-button>
</a-card>
</a-form>
</a-spin>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import api from '../../../api/client';
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
const route = useRoute();
const router = useRouter();
const id = route.params.id as string | undefined;
const loading = ref(false);
const allCategories = ref<{ id: string; title: string; type: number }[]>([]);
const form = reactive<Record<string, any>>({
type: 1,
title: '',
parent_id_id: undefined,
image: '',
icon: '',
meta: '',
body_html: '',
});
const err = reactive<Record<string, string>>({});
/** legacy: `${type==1?'(سایت)':type==2?'(موبایل)':''}` */
const typeSuffix = computed(() =>
form.type === 1 ? '(سایت)' : form.type === 2 ? '(موبایل)' : '',
);
/**
* Only categories of the same storefront can be a parent, and a category can
* never be offered as its own parent.
*/
const parentOptions = computed(() =>
allCategories.value.filter((c) => c.type === form.type && c.id !== id),
);
// Switching storefront invalidates a parent picked from the other one.
watch(
() => form.type,
() => {
if (form.parent_id_id && !parentOptions.value.some((c) => c.id === form.parent_id_id)) {
form.parent_id_id = undefined;
}
},
);
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
}
function validate(): boolean {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.type) err.type = 'لطفا نوع نمایش دسته بندی را انتخاب کنید';
if (!form.title) err.title = 'لطفا مقدار را وارد کنید';
return Object.keys(err).length === 0;
}
async function handleSubmit() {
if (!validate()) return;
try {
loading.value = true;
if (id) {
await api.put(`category/${id}`, { ...form, id });
} else {
await api.post('category/', form);
}
message.info('اطلاعات با موفقیت به ثبت رسید.');
router.push('/category/list');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const { data } = await api.get('admin/categories/tree');
allCategories.value = data;
} catch (e) {
console.log(e);
}
if (id) {
try {
const { data } = await api.get(`admin/categories/${id}`);
Object.assign(form, {
type: data.type ?? 1,
title: data.title ?? '',
parent_id_id: data.parent_id_id ?? undefined,
image: data.image ?? '',
icon: data.icon ?? '',
meta: data.meta ?? '',
body_html: data.body_html ?? '',
});
} catch (e) {
console.log(e);
}
}
});
</script>
<style scoped>
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
</style>
@@ -0,0 +1,20 @@
// Ported from legacy henza_admin/src/views/apps/category/CategoryTbl.js
//
// Column keys and Farsi titles preserved exactly, INCLUDING legacy's quirk that
// the text columns 'title' and 'parent.title' are declared .filterNumber().
// The large commented-out block in the middle of the legacy file is not revived.
import XTbl, { Xtc } from '../../../components/x-table/model';
export function buildCategoryModel(): XTbl {
const tbl = new XTbl('admin/categories/list', 'لیست دسته بندی');
tbl.add(new Xtc('id', 'شماره').filterNumber());
tbl.add(new Xtc('image', 'تصویر').noSort().renderSlot('image'));
tbl.add(new Xtc('type_str', 'نوع نمایش'));
// legacy declares filterNumber() on these two text columns — preserved
tbl.add(new Xtc('title', 'عنوان دسته').filterNumber());
tbl.add(new Xtc('parent.title', 'پدر دسته').filterNumber());
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildCategoryModel;
@@ -0,0 +1,12 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/category/Edit.vue.
Legacy's entire file is <Add/> — a 12-line wrapper, and it stays one here.
Add.vue reads the :id route param to switch between create and edit.
-->
<Add />
</template>
<script setup lang="ts">
import Add from './Add.vue';
</script>
@@ -0,0 +1,73 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/category/List.vue.
Farsi copy and permission gates preserved. The alternate commented-out `opt`
slot in legacy stays absent.
-->
<div id="page-user-list">
<a-card title="لیست دسته بندی ها">
<XTable ref="tbl" :model="model">
<template #image="{ text }">
<img
width="90px"
height="90px"
style="object-fit: scale-down"
:src="text && text.image ? text.image : noImage"
/>
</template>
<template #opt="{ record }">
<a-tooltip v-if="hasPermission('collection.list.edit')" title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="toEdit(record.id)" />
</a-tooltip>
<a-tooltip v-if="hasPermission('collection.list.remove')" title="حذف">
<a-popconfirm
placement="top"
ok-text="بله"
cancel-text="خیر"
@confirm="confirmDelete(record.id)"
>
<template #title><p>آیا مطمئن به حذف دسته مورد نظر هستید ؟</p></template>
<DeleteOutlined class="cursor-pointer mx-1 text-danger" />
</a-popconfirm>
</a-tooltip>
</template>
</XTable>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { message, Modal } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue';
import { buildCategoryModel } from './CategoryTbl';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
const router = useRouter();
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const noImage = '/img/no-items.png';
const model = buildCategoryModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
async function confirmDelete(id: string) {
try {
await api.delete(`category/${id}`);
message.info('دسته حذف شد');
tbl.value?.fetch();
} catch (e: any) {
// The backend refuses while products or child categories still reference it.
Modal.error({ title: e?.response?.data?.message ?? 'خطا در حذف دسته', okText: 'بستن' });
}
}
function toEdit(id: string) {
router.push(`/category/edit/${id}`);
}
</script>
@@ -0,0 +1,30 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/customer/AddCustomer.vue.
Legacy's file is 278 lines but almost all of it is COMMENTED OUT: the live
markup is a single tab wrapping <bulk-customer/>. The "مشتری تک" tab and the
whole inline form beneath it are commented out in the original and stay
absent here rather than being revived.
-->
<div id="page-user-view">
<div id="user-data">
<a-card class="mb-base">
<a-tabs v-if="hasPermission('persons.create')">
<a-tab-pane v-if="hasPermission('persons.create.client')" key="omde" tab="مشتری عمده">
<BulkCustomer />
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</div>
</template>
<script setup lang="ts">
import BulkCustomer from './bulkCustomer.vue';
import { useUsersStore } from '../../../stores/users';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
</script>
@@ -0,0 +1,12 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/customer/EditBulkCustomer.vue —
a 19-line wrapper whose entire template is <Customer /> (bulkCustomer).
bulkCustomer reads the :id route param to switch between create and edit.
-->
<BulkCustomer />
</template>
<script setup lang="ts">
import BulkCustomer from './bulkCustomer.vue';
</script>
@@ -0,0 +1,11 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/customer/EditReagent.vue — a
19-line wrapper over <ReagentCustomer />, which reads the :id route param.
-->
<ReagentCustomer />
</template>
<script setup lang="ts">
import ReagentCustomer from './ReagentCustomer.vue';
</script>
@@ -0,0 +1,12 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/customer/EditSingleCustomer.vue
— a 16-line wrapper over <Customer /> (singleCustomer), which reads the :id
route param to switch between create and edit.
-->
<SingleCustomerForm />
</template>
<script setup lang="ts">
import SingleCustomerForm from './singleCustomer.vue';
</script>
@@ -0,0 +1,278 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/customer/ReagentCustomer.vue —
the sales-expert ("کارشناس فروش") form. Structurally the wholesale customer
form plus a نوع کاربری selector and a commission percentage.
The avatar uploader (عکس پروفایل) is live: legacy's MyUpload posted to
`file/create` and kept the returned `id` in `image_id`, reading it back as a
nested `image: { id, image }`. Both halves of that contract are preserved.
LEGACY BUG, not reproduced: legacy's load path dereferenced `data.image.id`
unguarded (ReagentCustomer.vue:280), so opening an agent with no avatar threw
— swallowed by its own try/catch, leaving the form half-filled. The read is
guarded here.
-->
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card title="اطلاعات پایه" class="mb-base">
<div class="grid-4">
<a-form-item label="عکس پروفایل" class="avatar-cell">
<ImageUploadInput
v-model="form.image_url"
purpose="customer.avatar"
placeholder="عکس"
@ok="onAvatar"
@update:modelValue="(v: string | null) => { if (!v) form.image_id = ''; }"
/>
</a-form-item>
<a-form-item label="نام " :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item
label="نام خانوادگی"
:validate-status="err.lname ? 'error' : ''"
:help="err.lname"
>
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item label="کد ملی">
<a-input v-model:value="form.ncode" />
</a-form-item>
<a-form-item
label="شماره تلفن همراه"
:validate-status="err.mobile ? 'error' : ''"
:help="err.mobile"
>
<a-input v-model:value="form.mobile" />
</a-form-item>
<a-form-item label="رمز عبور">
<a-input-password v-model:value="form.password" />
</a-form-item>
<a-form-item label="نوع کاربری">
<a-select v-model:value="form.type">
<a-select-option v-for="t in userTypes" :key="t.id" :value="t.id">
{{ t.title }}
</a-select-option>
</a-select>
</a-form-item>
</div>
</a-card>
<a-card title="اطلاعات تجاری" class="mb-base">
<div class="grid-4">
<a-form-item label="کد مشتری">
<a-input v-model:value="form.mcode" />
</a-form-item>
<a-form-item label="نام مغازه">
<a-input v-model:value="form.store_name" />
</a-form-item>
<a-form-item label="تلفن">
<a-input v-model:value="form.phone" />
</a-form-item>
<a-form-item label="نام پاساژ">
<a-input v-model:value="form.pasazh_name" />
</a-form-item>
<a-form-item label="درصد روی محصول">
<a-input-number v-model:value="form.percent" class="w-full" :step="0.1" />
</a-form-item>
<a-form-item label="سقف اعتبار">
<a-input-number v-model:value="form.gram_level" class="w-full" />
</a-form-item>
</div>
</a-card>
<a-card title="اطلاعات تکمیلی" class="mb-base">
<div class="grid-2">
<a-form-item label="شهر">
<a-select
v-model:value="form.city_id"
show-search
allow-clear
placeholder="انتخاب کنید"
:filter-option="filterOption"
@change="onCityChange"
>
<a-select-option v-for="c in cities" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="آدرس">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
</div>
</a-card>
<a-button type="primary" @click="handleSubmit">ثبت اطلاعات</a-button>
</a-form>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import api from '../../../api/client';
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
const route = useRoute();
const router = useRouter();
const id = route.params.id as string | undefined;
const loading = ref(false);
const cities = ref<{ id: string; title: string }[]>([]);
const userTypes = ref<{ id: number; title: string }[]>([]);
const form = reactive<Record<string, any>>({
fname: '',
lname: '',
ncode: '',
password: '',
mobile: '',
type: undefined,
mcode: '',
store_name: '',
phone: '',
pasazh_name: '',
city_id: undefined,
address: '',
percent: undefined,
gram_level: undefined,
// legacy kept the FK in `image_id`; `image_url` only drives the thumbnail.
image_id: '',
image_url: null,
});
const err = reactive<Record<string, string>>({});
/** Snapshot of the server's record, captured after the edit form is populated. */
const initial = reactive<Record<string, any>>({});
/** The subset of `form` that differs from `initial`, minus `omit` keys. */
function diffFromInitial(omit: string[] = []): Record<string, any> {
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(form)) {
if (omit.includes(k)) continue;
if (JSON.stringify(v) !== JSON.stringify(initial[k])) out[k] = v;
}
return out;
}
/** Legacy `uploadIndexImage` — MyUpload emitted the whole response object. */
function onAvatar(file: Record<string, unknown>) {
form.image_id = (file.id as string) ?? '';
}
const REQUIRED = 'لطفا مقدار را وارد کنید';
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
}
/**
* legacy: picking a city pre-fills the agent's commission from that city's
* percent_aux (the sales-expert rate, as opposed to the wholesale rate).
*/
async function onCityChange(cityId: string) {
if (!cityId) return;
try {
const { data } = await api.get(`cities/get-percent-aux/${cityId}`);
if (data !== null && data !== undefined) form.percent = Number(data);
} catch (e) {
console.log(e);
}
}
async function handleSubmit() {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.fname) err.fname = REQUIRED;
if (!form.lname) err.lname = REQUIRED;
if (!form.mobile) err.mobile = REQUIRED;
if (Object.keys(err).length) return;
try {
loading.value = true;
// `image_url` is presentation only — the server stores the FK (`image_id`).
const { image_url: _unusedAvatarUrl, ...allFields } = form;
// On EDIT send only touched fields — see bulkCustomer.vue for why.
const fields = id ? diffFromInitial(['image_url']) : allFields;
const payload = {
...fields,
id: id ?? null,
name: `${form.fname} ${form.lname}`.trim(),
national_code: form.ncode,
};
if (id) {
await api.put(`customer/${id}`, payload);
} else {
await api.post('customer/create', payload);
}
message.info('اطلاعات با موفقیت به ثبت رسید.');
router.go(-1);
} catch (e: any) {
const mobileErr = e?.response?.data?.errors?.mobile?.[0];
message.error(mobileErr ?? e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const [c, t] = await Promise.all([api.post('cities/list'), api.get('const/__list__ut')]);
cities.value = c.data.rows ?? [];
userTypes.value = t.data ?? [];
} catch (e) {
console.log(e);
}
if (id) {
try {
const { data } = await api.get(`customer/${id}`);
Object.assign(form, {
fname: data.fname ?? '',
lname: data.lname ?? '',
ncode: data.national_code ?? '',
mobile: data.mobile ?? '',
city_id: data.city_id ?? undefined,
address: data.address ?? '',
percent: data.percent === null ? undefined : Number(data.percent),
gram_level: data.gram_level === null ? undefined : Number(data.gram_level),
// guarded, unlike legacy — an agent with no avatar must not throw
image_id: data.image?.id ?? '',
image_url: data.image?.image ?? null,
// Previously omitted — same silent-overwrite bug as bulkCustomer.vue:
// the form spread these back as '' and wiped good data on every save.
store_name: data.store_name ?? '',
pasazh_name: data.pasazh_name ?? '',
mcode: data.mcode ?? '',
phone: data.phone ?? '',
type: data.type ?? undefined,
});
// Baseline for the dirty-diff in handleSubmit.
Object.assign(initial, JSON.parse(JSON.stringify(form)));
} catch (e) {
console.log(e);
}
}
});
</script>
<style scoped>
.grid-4 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
/* Legacy put the avatar in the first grid cell spanning two rows
(md:row-span-2), with the uploader centred inside it. */
.avatar-cell {
grid-row: span 2;
display: flex;
justify-content: center;
}
</style>
@@ -0,0 +1,280 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/customer/bulkCustomer.vue.
This is the real wholesale-customer form; AddCustomer.vue is only a tab
wrapper around it (the rest of AddCustomer's markup is commented out in
legacy). Field names, Farsi labels and validation messages are preserved.
Dropped: the MyUpload avatar widget and the Jalali DateInput, which belong to
fields the redesigned Customer schema has no column for. Flagged in
docs/ADMIN-PORT-PLAN.md rather than faked.
The نوع کاربری selector is live again now that Customer.type exists; its four
options are legacy's own list, byte for byte.
-->
<a-form layout="vertical" :model="form" @submit.prevent="handleSubmitOmde">
<a-spin :spinning="loading">
<a-card title="اطلاعات پایه" class="mb-base">
<div class="grid-4">
<a-form-item label="نام" :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item label="نام خانوادگی" :validate-status="err.lname ? 'error' : ''" :help="err.lname">
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item label="کد ملی">
<a-input v-model:value="form.ncode" />
</a-form-item>
<a-form-item
label="شماره تلفن همراه"
:validate-status="err.mobile ? 'error' : ''"
:help="err.mobile"
>
<a-input v-model:value="form.mobile" />
</a-form-item>
<a-form-item label="رمز عبور">
<a-input-password v-model:value="form.password" />
</a-form-item>
<a-form-item label="تلفن ثابت">
<a-input v-model:value="form.phone" />
</a-form-item>
</div>
</a-card>
<a-card title="اطلاعات تجاری" class="mb-base">
<div class="grid-4">
<a-form-item label="نوع کاربری">
<a-select
v-model:value="form.type"
show-search
:filter-option="filterUserType"
style="width: 100%; direction: rtl !important"
>
<a-select-option v-for="item in userTypes" :key="item.id" :value="item.id">
{{ item.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="نام مغازه">
<a-input v-model:value="form.store_name" />
</a-form-item>
<a-form-item label="نام پاساژ">
<a-input v-model:value="form.pasazh_name" />
</a-form-item>
<a-form-item label="کد مشتری">
<a-input v-model:value="form.mcode" />
</a-form-item>
<a-form-item label="کارشناس فروش">
<a-select v-model:value="form.reagent_id" allow-clear placeholder="انتخاب کنید">
<a-select-option v-for="r in reagent" :key="r.id" :value="r.id">
{{ r.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="درصد">
<a-input-number v-model:value="form.percent" class="w-full" />
</a-form-item>
<a-form-item label="سقف اعتبار (برحسب گرم)">
<a-input-number v-model:value="form.gram_level" class="w-full" />
</a-form-item>
</div>
</a-card>
<a-card title="اطلاعات تکمیلی" class="mb-base">
<div class="grid-2">
<a-form-item label="شهر">
<a-select
v-model:value="form.city_id"
show-search
allow-clear
placeholder="انتخاب کنید"
:filter-option="filterCity"
>
<a-select-option v-for="c in cities" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="آدرس">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
</div>
</a-card>
<div>
<a-button type="primary" class="mr-4" @click="handleSubmitOmde">ثبت اطلاعات</a-button>
</div>
</a-spin>
</a-form>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import api from '../../../api/client';
const route = useRoute();
const router = useRouter();
const id = route.params.id as string | undefined;
const loading = ref(false);
const cities = ref<{ id: string; title: string }[]>([]);
const reagent = ref<{ id: string; name: string }[]>([]);
const form = reactive<Record<string, any>>({
fname: '',
lname: '',
ncode: '',
password: '',
mobile: '',
store_name: '',
phone: '',
pasazh_name: '',
city_id: undefined,
mcode: '',
address: '',
// legacy defaulted to no selection; 2 (مغازه دار اپلیکیشن) is what the
// backend falls back to when the field is absent.
type: undefined,
reagent_id: undefined,
percent: undefined,
gram_level: undefined,
});
/**
* Legacy bulkCustomer.vue:239-244, verbatim. The server's canonical map
* (Customer::$TYPES) also has 5 => سرپرست کارگاه, which this form never offered;
* that omission is preserved.
*/
const userTypes = [
{ id: 1, title: 'کارشناس فروش' },
{ id: 2, title: 'مغازه دار اپلیکیشن' },
{ id: 3, title: 'مغازه دار حضوری' },
{ id: 4, title: 'بنکدار' },
];
function filterUserType(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
}
const err = reactive<Record<string, string>>({});
/**
* Snapshot of the record as the server returned it, captured after the edit
* form is populated. Empty on create, where every field is intentionally new.
*/
const initial = reactive<Record<string, any>>({});
/** The subset of `form` that differs from `initial`. */
function diffFromInitial(): Record<string, any> {
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(form)) {
if (JSON.stringify(v) !== JSON.stringify(initial[k])) out[k] = v;
}
return out;
}
function filterCity(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
}
function validate(): boolean {
Object.keys(err).forEach((k) => delete err[k]);
// legacy required: fname, lname, mobile — message 'لطفا مقدار را وارد کنید'
if (!form.fname) err.fname = 'لطفا مقدار را وارد کنید';
if (!form.lname) err.lname = 'لطفا مقدار را وارد کنید';
if (!form.mobile) err.mobile = 'لطفا مقدار را وارد کنید';
return Object.keys(err).length === 0;
}
async function handleSubmitOmde() {
if (!validate()) return;
try {
loading.value = true;
// Legacy composed fname+lname separately; the schema has one fullName, so
// they are joined here and split back on read.
// On EDIT send only the fields the operator actually touched. Spreading the
// whole form means any field the edit path failed to repopulate is sent as
// '' and overwrites good data — see the note on Object.assign below.
const changed = id ? diffFromInitial() : { ...form };
const payload = {
...changed,
id: id ?? null,
name: `${form.fname} ${form.lname}`.trim(),
national_code: form.ncode,
};
if (id) {
await api.put(`customer/${id}`, payload);
} else {
await api.post('customer/create', payload);
}
message.info('اطلاعات با موفقیت به ثبت رسید.');
router.go(-1);
} catch (e: any) {
// legacy surfaced the server's mobile-uniqueness error specifically
const mobileErr = e?.response?.data?.errors?.mobile?.[0];
message.error(mobileErr ?? e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const { data } = await api.get('query/reagent');
reagent.value = data;
} catch (e) {
console.log(e);
}
try {
const { data } = await api.post('cities/list');
cities.value = data.rows;
} catch (e) {
console.log(e);
}
if (id) {
try {
const { data } = await api.get(`customer/${id}`);
Object.assign(form, {
fname: data.fname ?? '',
lname: data.lname ?? '',
ncode: data.national_code ?? '',
mobile: data.mobile ?? '',
city_id: data.city_id ?? undefined,
address: data.address ?? '',
percent: data.percent ?? undefined,
gram_level: data.gram_level ?? undefined,
reagent_id: data.reagent?.id ?? undefined,
// These five were previously OMITTED here. Because handleSubmitOmde
// spread the whole form and the server applies any key that is not
// `undefined`, saving an existing customer overwrote each of them with
// the empty string the reactive initializer left behind — silent data
// loss on every wholesale-customer edit.
store_name: data.store_name ?? '',
pasazh_name: data.pasazh_name ?? '',
mcode: data.mcode ?? '',
phone: data.phone ?? '',
type: data.type ?? undefined,
});
// Baseline for the dirty-diff in handleSubmitOmde.
Object.assign(initial, JSON.parse(JSON.stringify(form)));
} catch (e) {
console.log(e);
}
}
});
</script>
<style scoped>
.grid-4 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
</style>
@@ -0,0 +1,137 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/customer/singleCustomer.vue —
the retail ("تک") customer form. Used directly in admin mode and wrapped by
branch/addCustomer.vue in branch mode.
Legacy posts to `single` / `single/{id}`; the mapping layer points both at
the customer endpoints.
-->
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card :title="id ? 'ویرایش مشتری' : 'افزودن مشتری'" class="mb-base">
<div class="grid-3">
<a-form-item label="نام " :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item
label="نام خانوادگی"
:validate-status="err.lname ? 'error' : ''"
:help="err.lname"
>
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item
label="شماره تلفن همراه"
:validate-status="err.phone ? 'error' : ''"
:help="err.phone"
>
<a-input v-model:value="form.phone" />
</a-form-item>
<a-form-item label="شهر">
<a-select
v-model:value="form.city_id"
show-search
allow-clear
placeholder="انتخاب کنید"
:filter-option="filterOption"
>
<a-select-option v-for="c in cities" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
</div>
<a-button type="primary" @click="handleSubmit">ثبت اطلاعات</a-button>
</a-card>
</a-form>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import api from '../../../api/client';
const route = useRoute();
const router = useRouter();
const id = route.params.id as string | undefined;
const loading = ref(false);
const cities = ref<{ id: string; title: string }[]>([]);
const form = reactive<Record<string, any>>({
fname: '',
lname: '',
phone: '',
city_id: undefined,
});
const err = reactive<Record<string, string>>({});
const REQUIRED = 'لطفا مقدار را وارد کنید';
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
}
async function handleSubmit() {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.fname) err.fname = REQUIRED;
if (!form.lname) err.lname = REQUIRED;
if (!form.phone) err.phone = REQUIRED;
if (Object.keys(err).length) return;
try {
loading.value = true;
const payload = {
...form,
// one fullName in the schema; the two inputs are joined on submit
name: `${form.fname} ${form.lname}`.trim(),
mobile: form.phone,
};
if (id) {
await api.put(`single/${id}`, payload);
} else {
await api.post('single', payload);
}
message.info('اطلاعات با موفقیت به ثبت رسید.');
router.go(-1);
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const { data } = await api.post('cities/list');
cities.value = data.rows ?? [];
} catch (e) {
console.log(e);
}
if (id) {
try {
const { data } = await api.get(`customer/${id}`);
const parts = String(data.name ?? '').trim().split(' ');
Object.assign(form, {
fname: data.fname || parts[0] || '',
lname: data.lname || parts.slice(1).join(' '),
phone: data.mobile ?? '',
city_id: data.city_id ?? undefined,
});
} catch (e) {
console.log(e);
}
}
});
</script>
<style scoped>
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; }
</style>
@@ -0,0 +1,373 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/product/AddProduct.vue (1,044 lines).
Serves BOTH create and edit — EditProduct.vue is only a wrapper around this
component in legacy, and the same is true here.
Preserved: the Farsi labels, the show_type map, the inline variant table with
its add/edit modal, and the permission gates on the variant actions.
Fields with no column in the redesigned schema are NOT rendered rather than
collected and silently dropped — listed in docs/ADMIN-PORT-PLAN.md:
delivery_type, message_long_delivery, offer_day, has_warranty, is_exist,
tags, the two image uploaders, and the variant colour/rial-wage fields.
-->
<div id="page-user-view">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-spin :spinning="loading">
<a-card title="اطلاعات پایه" class="mb-base">
<div class="grid-3">
<a-form-item label="کد محصول">
<a-input v-model:value="form.product_code" />
</a-form-item>
<a-form-item
label="نام محصول"
:validate-status="err.title ? 'error' : ''"
:help="err.title"
>
<a-input v-model:value="form.title" />
</a-form-item>
<a-form-item label="نوع نمایش">
<a-select v-model:value="form.show_type">
<a-select-option :value="1">سایت</a-select-option>
<a-select-option :value="2">گوشی</a-select-option>
<a-select-option :value="3">سایت و گوشی</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="دسته بندی">
<a-select v-model:value="form.category_id" allow-clear placeholder="انتخاب کنید">
<a-select-option v-for="c in categories" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="برند">
<a-select v-model:value="form.brand_id" allow-clear placeholder="انتخاب کنید">
<a-select-option v-for="b in brands" :key="b.id" :value="b.id">
{{ b.title }}
</a-select-option>
</a-select>
</a-form-item>
<!-- legacy "سینی ها" — the Collection grouping -->
<a-form-item label="سینی ها">
<a-select
v-model:value="form.ciny_ids"
mode="multiple"
allow-clear
placeholder="انتخاب کنید"
>
<a-select-option v-for="c in collections" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
</div>
<div class="grid-1">
<a-form-item label="شرح محصول (سایت)">
<a-textarea v-model:value="form.body_html" :auto-size="{ minRows: 4, maxRows: 10 }" />
</a-form-item>
</div>
<div class="grid-3">
<a-form-item label="نمایش محصول">
<a-switch v-model:checked="form.is_active" />
</a-form-item>
</div>
</a-card>
<!-- تنوع ها -->
<a-card v-if="hasPermission('product.list.variants')" class="mb-8 overflow-auto">
<template #title>
<div class="flex">
<div class="flex-1">تنوع ها</div>
<a-button
v-if="hasPermission('product.list.variants.add')"
type="primary"
@click="showModal()"
>
افزودن تنوع
</a-button>
</div>
</template>
<a-table
:columns="variantColumns"
:data-source="variants"
:pagination="false"
size="small"
row-key="__key"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'op'">
<a-tooltip v-if="hasPermission('product.list.variants.edit')" title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="showModal(record, index)" />
</a-tooltip>
<a-tooltip v-if="hasPermission('product.list.variants.remove')" title="حذف">
<DeleteOutlined class="cursor-pointer mx-1 text-danger" @click="variants.splice(index, 1)" />
</a-tooltip>
</template>
<template v-else-if="column.dataIndex === 'is_active'">
<a-tag :color="record.is_active ? 'green' : 'red'">
{{ record.is_active ? 'فعال' : 'غیر فعال' }}
</a-tag>
</template>
<template v-else-if="column.dataIndex === 'is_pre_order'">
<a-tag :color="record.is_pre_order ? 'blue' : ''">
{{ record.is_pre_order ? 'بله' : 'خیر' }}
</a-tag>
</template>
</template>
</a-table>
</a-card>
<div>
<a-button type="primary" class="mr-4" @click="handleSubmit">ثبت اطلاعات</a-button>
</div>
</a-spin>
</a-form>
<!-- variant add/edit -->
<a-modal v-model:open="variantModal" :title="editingIndex === null ? 'افزودن تنوع' : 'ویرایش تنوع'">
<a-form layout="vertical">
<a-form-item label="عنوان">
<a-input v-model:value="variantForm.title" />
</a-form-item>
<a-form-item label="وزن">
<a-input-number v-model:value="variantForm.grams" class="w-full" :step="0.001" />
</a-form-item>
<a-form-item label="اجرت درصدی">
<a-input-number v-model:value="variantForm.percentage_pay" class="w-full" :step="0.1" />
</a-form-item>
<a-form-item label="تعداد">
<a-input-number v-model:value="variantForm.qty" class="w-full" />
</a-form-item>
<a-form-item label="فعال">
<a-switch v-model:checked="variantForm.is_active" />
</a-form-item>
<a-form-item label=" پیش سفارش">
<a-switch v-model:checked="variantForm.is_pre_order" />
</a-form-item>
</a-form>
<template #footer>
<a-button @click="variantModal = false">انصراف</a-button>
<a-button type="primary" @click="saveVariant">ثبت</a-button>
</template>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
const route = useRoute();
const router = useRouter();
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const id = route.params.id as string | undefined;
const loading = ref(false);
const brands = ref<{ id: string; title: string }[]>([]);
const categories = ref<{ id: string; title: string }[]>([]);
const collections = ref<{ id: string; title: string }[]>([]);
const form = reactive<Record<string, any>>({
title: '',
product_code: '',
show_type: 3,
category_id: undefined,
brand_id: undefined,
ciny_ids: [] as string[],
body_html: '',
is_active: true,
});
const err = reactive<Record<string, string>>({});
// ── variants (edited in memory, saved with the product)
interface VariantRow {
__key: string;
id?: string;
title: string;
grams?: number;
percentage_pay?: number;
qty?: number;
is_active: boolean;
is_pre_order: boolean;
}
const variants = ref<VariantRow[]>([]);
const variantModal = ref(false);
const editingIndex = ref<number | null>(null);
const variantForm = reactive<VariantRow>({
__key: '',
title: '',
grams: undefined,
percentage_pay: undefined,
qty: 0,
is_active: true,
is_pre_order: false,
});
const variantColumns = [
{ title: 'عنوان', dataIndex: 'title' },
{ title: 'وزن', dataIndex: 'grams' },
{ title: 'اجرت درصدی', dataIndex: 'percentage_pay' },
{ title: 'تعداد', dataIndex: 'qty' },
{ title: 'فعال', dataIndex: 'is_active' },
{ title: ' پیش سفارش', dataIndex: 'is_pre_order' },
{ title: 'عملیات', dataIndex: 'op' },
];
function showModal(record?: VariantRow, index?: number) {
if (record && index !== undefined) {
editingIndex.value = index;
Object.assign(variantForm, record);
} else {
editingIndex.value = null;
Object.assign(variantForm, {
__key: `v-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
id: undefined,
title: '',
grams: undefined,
percentage_pay: undefined,
qty: 0,
is_active: true,
is_pre_order: false,
});
}
variantModal.value = true;
}
function saveVariant() {
if (!variantForm.title) {
message.error('عنوان تنوع الزامی است');
return;
}
const row: VariantRow = { ...variantForm };
if (editingIndex.value === null) {
variants.value.push(row);
} else {
variants.value[editingIndex.value] = row;
}
variantModal.value = false;
}
function validate(): boolean {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.title) err.title = 'لطفا مقدار را وارد کنید';
return Object.keys(err).length === 0;
}
async function handleSubmit() {
if (!validate()) return;
try {
loading.value = true;
const payload = {
...form,
cats: form.category_id ? [form.category_id] : [],
variants: variants.value.map((v) => ({
id: v.id,
title: v.title,
grams: v.grams,
percentage_pay: v.percentage_pay,
qty: v.qty,
is_active: v.is_active,
is_pre_order: v.is_pre_order,
})),
};
if (id) {
await api.put(`product/update/${id}`, payload);
} else {
await api.post('product/create', payload);
}
message.info('اطلاعات با موفقیت به ثبت رسید.');
router.push('/apps/product/list');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const [b, c, col] = await Promise.all([
api.get('brand'),
api.post('category'),
api.get('admin/products/collections'),
]);
brands.value = b.data;
// legacy read data.rows and suffixed each title with its storefront:
// title: x.title + ` (${x.type_str}) `
categories.value = (c.data.rows ?? c.data).map((x: any) => ({
id: x.id,
title: x.type_str ? `${x.title} (${x.type_str}) ` : x.title,
type: x.type,
}));
collections.value = col.data;
} catch (e) {
console.log(e);
}
if (id) {
try {
const { data } = await api.get(`product/show/${id}`);
Object.assign(form, {
title: data.title ?? '',
product_code: data.product_code ?? '',
show_type: data.show_type ?? 3,
category_id: data.category?.id ?? undefined,
brand_id: data.brand?.id ?? undefined,
ciny_ids: data.ciny_ids ?? [],
body_html: data.body_html ?? '',
is_active: data.is_active ?? true,
});
variants.value = (data.variants_all ?? []).map((v: any, i: number) => ({
__key: v.id ?? `v-${i}`,
id: v.id,
title: v.title,
grams: Number(v.grams),
percentage_pay: Number(v.percentage_pay),
qty: v.qty,
is_active: v.is_active,
is_pre_order: v.is_pre_order,
}));
} catch (e) {
console.log(e);
}
}
});
</script>
<style scoped>
.grid-3 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
.grid-1 {
display: grid;
grid-template-columns: 1fr;
}
.flex-1 {
flex: 1;
}
.mb-8 {
margin-bottom: 2rem;
}
.overflow-auto {
overflow: auto;
}
</style>
@@ -0,0 +1,141 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/product/ChangePercent.vue.
Pick a category and/or brand, see how many variants match, then apply a new
wage percentage to all of them. Farsi copy preserved.
-->
<div id="page-user-list">
<a-card title="تغییر درصد محصولات">
<a-form layout="vertical">
<div class="grid-3">
<a-form-item label="دسته بندی">
<a-select
v-model:value="form.category_id"
allow-clear
show-search
placeholder="انتخاب کنید"
:filter-option="filterOption"
@change="getCount"
>
<a-select-option v-for="c in categories" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="برند">
<a-select
v-model:value="form.brand_id"
allow-clear
show-search
placeholder="انتخاب کنید"
:filter-option="filterOption"
@change="getCount"
>
<a-select-option v-for="b in brands" :key="b.id" :value="b.id">
{{ b.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="تعداد یافت شده">
<a-input :value="count" disabled />
</a-form-item>
<a-form-item label="اجرت درصدی">
<a-input-number v-model:value="form.percent" class="w-full" :step="0.1" />
</a-form-item>
</div>
<a-popconfirm
title="آیا از اعمال تغییرات مطمئن هستید؟"
ok-text="بله"
cancel-text="خیر"
@confirm="applyChange"
>
<a-button type="primary" :loading="changeLoading">اعمال تغییرات</a-button>
</a-popconfirm>
</a-form>
</a-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import api from '../../../api/client';
const brands = ref<{ id: string; title: string }[]>([]);
const categories = ref<{ id: string; title: string }[]>([]);
const count = ref<number | string>('-');
const changeLoading = ref(false);
const form = reactive<Record<string, any>>({
category_id: undefined,
brand_id: undefined,
percent: undefined,
});
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '')
.toLowerCase()
.indexOf(input.toLowerCase()) >= 0;
}
async function getCount() {
try {
const { data } = await api.post('change/percent/count', {
category_id: form.category_id,
brand_id: form.brand_id,
});
count.value = data.count;
} catch (e) {
console.log('e ==> ', e);
}
}
async function applyChange() {
if (form.percent === undefined || form.percent === null) {
message.error('لطفا اجرت درصدی را وارد کنید');
return;
}
try {
changeLoading.value = true;
const { data } = await api.post('change/all', {
percent: form.percent,
category_id: form.category_id,
brand_id: form.brand_id,
});
Modal.success({ title: 'تغییرات با موفقیت اعمال شد', okText: 'بستن' });
count.value = data.updated;
} catch (e) {
Modal.success({ title: 'خطا در اعمال تغییرات' });
console.log(e);
} finally {
changeLoading.value = false;
}
}
onMounted(async () => {
try {
const [b, c] = await Promise.all([api.get('brand'), api.post('category')]);
brands.value = b.data;
// `POST category` returns the grid envelope; the picker reads data.rows.
categories.value = (c.data.rows ?? c.data).map((x: any) => ({
id: x.id,
title: x.type_str ? `${x.title} (${x.type_str}) ` : x.title,
}));
} catch (e) {
console.log(e);
}
getCount();
});
</script>
<style scoped>
.grid-3 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
</style>
@@ -0,0 +1,16 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/product/EditProduct.vue.
Legacy's file is 71 lines but its entire template is <AddProduct /> — the
rest is page-level SCSS. It is one of the thin Edit wrappers identified in
the port plan, and stays a wrapper here. AddProduct reads the :id route param
to decide between create and edit.
-->
<div>
<AddProduct />
</div>
</template>
<script setup lang="ts">
import AddProduct from './AddProduct.vue';
</script>
@@ -0,0 +1,168 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/product/ProductList.vue.
Farsi copy, permission gates, row selection and the bulk-change panel are
preserved. The commented-out "تنوع ها" action stays absent, as in legacy.
-->
<div id="page-user-list">
<a-card title="لیست محصولات">
<h6>تعداد {{ selectedRows.length }} محصول انتخاب شده است.</h6>
<XTable
ref="tblProducts"
v-model="selectedRows"
:model="model"
:has-selection="true"
>
<template #image="{ record }">
<img
v-if="record.ProductImage && record.ProductImage.length"
:src="record.ProductImage[0].image"
class="product-thumb"
alt=""
/>
<span v-else>-</span>
</template>
<template #show_type="{ record }">
{{ record.show_type_str }}
</template>
<template #op="{ record }">
<div v-if="!is_branch" class="w-48 flex">
<a-tooltip v-if="hasPermission('product.list.edit')" title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="toEdit(record.id)" />
</a-tooltip>
<a-tooltip v-if="hasPermission('product.list.view')" title="اطلاعات جامع">
<ExclamationCircleOutlined
class="cursor-pointer mx-1 text-warning"
@click="showProductModal(record)"
/>
</a-tooltip>
<a-tooltip v-if="hasPermission('product.list.remove')" title="حذف">
<a-popconfirm
placement="top"
ok-text="بله"
cancel-text="خیر"
@confirm="confirmDelete(record.id)"
>
<template #title><p>آیا مطمئن به حذف محصول مورد نظر هستید ؟</p></template>
<DeleteOutlined class="cursor-pointer mx-1 text-danger" />
</a-popconfirm>
</a-tooltip>
</div>
</template>
</XTable>
<!-- اطلاعات جامع -->
<a-modal v-model:open="visibleProduct" title="اطلاعات جامع محصول" width="70%">
<div v-if="tempRow">
<a-descriptions bordered size="small" :column="2">
<a-descriptions-item label="نام محصول">{{ tempRow.title }}</a-descriptions-item>
<a-descriptions-item label="کد محصول">{{ tempRow.product_code || '-' }}</a-descriptions-item>
<a-descriptions-item label="برند">{{ tempRow.brand?.title || '-' }}</a-descriptions-item>
<a-descriptions-item label="دسته بندی">
{{ tempRow.category?.title || '-' }}
</a-descriptions-item>
<a-descriptions-item label="نوع نمایش">{{ tempRow.show_type_str }}</a-descriptions-item>
<a-descriptions-item label="تعداد تنوع">
{{ tempRow.variants_all?.length ?? tempRow.variants_count ?? 0 }}
</a-descriptions-item>
</a-descriptions>
<a-table
v-if="tempRow.variants_all?.length"
class="mt-4"
size="small"
:data-source="tempRow.variants_all"
:columns="variantColumns"
:pagination="false"
row-key="id"
/>
</div>
<a-empty v-else />
<template #footer>
<a-button @click="visibleProduct = false">بستن</a-button>
</template>
</a-modal>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message, Modal } from 'ant-design-vue';
import {
DeleteOutlined,
EditOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue';
import { buildProductListModel } from './productListModel';
import { useUsersStore } from '../../../stores/users';
import { isBranch } from '../../../utils';
import api from '../../../api/client';
const router = useRouter();
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildProductListModel();
const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null);
const selectedRows = ref<Record<string, any>[]>([]);
const is_branch = computed(() => isBranch());
const visibleProduct = ref(false);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const variantColumns = [
{ title: 'عنوان', dataIndex: 'title' },
{ title: 'وزن', dataIndex: 'grams' },
{ title: 'اجرت درصدی', dataIndex: 'percentage_pay' },
{ title: 'تعداد', dataIndex: 'qty' },
];
async function showProductModal(record: Record<string, any>) {
const { data } = await api.get(`admin/products/${record.id}`);
tempRow.value = data;
visibleProduct.value = true;
}
async function confirmDelete(id: string) {
try {
await api.post(`delete/product/${id}`);
message.info('محصول حذف شد');
tblProducts.value?.fetch();
} catch (e: any) {
// The backend refuses when the product appears in past orders; show its
// Farsi explanation rather than a generic failure.
Modal.error({
title: e?.response?.data?.message ?? 'خطا در حذف محصول',
okText: 'بستن',
});
}
}
function toEdit(id: string) {
router.push(`/apps/product/edit/${id}`);
}
</script>
<style scoped>
.w-48 {
width: 12rem;
}
.mt-4 {
margin-top: 1rem;
}
.product-thumb {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 4px;
}
</style>
@@ -0,0 +1,30 @@
// Ported from legacy henza_admin/src/table/product.js
//
// Column keys, Farsi titles, filter types and the show_type value map are
// preserved exactly. The legacy file also carries a large commented-out
// ColCreator block above the live XTbl definition — not revived.
import XTbl, { Xtc } from '../../../components/x-table/model';
export function buildProductListModel(): XTbl {
const tbl = new XTbl('admin/products/list', 'لیست محصولات');
tbl.add(new Xtc('id', 'کد').filterNumber());
tbl.add(new Xtc('ProductImage', 'تصویر محصول').renderSlot('image').noSort());
tbl.add(new Xtc('title', 'نام محصول').filterText());
tbl.add(new Xtc('brand.title', 'برند').filterText());
// legacy key is 'category.collection.title'; the mapping layer aliases it
tbl.add(new Xtc('category.collection.title', 'دسته بندی').filterText());
tbl.add(new Xtc('product_code', 'کد محصول').filterText());
tbl.add(
new Xtc('show_type', 'نوع نمایش')
.filterSelect({
1: 'سایت',
2: 'گوشی',
3: 'سایت و گوشی',
})
.renderSlot('show_type'),
);
tbl.add(new Xtc('action', 'عملیات').renderSlot('op').noSort());
return tbl;
}
export default buildProductListModel;
+161
View File
@@ -0,0 +1,161 @@
<template>
<!--
Ported from legacy henza_admin/src/views/auth/Login.vue.
Same two fields, same Farsi copy, same logo, same submit gate. Vuesax's
vs-input/vs-button and VeeValidate become ant-design-vue equivalents, and
the loading overlay becomes the button's own loading state.
-->
<div id="page-login">
<a-card class="login-card" :bordered="false">
<div class="login-grid">
<div class="login-form">
<div class="vx-card__title">
<h4>ورود به محیط مدیریت</h4>
<p>لطفا شماره تلفن همراه خود به همراه رمز عبور وارد کنید.</p>
</div>
<a-form layout="vertical" class="mt-8" @submit.prevent="loginJWT">
<a-form-item
label="موبایل"
:validate-status="mobileError ? 'error' : ''"
:help="mobileError"
>
<a-input
v-model:value="mobile"
autocomplete="off"
size="large"
@blur="touchedMobile = true"
/>
</a-form-item>
<a-form-item
label="رمز عبور"
:validate-status="passwordError ? 'error' : ''"
:help="passwordError"
>
<a-input-password
v-model:value="password"
size="large"
@blur="touchedPassword = true"
@pressEnter="loginJWT"
/>
</a-form-item>
<a-button
type="primary"
size="large"
:disabled="!validateForm"
:loading="loading"
@click="loginJWT"
>
ورود
</a-button>
</a-form>
</div>
<div class="login-art">
<img src="../../assets/images/logo/henza-icon.svg" alt="login" />
</div>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { useAuthStore } from '../../stores/auth';
const router = useRouter();
const auth = useAuthStore();
const mobile = ref('');
const password = ref('');
const loading = ref(false);
const touchedMobile = ref(false);
const touchedPassword = ref(false);
// legacy VeeValidate rules: mobile 'required|min:8', password 'required|min:5',
// both validated on blur.
const mobileError = computed(() => {
if (!touchedMobile.value) return '';
if (!mobile.value) return 'وارد کردن موبایل الزامی است';
if (mobile.value.length < 8) return 'مقدار ورودی صحیح نیست';
return '';
});
const passwordError = computed(() => {
if (!touchedPassword.value) return '';
if (!password.value) return 'وارد کردن رمز عبور الزامی است';
if (password.value.length < 5) return 'مقدار ورودی صحیح نیست';
return '';
});
// legacy: !errors.any() && mobile !== '' && password !== ''
const validateForm = computed(
() => !mobileError.value && !passwordError.value && mobile.value !== '' && password.value !== '',
);
async function loginJWT() {
if (!validateForm.value || loading.value) return;
loading.value = true;
try {
// legacy also sent token_mobile from localStorage for Firebase push, which is
// inactive in this port; the field is omitted rather than sent as null.
await auth.login({ phone: mobile.value, password: password.value });
// legacy did a hard `window.location.href = '/admin/my-data'`; a router push
// reaches the same route without discarding the freshly-populated store.
await router.push('/my-data');
} catch (e) {
console.log(e);
message.error('خطا در ورود');
} finally {
loading.value = false;
}
}
</script>
<style scoped>
#page-login {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.login-card {
width: 100%;
max-width: 900px;
}
.login-grid {
display: grid;
grid-template-columns: 1fr;
align-items: center;
}
@media (min-width: 992px) {
.login-grid {
grid-template-columns: 1fr 1fr;
}
}
.login-form {
background-color: #eff2f7; /* legacy inline style */
padding: 2rem;
min-height: 505px; /* legacy .login-tabs-container */
}
.login-art {
display: none;
text-align: center;
}
@media (min-width: 992px) {
.login-art {
display: block;
}
}
.login-art img {
max-width: 70%;
margin: 0 auto;
}
.mt-8 {
margin-top: 2rem;
}
</style>
@@ -0,0 +1,122 @@
<template>
<!--
Ported from legacy henza_admin/src/views/branch/AddBranches.vue.
Serves BOTH create and edit — EditBranches.vue is a wrapper over it.
A branch's manager is an EXISTING admin picked from user/list. (The workshop
form differs: it creates its supervisor inline — see AddWorkshop.vue.)
-->
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card :title="id ? 'ویرایش شعبه' : 'افزودن شعبه'" class="mb-base">
<div class="grid-2">
<a-form-item label="نام شعبه" :validate-status="err.name ? 'error' : ''" :help="err.name">
<a-input v-model:value="form.name" />
</a-form-item>
<a-form-item label="تلفن شعبه">
<a-input v-model:value="form.phone" />
</a-form-item>
<a-form-item label="مدیر">
<a-select
v-model:value="form.user_id"
show-search
allow-clear
placeholder="انتخاب کنید"
:filter-option="filterOption"
>
<a-select-option v-for="u in adminUsers" :key="u.id" :value="u.id">
{{ u.name }}
</a-select-option>
</a-select>
</a-form-item>
</div>
<a-form-item label="آدرس">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 5 }" />
</a-form-item>
<a-button type="primary" @click="handleSubmit">ثبت اطلاعات</a-button>
</a-card>
</a-form>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import api from '../../api/client';
const route = useRoute();
const router = useRouter();
const id = route.params.id as string | undefined;
const loading = ref(false);
const adminUsers = ref<{ id: string; name: string }[]>([]);
const form = reactive<Record<string, any>>({
name: '',
phone: '',
address: '',
user_id: undefined,
});
const err = reactive<Record<string, string>>({});
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
}
async function handleSubmit() {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.name) {
err.name = 'لطفا مقدار را وارد کنید';
return;
}
try {
loading.value = true;
if (id) {
await api.put(`branch/${id}`, form);
} else {
await api.post('branch', form);
}
message.info('اطلاعات با موفقیت به ثبت رسید.');
router.push('/branch/list');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const { data } = await api.get('user/list');
adminUsers.value = data;
} catch (e) {
console.log(e);
}
if (id) {
try {
const { data } = await api.get(`branch/${id}`);
Object.assign(form, {
name: data.name ?? '',
phone: data.phone ?? '',
address: data.address ?? '',
user_id: data.user_id ?? undefined,
});
} catch (e) {
console.log(e);
}
}
});
</script>
<style scoped>
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
</style>
@@ -0,0 +1,9 @@
<template>
<!-- Ported from legacy views/branch/EditBranches.vue. AddBranches reads the
:id route param to switch between create and edit. -->
<AddBranches />
</template>
<script setup lang="ts">
import AddBranches from './AddBranches.vue';
</script>
@@ -0,0 +1,72 @@
<template>
<!--
Shared implementation of legacy ListBranches.vue and ListWorkshop.vue.
Verified by diff: the two files differ by 14 lines — the card title, the
model import, and the edit route. Parameterised rather than duplicated.
Legacy quirks preserved:
- the delete action is COMMENTED OUT in both files and stays absent
- the status tooltip is labelled "وضعیت" and toggles on click
- legacy binds `slot-scope="text,record"` then reads `text.record.id`,
i.e. the row arrives as the FIRST argument; the Vue 3 slot gives us
`record` directly, which is the same row.
-->
<a-card :title="title">
<XTable ref="tbl" :model="model">
<template #actions="{ record }">
<div class="flex gap-2">
<a-tooltip title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="toEdit(record.id)" />
</a-tooltip>
<a-tooltip title="وضعیت">
<CheckOutlined
v-if="record.is_active"
class="cursor-pointer mx-1 text-success"
@click="setOffer(record.id)"
/>
<CloseOutlined v-else class="cursor-pointer mx-1 text-danger" @click="setOffer(record.id)" />
</a-tooltip>
</div>
</template>
</XTable>
</a-card>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { CheckOutlined, CloseOutlined, EditOutlined } from '@ant-design/icons-vue';
import XTable from '../../components/x-table/XTable.vue';
import api from '../../api/client';
import type { XTbl } from '../../components/x-table/model';
const props = defineProps<{
title: string;
model: XTbl;
/** Where the edit pencil navigates, e.g. '/branch/edit' or '/workshop/edit'. */
editPath: string;
/** The toggle endpoint, e.g. 'branch/active' or 'workshop/active'. */
activePath: string;
}>();
const router = useRouter();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
function toEdit(id: string) {
router.push(`${props.editPath}/${id}`);
}
/** legacy setOffer(): the status toggle */
async function setOffer(id: string) {
try {
await api.get(`${props.activePath}/${id}`);
message.success('تغییرات ثبت شد');
tbl.value?.fetch();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در تغییر وضعیت');
}
}
</script>
@@ -0,0 +1,19 @@
// Ported from legacy henza_admin/src/views/branch/ListBranch.js
//
// Legacy sets `_noFilter` and `_noDownload` — note `_noFilter` (singular), which
// is NOT the `_noFilters` flag XTable actually reads, so the filter bar stayed
// visible. Preserved: neither flag is set here either.
import XTbl, { Xtc } from '../../components/x-table/model';
export function buildBranchListModel(): XTbl {
const tbl = new XTbl('admin/branches/list', 'لیست شعب');
tbl.add(new Xtc('name', 'نام').filterText());
tbl.add(new Xtc('address', 'آدرس').filterText());
// legacy declares filterNumber() on these two text columns — preserved
tbl.add(new Xtc('phone', 'شماره تلفن').filterNumber());
tbl.add(new Xtc('user.name', 'مدیر شعبه').filterNumber().noSort());
tbl.add(new Xtc('op', 'عملیات').noSort().renderSlot('actions'));
return tbl;
}
export default buildBranchListModel;
@@ -0,0 +1,17 @@
<template>
<!-- Ported from legacy views/branch/ListBranches.vue — a thin wrapper over the
shared facility list (see FacilityList.vue for why). -->
<FacilityList
title="لیست شعب"
:model="model"
edit-path="/branch/edit"
active-path="branch/active"
/>
</template>
<script setup lang="ts">
import FacilityList from './FacilityList.vue';
import { buildBranchListModel } from './ListBranch';
const model = buildBranchListModel();
</script>
@@ -0,0 +1,13 @@
<template>
<!--
Ported from legacy henza_admin/src/views/branch/addCustomer.vue — a 19-line
wrapper whose entire template is <singleCustomer />. Kept as a wrapper.
-->
<div>
<SingleCustomerForm />
</div>
</template>
<script setup lang="ts">
import SingleCustomerForm from '../apps/customer/singleCustomer.vue';
</script>
@@ -0,0 +1,79 @@
<template>
<!--
Ported from legacy henza_admin/src/views/branch/buyList.vue — the branch's
purchase invoices: the product allocations head office sent to this branch.
The backend scopes allocations by the caller's branch, so a branch user sees
only their own without the view having to filter.
-->
<div id="page-user-list">
<a-card title="فاکتور خرید">
<XTable ref="tbl" :model="model">
<template #opt="{ record }">
<a-tooltip title="مشاهده">
<EyeOutlined class="cursor-pointer mx-1 text-primary" @click="view(record)" />
</a-tooltip>
</template>
</XTable>
<a-modal v-model:open="visible" title="مشاهده فاکتور" width="70%">
<div v-if="tempRow">
<a-descriptions bordered size="small" :column="2">
<a-descriptions-item label="شعبه">{{ tempRow.name }}</a-descriptions-item>
<a-descriptions-item label="ثبت کننده">
{{ tempRow.allocator_user?.name ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="تاریخ">{{ jdate(tempRow.caj) }}</a-descriptions-item>
<a-descriptions-item label="تعداد آیتم">{{ tempRow.count_item }}</a-descriptions-item>
</a-descriptions>
<a-table
class="mt-4"
size="small"
:data-source="tempRow.products"
:columns="productColumns"
:pagination="false"
row-key="id"
/>
</div>
<a-empty v-else />
<template #footer>
<a-button @click="visible = false">بستن</a-button>
</template>
</a-modal>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { EyeOutlined } from '@ant-design/icons-vue';
import XTable from '../../components/x-table/XTable.vue';
import { buildBranchBuyListModel } from './orderListModel';
import { jdate } from '../../utils/filters';
import api from '../../api/client';
const model = buildBranchBuyListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const visible = ref(false);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const productColumns = [
{ title: 'کد محصول', dataIndex: 'product_code' },
{ title: 'نام محصول', dataIndex: 'title' },
{ title: 'وزن', dataIndex: 'grams' },
];
async function view(record: Record<string, any>) {
const { data } = await api.get(`branch/product/${record.id}`);
tempRow.value = data;
visible.value = true;
}
</script>
<style scoped>
.mt-4 {
margin-top: 1rem;
}
</style>
@@ -0,0 +1,92 @@
<template>
<!--
Ported from legacy henza_admin/src/views/branch/dashboard.vue — the branch
home screen: this week's orders and best-selling products.
LEGACY GAP, deliberately closed: the legacy file declares both tables against
demo arrays defined in the component and makes NO server call at all
(verified: zero axios references in the file). The cards are wired to real
endpoints here, because a dashboard showing placeholder rows is worse than
one showing nothing. Recorded in docs/ADMIN-PORT-PLAN.md.
-->
<a-spin :spinning="loading">
<a-card title="سفارشات هفته جاری">
<a-table
size="small"
:data-source="lastOrder"
:columns="orderColumns"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'sts_str'">
<a-tag :color="tagColor(record.statusColor)">{{ record.sts_str }}</a-tag>
</template>
<template v-else-if="column.dataIndex === 'caj'">
{{ jdate(record.caj) }}
</template>
</template>
</a-table>
</a-card>
<a-card title="محصولات پر فروش" class="mt-4">
<a-table
size="small"
:data-source="topProducts"
:columns="productColumns"
:pagination="false"
row-key="id"
/>
</a-card>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { jdate } from '../../utils/filters';
import api from '../../api/client';
const loading = ref(false);
const lastOrder = ref<Record<string, any>[]>([]);
const topProducts = ref<Record<string, any>[]>([]);
const orderColumns = [
{ title: 'شماره سفارش', dataIndex: 'name' },
{ title: 'مشتری', dataIndex: 'customer' },
{ title: 'وزن', dataIndex: 'w' },
{ title: 'وضعیت', dataIndex: 'sts_str' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
const productColumns = [
{ title: 'کد محصول', dataIndex: 'product_code' },
{ title: 'نام محصول', dataIndex: 'title' },
{ title: 'تعداد تنوع', dataIndex: 'variants_count' },
];
function tagColor(c: string) {
return { success: 'green', warning: 'orange', danger: 'red', primary: 'blue' }[c] ?? '';
}
onMounted(async () => {
try {
loading.value = true;
const [o, p] = await Promise.all([
api.get('get-analytics-tak'),
api.post('products', { page: 1, limit: 5 }),
]);
lastOrder.value = o.data.lastOrder ?? [];
topProducts.value = p.data.rows ?? [];
} catch (e) {
console.log(e);
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.mt-4 {
margin-top: 1rem;
}
</style>
+104
View File
@@ -0,0 +1,104 @@
<template>
<!--
Ported from legacy henza_admin/src/views/branch/login.vue — the branch-mode
sign-in. A different layout from the admin login (centred logo, no split
illustration panel), but the same auth action underneath.
Legacy's right-hand illustration panel is COMMENTED OUT and stays absent.
-->
<div class="flex gap-6 w-full justify-center h-screen branch-login-page">
<div class="flex flex-col h-screen items-center justify-center">
<img
src="../../assets/images/logo/henza-icon.svg"
width="150"
alt="login"
class="mx-auto mb-5"
/>
<a-form layout="vertical" class="login-form" @submit.prevent="handleSubmit">
<a-form-item
label="نام کاربری"
class="w-full"
:validate-status="err.phone ? 'error' : ''"
:help="err.phone"
>
<a-input v-model:value="form.phone" size="large" />
</a-form-item>
<a-form-item
label="رمز عبور"
class="w-full"
:validate-status="err.password ? 'error' : ''"
:help="err.password"
>
<a-input-password
v-model:value="form.password"
size="large"
@press-enter="handleSubmit"
/>
</a-form-item>
<a-button
size="large"
type="primary"
class="mt-8 w-full"
:loading="loading"
@click="handleSubmit"
>
ورود
</a-button>
</a-form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { useAuthStore } from '../../stores/auth';
const router = useRouter();
const auth = useAuthStore();
const loading = ref(false);
const form = reactive({ phone: '', password: '' });
const err = reactive<Record<string, string>>({});
async function handleSubmit() {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.phone) err.phone = 'نام کاربری را وارد نمایید';
if (!form.password) err.password = 'رمز عبور را وارد نمایید';
if (Object.keys(err).length) return;
try {
loading.value = true;
await auth.login({ phone: form.phone, password: form.password });
// legacy did a hard window.location.href = '/branch/dashboard'; a router
// push reaches the same route without discarding the populated store.
await router.push('/dashboard');
} catch (e) {
console.log(e);
message.error('خطا در ورود');
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.branch-login-page {
display: flex;
width: 100%;
justify-content: center;
min-height: 100vh;
}
.flex-col { flex-direction: column; }
.justify-center { justify-content: center; }
.h-screen { min-height: 100vh; }
.gap-6 { gap: 1.5rem; }
.mx-auto { margin: 0 auto; }
.mb-5 { margin-bottom: 1.25rem; }
.mt-8 { margin-top: 2rem; }
.login-form { width: 320px; max-width: 90vw; }
</style>
@@ -0,0 +1,252 @@
<template>
<!--
Ported from legacy henza_admin/src/views/branch/order/create.vue — the
branch-mode order builder.
It differs from the admin builder (views/order/create.vue) in flow, not in
substance: the CUSTOMER is picked first and the product grid stays hidden
until one is chosen ("جهت ثبت سفارش مشتری را انتخاب نمایید"). Preserved.
It also drops the admin version's `$hasPermission('order.create')` wrapper —
branch mode gates the whole route instead. Preserved.
-->
<a-spin :spinning="loading">
<a-card title="ثبت سفارش">
<span>جهت ثبت سفارش مشتری را انتخاب نمایید</span>
<a-form layout="vertical" class="mt-4" @submit.prevent="submitOrder">
<a-form-item
label="مشتری"
class="customer-field"
:validate-status="err.customer_id ? 'error' : ''"
:help="err.customer_id"
>
<a-select
v-model:value="form.customer_id"
show-search
option-filter-prop="children"
:filter-option="filterOption"
>
<a-select-option v-for="item in customers" :key="item.id" :value="item.id">
{{ `${item.id}-${item.fname} ${item.lname}` }}
</a-select-option>
</a-select>
</a-form-item>
<!-- legacy: the grid appears only once a customer is chosen -->
<div v-if="form.customer_id != null">
<h6>تعداد {{ items.length }} محصول انتخاب شده است.</h6>
<XTable ref="tblProducts" :model="cols">
<template #image="{ record }">
<img
width="90px"
height="90px"
style="object-fit: scale-down"
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
/>
</template>
<template #op="{ record }">
<a-dropdown>
<a-button style="margin-left: 8px">
افزودن
<DownOutlined />
</a-button>
<template #overlay>
<a-menu @click="onVariantPick(record, $event)">
<a-menu-item v-for="item in record.variants_all" :key="item.id">
افزودن تنوع {{ item.title }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</XTable>
<a-card v-if="items.length > 0" title="سبد سفارش" class="mt-4">
<a-table
:columns="columns"
:data-source="items"
bordered
:pagination="false"
row-key="vid"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'delete'">
<CloseOutlined class="cursor-pointer" @click="cart.remove(record)" />
</template>
<template v-else-if="column.dataIndex === 'product'">
<div class="flex items-center">
<img :src="getImage(record.product)" width="50px" />
{{ record.product.title }}
</div>
</template>
<template v-else-if="column.dataIndex === 'qty'">
<a-input-number
v-model:value="items[index].qty"
:min="1"
:max="maxQtyFor(index)"
@change="onQtyChange(index, $event)"
/>
</template>
<template v-else-if="column.dataIndex === 'weight'">
{{ getVariant(record).grams }}
</template>
<template v-else-if="column.dataIndex === 'total'">
{{ rowWeight(record) }} گرم
</template>
</template>
</a-table>
<div class="totals">
<a-tag color="blue">مجموع وزن: {{ totalWeight }} گرم</a-tag>
</div>
<a-form-item label="توضیحات">
<a-textarea v-model:value="form.note" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
<a-button type="primary" :disabled="items.length < 1" @click="submitOrder">
ثبت سفارش
</a-button>
</a-card>
</div>
</a-form>
</a-card>
</a-spin>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { CloseOutlined, DownOutlined } from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue';
import { buildProductListModel } from '../../apps/product/productListModel';
import { useCartStore, type CartItem, type CartVariant } from '../../../stores/cart';
import api from '../../../api/client';
const router = useRouter();
const cart = useCartStore();
const noImage = '/img/no-items.png';
const cols = buildProductListModel();
const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null);
const loading = ref(false);
const customers = ref<{ id: string; fname: string; lname: string }[]>([]);
const form = reactive<Record<string, any>>({ customer_id: undefined, note: '' });
const err = reactive<Record<string, string>>({});
const items = computed(() => cart.items);
const columns = [
{ title: 'حذف', dataIndex: 'delete', width: 60, align: 'center' },
{ title: 'محصول', dataIndex: 'product' },
{ title: 'تعداد', dataIndex: 'qty', align: 'center', width: 140 },
{ title: 'وزن', dataIndex: 'weight', align: 'center', width: 90 },
{ title: 'مجموع', dataIndex: 'total', align: 'center' },
];
function getVariant(item: CartItem): CartVariant & Record<string, any> {
return (item.product.variants_all.find((x) => x.id === item.vid) ?? {}) as CartVariant;
}
function getImage(product: Record<string, any>) {
return product?.thumb?.full ?? product?.ProductImage?.[0]?.image ?? noImage;
}
/** Same as the admin builder: 0 stock means uncapped, which keeps pre-orders orderable. */
function maxQtyFor(index: number): number {
const item = items.value[index];
if (!item) return Infinity;
const stock = Number(getVariant(item).qty ?? 0);
return stock > 0 ? stock : Infinity;
}
function onQtyChange(index: number, value: unknown) {
cart.updateItemQuantity({ index, quantity: Number(value) });
}
function onVariantPick(record: Record<string, any>, e: { key: string | number }) {
cart.add({
vid: String(e.key),
pid: record.id,
product: record as any,
qty: 1,
promotion_id: record.promotion && record.promotion.id ? record.promotion.id : null,
});
message.info('اضافه شد');
}
function rowWeight(record: CartItem) {
return Number(getVariant(record).grams ?? 0) * record.qty;
}
/** legacy sums the gram figures WITHOUT multiplying by qty — preserved. */
const totalWeight = computed(() => {
let total = 0;
for (const item of items.value) total += Number(getVariant(item).grams ?? 0);
return total;
});
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '')
.toLowerCase()
.includes(input.toLowerCase());
}
async function submitOrder() {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.customer_id) {
err.customer_id = 'مقدار را وارد نمایید';
return;
}
try {
loading.value = true;
const order_items = items.value.map((x) => {
const v = getVariant(x);
return { vid: x.vid, qty: x.qty, percent: v.percentage_pay };
});
await api.post('order', {
customer_id: form.customer_id,
note: form.note,
sts: 1,
order_items,
});
message.info('عملیات با موفقیت انجام شد');
cart.clear();
router.push('/order/list');
} catch (e: any) {
console.log(e);
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const { data } = await api.post('single-customer', { page: 1, limit: 500 });
customers.value = data.rows ?? [];
} catch (e) {
console.log(e);
}
await cart.init();
});
</script>
<style scoped>
.customer-field {
max-width: 320px;
}
.mt-4 {
margin-top: 1rem;
}
.totals {
margin: 1rem 0;
}
</style>
@@ -0,0 +1,19 @@
// Ported from legacy henza_admin/src/views/branch/orderListModel.js — the
// branch's "فاکتور خرید": the allocations head office sent to this branch.
//
// Legacy carries several commented-out columns (customer name, total price,
// wage, and an order.tamin.state block). They stay absent.
import XTbl, { Xtc } from '../../components/x-table/model';
export function buildBranchBuyListModel(): XTbl {
const tbl = new XTbl('admin/invoices/list', 'فاکتور خرید');
tbl.add(new Xtc('id', 'شماره').filterNumber());
tbl.add(new Xtc('count_item', 'تعداد آیتم ها').noSort());
tbl.add(new Xtc('sum_weight', 'جمع وزن').noSort());
tbl.add(new Xtc('caj', 'تاریخ ثبت').filterKey('createdAt').filterDate().noSort());
tbl.add(new Xtc('uaj', 'تاریخ ویرایش').hidden().filterKey('createdAt').filterDate().noSort());
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildBranchBuyListModel;
@@ -0,0 +1,17 @@
// Ported from legacy henza_admin/src/views/city/CityListModel.js
//
// Note the 'level' column: legacy declares it but the cities payload has no such
// field, so the cell always rendered blank. Preserved — see the port plan.
import XTbl, { Xtc } from '../../components/x-table/model';
export function buildCityListModel(): XTbl {
const tbl = new XTbl('admin/misc/cities/list', 'لیست شهر ها');
tbl.add(new Xtc('title', 'نام شهر').filterKey('name').filterText());
tbl.add(new Xtc('level', 'سطح').filterNumber().noSort());
tbl.add(new Xtc('percent', 'درصد مشتری عمده').renderSlot('percent').noSort());
tbl.add(new Xtc('percent_aux', 'درصد کارشناس فروش').renderSlot('percent_aux').noSort());
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildCityListModel;
@@ -0,0 +1,120 @@
<template>
<!--
Ported from legacy henza_admin/src/views/city/cityList.vue.
The two percent columns are edited in a modal that legacy loads through two
separate GETs (get-percent / get-percent-aux) before opening.
-->
<div id="page-user-list">
<a-card title="لیست شهر ها">
<XTable ref="citiesTable" :model="model">
<template #percent="{ text }">
{{ text ?? '-' }}
</template>
<template #percent_aux="{ text }">
{{ text ?? '-' }}
</template>
<template #opt="{ record }">
<a-tooltip v-if="hasPermission('cities.list.view.edit')" title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="edit(record)" />
</a-tooltip>
<a-tooltip v-if="hasPermission('cities.list.remove')" title="حذف">
<a-popconfirm
placement="top"
ok-text="بله"
cancel-text="خیر"
@confirm="confirmDelete(record.id)"
>
<template #title><p>آیا مطمئن به حذف شهر مورد نظر هستید ؟</p></template>
<DeleteOutlined class="cursor-pointer mx-1 text-danger" />
</a-popconfirm>
</a-tooltip>
</template>
</XTable>
<a-modal v-model:open="visible" title="ویرایش شهر" @ok="save">
<a-form layout="vertical">
<a-form-item label="نام شهر">
<a-input v-model:value="form.title" />
</a-form-item>
<a-form-item label="درصد مشتری عمده">
<a-input-number v-model:value="form.percent" class="w-full" :step="0.1" />
</a-form-item>
<a-form-item label="درصد کارشناس فروش">
<a-input-number v-model:value="form.percent_aux" class="w-full" :step="0.1" />
</a-form-item>
</a-form>
</a-modal>
</a-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import XTable from '../../components/x-table/XTable.vue';
import { buildCityListModel } from './CityListModel';
import { useUsersStore } from '../../stores/users';
import api from '../../api/client';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildCityListModel();
const citiesTable = ref<{ fetch: () => Promise<void> } | null>(null);
const visible = ref(false);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const form = reactive<Record<string, any>>({ title: '', percent: null, percent_aux: null });
/**
* legacy edit(): fetches the two percents separately before opening the modal.
* Both are already on the row here, but the legacy calls are kept so the two
* endpoints stay exercised and the values are always current.
*/
async function edit(row: Record<string, any>) {
tempRow.value = row;
form.title = row.title ?? '';
try {
const [p, pa] = await Promise.all([
api.get(`cities/get-percent/${row.id}`),
api.get(`cities/get-percent-aux/${row.id}`),
]);
form.percent = p.data === null ? null : Number(p.data);
form.percent_aux = pa.data === null ? null : Number(pa.data);
} catch (e) {
form.percent = row.percent === null ? null : Number(row.percent);
form.percent_aux = row.percent_aux === null ? null : Number(row.percent_aux);
}
visible.value = true;
}
async function save() {
try {
await api.put(`cities/${tempRow.value?.id}`, {
title: form.title,
percent: form.percent,
percent_aux: form.percent_aux,
});
message.success('تغییرات ثبت شد');
visible.value = false;
citiesTable.value?.fetch();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت تغییرات');
}
}
async function confirmDelete(id: string) {
try {
await api.delete(`cities/${id}`);
message.success('شهر حذف شد');
citiesTable.value?.fetch();
} catch (e: any) {
// The backend refuses while customers, branches or addresses reference it.
Modal.error({ title: e?.response?.data?.message ?? 'خطا در حذف شهر', okText: 'بستن' });
}
}
</script>
@@ -0,0 +1,52 @@
<template>
<!-- Ported from legacy henza_admin/src/views/city/createCity.vue. -->
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card title="افزودن شهر">
<div class="grid-2">
<a-form-item label="نام شهر" :validate-status="err.title ? 'error' : ''" :help="err.title">
<a-input v-model:value="form.title" />
</a-form-item>
<a-form-item label="استان">
<a-input v-model:value="form.province" />
</a-form-item>
</div>
<a-button type="primary" @click="handleSubmit">ثبت اطلاعات</a-button>
</a-card>
</a-form>
</a-spin>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import api from '../../api/client';
const router = useRouter();
const loading = ref(false);
const form = reactive<Record<string, any>>({ title: '', province: '' });
const err = reactive<Record<string, string>>({});
async function handleSubmit() {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.title) {
err.title = 'لطفا مقدار را وارد کنید';
return;
}
try {
loading.value = true;
await api.post('cities', form);
message.info('اطلاعات با موفقیت به ثبت رسید.');
router.push('/city/list');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; }
</style>
@@ -0,0 +1,29 @@
// Ported from legacy henza_admin/src/views/customer/Comments/CommentListModel.js
//
// Column keys, Farsi titles and slots preserved exactly — including legacy's
// quirk that 'product.title' (a text column) is declared .filterNumber(), and
// that an 'sts' column is added once commented-out and once live.
import XTbl, { Xtc } from '../../../components/x-table/model';
import { useUsersStore } from '../../../stores/users';
export function buildCommentListModel(): XTbl {
const users = useUsersStore();
const tbl = new XTbl('admin/comments/list', 'لیست دیدگاه ها');
tbl.add(new Xtc('product_id', 'شناسه محصول').filterNumber().hidden());
// legacy declares filterNumber() on a title column — preserved
tbl.add(new Xtc('product.title', 'نام محصول').filterNumber().renderSlot('productName'));
tbl.add(new Xtc('customer.id', 'شناسه نویسنده دیدگاه').filterNumber().hidden());
tbl.add(new Xtc('customer', 'نام نویسنده ').filterText().renderSlot('customer'));
tbl.add(new Xtc('reply', 'پاسخ').renderSlot('reply').noSort());
tbl.add(new Xtc('sts', 'وضعیت').renderSlot('sts').noSort());
if (users.roles && users.roles.includes('comment.state')) {
tbl.add(new Xtc('changeState', 'تغییر وضعیت').renderSlot('changeState').noSort());
}
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildCommentListModel;
@@ -0,0 +1,273 @@
<template>
<!--
Ported from legacy henza_admin/src/views/customer/Comments/Comments.vue.
Farsi copy, permission gates, slot names and the status dropdown are
preserved. The inline avatar SVG is replaced by an ant icon; the hardcoded
legacy asset path (/admin/img/henza-icon.2b1e8c97.svg — a build hash that no
longer exists) becomes the bundled logo.
-->
<div id="page-user-list">
<a-card title="لیست دیدگاه ها">
<XTable ref="tbl" :model="model">
<template #customer="{ text }">
{{ text && text.fname && text.lname ? `${text.fname} ${text.lname}` : 'بدون نام' }}
</template>
<template #productName="{ record }">
<a :href="productLink(record)" target="_blank">{{ record.product.title }}</a>
</template>
<template #sts="{ text }">
<a-tag v-if="text == 1">در انتظار تایید</a-tag>
<a-tag v-else-if="text == 2" color="green">تایید شده</a-tag>
<a-tag v-else color="red">عدم تایید</a-tag>
</template>
<template #reply="{ text }">
<a-tag v-if="text && text.length > 0 && text[0].comment !== null" color="green">
پاسخ داده شده
</a-tag>
<a-tag v-else>بدون پاسخ</a-tag>
</template>
<template #changeState="{ record }">
<a-dropdown v-if="hasPermission('comment.state')">
<a-tag
style="cursor: pointer"
:color="record.sts === 1 ? '' : record.sts === 2 ? 'green' : record.sts === 3 ? 'red' : ''"
@click="(e: Event) => e.preventDefault()"
>
{{ record.sts === 1 ? 'در انتظار تایید' : record.sts === 2 ? 'تایید' : 'عدم تایید' }}
<DownOutlined />
</a-tag>
<template #overlay>
<a-menu>
<a-menu-item>
<div
@click="
record.sts !== 2
? changeSts(record.id, 2)
: message.error('حالت فعلی تایید می باشد')
"
>
تایید
</div>
</a-menu-item>
<a-menu-item class="red">
<div
@click="
record.sts !== 3
? changeSts(record.id, 3)
: message.error('حالت فعلی عدم تایید می باشد')
"
>
عدم تایید
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<template #opt="{ record }">
<div class="flex">
<TooltipBtn
v-if="hasPermission('comment.view')"
icon-class="w-5 h-5 text-primary"
class="mr-3"
icon="EyeIcon"
title="مشاهده دیدگاه"
@click="viewComment(record)"
/>
<TooltipBtn
v-if="hasPermission('comment.remove')"
icon-class="w-5 h-5 text-danger"
class="mr-3"
icon="TrashIcon"
pop-title="دیدگاه حذف شود؟"
@click="deleteComment(record.id)"
/>
</div>
</template>
</XTable>
<!-- مشاهده دیدگاه -->
<a-modal v-model:open="visibleComment" title="مشاهده دیدگاه" width="80%">
<div v-if="tempRow" class="comments-list">
<ul>
<li class="comment-item">
<span class="img">
<UserOutlined style="font-size: 48px; color: #1c2d56" />
</span>
<div class="comment-body">
<h5>
{{
tempRow.customer && tempRow.customer.fname && tempRow.customer.lname
? `${tempRow.customer.fname} ${tempRow.customer.lname}`
: 'بدون نام'
}}
</h5>
<span class="time">
ارسال شده در تاریخ : {{ tempRow.caj ? String(tempRow.caj).split('-')[0] : null }}
</span>
<a-form-item style="width: 100%">
<a-textarea
v-model:value="commentText"
:disabled="!hasPermission('comment.view.edit')"
:auto-size="{ minRows: 3, maxRows: 5 }"
/>
</a-form-item>
</div>
<ul>
<li class="comment-item">
<img src="../../../assets/images/logo/henza-icon.svg" alt="comment author" class="img border" />
<div class="comment-body">
<h5>ادمین</h5>
<span v-if="tempRow.reply && tempRow.reply[0]" class="time">
ارسال شده در تاریخ :
{{ tempRow.reply[0].caj ? String(tempRow.reply[0].caj).split('-')[0] : '' }}
</span>
<a-form-item style="width: 100%">
<a-textarea
v-model:value="replyText"
:disabled="!hasPermission('comment.view.edit')"
:auto-size="{ minRows: 3, maxRows: 5 }"
/>
</a-form-item>
<a-button
v-if="(showChangeReplyBtn || showChangeCommentBtn) && hasPermission('comment.view.edit')"
class="mr-4"
@click="submitChanges(tempRow)"
>
ثبت تغییرات
</a-button>
</div>
</li>
</ul>
</li>
</ul>
</div>
<template #footer>
<a-button @click="visibleComment = false">بستن</a-button>
</template>
</a-modal>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { DownOutlined, UserOutlined } from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue';
import TooltipBtn from '../../../components/tooltip-btn/TooltipBtn.vue';
import { buildCommentListModel } from './CommentListModel';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildCommentListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const visibleComment = ref(false);
const commentText = ref('');
const replyText = ref('');
const showChangeCommentBtn = computed(() => commentText.value !== (tempRow.value?.comment ?? ''));
const showChangeReplyBtn = computed(
() => replyText.value !== (tempRow.value?.reply?.[0]?.comment ?? ''),
);
/**
* legacy built a storefront link from the product title:
* https://henzagold.com/product/<title-with-dashes>/<id>
* The host is kept because it points at the live public site, not the API.
*/
function productLink(record: Record<string, any>) {
const slug = String(record.product?.title ?? '').split(' ').join('-');
return `https://henzagold.com/product/${slug}/${record.product?.id}`;
}
async function changeSts(id: string, sts: number) {
try {
await api.post('comment/sts', { id, sts });
message.info('تغییرات ثبت شد ');
tbl.value?.fetch();
} catch (e) {
message.error('خطا در تغییر وضعیت');
}
}
function viewComment(record: Record<string, any>) {
tempRow.value = record;
commentText.value = record.comment ?? '';
replyText.value = record.reply?.[0]?.comment ?? '';
visibleComment.value = true;
}
async function deleteComment(id: string) {
try {
await api.delete(`comment/${id}`);
message.info('دیدگاه حذف شد');
tbl.value?.fetch();
} catch (e) {
message.error('خطا در حذف');
}
}
async function submitChanges(row: Record<string, any>) {
try {
await api.post('comment', {
product_id: row.product_id,
customer_id: row.customer_id,
comment: replyText.value,
reply_to: row.id,
});
message.info('تغییرات ثبت شد ');
visibleComment.value = false;
tbl.value?.fetch();
} catch (e) {
message.error('خطا در ثبت تغییرات');
}
}
</script>
<style scoped>
.mr-3 {
margin-left: 0.75rem;
}
.comments-list ul {
list-style: none;
padding-right: 0;
}
.comment-item {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.comment-item .img {
width: 65px;
height: 65px;
display: flex;
align-items: center;
justify-content: center;
}
.comment-body {
flex: 1;
min-width: 240px;
}
.comment-body .time {
font-size: 12px;
color: #888;
}
.border {
border: 1px solid grey;
border-radius: 50%;
}
</style>
@@ -0,0 +1,210 @@
<template>
<!--
Shared implementation of legacy ViewOmdeCustomer.vue (376 lines) and
ViewSingleCustomer.vue (425 lines) — the customer detail screens.
Both show the same four panels (profile, orders, financial documents, wallet)
against the same customer record; legacy duplicated the whole page per
customer kind. The differing bits are parameterised:
- the wholesale screen adds an activity-log panel (legacy log/ago/{id})
- the retail screen adds a wallet panel (legacy single/wallet/{id})
-->
<a-spin :spinning="loading">
<a-card :title="title">
<a-descriptions bordered size="small" :column="3">
<a-descriptions-item label="نام">{{ customer?.name ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="موبایل">{{ customer?.mobile ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="کد ملی">
{{ customer?.national_code ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="شهر">{{ customer?.city?.title ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="کارشناس فروش">
{{ customer?.reagent?.name ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="وضعیت">
<a-tag :color="customer?.active === 1 ? 'green' : 'red'">
{{ customer?.active === 1 ? 'فعال' : 'غیر فعال' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="سطح اعتبار">
{{ customer?.gram_level ?? '-' }} گرم
</a-descriptions-item>
<a-descriptions-item label="بدهی گرمی">{{ customer?.remain ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="بدهی ریالی">
{{ customer?.rial_remain ?? '-' }}
</a-descriptions-item>
</a-descriptions>
<div v-if="dashboard" class="stats mt-4">
<a-tag color="blue">کل سفارش‌ها: {{ dashboard.total_order }}</a-tag>
<a-tag color="orange">سفارش باز: {{ dashboard.open_order }}</a-tag>
<a-tag color="green">اسناد: {{ dashboard.ledger_entries }}</a-tag>
</div>
</a-card>
<a-tabs class="mt-4" :default-active-key="defaultTab">
<a-tab-pane key="orders" tab="سفارش ها">
<a-table
size="small"
:data-source="orders"
:columns="orderColumns"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="documents" tab="سند های مالی">
<a-table
size="small"
:data-source="documents"
:columns="documentColumns"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'confirmed'">
<a-tag :color="record.confirmed === 1 ? 'green' : ''">
{{ record.confirmed === 1 ? 'تایید شده' : 'تایید نشده' }}
</a-tag>
</template>
<template v-else-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane v-if="showWallet" key="wallet" tab="کیف پول">
<a-table
size="small"
:data-source="wallet"
:columns="walletColumns"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
<template v-else-if="column.dataIndex === 'expires_at'">
{{ jdate(record.expires_at) }}
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane v-if="showActivity" key="activity" tab="فعالیت ها">
<a-table
size="small"
:data-source="activity"
:columns="activityColumns"
:pagination="{ pageSize: 15 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">{{ jdateTime(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { jdate, jdateTime } from '../../utils/filters';
import api from '../../api/client';
const props = withDefaults(
defineProps<{
title?: string;
/** The retail screen shows the wallet panel. */
showWallet?: boolean;
/** The wholesale screen shows the activity log. */
showActivity?: boolean;
defaultTab?: string;
}>(),
{ title: 'مشاهده اطلاعات مشتری', showWallet: false, showActivity: false, defaultTab: 'orders' },
);
const route = useRoute();
const id = route.params.id as string;
const loading = ref(false);
const customer = ref<Record<string, any> | undefined>(undefined);
const dashboard = ref<Record<string, any> | undefined>(undefined);
const orders = ref<Record<string, any>[]>([]);
const documents = ref<Record<string, any>[]>([]);
const wallet = ref<Record<string, any>[]>([]);
const activity = ref<Record<string, any>[]>([]);
const orderColumns = [
{ title: 'شماره سفارش', dataIndex: 'name' },
{ title: 'وزن', dataIndex: 'w' },
{ title: 'تعداد آیتم', dataIndex: 'items_count' },
{ title: 'شعبه', dataIndex: 'branch' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
const documentColumns = [
{ title: 'نوع', dataIndex: 'type' },
{ title: 'مقدار', dataIndex: 'value' },
{ title: 'وضعیت', dataIndex: 'confirmed' },
{ title: 'توضیحات', dataIndex: 'note' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
const walletColumns = [
{ title: 'مقدار (گرم)', dataIndex: 'value' },
{ title: 'تاریخ اعطا', dataIndex: 'caj' },
{ title: 'انقضا', dataIndex: 'expires_at' },
];
const activityColumns = [
{ title: 'عملیات', dataIndex: 'action' },
{ title: 'جزئیات', dataIndex: 'detail' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
onMounted(async () => {
try {
loading.value = true;
const [c, d, o, docs] = await Promise.all([
api.get(`customer/${id}`),
api.get(`major/dashboard/${id}`).catch(() => ({ data: undefined })),
api.get(`admin/customers-omde/${id}/orders`).catch(() => ({ data: [] })),
api.get(`admin/customers-omde/${id}/documents`).catch(() => ({ data: [] })),
]);
customer.value = c.data;
dashboard.value = d.data;
orders.value = o.data;
documents.value = docs.data;
if (props.showWallet) {
const w = await api
.get(`admin/customers-omde/${id}/wallet`)
.catch(() => ({ data: [] }));
wallet.value = w.data;
}
if (props.showActivity) {
const a = await api.get(`log/ago/${id}`).catch(() => ({ data: [] }));
activity.value = a.data;
}
} catch (e) {
console.log(e);
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.stats {
display: flex;
gap: 0.5rem;
}
.mt-4 {
margin-top: 1rem;
}
</style>
@@ -0,0 +1,12 @@
<template>
<!-- Ported from legacy views/customer/omde/BankerList.vue — differs from OmdeList by title + model url only (verified: 18 diff lines, all whitespace besides those two). -->
<OmdeCustomerList :title="title" :model="model" />
</template>
<script setup lang="ts">
import OmdeCustomerList from './OmdeCustomerList.vue';
import { buildOmdeListModel, OMDE_SOURCES } from './omdeListModel';
const title = OMDE_SOURCES.banker.title;
const model = buildOmdeListModel('banker');
</script>
@@ -0,0 +1,12 @@
<template>
<!-- Ported from legacy views/customer/omde/OmdeArchivedList.vue. -->
<OmdeCustomerList :title="title" :model="model" />
</template>
<script setup lang="ts">
import OmdeCustomerList from './OmdeCustomerList.vue';
import { buildOmdeListModel, OMDE_SOURCES } from './omdeListModel';
const title = OMDE_SOURCES.archived.title;
const model = buildOmdeListModel('archived');
</script>
@@ -0,0 +1,414 @@
<template>
<!--
Shared implementation of the five legacy wholesale-customer lists
(OmdeList, BankerList, ShopkeeperList, OmdeNonVerifiedList, OmdeArchivedList).
Legacy shipped these as five ~615-line files. Verified by diff, they differ by:
- ShopkeeperList vs OmdeList: 4 lines (card title + model import ONLY)
- BankerList: 18 lines, all whitespace/formatting except the same two
- OmdeArchived: 24 lines, same plus method ordering
- OmdeNonVerified: 34 lines, same plus two genuine quirks (see props below)
Per the agreed decision, behaviour and appearance are preserved exactly while
the implementation lives once, parameterised by props.
-->
<div id="page-user-list">
<a-card :title="title">
<div v-if="query && query.count && query.total">
{{ query.total }} رکورد یافت شد -
<span class="text-danger">{{ query.count }} رکورد غیر فعال</span>
</div>
<XTable ref="tbl" :model="model" @query="setCount">
<template #city="{ record }">
<div v-if="record.city && record.city.title">{{ record.city.title }}</div>
<div v-else>-</div>
</template>
<template #sts="{ record }">
{{ record.sts_str }}
</template>
<template #active="{ record }">
<a-tag :color="record.active === 1 ? 'green' : 'red'">
{{ record.active === 1 ? 'فعال' : 'غیر فعال' }}
</a-tag>
</template>
<template #opt="{ record }">
<div class="flex">
<a-dropdown>
<div class="cursor-pointer flex items-center">
<DownOutlined />
<SettingOutlined class="cursor-pointer w-5 ml-1" />
</div>
<template #overlay>
<a-menu @click="handleMenuClick($event, record.id)">
<a-menu-item
v-if="hasPermission('persons.list.client.omde.edit')"
key="1"
@click="toEdit(record.id)"
>
<div class="flex gap-2">
<EditOutlined class="cursor-pointer w-5 ml-1" />
<div>ویرایش</div>
</div>
</a-menu-item>
<a-menu-item
v-if="hasPermission('persons.list.client.omde.view')"
key="2"
@click="toView(record.id)"
>
<div class="flex gap-2">
<EyeOutlined class="cursor-pointer w-5 ml-1" />
<div>مشاهده اطلاعات</div>
</div>
</a-menu-item>
<a-menu-item
v-if="hasPermission('persons.list.client.omde.view.orders')"
key="3"
@click="viewOrder(record)"
>
<div class="flex gap-2">
<UnorderedListOutlined class="cursor-pointer w-5 ml-1" />
<div>مشاهده سفارش</div>
</div>
</a-menu-item>
<a-menu-item
v-if="hasPermission('persons.list.client.omde.view.documents')"
key="4"
@click="viewCash(record)"
>
<div class="flex gap-2">
<FileTextOutlined class="cursor-pointer w-5 ml-1" />
<div>سند های مالی</div>
</div>
</a-menu-item>
<a-menu-item v-if="hasPermission('persons.list.client.omde.able')" key="7">
<a-popconfirm
ok-text="بله"
cancel-text="خیر"
title="آیا مطمئن هستید؟"
@confirm="toggleArchive(record.id)"
>
<div class="flex gap-2">
<CloudDownloadOutlined class="cursor-pointer w-5 ml-1" />
<div>بایگانی کردن</div>
</div>
</a-popconfirm>
</a-menu-item>
<!-- step one -->
<a-menu-item key="8">
<a-popconfirm
:ok-text="record.sts_verify_step_one === 1 ? 'تایید' : 'بله'"
:cancel-text="record.sts_verify_step_one === 1 ? 'رد' : 'خیر'"
:title="
record.sts_verify_step_one === 1
? 'وضعیت تایید اولیه مشتری به چه نحوی تغییر یابد؟'
: 'آیا از تغییر وضعیت مشتری اطمینان دارید؟'
"
@confirm="onConfirmStepOne(record)"
@cancel="onCancelStepOne(record)"
>
<div class="flex gap-2">
<component
:is="stepIconComponent(getStepOneIcon(record))"
class="cursor-pointer w-5 ml-1"
:class="stepIconClass(getStepOneIcon(record))"
/>
<div>{{ getStepOneText(record) }}</div>
</div>
</a-popconfirm>
</a-menu-item>
<!-- step two: unlocked only once step one is ACCEPTED (=== 2) -->
<a-menu-item :disabled="record.sts_verify_step_one !== 2" key="9">
<a-popconfirm
:ok-text="record.sts_verify_step_two === 1 ? 'تایید' : 'بله'"
:cancel-text="record.sts_verify_step_two === 1 ? 'رد' : 'خیر'"
:title="
record.sts_verify_step_two === 1
? 'وضعیت تایید سفارش مشتری به چه نحوی تغییر یابد؟'
: 'آیا از تغییر وضعیت سفارش مشتری اطمینان دارید؟'
"
@confirm="onConfirmStepTwo(record)"
@cancel="onCancelStepTwo(record)"
>
<div class="flex gap-2">
<component
:is="stepIconComponent(getStepTwoIcon(record))"
class="cursor-pointer w-5 ml-1"
:class="stepIconClass(getStepTwoIcon(record))"
/>
<div>{{ getStepTwoText(record) }}</div>
</div>
</a-popconfirm>
</a-menu-item>
<a-menu-item
v-if="hasPermission('persons.list.client.omde.able.order')"
:disabled="record.sts_verify_step_one !== 2"
key="5"
>
<a-popconfirm
ok-text="بله"
cancel-text="خیر"
title="آیا مطمئن هستید؟"
@confirm="toggleSts(record)"
>
<div class="flex gap-2">
<component
:is="record.sts === 1 ? CheckOutlined : StopOutlined"
class="cursor-pointer w-5 ml-1"
:class="record.sts === 1 ? 'text-success' : 'text-danger'"
/>
<div>{{ record.sts === 1 ? 'معلق کردن سفارش' : 'فعال کردن سفارش' }}</div>
</div>
</a-popconfirm>
</a-menu-item>
<a-menu-item
v-if="hasPermission('persons.list.client.omde.able')"
:disabled="record.sts_verify_step_one !== 2"
key="6"
>
<a-popconfirm
ok-text="بله"
cancel-text="خیر"
title="آیا مطمئن هستید؟"
@confirm="toggleActive(record)"
>
<div class="flex gap-2">
<!--
NOTE legacy binds :icon="getStsIcon(record)" here while
colouring by getActiveIcon(record) — it shows the ORDER
status icon next to the ACCOUNT label. Preserved.
-->
<component
:is="record.sts === 1 ? CheckOutlined : StopOutlined"
class="cursor-pointer w-5 ml-1"
:class="record.active === 1 ? 'text-success' : 'text-danger'"
/>
<div>
{{ record.active === 1 ? 'معلق کردن حساب کاربری' : ' فعال کردن حساب کاربری' }}
</div>
</div>
</a-popconfirm>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
</XTable>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
CheckOutlined,
CloudDownloadOutlined,
DownOutlined,
EditOutlined,
EyeOutlined,
FileTextOutlined,
QuestionCircleOutlined,
SettingOutlined,
StopOutlined,
UnorderedListOutlined,
} from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
import type { XTbl } from '../../../components/x-table/model';
const props = withDefaults(
defineProps<{
/** Card heading — the only visible difference between four of the five lists. */
title: string;
/** The table definition (differs only in its url). */
model: XTbl;
/**
* OmdeNonVerifiedList's quirk: its popconfirm calls rejectStepOne() in BOTH
* the accept and reject branches, so the first step can never be approved
* from that screen. Faithfully reproduced behind this flag rather than
* silently "fixed" — see docs/ADMIN-PORT-PLAN.md.
*/
rejectOnlyStepOne?: boolean;
}>(),
{ rejectOnlyStepOne: false },
);
const router = useRouter();
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const query = ref<{ count?: number; total?: number } | undefined>(undefined);
/** legacy setCount(): the "N رکورد غیر فعال" banner above the grid */
function setCount(raw: unknown) {
const value = raw as { count?: number; total?: number } | undefined;
query.value = {
count: value && value.count ? value.count : undefined,
total: value && value.total ? value.total : undefined,
};
}
/**
* legacy handleMenuClick(): closes the dropdown for the keys that open a
* popconfirm, so the menu does not stay open behind the confirmation.
*/
function handleMenuClick(e: { key: string | number }, _id: string) {
const key = String(e.key);
return key === '5' || key === '6' || key === '7' || key === '8' || key === '9';
}
// ── verification step display, ported verbatim (1 pending / 2 accepted / 3 rejected)
function getStepOneText(row: Record<string, any>) {
switch (row.sts_verify_step_one) {
case 1:
return 'در انتظار تایید ورود مشتری';
case 2:
return 'رد ورود مشتری';
case 3:
return 'تایید ورود مشتری';
}
}
function getStepOneIcon(row: Record<string, any>) {
switch (row.sts_verify_step_one) {
case 1:
return 'HelpCircleIcon';
case 2:
return 'SlashIcon';
case 3:
return 'CheckIcon';
}
}
function getStepTwoText(row: Record<string, any>) {
switch (row.sts_verify_step_two) {
case 1:
return 'در انتظار فعال سازی';
case 2:
return 'غیر فعال سازی';
case 3:
return 'فعال سازی';
}
}
function getStepTwoIcon(row: Record<string, any>) {
switch (row.sts_verify_step_two) {
case 1:
return 'HelpCircleIcon';
case 2:
return 'SlashIcon';
case 3:
return 'CheckIcon';
}
}
/** feather name -> ant component, matching legacy's three icons */
function stepIconComponent(name?: string) {
if (name === 'HelpCircleIcon') return QuestionCircleOutlined;
if (name === 'CheckIcon') return CheckOutlined;
return StopOutlined; // SlashIcon
}
function stepIconClass(name?: string) {
if (name === 'HelpCircleIcon') return 'text-primary';
if (name === 'CheckIcon') return 'text-success';
return 'text-danger';
}
// ── actions
async function postVerify(payload: Record<string, unknown>, errorMsg: string) {
try {
await api.post('major/verify', payload);
message.info('تغییرات ثبت شد ');
tbl.value?.fetch();
} catch (e) {
message.error(errorMsg);
}
}
const acceptStepOne = (id: string) =>
postVerify({ customer_id: id, sts_verify_step_one: 2 }, 'خطا در تغییر وضعیت اولیه مشتری');
const rejectStepOne = (id: string) =>
postVerify({ customer_id: id, sts_verify_step_one: 3 }, 'خطا در تغییر وضعیت اولیه مشتری');
const acceptStepTwo = (id: string) =>
postVerify({ customer_id: id, sts_verify_step_two: 2 }, 'خطا در تغییر وضعیت سفارش مشتری');
const rejectStepTwo = (id: string) =>
postVerify({ customer_id: id, sts_verify_step_two: 3 }, 'خطا در تغییر وضعیت سفارش مشتری');
/** legacy inline ternary on @confirm, preserved including the NonVerified quirk */
function onConfirmStepOne(record: Record<string, any>) {
if (record.sts_verify_step_one === 1) {
return props.rejectOnlyStepOne ? rejectStepOne(record.id) : acceptStepOne(record.id);
}
if (record.sts_verify_step_one === 2) return rejectStepOne(record.id);
return acceptStepOne(record.id);
}
function onCancelStepOne(record: Record<string, any>) {
if (record.sts_verify_step_one === 1) return rejectStepOne(record.id);
}
function onConfirmStepTwo(record: Record<string, any>) {
if (record.sts_verify_step_two === 1) return acceptStepTwo(record.id);
if (record.sts_verify_step_two === 2) return rejectStepTwo(record.id);
return acceptStepTwo(record.id);
}
function onCancelStepTwo(record: Record<string, any>) {
if (record.sts_verify_step_two === 1) return rejectStepTwo(record.id);
}
async function toggleActive(record: Record<string, any>) {
try {
await api.post('customer/toggle-active', { id: record.id });
message.info('تغییرات ثبت شد ');
await tbl.value?.fetch();
} catch (e) {
message.error('خطا در تغییر وضعیت');
}
}
async function toggleSts(record: Record<string, any>) {
try {
// BankerList sent an explicit {id, sts} object while the others posted the
// whole row; the server reads only `id`, so one shape is used here.
await api.post('customer/toggle-sts', { id: record.id, sts: record.sts });
message.info('تغییرات ثبت شد ');
await tbl.value?.fetch();
} catch (e) {
message.error('خطا در تغییر وضعیت');
}
}
async function toggleArchive(id: string) {
try {
await api.get(`major/archive/${id}`);
message.info('تغییرات ثبت شد ');
await tbl.value?.fetch();
} catch (e) {
message.error('خطا در بایگانی');
}
}
function toEdit(id: string) {
router.push(`/bulkCustomer/edit/${id}`);
}
function toView(id: string) {
router.push(`/bulkCustomer/view/${id}`);
}
function viewOrder(record: Record<string, any>) {
router.push(`/bulkCustomer/view/${record.id}?tab=orders`);
}
function viewCash(record: Record<string, any>) {
router.push(`/bulkCustomer/view/${record.id}?tab=documents`);
}
</script>
@@ -0,0 +1,12 @@
<template>
<!-- Ported from legacy views/customer/omde/OmdeList.vue (615 lines). -->
<OmdeCustomerList :title="title" :model="model" />
</template>
<script setup lang="ts">
import OmdeCustomerList from './OmdeCustomerList.vue';
import { buildOmdeListModel, OMDE_SOURCES } from './omdeListModel';
const title = OMDE_SOURCES.omde.title;
const model = buildOmdeListModel('omde');
</script>
@@ -0,0 +1,12 @@
<template>
<!-- Ported from legacy views/customer/omde/OmdeNonVerifiedList.vue. Carries the legacy quirk where step-one confirm rejects instead of accepting. -->
<OmdeCustomerList :title="title" :model="model" reject-only-step-one />
</template>
<script setup lang="ts">
import OmdeCustomerList from './OmdeCustomerList.vue';
import { buildOmdeListModel, OMDE_SOURCES } from './omdeListModel';
const title = OMDE_SOURCES.nonVerified.title;
const model = buildOmdeListModel('nonVerified');
</script>
@@ -0,0 +1,12 @@
<template>
<!-- Ported from legacy views/customer/omde/ShopkeeperList.vue — verified to differ from OmdeList by exactly 4 lines: card title and model import. -->
<OmdeCustomerList :title="title" :model="model" />
</template>
<script setup lang="ts">
import OmdeCustomerList from './OmdeCustomerList.vue';
import { buildOmdeListModel, OMDE_SOURCES } from './omdeListModel';
const title = OMDE_SOURCES.shopkeeper.title;
const model = buildOmdeListModel('shopkeeper');
</script>
@@ -0,0 +1,17 @@
<template>
<!--
Ported from legacy henza_admin/src/views/customer/omde/ViewOmdeCustomer.vue
(376 lines). Shares its implementation with the retail detail screen — see
CustomerDetail.vue for why. The wholesale variant adds the activity log
(legacy `GET log/ago/{id}`).
-->
<CustomerDetail
title="مشاهده اطلاعات مشتری عمده"
:show-activity="true"
:show-wallet="false"
/>
</template>
<script setup lang="ts">
import CustomerDetail from '../CustomerDetail.vue';
</script>
@@ -0,0 +1,70 @@
// Ported from legacy henza_admin/src/views/customer/omde/*Model.js
//
// The five legacy model files (OmdeListModel, BankerListModel,
// shopkeeperListModel, OmdeListNonVerifiedModel, OmdeArchiveListModel) are
// byte-identical except for ONE line — the endpoint url, plus a different title
// on two of them. Verified by diff. They are built here from one factory.
import XTbl, { Xtc } from '../../../components/x-table/model';
import { useUsersStore } from '../../../stores/users';
/** Endpoint + title per legacy file. */
export const OMDE_SOURCES = {
omde: { url: 'admin/customers-omde/list', title: 'لیست مشتریان های عمده' },
banker: { url: 'admin/customers-omde/banker/list', title: 'لیست بنکداران' },
shopkeeper: { url: 'admin/customers-omde/shopkeeper/list', title: 'لیست مغازه داران حضوری' },
// NOTE both of these carry the SAME title as the plain omde list in legacy —
// only their url differs. Preserved rather than "corrected".
nonVerified: { url: 'admin/customers-omde/non-verified/list', title: 'لیست مشتریان های عمده' },
archived: { url: 'admin/customers-omde/archived/list', title: 'لیست مشتریان های عمده' },
} as const;
export type OmdeSourceKey = keyof typeof OMDE_SOURCES;
export function buildOmdeListModel(source: OmdeSourceKey): XTbl {
const users = useUsersStore();
// legacy: fetch the city list once if the filter map is empty
if (Object.keys(users.cityFilters).length < 1) {
users.getCities();
}
const { url, title } = OMDE_SOURCES[source];
const tbl = new XTbl(url, title);
tbl.add(new Xtc('id', 'کد').filterNumber());
tbl.add(new Xtc('fname', 'نام').filterText().hidden());
tbl.add(new Xtc('lname', 'نام خانوادگی').filterText().hidden());
tbl.add(new Xtc('name', 'نام و نام خانوادگی').filterText());
tbl.add(new Xtc('mobile', 'موبایل').filterText().hidden().noSort());
tbl.add(
new Xtc('city_id', 'شهر')
.hidden()
.renderSlot('city')
.filterKey('city_id')
.filterSelect(users.cityFilters),
);
tbl.add(new Xtc('reagent.name', 'کارشناس فروش').filterText().noSort());
tbl.add(new Xtc('gram_level', 'سطح اعتبار').filterNumber().noSort());
tbl.add(new Xtc('percent', 'درصد').filterNumber().noSort());
tbl.add(new Xtc('remain', 'بدهی گرمی').filterNumber().noSort());
tbl.add(new Xtc('rial_remain', 'بدهی ریالی').filterNumber().noSort());
tbl.add(new Xtc('caj', 'تاریخ ثبت').filterDate().noSort());
tbl.add(new Xtc('uaj', 'بروزرسانی').hidden().filterDate().noSort());
tbl.add(new Xtc('sts_str', 'وضعیت ثبت سفارش').renderSlot('sts').noSort());
tbl.add(
new Xtc('active', 'وضعیت حساب کاربری').renderSlot('active').noSort().filterSelect({
'1': 'فعال',
'-1': 'غیر فعال',
}),
);
tbl.add(new Xtc('phone', 'تلفن').filterText().hidden().noSort());
tbl.add(new Xtc('address', 'آدرس').filterText().hidden().noSort());
tbl.add(new Xtc('store_name', 'نام مغازه').filterText().hidden().noSort());
tbl.add(new Xtc('zone', 'منطقه').filterText().hidden().noSort());
tbl.add(new Xtc('pasazh_name', 'نام پاساژ').filterText().hidden().noSort());
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildOmdeListModel;
@@ -0,0 +1,159 @@
<template>
<!--
Ported from legacy henza_admin/src/views/customer/reagentList/ReagentList.vue.
Unlike the five wholesale lists this one uses inline TooltipBtn icons rather
than a dropdown menu. Farsi copy and permission gates preserved verbatim,
including legacy's typo in the "فعال سازی سفارش سفارش" tooltip.
-->
<div id="page-user-list">
<a-card title="لیست کارشناسان فروش">
<XTable ref="tbl" :model="model">
<template #city="{ record }">
<div v-if="record.city && record.city.title">{{ record.city.title }}</div>
<div v-else>-</div>
</template>
<template #sts="{ record }">
{{ record.sts_str }}
</template>
<template #active="{ record }">
<a-tag :color="record.active === 1 ? 'green' : 'red'">
{{ record.active === 1 ? 'فعال' : 'غیر فعال' }}
</a-tag>
</template>
<template #opt="{ record }">
<div class="flex">
<TooltipBtn
v-if="hasPermission('persons.list.sellers.edit')"
icon-class="w-5 h-5 text-primary"
class="ml-3"
icon="EditIcon"
title="ویرایش"
@click="toEdit(record.id)"
/>
<TooltipBtn
v-if="hasPermission('persons.list.sellers.view')"
icon-class="w-5 h-5 text-success"
class="ml-3"
icon="EyeIcon"
title="مشاهده اطلاعات"
@click="toView(record.id)"
/>
<TooltipBtn
v-if="hasPermission('persons.list.sellers.view.orders')"
icon-class="w-5 h-5 text-primary"
class="ml-3"
icon="ListIcon"
title="مشاهده سفارش"
@click="viewOrder(record)"
/>
<TooltipBtn
v-if="hasPermission('persons.list.sellers.view.documents')"
icon-class="w-5 h-5 text-success"
class="ml-3"
icon="FileTextIcon"
title="سند های مالی"
@click="viewCash(record)"
/>
<TooltipBtn
v-if="record.sts == 1 && hasPermission('persons.list.sellers.unable.order')"
icon-class="w-5 h-5 text-danger"
class="ml-3"
:icon="getStsIcon(record)"
title="توقف سفارش"
pop-title="آیا مطمئن هستید؟"
@click="toggleSts(record)"
/>
<TooltipBtn
v-if="record.sts !== 1 && hasPermission('persons.list.sellers.able.order')"
icon-class="w-5 h-5 text-success"
class="ml-3"
:icon="getStsIcon(record)"
title="فعال سازی سفارش سفارش"
pop-title="آیا مطمئن هستید؟"
@click="toggleSts(record)"
/>
<TooltipBtn
v-if="record.active == 1 && hasPermission('persons.list.sellers.unable')"
icon-class="w-5 h-5 text-danger"
class="ml-3"
icon="XIcon"
title="غیر فعال کردن حساب کاربری"
pop-title="آیا مطمئن هستید؟"
@click="toggleActive(record)"
/>
<TooltipBtn
v-if="record.active !== 1 && hasPermission('persons.list.sellers.able')"
icon-class="w-5 h-5 text-success"
class="ml-3"
icon="CheckIcon"
title="فعال سازی حساب کاربری"
pop-title="آیا مطمئن هستید؟"
@click="toggleActive(record)"
/>
</div>
</template>
</XTable>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import XTable from '../../../components/x-table/XTable.vue';
import TooltipBtn from '../../../components/tooltip-btn/TooltipBtn.vue';
import { buildReagentListModel } from './ReagentListModel';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
const router = useRouter();
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildReagentListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
function getStsIcon(row: Record<string, any>) {
return row.sts === 1 ? 'CheckIcon' : 'XIcon';
}
async function toggleActive(record: Record<string, any>) {
try {
await api.post('customer/toggle-active', { id: record.id });
message.info('تغییرات ثبت شد ');
await tbl.value?.fetch();
} catch (e) {
message.error('خطا در تغییر وضعیت');
}
}
async function toggleSts(record: Record<string, any>) {
try {
await api.post('customer/toggle-sts', { id: record.id, sts: record.sts });
message.info('تغییرات ثبت شد ');
await tbl.value?.fetch();
} catch (e) {
message.error('خطا در تغییر وضعیت');
}
}
function toEdit(id: string) {
router.push(`/reagent/edit/${id}`);
}
function toView(id: string) {
router.push(`/bulkCustomer/view/${id}`);
}
function viewOrder(record: Record<string, any>) {
router.push(`/bulkCustomer/view/${record.id}?tab=orders`);
}
function viewCash(record: Record<string, any>) {
router.push(`/bulkCustomer/view/${record.id}?tab=documents`);
}
</script>
@@ -0,0 +1,47 @@
// Ported from legacy henza_admin/src/views/customer/reagentList/ReagentListModel.js
//
// Column keys, Farsi titles and filter types preserved exactly, INCLUDING the
// legacy quirk that 'name' is added twice (once visible as 'نام و نام خانوادگی'
// and once hidden as 'نام'), which makes two columns share one dataIndex.
import XTbl, { Xtc } from '../../../components/x-table/model';
import { useUsersStore } from '../../../stores/users';
export function buildReagentListModel(): XTbl {
const users = useUsersStore();
if (Object.keys(users.cityFilters).length < 1) {
users.getCities();
}
const tbl = new XTbl('admin/customers-omde/reagent/list', 'لیست کارشناسان فروش');
tbl.add(new Xtc('id', 'کد').filterNumber());
tbl.add(new Xtc('lname', 'نام خانوادگی').filterText().hidden());
tbl.add(new Xtc('name', 'نام و نام خانوادگی').filterText());
// legacy adds 'name' a second time, hidden — preserved
tbl.add(new Xtc('name', 'نام').filterText().hidden());
tbl.add(new Xtc('mobile', 'موبایل').filterText().hidden().noSort());
tbl.add(
new Xtc('city_id', 'شهر')
.hidden()
.renderSlot('city')
.filterKey('city_id')
.filterSelect(users.cityFilters),
);
tbl.add(new Xtc('gram_level', 'سطح اعتبار').filterNumber().noSort());
tbl.add(new Xtc('percent', 'درصد').filterNumber().noSort());
tbl.add(new Xtc('remain', 'بدهی گرمی').filterNumber().noSort());
tbl.add(new Xtc('rial_remain', 'بدهی ریالی').filterNumber().noSort());
tbl.add(new Xtc('grams_sell', 'میزان فروش').filterNumber().noSort());
tbl.add(new Xtc('caj', 'تاریخ ثبت').filterDate().noSort());
tbl.add(new Xtc('uaj', 'بروزرسانی').hidden().filterDate().noSort());
tbl.add(new Xtc('sts_str', 'وضعیت ثبت سفارش').renderSlot('sts').noSort());
tbl.add(
new Xtc('active', 'وضعیت حساب کاربری').renderSlot('active').noSort().filterSelect({
'1': 'فعال',
'-1': 'غیر فعال',
}),
);
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildReagentListModel;
@@ -0,0 +1,130 @@
<template>
<!--
Ported from legacy henza_admin/src/views/customer/single-customer/SingleCustomer.vue.
Farsi copy, permission gates and the wallet-charge modal are preserved,
including legacy's quirk that the "مشاهده اطلاعات" icon is gated on the
`wallet.charge` rid rather than a view rid.
-->
<a-card :title="is_branch ? 'لیست مشتریان' : 'لیست مشتریان تک فروشی'">
<XTable ref="tbl" :model="model">
<template #op="{ record }">
<a-tooltip title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="toEdit(record.id)" />
</a-tooltip>
<a-tooltip v-if="hasPermission('wallet.charge') && !is_branch" title="شارژ کیف پول">
<CreditCardOutlined
class="cursor-pointer mx-1"
style="color: #d48806"
@click="toCharge(record.id)"
/>
</a-tooltip>
<!-- legacy gates this on wallet.charge too — preserved -->
<a-tooltip v-if="hasPermission('wallet.charge')" title="مشاهده اطلاعات">
<EyeOutlined class="cursor-pointer mx-1 text-success" @click="toView(record.id)" />
</a-tooltip>
</template>
</XTable>
<a-modal
v-if="hasPermission('wallet.charge')"
v-model:open="showChargeForm"
:footer="null"
title="شارژ کیف پول"
>
<a-spin :spinning="loading">
<a-form layout="vertical">
<div class="charge-grid">
<a-form-item label="میزان شارژ">
<a-input-number v-model:value="chargeValue" class="w-full" />
</a-form-item>
<a-popconfirm
style="margin-bottom: 24px"
title="آیا از شارژ کیف پول مطمعن هستید؟"
ok-text="بله"
cancel-text="خیر"
@confirm="chargeWallet"
@cancel="onCancelCharge"
>
<a-button type="primary">ثبت</a-button>
</a-popconfirm>
</div>
</a-form>
</a-spin>
</a-modal>
</a-card>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { CreditCardOutlined, EditOutlined, EyeOutlined } from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue';
import { buildSingleCustomerListModel } from './SingleCustomerListModel';
import { useUsersStore } from '../../../stores/users';
import { isBranch } from '../../../utils';
import api from '../../../api/client';
const router = useRouter();
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildSingleCustomerListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const is_branch = computed(() => isBranch());
const showChargeForm = ref(false);
const loading = ref(false);
const chargeValue = ref<number | undefined>(undefined);
const chargeCustomerId = ref<string | undefined>(undefined);
function toCharge(id: string) {
chargeCustomerId.value = id;
chargeValue.value = undefined;
showChargeForm.value = true;
}
function onCancelCharge() {
chargeValue.value = undefined;
showChargeForm.value = false;
}
async function chargeWallet() {
if (!chargeCustomerId.value || !chargeValue.value) {
message.error('لطفا مقدار را وارد کنید');
return;
}
try {
loading.value = true;
await api.post('wallet/charge', {
customer_id: chargeCustomerId.value,
value: chargeValue.value,
});
message.info('عملیات با موفقیت انجام شد');
showChargeForm.value = false;
chargeValue.value = undefined;
await router.push('/wallet');
} catch (e: any) {
console.log(e);
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
function toEdit(id: string) {
router.push(`/singleCustomer/edit/${id}`);
}
function toView(id: string) {
router.push(`/singleCustomer/view/${id}`);
}
</script>
<style scoped>
.charge-grid {
display: grid;
grid-template-columns: 1fr auto;
gap: 1rem;
align-items: end;
}
</style>
@@ -0,0 +1,16 @@
// Ported from legacy henza_admin/src/views/customer/single-customer/SingleCustomerList.js
import XTbl, { Xtc } from '../../../components/x-table/model';
export function buildSingleCustomerListModel(): XTbl {
const tbl = new XTbl('admin/customers-omde/single/list', 'لیست مشتریان تک');
tbl.add(new Xtc('id', 'کد').filterNumber());
tbl.add(new Xtc('fname', 'نام').filterKey('fullName').filterText());
tbl.add(new Xtc('lname', 'نام خانوادگی').filterKey('fullName').filterText().noSort());
tbl.add(new Xtc('phone', 'تلفن تماس').filterKey('mobile').filterText().noSort());
tbl.add(new Xtc('email', 'ایمیل').filterText().noSort());
tbl.add(new Xtc('branch.name', 'نام شعبه').filterText().noSort());
tbl.add(new Xtc('op', 'عملیات').noSort().renderSlot('op'));
return tbl;
}
export default buildSingleCustomerListModel;
@@ -0,0 +1,18 @@
<template>
<!--
Ported from legacy
henza_admin/src/views/customer/single-customer/ViewSingleCustomer.vue
(425 lines). Shares its implementation with the wholesale detail screen —
see CustomerDetail.vue. The retail variant adds the wallet panel (legacy
`GET single/wallet/{id}`).
-->
<CustomerDetail
title="مشاهده اطلاعات مشتری"
:show-wallet="true"
:show-activity="false"
/>
</template>
<script setup lang="ts">
import CustomerDetail from '../CustomerDetail.vue';
</script>
@@ -0,0 +1,237 @@
<template>
<!--
Ported from legacy henza_admin/src/views/finical/addFinDoc.vue — filing a
financial document (a customer repayment). Serves both create and edit.
The form's defining behaviour, preserved: which fields appear depends on the
document type.
1 آب شده -> weight, karat, lab, hallmark + the normalized final weight
2 حواله -> weight + destination
3 چک -> cheque amount, account holder, bank
4 پولی -> Rial amount + quoted price per gram, with a live conversion
-->
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card title="ثبت سند مالی" class="mb-base">
<div class="grid-3">
<a-form-item label="نوع سند">
<a-select v-model:value="form.type">
<a-select-option :value="2">حواله</a-select-option>
<a-select-option :value="1">آب شده</a-select-option>
<a-select-option :value="4">پولی</a-select-option>
<a-select-option :value="3">چک</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="مشتری"
:validate-status="err.customer_id ? 'error' : ''"
:help="err.customer_id"
>
<a-select
v-model:value="form.customer_id"
show-search
option-filter-prop="children"
:filter-option="filterOption"
placeholder="انتخاب کنید"
>
<a-select-option v-for="c in customers" :key="c.id" :value="c.id">
{{ `${c.name} - ${c.mobile}` }}
</a-select-option>
</a-select>
</a-form-item>
</div>
<!-- 1 آب شده -->
<div v-if="form.type == 1" class="grid-3">
<a-form-item label="وزن (گرم)">
<a-input-number v-model:value="form.original_value" class="w-full" :step="0.001" />
</a-form-item>
<a-form-item label="عیار">
<a-input-number v-model:value="form.ayar" class="w-full" />
</a-form-item>
<a-form-item label="نام آزمایشگاه">
<a-input v-model:value="form.lab" />
</a-form-item>
<a-form-item label="انگ">
<a-input v-model:value="form.ang" />
</a-form-item>
<a-form-item label="وزن نهایی اعمال شده">
<a-input :value="meltedFinalWeight" disabled />
</a-form-item>
</div>
<!-- 2 حواله -->
<div v-if="form.type == 2" class="grid-3">
<a-form-item label="وزن (گرم)">
<a-input-number v-model:value="form.value" class="w-full" :step="0.001" />
</a-form-item>
<a-form-item label="حواله به">
<a-input v-model:value="form.to" />
</a-form-item>
</div>
<!-- 3 چک -->
<div v-if="form.type == 3" class="grid-3">
<a-form-item label="مبلغ چک">
<a-input-number v-model:value="form.price" class="w-full" />
</a-form-item>
<a-form-item label="وزن (گرم)">
<a-input-number v-model:value="form.value" class="w-full" :step="0.001" />
</a-form-item>
<a-form-item label="نام صاحب حساب">
<a-input v-model:value="form.account_name" />
</a-form-item>
<a-form-item label="نام بانک">
<a-input v-model:value="form.bank_name" />
</a-form-item>
</div>
<!-- 4 پولی -->
<div v-if="form.type == 4" class="grid-3">
<a-form-item label="میزان پول">
<a-input-number v-model:value="form.price" class="w-full" />
</a-form-item>
<a-form-item label="مظنه">
<a-input-number v-model:value="form.price_per_gram" class="w-full" />
</a-form-item>
<a-form-item v-if="form.price_per_gram" label="وزن نهایی اعمال شده">
<a-input :value="cashFinalWeight" disabled />
</a-form-item>
</div>
<a-form-item label="توضیحات">
<a-textarea v-model:value="form.note" :auto-size="{ minRows: 3, maxRows: 5 }" />
</a-form-item>
<a-button type="primary" @click="handleSubmit">ثبت اطلاعات</a-button>
</a-card>
</a-form>
</a-spin>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import api from '../../api/client';
const route = useRoute();
const router = useRouter();
const id = route.params.id as string | undefined;
const loading = ref(false);
const customers = ref<{ id: string; name: string; mobile: string }[]>([]);
const form = reactive<Record<string, any>>({
type: 2, // legacy initialValue
customer_id: undefined,
value: undefined,
original_value: undefined,
ayar: undefined,
lab: '',
ang: '',
to: '',
price: undefined,
price_per_gram: undefined,
account_name: '',
bank_name: '',
note: '',
});
const err = reactive<Record<string, string>>({});
/**
* Live previews of the same conversions the server performs, so the operator
* sees the figure before filing. The SERVER is authoritative — these are for
* display only and are not sent.
*
* legacy: value = original_value * ayar / 750
*/
const meltedFinalWeight = computed(() => {
const raw = Number(form.original_value ?? 0);
const karat = Number(form.ayar ?? 0);
if (!raw || !karat) return '';
return ((raw * karat) / 750).toFixed(4);
});
/** legacy: value = price / (price_per_gram / 4.331802) */
const GRAMS_PER_MESGHAL = 4.331802;
const cashFinalWeight = computed(() => {
const price = Number(form.price ?? 0);
const perGram = Number(form.price_per_gram ?? 0);
if (!price || !perGram) return '';
return (price / (perGram / GRAMS_PER_MESGHAL)).toFixed(4);
});
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '')
.toLowerCase()
.includes(input.toLowerCase());
}
function validate(): boolean {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.customer_id) err.customer_id = 'مقدار را وارد نمایید';
return Object.keys(err).length === 0;
}
async function handleSubmit() {
if (!validate()) return;
try {
loading.value = true;
if (id) {
await api.put(`cash/${id}`, form);
} else {
await api.post('cash', form);
}
message.info('اطلاعات با موفقیت به ثبت رسید.');
router.push('/finical/list');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const { data } = await api.get('query/customer');
customers.value = data;
} catch (e) {
console.log(e);
}
if (id) {
try {
const { data } = await api.get(`cash/${id}`);
Object.assign(form, {
type: data.type ?? 2,
customer_id: data.customer?.id,
value: data.value !== null ? Number(data.value) : undefined,
original_value: data.original_value !== null ? Number(data.original_value) : undefined,
ayar: data.ayar ?? undefined,
lab: data.lab ?? '',
ang: data.ang ?? '',
to: data.to ?? '',
price: data.price !== null ? Number(data.price) : undefined,
price_per_gram:
data.price_per_gram !== null ? Number(data.price_per_gram) : undefined,
account_name: data.account_name ?? '',
bank_name: data.bank_name ?? '',
note: data.note ?? '',
});
} catch (e) {
console.log(e);
}
}
});
</script>
<style scoped>
.grid-3 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
</style>
+120
View File
@@ -0,0 +1,120 @@
<template>
<!--
Ported from legacy henza_admin/src/views/finical/list.vue — the financial
documents grid. Farsi copy and permission gates preserved, including the fact
that confirm and deny are the SAME endpoint gated on two different rids
(document.list.confirm / document.list.deny).
-->
<div id="page-user-list">
<a-spin :spinning="loading">
<a-card title="لیست اسناد مالی">
<XTable ref="tbl" :model="model">
<template #confirmed="{ text }">
<a-tag v-if="text === 1" color="green">تایید شده</a-tag>
<a-tag v-else>تایید نشده</a-tag>
</template>
<template #opt="{ record }">
<a-tooltip v-if="!record.confirmed && hasPermission('document.list.confirm')" title="تایید سند">
<a-popconfirm
placement="top"
ok-text="بله"
cancel-text="خیر"
@confirm="changeConfirme(record)"
>
<template #title><p>آیا سند تایید شود ؟</p></template>
<CheckOutlined class="cursor-pointer mx-1 text-success" />
</a-popconfirm>
</a-tooltip>
<a-tooltip v-if="record.confirmed && hasPermission('document.list.deny')" title="رد کردن سند">
<a-popconfirm
placement="top"
ok-text="بله"
cancel-text="خیر"
@confirm="changeConfirme(record)"
>
<template #title><p>آیا سند رد شود ؟</p></template>
<CloseOutlined class="cursor-pointer mx-1 text-danger" />
</a-popconfirm>
</a-tooltip>
<a-tooltip v-if="hasPermission('document.list.edit')" title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="toEdit(record.id)" />
</a-tooltip>
<a-tooltip v-if="hasPermission('document.list.remove')" title="حذف">
<a-popconfirm
placement="top"
ok-text="بله"
cancel-text="خیر"
@confirm="deleteCash(record)"
>
<template #title><p>آیا مطمئن به حذف سند مورد نظر هستید ؟</p></template>
<DeleteOutlined class="cursor-pointer mx-1 text-danger" />
</a-popconfirm>
</a-tooltip>
</template>
</XTable>
</a-card>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { message, Modal } from 'ant-design-vue';
import {
CheckOutlined,
CloseOutlined,
DeleteOutlined,
EditOutlined,
} from '@ant-design/icons-vue';
import XTable from '../../components/x-table/XTable.vue';
import { buildDocumentListModel } from './list_model';
import { useUsersStore } from '../../stores/users';
import api from '../../api/client';
const router = useRouter();
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildDocumentListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const loading = ref(false);
/** legacy changeConfirme(): one toggle endpoint for both confirm and deny */
async function changeConfirme(record: Record<string, any>) {
try {
loading.value = true;
await api.post(`cash/confirem/${record.id}`);
message.success('تغییرات با موفقیت ثبت شد');
tbl.value?.fetch();
} catch (e: any) {
console.log(e);
message.error(e?.response?.data?.message ?? 'خطا در ثبت تغییرات');
} finally {
loading.value = false;
}
}
async function deleteCash(record: Record<string, any>) {
try {
await api.delete(`cash/${record.id}`);
message.success('سند حذف شد');
tbl.value?.fetch();
} catch (e: any) {
// The backend refuses to delete a confirmed entry — it has already moved the
// customer's balance. Surface its Farsi explanation.
Modal.error({ title: e?.response?.data?.message ?? 'خطا در حذف سند', okText: 'بستن' });
}
}
function toEdit(id: string) {
// legacy list.vue:137 opened /admin/edit/findoc/{id}; the route is
// /edit/findoc/:id, not /finical/edit/:id.
router.push(`/edit/findoc/${id}`);
}
</script>
@@ -0,0 +1,38 @@
// Ported from legacy henza_admin/src/views/finical/list_model.js
//
// Column keys, Farsi titles and filter types preserved exactly — INCLUDING two
// legacy quirks: the numeric columns 'price' and 'value' are declared
// .filterDate(), and the commented-out 'title' column stays absent.
import XTbl, { Xtc } from '../../components/x-table/model';
export function buildDocumentListModel(): XTbl {
const tbl = new XTbl('admin/documents/list', 'لیست اسناد');
tbl.add(new Xtc('id', 'شماره سفارش').filterNumber());
tbl.add(new Xtc('type_str', 'نوع سند').filterText());
// legacy declares filterDate() on these two numeric columns — preserved
tbl.add(new Xtc('price', 'پول').filterDate().noSort());
tbl.add(new Xtc('value', 'وزن نهایی (گرم)').filterDate().noSort());
tbl.add(new Xtc('to', 'به').filterText().hidden());
tbl.add(new Xtc('customer.name', 'نام و نام خانوادگی مشتری').filterText());
tbl.add(new Xtc('note', 'توضیحات').filterText());
tbl.add(new Xtc('ayar', 'عیار').filterNumber().noSort());
tbl.add(new Xtc('lab', 'آزمایشگاه').filterText().noSort());
tbl.add(new Xtc('recive_date', 'تاریخ دریافت').filterDate().noSort());
tbl.add(new Xtc('account_name', 'نام حساب').filterText().hidden());
tbl.add(new Xtc('bank_name', 'بانک').filterText().hidden());
tbl.add(new Xtc('caj', 'تاریخ ثبت').filterDate().noSort());
tbl.add(new Xtc('uaj', 'بروزرسانی').hidden().filterDate().noSort());
tbl.add(
new Xtc('confirmed', 'وضعیت تایید سند').renderSlot('confirmed').noSort().filterSelect({
0: 'تایید نشده',
1: 'تایید شده',
}),
);
tbl.add(new Xtc('price_per_gram', 'مظنه').hidden().filterText().noSort());
tbl.add(new Xtc('original_value', 'وزن طلای داده شده').hidden().filterText().noSort());
tbl.add(new Xtc('ang', 'انگ').hidden().filterText().noSort());
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildDocumentListModel;
@@ -0,0 +1,19 @@
// Ported from legacy henza_admin/src/views/invoice/AddInvoice.js — the product
// picker grid shown at the top of the invoice form.
import XTbl, { Xtc } from '../../components/x-table/model';
export function buildInvoiceProductModel(): XTbl {
const tbl = new XTbl('admin/products/list', 'لیست محصولات');
tbl.add(new Xtc('id', 'کد').filterNumber());
tbl.add(new Xtc('ProductImage', 'تصویر محصول').renderSlot('image').noSort());
tbl.add(new Xtc('title', 'نام محصول').filterText());
tbl.add(new Xtc('min_gram', 'وزن').filterNumber().noSort());
tbl.add(new Xtc('brand.title', 'برند').filterText());
tbl.add(new Xtc('category.collection.title', 'دسته بندی').filterText());
tbl.add(new Xtc('product_code', 'کد محصول').filterText());
tbl.add(new Xtc('max_gram', 'حداکثر وزن').filterNumber().hidden().noSort());
tbl.add(new Xtc('op', 'عملیات').noSort().renderSlot('op'));
return tbl;
}
export default buildInvoiceProductModel;
@@ -0,0 +1,195 @@
<template>
<!--
Ported from legacy henza_admin/src/views/invoice/AddInvoice.vue — the branch
allocation ("فاکتور انتقال") form. Serves BOTH create and edit; EditInvoice
is a wrapper over it.
Pick products from the grid, choose the receiving branch, submit. Legacy
posts `{ branch_id, products: [productId...] }`.
-->
<a-spin :spinning="loading">
<a-card title="لیست محصولات">
<XTable ref="tblProducts" :model="productCols">
<template #image="{ record }">
<img
width="90px"
height="90px"
style="object-fit: scale-down"
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
/>
</template>
<template #op="{ record }">
<a-button size="small" type="primary" @click="addToCart(record)">افزودن</a-button>
</template>
</XTable>
<a-card v-if="items.length > 0" title="ثبت سفارش" class="mt-5">
<a-table
:columns="columns"
:data-source="items"
bordered
:pagination="false"
row-key="pid"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'delete'">
<CloseOutlined class="cursor-pointer" @click="cart.remove(record)" />
</template>
<template v-else-if="column.dataIndex === 'product'">
<div class="flex items-center">
<img :src="getImage(record.product)" width="50px" />
{{ record.product.title }}
</div>
</template>
<template v-else-if="column.dataIndex === 'weight'">
{{ record.product.min_gram ?? '-' }}
</template>
</template>
</a-table>
<a-form layout="vertical" class="mt-4" @submit.prevent="handleSubmit">
<div class="grid-3">
<a-form-item
label="نام شعبه"
:validate-status="err.branch_id ? 'error' : ''"
:help="err.branch_id"
>
<a-select
v-model:value="form.branch_id"
show-search
placeholder="انتخاب کنید"
:filter-option="filterOption"
>
<a-select-option v-for="b in branches" :key="b.id" :value="b.id">
{{ b.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="توضیحات">
<a-input v-model:value="form.note" />
</a-form-item>
</div>
<a-button type="primary" :disabled="items.length < 1" @click="handleSubmit">
ثبت تغییرات
</a-button>
</a-form>
</a-card>
</a-card>
</a-spin>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { CloseOutlined } from '@ant-design/icons-vue';
import XTable from '../../components/x-table/XTable.vue';
import { buildInvoiceProductModel } from './AddInvoice';
import { useInvoiceCartStore } from '../../stores/invoiceCart';
import api from '../../api/client';
const route = useRoute();
const router = useRouter();
const cart = useInvoiceCartStore();
const id = route.params.id as string | undefined;
const noImage = '/img/no-items.png';
const loading = ref(false);
const productCols = buildInvoiceProductModel();
const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null);
const branches = ref<{ id: string; name: string }[]>([]);
const form = reactive<Record<string, any>>({ branch_id: undefined, note: '' });
const err = reactive<Record<string, string>>({});
const items = computed(() => cart.items);
const columns = [
{ title: 'حذف', dataIndex: 'delete', width: 60, align: 'center' },
{ title: 'کد محصول', dataIndex: 'pid', align: 'center', width: 120 },
{ title: 'محصول', dataIndex: 'product' },
{ title: 'وزن', dataIndex: 'weight', align: 'center' },
];
function getImage(product: Record<string, any>) {
return product?.thumb?.full ?? product?.ProductImage?.[0]?.image ?? noImage;
}
function addToCart(record: Record<string, any>) {
cart.add({ pid: record.id, qty: 1, product: record as Record<string, unknown> });
message.info('اضافه شد');
}
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
}
async function handleSubmit() {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.branch_id) {
err.branch_id = 'مقدار را وارد نمایید';
return;
}
try {
loading.value = true;
const products = items.value.map((x) => x.pid);
if (id) {
await api.put(`branch/product/${id}`, { ...form, products });
} else {
await api.post('branch/product', { ...form, products });
}
message.info('عملیات با موفقیت انجام شد');
cart.clear();
router.push('/invoice/list');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
cart.init();
try {
const { data } = await api.get('branch/list');
branches.value = data;
} catch (e) {
console.log(e);
}
if (id) {
try {
const { data } = await api.get(`branch/product/${id}`);
form.branch_id = data.branch_id;
form.note = data.note ?? '';
// seed the basket with the allocation's current products
cart.setItems(
(data.products ?? []).map((p: any) => ({
pid: p.id,
qty: 1,
product: { id: p.id, title: p.title, min_gram: p.grams },
})),
);
} catch (e) {
console.log(e);
}
}
});
</script>
<style scoped>
.grid-3 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.mt-4 {
margin-top: 1rem;
}
.mt-5 {
margin-top: 1.25rem;
}
</style>
@@ -0,0 +1,14 @@
<template>
<!--
Ported from legacy henza_admin/src/views/invoice/EditInvoice.vue.
Legacy ships this as a 333-line near-copy of AddInvoice (the diff is the
extra GET that loads the existing allocation). Here AddInvoice reads the :id
route param and performs that load itself, so this is a thin wrapper.
-->
<AddInvoice />
</template>
<script setup lang="ts">
import AddInvoice from './AddInvoice.vue';
</script>
@@ -0,0 +1,18 @@
// Ported from legacy henza_admin/src/views/invoice/ListInvoice.js
import XTbl, { Xtc } from '../../components/x-table/model';
export function buildInvoiceListModel(): XTbl {
const tbl = new XTbl('admin/invoices/list', 'لیست فاکتورها');
// legacy keeps a commented-out duplicate of this column above it — not revived
tbl.add(new Xtc('id', 'ردیف').filterNumber());
tbl.add(new Xtc('name', 'نام مشتری').filterKey('branch.name').filterText());
tbl.add(new Xtc('sum_weight', 'جمع کل سفارش').filterNumber().noSort());
tbl.add(new Xtc('count_item', 'تعداد آیتم ها').filterNumber().noSort());
tbl.add(new Xtc('caj', 'تاریخ ثبت').filterKey('createdAt').filterDate().noSort());
tbl.add(new Xtc('allocator_user.name', 'ثبت کننده').renderSlot('user').noSort());
tbl.add(new Xtc('note', 'توضیحات').filterText().noSort());
tbl.add(new Xtc('op', 'عملیات').noSort().renderSlot('actions'));
return tbl;
}
export default buildInvoiceListModel;
@@ -0,0 +1,117 @@
<template>
<!--
Ported from legacy henza_admin/src/views/invoice/ListInvoice.vue — the
branch-allocation grid.
-->
<div id="page-user-list">
<a-card title="لیست فاکتورها">
<XTable ref="tbl" :model="model">
<template #user="{ record }">
{{ record.allocator_user?.name ?? '-' }}
</template>
<template #actions="{ record }">
<div class="flex gap-2">
<a-tooltip title="مشاهده">
<EyeOutlined class="cursor-pointer mx-1 text-success" @click="view(record)" />
</a-tooltip>
<a-tooltip title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="toEdit(record.id)" />
</a-tooltip>
<a-tooltip title="حذف">
<a-popconfirm
placement="top"
ok-text="بله"
cancel-text="خیر"
@confirm="confirmDelete(record.id)"
>
<template #title><p>آیا مطمئن به حذف فاکتور مورد نظر هستید ؟</p></template>
<DeleteOutlined class="cursor-pointer mx-1 text-danger" />
</a-popconfirm>
</a-tooltip>
</div>
</template>
</XTable>
<a-modal v-model:open="visible" title="مشاهده فاکتور" width="70%">
<div v-if="tempRow">
<a-descriptions bordered size="small" :column="2">
<a-descriptions-item label="شعبه">{{ tempRow.name }}</a-descriptions-item>
<a-descriptions-item label="ثبت کننده">
{{ tempRow.allocator_user?.name ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="تاریخ ثبت">{{ jdate(tempRow.caj) }}</a-descriptions-item>
<a-descriptions-item label="تعداد آیتم">{{ tempRow.count_item }}</a-descriptions-item>
<a-descriptions-item label="جمع وزن">{{ tempRow.sum_weight }}</a-descriptions-item>
<a-descriptions-item label="توضیحات">{{ tempRow.note ?? '-' }}</a-descriptions-item>
</a-descriptions>
<a-table
class="mt-4"
size="small"
:data-source="tempRow.products"
:columns="productColumns"
:pagination="false"
row-key="id"
/>
</div>
<a-empty v-else />
<template #footer>
<a-button @click="visible = false">بستن</a-button>
</template>
</a-modal>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined, EyeOutlined } from '@ant-design/icons-vue';
import XTable from '../../components/x-table/XTable.vue';
import { buildInvoiceListModel } from './ListInvoice';
import { jdate } from '../../utils/filters';
import api from '../../api/client';
const router = useRouter();
const model = buildInvoiceListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const visible = ref(false);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const productColumns = [
{ title: 'کد محصول', dataIndex: 'product_code' },
{ title: 'نام محصول', dataIndex: 'title' },
{ title: 'وزن', dataIndex: 'grams' },
];
async function view(record: Record<string, any>) {
const { data } = await api.get(`branch/product/${record.id}`);
tempRow.value = data;
visible.value = true;
}
function toEdit(id: string) {
router.push(`/invoice/edit/${id}`);
}
async function confirmDelete(id: string) {
try {
await api.delete(`branch/product/${id}`);
message.success('فاکتور حذف شد');
tbl.value?.fetch();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در حذف فاکتور');
}
}
</script>
<style scoped>
.mt-4 {
margin-top: 1rem;
}
</style>
+372
View File
@@ -0,0 +1,372 @@
<template>
<!--
Ported from legacy henza_admin/src/views/manage/Const.vue (977 lines) — the
"مدیریت ثابت ها" screen, which manages several unrelated things in one card:
brands, tags, the mobile home slider, collections ("سینی") and site config.
Reorganised into tabs rather than one long scroll, because the legacy page is
a single card with five stacked sections and no way to navigate between them.
Every section keeps its own Farsi labels and its own rid gate.
The slider section is IMAGE-based in legacy (an uploader per slide), and is
one here too — each slide uses the shared ImageUploadInput, which posts to the
ported `file/create` endpoint and stores the returned URL.
-->
<a-card v-if="hasPermission('config.statics')" title="مدیریت ثابت ها">
<a-spin :spinning="loading">
<a-tabs>
<!-- برندها -->
<a-tab-pane key="brands" tab="برند ها">
<div class="row">
<a-input
v-model:value="newBrand"
placeholder="نام برند"
style="max-width: 260px"
@pressEnter="addBrand"
/>
<a-button
v-if="hasPermission('config.statics.brand.create')"
type="primary"
@click="addBrand"
>
افزودن
</a-button>
</div>
<a-table
class="mt-4"
size="small"
:data-source="brands"
:columns="brandColumns"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'op'">
<a-tooltip v-if="hasPermission('config.statics.brand.edit')" title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="editBrand(record)" />
</a-tooltip>
<a-tooltip v-if="hasPermission('config.statics.brand.remove')" title="حذف">
<a-popconfirm
ok-text="بله"
cancel-text="خیر"
title="آیا مطمئن هستید؟"
@confirm="removeBrand(record.id)"
>
<DeleteOutlined class="cursor-pointer mx-1 text-danger" />
</a-popconfirm>
</a-tooltip>
</template>
</template>
</a-table>
</a-tab-pane>
<!-- برچسب‌ها -->
<a-tab-pane key="tags" tab="برچسب ها">
<div class="row">
<a-input
v-model:value="tagsInputValue"
placeholder="برچسب"
style="max-width: 260px"
@pressEnter="addTag"
/>
<a-button
v-if="hasPermission('config.statics.tags.create')"
type="primary"
@click="addTag"
>
افزودن
</a-button>
</div>
<div class="mt-4 tags">
<a-tag
v-for="(t, i) in tags"
:key="i"
:closable="hasPermission('config.statics.tags.remove')"
@close="removeTag(i)"
>
{{ t }}
</a-tag>
<a-empty v-if="tags.length === 0" description="برچسبی ثبت نشده است" />
</div>
<a-button class="mt-4" type="primary" @click="saveTags">ذخیره برچسب ها</a-button>
</a-tab-pane>
<!-- اسلایدر موبایل -->
<a-tab-pane key="slider" tab="اسلایدر موبایل">
<div v-for="(s, index) in sliders" :key="index" class="slide">
<h6 class="mobile-c">اسلاید {{ index + 1 }}</h6>
<div class="row">
<ImageUploadInput v-model="s.image" purpose="slider" placeholder="تصویر اسلاید" />
<a-input v-model:value="s.link" placeholder="لینک" />
<a-button danger @click="sliders.splice(index, 1)">حذف</a-button>
</div>
</div>
<div class="row mt-4">
<a-button @click="sliders.push({ image: '', link: '' })">افزودن اسلاید</a-button>
<a-button type="primary" @click="saveSliders">ذخیره اسلایدر</a-button>
</div>
</a-tab-pane>
<!-- سینی‌ها -->
<a-tab-pane key="collections" tab="سینی ها">
<div class="row">
<a-input
v-model:value="newCollection"
placeholder="نام سینی"
style="max-width: 260px"
@pressEnter="addCollection"
/>
<a-button type="primary" @click="addCollection">
{{ collectionIsEdit ? 'ویرایش' : 'ثبت' }} سینی
</a-button>
<a-button v-if="collectionIsEdit" @click="cancelCollectionEdit">انصراف</a-button>
</div>
<a-table
class="mt-4"
size="small"
:data-source="collections"
:columns="collectionColumns"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'op'">
<a-tooltip title="ویرایش">
<EditOutlined
class="cursor-pointer mx-1 text-primary"
@click="editCollection(record)"
/>
</a-tooltip>
<a-tooltip title="حذف">
<a-popconfirm
ok-text="بله"
cancel-text="خیر"
title="آیا مطمئن هستید؟"
@confirm="removeCollection(record.id)"
>
<DeleteOutlined class="cursor-pointer mx-1 text-danger" />
</a-popconfirm>
</a-tooltip>
</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</a-spin>
<a-modal v-model:open="brandModal" title="ویرایش برند" @ok="saveBrandEdit">
<a-input v-model:value="brandEditTitle" />
</a-modal>
</a-card>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import { useUsersStore } from '../../stores/users';
import api from '../../api/client';
import ImageUploadInput from '../../components/upload/ImageUploadInput.vue';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const loading = ref(false);
// ── brands
const brands = ref<{ id: string; title: string }[]>([]);
const newBrand = ref('');
const brandModal = ref(false);
const brandEditTitle = ref('');
const brandEditId = ref<string | undefined>(undefined);
const brandColumns = [
{ title: 'عنوان', dataIndex: 'title' },
{ title: 'عملیات', dataIndex: 'op' },
];
// ── tags (stored as one setting key, as legacy did with __list__tags)
const tags = ref<string[]>([]);
const tagsInputValue = ref('');
// ── mobile slider (legacy key: mobile_main_slider)
const sliders = ref<{ image: string; link: string }[]>([]);
// ── collections
const collections = ref<{ id: string; title: string }[]>([]);
const newCollection = ref('');
const collectionIsEdit = ref(false);
const collectionEditId = ref<string | undefined>(undefined);
const collectionColumns = [
{ title: 'عنوان', dataIndex: 'title' },
{ title: 'عملیات', dataIndex: 'op' },
];
// ── brands
async function loadBrands() {
const { data } = await api.get('brand');
brands.value = data;
}
async function addBrand() {
if (!newBrand.value.trim()) return;
try {
await api.post('brand/create', { title: newBrand.value.trim() });
newBrand.value = '';
await loadBrands();
message.success('برند اضافه شد');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در افزودن برند');
}
}
function editBrand(record: { id: string; title: string }) {
brandEditId.value = record.id;
brandEditTitle.value = record.title;
brandModal.value = true;
}
async function saveBrandEdit() {
try {
await api.put(`brand/${brandEditId.value}`, { title: brandEditTitle.value });
brandModal.value = false;
await loadBrands();
message.success('تغییرات ثبت شد');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ویرایش');
}
}
async function removeBrand(id: string) {
try {
await api.delete(`brand/${id}`);
await loadBrands();
message.success('برند حذف شد');
} catch (e: any) {
// refused while products reference it
Modal.error({ title: e?.response?.data?.message ?? 'خطا در حذف برند', okText: 'بستن' });
}
}
// ── tags
function addTag() {
const v = tagsInputValue.value.trim();
if (!v) return;
if (!tags.value.includes(v)) tags.value.push(v);
tagsInputValue.value = '';
}
function removeTag(index: number) {
tags.value.splice(index, 1);
}
async function saveTags() {
try {
await api.put('const/__list__tags', { val: tags.value });
message.success('برچسب ها ذخیره شد');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ذخیره');
}
}
// ── slider
async function saveSliders() {
try {
await api.put('const/mobile_main_slider', { val: sliders.value });
message.success('اسلایدر ذخیره شد');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ذخیره');
}
}
// ── collections
async function loadCollections() {
const { data } = await api.post('ciny', { page: 1, limit: 200 });
collections.value = data.rows ?? data;
}
async function addCollection() {
if (!newCollection.value.trim()) return;
try {
if (collectionIsEdit.value) {
await api.put(`ciny/${collectionEditId.value}`, { title: newCollection.value.trim() });
} else {
await api.post('ciny/create', { title: newCollection.value.trim() });
}
cancelCollectionEdit();
await loadCollections();
message.success('عملیات انجام شد');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا');
}
}
function editCollection(record: { id: string; title: string }) {
collectionIsEdit.value = true;
collectionEditId.value = record.id;
newCollection.value = record.title;
}
function cancelCollectionEdit() {
collectionIsEdit.value = false;
collectionEditId.value = undefined;
newCollection.value = '';
}
async function removeCollection(id: string) {
try {
await api.delete(`ciny/${id}`);
await loadCollections();
message.success('حذف شد');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در حذف');
}
}
onMounted(async () => {
try {
loading.value = true;
await Promise.all([loadBrands(), loadCollections()]);
const [t, s] = await Promise.all([
api.get('const/__list__tags'),
api.get('const/mobile_main_slider'),
]);
tags.value = Array.isArray(t.data) ? t.data : [];
sliders.value = Array.isArray(s.data) ? s.data : [];
} catch (e) {
console.log(e);
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.row {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.slide {
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 0.75rem;
}
.mobile-c {
margin: 0 0 0.5rem;
}
.mt-4 {
margin-top: 1rem;
}
</style>
@@ -0,0 +1,42 @@
<template>
<!--
Ported from legacy henza_admin/src/views/manage/report.vue — a shortcut board
of report links, read from the `dashboard.links` setting.
-->
<a-card title="مدیریت گزارشات">
<a-empty v-if="links.length === 0" description="هنوز لینکی تعریف نشده است" />
<div v-else class="links">
<a v-for="(l, i) in links" :key="i" :href="l.url" target="_blank" class="link-card">
{{ l.title }}
</a>
</div>
</a-card>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import api from '../../api/client';
const links = ref<{ title: string; url: string }[]>([]);
onMounted(async () => {
try {
const { data } = await api.get('dashboard/link');
links.value = Array.isArray(data) ? data : [];
} catch (e) {
console.log(e);
}
});
</script>
<style scoped>
.links { display: flex; flex-wrap: wrap; gap: 1rem; }
.link-card {
display: block;
padding: 1rem 1.5rem;
border: 1px solid #f0f0f0;
border-radius: 6px;
color: #1c2d56;
}
.link-card:hover { border-color: #1c2d56; }
</style>
@@ -0,0 +1,160 @@
<template>
<!--
Ported from legacy henza_admin/src/views/manage/wallet.vue — the wallet
settings screen. Two tabs, each writing one settings key.
Legacy quirk preserved: the "تاریخ تولد" tab is declared FIRST in the markup
but carries key="2", while "پس از خرید" carries key="1" and is the default
active tab — so the visually-second tab opens first.
-->
<div>
<a-card v-if="hasPermission('config.wallet')" title="مدیریت کیف پول">
<a-tabs default-active-key="1">
<a-tab-pane key="2" tab="تاریخ تولد" force-render>
<a-spin :spinning="loading" tip="لطفا کمی تامل کنید">
<a-form layout="vertical" @submit.prevent="setWallet2">
<div class="grid-2">
<a-form-item
label="انقضا (روز)"
:validate-status="err2.birthday_expire ? 'error' : ''"
:help="err2.birthday_expire"
>
<a-input-number v-model:value="form2.birthday_expire" class="w-full" :min="0" />
</a-form-item>
<a-form-item
label="میزان (تومان)"
:validate-status="err2.birthday_credit ? 'error' : ''"
:help="err2.birthday_credit"
>
<a-input-number v-model:value="form2.birthday_credit" class="w-full" :min="0" />
</a-form-item>
</div>
<a-button type="primary" class="mb-4 mt-4 mr-4" @click="setWallet2">ارسال</a-button>
</a-form>
</a-spin>
</a-tab-pane>
<a-tab-pane key="1" tab="پس از خرید" force-render>
<a-spin :spinning="loading" tip="لطفا کمی تامل کنید">
<a-form layout="vertical" @submit.prevent="setWallet1">
<div class="grid-2">
<a-form-item
label="انقضا (روز)"
:validate-status="err1.expire ? 'error' : ''"
:help="err1.expire"
>
<a-input-number v-model:value="form1.expire" class="w-full" :min="0" />
</a-form-item>
<a-form-item
label="درصد"
:validate-status="err1.percent ? 'error' : ''"
:help="err1.percent"
>
<a-input-number
v-model:value="form1.percent"
class="w-full"
:min="0"
:max="100"
:formatter="(v: number | string) => `${v}%`"
:parser="(v: string) => v.replace('%', '')"
/>
</a-form-item>
</div>
<a-button type="primary" class="mb-4 mt-4 mr-4" @click="setWallet1">ارسال</a-button>
</a-form>
</a-spin>
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import { useUsersStore } from '../../stores/users';
import api from '../../api/client';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const loading = ref(false);
const form1 = reactive<Record<string, any>>({ expire: undefined, percent: undefined });
const form2 = reactive<Record<string, any>>({
birthday_expire: undefined,
birthday_credit: undefined,
});
const err1 = reactive<Record<string, string>>({});
const err2 = reactive<Record<string, string>>({});
const REQUIRED = 'لطفا مقدار را وارد کنید';
async function setWallet1() {
Object.keys(err1).forEach((k) => delete err1[k]);
if (form1.expire === undefined || form1.expire === null) err1.expire = REQUIRED;
if (form1.percent === undefined || form1.percent === null) err1.percent = REQUIRED;
if (Object.keys(err1).length) return;
try {
loading.value = true;
await api.post('wallet/config', { expire: form1.expire, percent: form1.percent });
message.success('تغییرات ثبت شد');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت تغییرات');
} finally {
loading.value = false;
}
}
async function setWallet2() {
Object.keys(err2).forEach((k) => delete err2[k]);
if (form2.birthday_expire === undefined || form2.birthday_expire === null) {
err2.birthday_expire = REQUIRED;
}
if (form2.birthday_credit === undefined || form2.birthday_credit === null) {
err2.birthday_credit = REQUIRED;
}
if (Object.keys(err2).length) return;
try {
loading.value = true;
await api.post('wallet/birthday/config', {
birthday_expire: form2.birthday_expire,
birthday_credit: form2.birthday_credit,
});
message.success('تغییرات ثبت شد');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت تغییرات');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const [c1, c2] = await Promise.all([
api.get('wallet/config'),
api.get('wallet/birthday/config'),
]);
Object.assign(form1, c1.data ?? {});
Object.assign(form2, c2.data ?? {});
} catch (e) {
console.log(e);
}
});
</script>
<style scoped>
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.mt-4 {
margin-top: 1rem;
}
</style>
@@ -0,0 +1,16 @@
// Ported from legacy henza_admin/src/views/newsletters/newsletter.js
//
// Legacy's url is 'fashion/table' while the view's own delete call posts to
// '/ddf' — two unrelated-looking endpoint names for one screen. Both map to the
// newsletter list here.
import XTbl, { Xtc } from '../../components/x-table/model';
export function buildNewsletterModel(): XTbl {
const tbl = new XTbl('admin/misc/newsletter/list', 'لیست خبرنامه');
tbl.add(new Xtc('name', 'نام').filterKey('email').filterText());
tbl.add(new Xtc('mobile', 'شماره موبایل').filterNumber());
tbl.add(new Xtc('op', 'عملیات').noSort().renderSlot('actions'));
return tbl;
}
export default buildNewsletterModel;
@@ -0,0 +1,26 @@
<template>
<!--
Ported from legacy henza_admin/src/views/newsletters/newsletters.vue.
The delete action is COMMENTED OUT in legacy and stays absent.
-->
<a-card title="لیست خبرنامه">
<XTable ref="newsletter" :model="model">
<template #actions="{ record }">
<span class="muted">{{ record.email ?? '-' }}</span>
</template>
</XTable>
</a-card>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import XTable from '../../components/x-table/XTable.vue';
import { buildNewsletterModel } from './newsletter';
const model = buildNewsletterModel();
const newsletter = ref<{ fetch: () => Promise<void> } | null>(null);
</script>
<style scoped>
.muted { color: #888; }
</style>
@@ -0,0 +1,61 @@
<template>
<!-- Ported from legacy henza_admin/src/views/notify/create.vue. -->
<a-spin :spinning="loading">
<a-card title="ارسال نوتیفیکیشن">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<div class="grid-2">
<a-form-item label="عنوان" :validate-status="err.title ? 'error' : ''" :help="err.title">
<a-input v-model:value="form.title" />
</a-form-item>
<a-form-item label="نوع کاربر">
<a-select v-model:value="form.type">
<a-select-option :value="0">همه مشتریان</a-select-option>
<a-select-option :value="2">مشتریان عمده</a-select-option>
<a-select-option :value="1">کارشناسان فروش</a-select-option>
</a-select>
</a-form-item>
</div>
<a-form-item label="متن پیام" :validate-status="err.text ? 'error' : ''" :help="err.text">
<a-textarea v-model:value="form.text" :auto-size="{ minRows: 4, maxRows: 8 }" />
</a-form-item>
<a-button type="primary" @click="handleSubmit">ارسال</a-button>
</a-form>
</a-card>
</a-spin>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import api from '../../api/client';
const loading = ref(false);
const form = reactive<Record<string, any>>({ title: '', text: '', type: 0 });
const err = reactive<Record<string, string>>({});
async function handleSubmit() {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.title) err.title = 'عنوان الزامی است';
if (!form.text) err.text = 'متن پیام الزامی است';
if (Object.keys(err).length) return;
try {
loading.value = true;
const { data } = await api.post('notify', form);
message.success('نوتیفیکیشن ارسال شد');
form.title = '';
form.text = '';
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ارسال');
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; }
</style>
@@ -0,0 +1,15 @@
// Ported from legacy henza_admin/src/views/notify/notifyTbl.js
import XTbl, { Xtc } from '../../components/x-table/model';
export function buildNotifyModel(): XTbl {
const tbl = new XTbl('admin/misc/notify/list', 'لیست نوتیفیکیشن های دریافتی');
tbl.add(new Xtc('id', 'شماره'));
tbl.add(new Xtc('title', 'عنوان'));
// legacy column key is 'note' but the payload carries the body as `text`
tbl.add(new Xtc('text', 'متن').noSort());
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
tbl.noFilters();
return tbl;
}
export default buildNotifyModel;
@@ -0,0 +1,93 @@
<template>
<!--
Ported from legacy henza_admin/src/views/notify/user.vue — the received
notifications list, with a read-state modal.
-->
<a-card title="لیست نوتیفیکیشن های دریافتی">
<XTable ref="tbl" :model="model">
<template #opt="{ record }">
<a-tooltip title="مشاهده">
<EyeOutlined class="cursor-pointer mx-1 text-primary" @click="view(record)" />
</a-tooltip>
</template>
</XTable>
<a-modal v-model:open="visible" title="مشاهده نوتیفیکیشن" width="60%">
<div v-if="tempRow">
<a-descriptions bordered size="small" :column="1">
<a-descriptions-item label="عنوان">{{ tempRow.title }}</a-descriptions-item>
<a-descriptions-item label="متن">{{ tempRow.body }}</a-descriptions-item>
<a-descriptions-item label="ارسال کننده">{{ tempRow.sender ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="خوانده شده">
{{ tempRow.read_count }} از {{ tempRow.total_count }}
</a-descriptions-item>
</a-descriptions>
<a-table
class="mt-4"
size="small"
:data-source="tempRow.recipients"
:columns="recipientColumns"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'is_read'">
<a-tag v-if="record.is_read" color="green">خوانده شده</a-tag>
<a-button v-else size="small" @click="markRead(record)">علامت‌گذاری</a-button>
</template>
<template v-else-if="column.dataIndex === 'customer'">
{{ record.customer.name }}
</template>
</template>
</a-table>
</div>
<a-empty v-else />
<template #footer>
<a-button @click="visible = false">بستن</a-button>
</template>
</a-modal>
</a-card>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { EyeOutlined } from '@ant-design/icons-vue';
import XTable from '../../components/x-table/XTable.vue';
import { buildNotifyModel } from './notifyTbl';
import api from '../../api/client';
const model = buildNotifyModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const visible = ref(false);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const recipientColumns = [
{ title: 'مشتری', dataIndex: 'customer' },
{ title: 'وضعیت', dataIndex: 'is_read' },
];
async function view(record: Record<string, any>) {
const { data } = await api.get(`notify/${record.id}`);
tempRow.value = data;
visible.value = true;
}
/** legacy GET notify/read/{id} */
async function markRead(recipient: Record<string, any>) {
try {
await api.get(`notify/read/${recipient.id}`);
recipient.is_read = true;
if (tempRow.value) tempRow.value.read_count += 1;
message.success('علامت‌گذاری شد');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا');
}
}
</script>
<style scoped>
.mt-4 { margin-top: 1rem; }
</style>
@@ -0,0 +1,228 @@
<template>
<!--
Ported from legacy henza_admin/src/views/offersIgnore/offersIgnore-add-edit.vue.
Pick an exclusion type, then tick the specific products / categories /
customers / brands to exclude from the automatic discount. The ticked set is
the complete selection for that type — legacy posts `{type, item_id: [...]}`
and the server replaces that type's rows with it.
The card title in legacy is "تعریف تخفیف" (the same string the promotion form
uses) even though this screen defines EXCLUSIONS — preserved, not corrected.
-->
<div id="page-user-view">
<div id="user-data">
<a-spin :spinning="loading">
<a-form layout="vertical">
<a-card title="تعریف تخفیف" class="mb-base mb-10">
<div class="grid-4">
<a-form-item label="نوع استثناء">
<a-select v-model:value="type" @change="changeTable">
<a-select-option v-for="(label, code) in types" :key="code" :value="Number(code)">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
</div>
<template v-if="type === 1">
<h6>تعداد {{ selectedRow.length }} محصول انتخاب شده است</h6>
<XTable ref="tblProducts" v-model="selectedRow" :model="productCols" :has-selection="true">
<template #image="{ record }">
<img
width="90px"
height="90px"
style="object-fit: scale-down"
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
/>
</template>
</XTable>
<h6>تعداد {{ selectedRow.length }} محصول انتخاب شده است</h6>
</template>
<template v-if="type === 2">
<h6>تعداد {{ selectedRow.length }} دسته بندی انتخاب شده است</h6>
<XTable ref="tblCategory" v-model="selectedRow" :model="categoryCols" :has-selection="true" />
<h6>تعداد {{ selectedRow.length }} دسته بندی انتخاب شده است</h6>
</template>
<template v-if="type === 3">
<h6>تعداد {{ selectedRow.length }} مشتری انتخاب شده است</h6>
<XTable ref="tblCustomer" v-model="selectedRow" :model="customerCols" :has-selection="true" />
<h6>تعداد {{ selectedRow.length }} مشتری انتخاب شده است</h6>
</template>
<template v-if="type === 4">
<h6>تعداد {{ selectedRow.length }} برند انتخاب شده است</h6>
<XTable ref="tblBrand" v-model="selectedRow" :model="brandCols" :has-selection="true" />
<h6>تعداد {{ selectedRow.length }} برند انتخاب شده است</h6>
</template>
<div class="mt-5">
<a-button type="primary" class="mr-4" @click="handleSubmit">ثبت</a-button>
</div>
</a-card>
</a-form>
</a-spin>
<a-spin :spinning="deleteLoading">
<a-card title="حذف نوع استثناء">
<div class="grid-4 items-end">
<a-form-item label="نوع">
<a-select v-model:value="deleteType">
<a-select-option v-for="(label, code) in types" :key="code" :value="Number(code)">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-popconfirm
title="آیا مطمئن هستید؟"
ok-text="بله"
cancel-text="خیر"
@confirm="deleteIgnoreType"
>
<a-button danger>حذف</a-button>
</a-popconfirm>
</div>
</a-card>
</a-spin>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import XTable from '../../components/x-table/XTable.vue';
import XTbl, { Xtc } from '../../components/x-table/model';
import api from '../../api/client';
const noImage = '/img/no-items.png';
const loading = ref(false);
const deleteLoading = ref(false);
/** Legacy's exclusion types reuse the promotion scope codes. */
const types: Record<number, string> = {
1: 'محصولات',
2: 'دسته بندی',
3: 'مشتری',
4: 'برند',
};
const type = ref(1);
const deleteType = ref(1);
const selectedRow = ref<Record<string, any>[]>([]);
const ignoreList = ref<{ id: string; type: number; item_id: string }[]>([]);
// One picker table per type.
const productCols = buildProducts();
const categoryCols = buildCategories();
const customerCols = buildCustomers();
const brandCols = buildBrands();
function buildProducts(): XTbl {
const t = new XTbl('admin/products/list', 'محصولات');
t.add(new Xtc('id', 'کد').filterNumber());
t.add(new Xtc('ProductImage', 'تصویر').renderSlot('image').noSort());
t.add(new Xtc('title', 'نام محصول').filterText());
t.add(new Xtc('product_code', 'کد محصول').filterText());
return t;
}
function buildCategories(): XTbl {
const t = new XTbl('admin/categories/list', 'دسته بندی');
t.add(new Xtc('id', 'کد').filterNumber());
t.add(new Xtc('title', 'عنوان دسته').filterText());
t.add(new Xtc('type_str', 'نوع نمایش').noSort());
return t;
}
function buildCustomers(): XTbl {
const t = new XTbl('admin/customers-omde/list', 'مشتریان');
t.add(new Xtc('id', 'کد').filterNumber());
t.add(new Xtc('name', 'نام و نام خانوادگی').filterText());
t.add(new Xtc('mobile', 'موبایل').filterText().noSort());
return t;
}
function buildBrands(): XTbl {
const t = new XTbl('admin/products/brands-table', 'برندها');
t.add(new Xtc('id', 'کد').filterNumber());
t.add(new Xtc('title', 'عنوان برند').filterText());
return t;
}
/** legacy items_id(type): the already-excluded ids for a type */
function itemsId(t: number) {
return ignoreList.value.filter((i) => i.type === t).map((x) => x.item_id);
}
/** legacy changeTable(): reload the tick state when the type changes */
function changeTable(t: number) {
const ids = itemsId(t);
selectedRow.value = ids.map((id) => ({ id }));
}
async function handleSubmit() {
try {
loading.value = true;
await api.post('ignore', {
type: type.value,
item_id: selectedRow.value.map((r) => r.id),
});
message.success('عملیات با موفقیت انجام شد');
await getData();
changeTable(type.value);
} catch (e: any) {
console.log(e);
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
async function deleteIgnoreType() {
try {
deleteLoading.value = true;
await api.delete(`ignore/${deleteType.value}`);
message.success('حذف شد');
await getData();
changeTable(type.value);
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در حذف');
} finally {
deleteLoading.value = false;
}
}
async function getData() {
try {
loading.value = true;
const { data } = await api.get('ignore');
ignoreList.value = data;
// legacy seeds the selection from type 1 on load
selectedRow.value = itemsId(1).map((id) => ({ id }));
} catch (e) {
console.log(e);
message.error('خطا در دریافت اطلاعات');
} finally {
loading.value = false;
}
}
onMounted(getData);
</script>
<style scoped>
.grid-4 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.items-end {
align-items: end;
}
.mb-10 {
margin-bottom: 2.5rem;
}
.mt-5 {
margin-top: 1.25rem;
}
</style>
+262
View File
@@ -0,0 +1,262 @@
<template>
<!--
Ported from legacy henza_admin/src/views/order/List.vue.
Farsi copy, permission gates, slot names, modal structure and the status
timeline are preserved. Vuesax's vx-card becomes a-card; the Vue 2 slot
syntax becomes Vue 3 named slots.
-->
<div id="page-user-list">
<a-card :title="is_branch ? 'لیست سفارش' : 'لیست سفارش های تک فروشی'">
<XTable ref="tbl" :model="model">
<template #opt="{ record }">
<div class="flex">
<TooltipBtn
v-if="hasPermission('order.list.view')"
icon-class="w-5 h-5 text-primary"
class="mr-3"
icon="ListIcon"
title="مشاهده سفارش"
@click="viewOrder(record)"
/>
<TooltipBtn
v-if="hasPermission('order.list.pdf')"
icon-class="w-5 h-5 text-danger"
class="mr-3"
icon="FileIcon"
title="دانلود pdf"
@click="viewPdf(record.id)"
/>
</div>
</template>
<template #items_len="{ text }">
<div v-if="text">{{ text.length }}</div>
</template>
<template #user="{ text, record }">
<div v-if="text">{{ text.name }}</div>
<div v-else>{{ record.customer.fname + ' ' + record.customer.lname }}</div>
</template>
<template #total_price="{ text }">
<div v-if="text">{{ priceUnit(text) }}</div>
</template>
<template #sts="{ text, record }">
<a v-if="hasPermission('order.list.sts')" href="" @click.prevent="showSts(record)">
{{ text }}
</a>
<span v-else>{{ text }}</span>
</template>
</XTable>
<!-- وضعیت سفارش -->
<a-modal v-model:open="visibleSts" title="وضعیت سفارش">
<a-timeline v-if="tempRow">
<a-timeline-item
v-for="(item, index) in tempRow.activity"
:key="index"
:color="getColorSts(item)"
>
<div class="flex flex-col">
<div>{{ item.title }}</div>
<div v-if="item.proceed">
<div class="flex">
<div class="flex-1">
<a-tag>{{ formatDate(item.uaj) }}</a-tag>
</div>
<a-popconfirm
v-if="index > 0 && hasPermission('order.list.sts.remove')"
ok-text="بله"
cancel-text="بی خیال"
@confirm="removeSts(item)"
>
<template #title><p>آیا مطمئن به حذف وضعیت مورد نظر هستید ؟</p></template>
<a-button size="small" danger>حذف وضعیت</a-button>
</a-popconfirm>
</div>
</div>
<div
v-if="!item.proceed && hasPermission('order.list.sts.submit')"
class="flex items-center"
>
<a-input v-model:value="item.note" placeholder="یادداشت" />
<a-button type="text" size="small" @click="setSts(item)">ثبت وضعیت</a-button>
</div>
<div v-if="item.proceed">{{ item.note }}</div>
</div>
</a-timeline-item>
</a-timeline>
<template #footer>
<a-button @click="visibleSts = false">باشه</a-button>
</template>
</a-modal>
<!-- مشاهده سفارش -->
<a-modal v-model:open="visibleList" title="مشاهده سفارش" width="80%">
<div v-if="tempRow">
<a-descriptions>
<a-descriptions-item label="مشتری">
{{ tempRow.customer.fname }} {{ tempRow.customer.lname }}
</a-descriptions-item>
<a-descriptions-item label="تلفن همراه">
{{ tempRow.customer.phone }}
</a-descriptions-item>
<a-descriptions-item label="تاریخ ثبت">{{ formatDate(tempRow.caj) }}</a-descriptions-item>
<a-descriptions-item label="وضعیت">{{ tempRow.sts_str }}</a-descriptions-item>
</a-descriptions>
<a-card title="آیتم های سفارش">
<div v-for="(item, index) in tempRow.items" :key="index">
<a-descriptions>
<a-descriptions-item label="نام محصول">
<a-tag color="blue">{{ item.title }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="عنوان تنوع">{{ item.variant_title }}</a-descriptions-item>
<a-descriptions-item label="وزن نهایی">
<a-input-number
v-if="editMode && hasPermission('order.list.view.edit')"
v-model:value="item.grams"
class="w-full mt-0"
/>
<a-tag v-else color="red">{{ item.grams }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="تعداد">
<a-input-number
v-if="editMode && hasPermission('order.list.view.edit')"
v-model:value="item.qty"
/>
<a-tag v-else color="orange">{{ item.qty }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="توضیحات">
<div>{{ item.note }}</div>
</a-descriptions-item>
</a-descriptions>
<a-divider v-if="index < tempRow.items.length - 1" />
</div>
</a-card>
</div>
<a-empty v-else />
<template #footer>
<div class="flex justify-between">
<a-button @click="visibleList = false">باشه</a-button>
<a-button
v-if="editMode && hasPermission('order.list.view.edit')"
danger
@click="editRow()"
>
ویرایش سفارش
</a-button>
</div>
</template>
</a-modal>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import XTable from '../../components/x-table/XTable.vue';
import TooltipBtn from '../../components/tooltip-btn/TooltipBtn.vue';
import { buildOrderListModel } from './orderListModel';
import { useUsersStore } from '../../stores/users';
import { isBranch } from '../../utils';
import api from '../../api/client';
import { jdate, priceUnit } from '../../utils/filters';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildOrderListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const visibleList = ref(false);
const visibleSts = ref(false);
const editMode = ref(false);
const is_branch = computed(() => isBranch());
const formatDate = (v: string | null) => (v ? jdate(v) : '');
function getColorSts(row: { proceed?: boolean }) {
return row.proceed ? 'green' : 'gray';
}
async function showSts(row: Record<string, any>) {
// legacy: branch orders have no editable status timeline
if (!row.branch_id) {
tempRow.value = await loadDetail(row.id);
visibleSts.value = true;
}
}
async function loadDetail(id: string) {
const { data } = await api.get(`admin/orders/${id}`);
return data;
}
async function setSts(row: Record<string, any>) {
try {
await api.post('single/order-sts/edit', row);
visibleSts.value = false;
message.info('ویرایش موفق آمیز');
tbl.value?.fetch();
} catch (e) {
message.error('خطا در ویرایش');
}
}
async function removeSts(row: Record<string, any>) {
try {
row.remove = true;
await api.post('single/order-sts/edit', row);
visibleSts.value = false;
message.info('ویرایش موفق آمیز');
tbl.value?.fetch();
} catch (e) {
message.error('خطا در ویرایش');
}
}
async function editRow() {
try {
// Legacy's PUT call is commented out in the original too — it only toggles
// the UI and re-fetches. Ported as-is; the edit is not persisted.
editMode.value = !editMode.value;
visibleList.value = !visibleList.value;
message.info('ویرایش موفق آمیز');
tbl.value?.fetch();
} catch (e) {
message.error('خطا در ویرایش');
}
}
async function viewOrder(row: Record<string, any>) {
tempRow.value = await loadDetail(row.id);
visibleList.value = true;
}
async function viewPdf(id: string) {
// Legacy opened the PHP backend's PDF endpoint directly.
// No equivalent exists in the new API yet.
message.warning('خروجی PDF هنوز در بک‌اند جدید پیاده‌سازی نشده است');
}
</script>
<style scoped>
.flex-col {
flex-direction: column;
}
.flex-1 {
flex: 1;
}
.justify-between {
justify-content: space-between;
}
.mr-3 {
margin-left: 0.75rem;
}
</style>
@@ -0,0 +1,223 @@
<template>
<!--
Ported from legacy henza_admin/src/views/order/MakeOrder.vue.
These are custom/made-to-order design requests (PreOrder in the new schema).
Farsi copy, permission gates and the edit-in-place flow are preserved; the
delete action and the report link are COMMENTED OUT in legacy and stay absent.
-->
<div id="page-user-list">
<a-card title="سفارش های ساختنی">
<XTable ref="tbl" :model="model">
<template #name="{ record }">
{{ record.customer?.name }}
</template>
<template #opt="{ record }">
<div class="flex">
<TooltipBtn
v-if="hasPermission('order.makeOrder.view')"
icon-class="w-5 h-5 text-primary"
class="mr-3"
icon="ListIcon"
title="مشاهده سفارش"
@click="viewOrder(record)"
/>
</div>
</template>
<template #sts="{ record }">
<a
v-if="hasPermission('order.makeOrder.state')"
href=""
@click.prevent="viewOrder(record)"
>{{ record.sts_str_fa }}</a
>
<span v-else>{{ record.sts_str_fa }}</span>
</template>
</XTable>
<!-- مشاهده سفارش -->
<a-modal
v-model:open="visibleList"
title="مشاهده سفارش"
width="80%"
@cancel="onCloseDetail"
>
<div v-if="tempRow">
<a-descriptions>
<a-descriptions-item label="مشتری">{{ customerName }}</a-descriptions-item>
<a-descriptions-item label="تلفن همراه">{{ customerMobile }}</a-descriptions-item>
<a-descriptions-item label="تاریخ ثبت">
{{ formatDate(tempRow.time_creat_fa) || '-' }}
</a-descriptions-item>
<a-descriptions-item label="وضعیت">{{ tempRow.sts_str_fa || '-' }}</a-descriptions-item>
</a-descriptions>
<a-card>
<template #title>
<div class="flex">
<div class="flex-1">آیتم های سفارش</div>
<div>
<TooltipBtn
v-if="hasPermission('order.makeOrder.view.edit')"
icon-class="w-5 h-5 text-success"
title="ویرایش"
:icon="getIcon"
@click="editW"
/>
</div>
</div>
</template>
<div class="flex gap-8">
<div>
<img
v-if="tempRow.designImageUrl"
:src="tempRow.designImageUrl"
width="90px"
height="90px"
style="object-fit: scale-down"
/>
<a-empty v-else :image="undefined" description="بدون تصویر" />
</div>
<div class="flex">
<a-descriptions>
<a-descriptions-item label="رنگ محصول">
<div v-if="!editMode">{{ tempRow.color || '-' }}</div>
<a-input
v-if="editMode && hasPermission('order.makeOrder.view.edit')"
v-model:value="tempRow.color"
/>
</a-descriptions-item>
<a-descriptions-item label="رنگ مینا">
<div v-if="!editMode">{{ tempRow.color_mina || '-' }}</div>
<a-input
v-if="editMode && hasPermission('order.makeOrder.view.edit')"
v-model:value="tempRow.color_mina"
/>
</a-descriptions-item>
<a-descriptions-item label="رنگ سنگ">
<div v-if="!editMode">{{ tempRow.color_stone || '-' }}</div>
<a-input
v-if="editMode && hasPermission('order.makeOrder.view.edit')"
v-model:value="tempRow.color_stone"
/>
</a-descriptions-item>
<a-descriptions-item label="تعداد">
<a-tag v-if="!editMode" color="orange">{{ tempRow.sum || 0 }}</a-tag>
<a-input-number
v-if="editMode && hasPermission('order.makeOrder.view.edit')"
v-model:value="tempRow.sum"
class="w-full"
/>
</a-descriptions-item>
<a-descriptions-item label="انتخاب کارگاه">
<a-select
v-model:value="tempRow.workshop_id"
placeholder="لطفا یک کارگاه را انتخاب کنید"
style="min-width: 200px"
>
<a-select-option v-for="item in option" :key="item.id" :value="item.id">
{{ item.name }}
</a-select-option>
</a-select>
</a-descriptions-item>
</a-descriptions>
</div>
</div>
</a-card>
</div>
<a-empty v-else />
<template #footer>
<a-button @click="visibleList = false">باشه</a-button>
</template>
</a-modal>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import XTable from '../../components/x-table/XTable.vue';
import TooltipBtn from '../../components/tooltip-btn/TooltipBtn.vue';
import { buildMakeOrderModel } from './MakeOrderModel';
import { useUsersStore } from '../../stores/users';
import api from '../../api/client';
import { jdate } from '../../utils/filters';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildMakeOrderModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const visibleList = ref(false);
const editMode = ref(false);
const option = ref<{ id: string; name: string }[]>([]);
const formatDate = (v: string | null) => (v ? jdate(v) : '');
const getIcon = computed(() => (!editMode.value ? 'EditIcon' : 'CheckIcon'));
const customerName = computed(() => tempRow.value?.customer?.name ?? '-');
const customerMobile = computed(() => tempRow.value?.customer?.mobile ?? '-');
async function loadWorkshops() {
try {
const { data } = await api.post('workshop/table', { page: 1, limit: 100 });
option.value = (data.rows ?? []).map((w: any) => ({ id: w.id, name: w.name }));
} catch (e) {
// Legacy silently ignored a failed workshop fetch too.
console.log(e);
}
}
async function viewOrder(row: Record<string, any>) {
const { data } = await api.get(`admin/pre-orders/${row.id}`);
tempRow.value = data;
visibleList.value = true;
loadWorkshops();
}
/**
* legacy editW(): the same button toggles into edit mode and, on the second
* click, saves. Legacy's save was `PUT custom-order/{id}`.
*/
async function editW() {
if (!editMode.value) {
editMode.value = true;
return;
}
try {
await api.put(`custom-order/${tempRow.value?.id}`, {
color: tempRow.value?.color,
color_mina: tempRow.value?.color_mina,
color_stone: tempRow.value?.color_stone,
});
editMode.value = false;
message.info('ویرایش موفق آمیز');
tbl.value?.fetch();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ویرایش');
}
}
/** legacy: @cancel="$refs.tbl.fetch(); editMode=false" */
function onCloseDetail() {
tbl.value?.fetch();
editMode.value = false;
}
</script>
<style scoped>
.flex-1 {
flex: 1;
}
.gap-8 {
gap: 2rem;
}
.mr-3 {
margin-left: 0.75rem;
}
</style>
@@ -0,0 +1,37 @@
// Ported from legacy henza_admin/src/views/order/MakeOrderModel.js
//
// Column keys, Farsi titles and filter types preserved exactly, including
// legacy's commented-out columns (total_price, items, branch.name) which stay
// absent. The gate on this table's status column is 'order.tamin.state' in
// legacy — NOT 'order.makeOrder.state' as the page name would suggest. Kept.
import XTbl, { Xtc } from '../../components/x-table/model';
import { useUsersStore } from '../../stores/users';
export function buildMakeOrderModel(): XTbl {
const users = useUsersStore();
const tbl = new XTbl('admin/pre-orders/list', 'سفارش های ساختنی');
tbl.add(new Xtc('customer.id', 'شماره').filterKey('id').filterNumber());
tbl.add(new Xtc('customer.fname', 'نام مشتری').filterKey('customer.fullName').filterText());
tbl.add(new Xtc('customer.lname', 'نام خانوادگی').filterKey('customer.fullName').filterText());
tbl.add(new Xtc('color', 'رنگ محصول').filterKey('colorName').filterText());
tbl.add(new Xtc('size', 'سایز محصول').filterKey('sizeSpec').filterText());
tbl.add(new Xtc('color_mina', 'رنگ مینا').filterKey('minaColor').filterText());
tbl.add(new Xtc('color_stone', 'رنگ سنگ').filterKey('stoneColor').filterText());
tbl.add(new Xtc('sum', 'تعداد').filterText());
tbl.add(new Xtc('time_creat_fa', 'تاریخ ثبت').filterKey('createdAt').filterDate().noSort());
tbl.add(
new Xtc('time_update_fa', 'تاریخ ویرایش').hidden().filterKey('updatedAt').filterDate().noSort(),
);
tbl.add(new Xtc('customer.name', 'ثبت کننده').renderSlot('name').noSort());
// legacy gate: 'order.tamin.state' (not makeOrder) — preserved verbatim
if (users.roles && users.roles.includes('order.tamin.state')) {
tbl.add(new Xtc('sts_str_fa', 'وضعیت').renderSlot('sts').noSort());
}
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildMakeOrderModel;
@@ -0,0 +1,237 @@
<template>
<!--
Ported from legacy henza_admin/src/views/order/OmdeList.vue.
Farsi copy, permission gates, slot names and modal structure preserved.
The report download <a href> is kept as a link (legacy opened the PHP
report endpoint directly) but now points at the new API.
-->
<div id="page-user-list">
<a-card title="لیست سفارش های عمده">
<XTable ref="tbl" :model="model">
<template #city="{ text }">
<div v-if="text">{{ getCity(text) }}</div>
<div v-else>-</div>
</template>
<template #opt="{ record }">
<div class="flex">
<TooltipBtn
v-if="hasPermission('order.omde.view')"
icon-class="w-5 h-5 text-primary"
class="mr-3"
icon="ListIcon"
title="مشاهده سفارش"
@click="viewOrder(record)"
/>
<TooltipBtn
v-if="hasPermission('order.omde.remove')"
icon-class="w-5 h-5 text-danger"
class="mr-3"
icon="TrashIcon"
title="حذف"
pop-title="آیا مطمئن به حدف رکورد مورد نظر هستید؟"
@click="deleteOrder(record)"
/>
</div>
</template>
<template #items_len="{ text }">
<div v-if="text">{{ text.length }}</div>
</template>
<template #sts_str="{ record }">
<a
v-if="hasPermission('order.omde.state')"
href=""
@click.prevent="showSts(record)"
>{{ record.sts_str }}</a
>
<span v-else>{{ record.sts_str }}</span>
</template>
<template #confirmed_str="{ record }">
{{ record.confirmed_str }}
</template>
</XTable>
<!-- وضعیت سفارش -->
<a-modal v-model:open="visibleSts" title="وضعیت سفارش">
<a-timeline v-if="tempRow">
<a-timeline-item
v-for="(item, index) in tempRow.activity"
:key="index"
:color="getColorSts(item)"
>
<div class="flex flex-col">
<div>{{ item.title }}</div>
<div v-if="item.proceed">
<div class="flex">
<div class="flex-1">
<a-tag>{{ formatDate(item.uaj) }}</a-tag>
</div>
<a-popconfirm
v-if="index > 0 && hasPermission('order.omde.state.remove')"
ok-text="بله"
cancel-text="بی خیال"
@confirm="removeSts(item)"
>
<template #title><p>آیا مطمئن به حذف وضعیت مورد نظر هستید ؟</p></template>
<a-button size="small" danger>حذف وضعیت</a-button>
</a-popconfirm>
</div>
</div>
<div
v-if="!item.proceed && hasPermission('order.omde.state.submit')"
class="flex items-center"
>
<a-input v-model:value="item.note" placeholder="یادداشت" />
<a-button type="text" size="small" @click="setSts(item)">ثبت وضعیت</a-button>
</div>
<div v-if="item.proceed">{{ item.note }}</div>
</div>
</a-timeline-item>
</a-timeline>
<template #footer>
<a-button @click="visibleSts = false">باشه</a-button>
</template>
</a-modal>
<!-- مشاهده سفارش -->
<a-modal v-model:open="visibleList" title="مشاهده سفارش" width="80%">
<div v-if="tempRow">
<a-descriptions>
<a-descriptions-item label="مشتری">
{{ tempRow.customer.fname }} {{ tempRow.customer.lname }}
</a-descriptions-item>
<a-descriptions-item label="تلفن همراه">{{ tempRow.customer.phone }}</a-descriptions-item>
<a-descriptions-item label="تاریخ ثبت">{{ formatDate(tempRow.caj) }}</a-descriptions-item>
<a-descriptions-item label="وضعیت">{{ tempRow.sts_str }}</a-descriptions-item>
</a-descriptions>
<a-card title="آیتم های سفارش">
<div v-for="(item, index) in tempRow.items" :key="index">
<a-descriptions>
<a-descriptions-item label="نام محصول">
<a-tag color="blue">{{ item.title }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="عنوان تنوع">{{ item.variant_title }}</a-descriptions-item>
<a-descriptions-item label="وزن نهایی">
<a-tag color="red">{{ item.grams }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="تعداد">
<a-tag color="orange">{{ item.qty }}</a-tag>
</a-descriptions-item>
</a-descriptions>
<a-divider v-if="index < tempRow.items.length - 1" />
</div>
</a-card>
</div>
<a-empty v-else />
<template #footer>
<a-button @click="visibleList = false">باشه</a-button>
</template>
</a-modal>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import XTable from '../../components/x-table/XTable.vue';
import TooltipBtn from '../../components/tooltip-btn/TooltipBtn.vue';
import { buildOmdeListModel } from './OmdeListModel';
import { useUsersStore } from '../../stores/users';
import api from '../../api/client';
import { jdate } from '../../utils/filters';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildOmdeListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const visibleList = ref(false);
const visibleSts = ref(false);
const formatDate = (v: string | null) => (v ? jdate(v) : '');
/** legacy getCity(): resolve a city id through the users store lookup */
function getCity(id: string | number) {
return users.cityFilters[id] ?? '-';
}
function getColorSts(row: { proceed?: boolean }) {
return row.proceed ? 'green' : 'gray';
}
async function loadDetail(id: string) {
const { data } = await api.get(`admin/orders/${id}`);
return data;
}
async function viewOrder(row: Record<string, any>) {
tempRow.value = await loadDetail(row.id);
visibleList.value = true;
}
async function showSts(row: Record<string, any>) {
tempRow.value = await loadDetail(row.id);
visibleSts.value = true;
}
async function setSts(row: Record<string, any>) {
try {
await api.post('order-sts/edit', { orderId: tempRow.value?.id, status: row.status, note: row.note });
visibleSts.value = false;
message.info('ویرایش موفق آمیز');
tbl.value?.fetch();
} catch (e) {
message.error('خطا در ویرایش');
}
}
async function removeSts(row: Record<string, any>) {
try {
await api.post('order-sts/edit', {
orderId: tempRow.value?.id,
status: row.status,
remove: true,
});
visibleSts.value = false;
message.info('ویرایش موفق آمیز');
tbl.value?.fetch();
} catch (e: any) {
// The backend refuses to unwind a status that already posted debt; surface
// its Farsi explanation rather than a generic failure.
message.error(e?.response?.data?.message ?? 'خطا در ویرایش');
}
}
function deleteOrder(record: Record<string, any>) {
api
.post('delete-order', { id: record.id })
.then(() => {
message.info('سفارش با موفقیت حذف گردید');
tbl.value?.fetch();
})
.catch((error) => {
console.log(error);
message.error(error?.response?.data?.message ?? 'خطا در حذف');
});
}
</script>
<style scoped>
.flex-col {
flex-direction: column;
}
.flex-1 {
flex: 1;
}
.mr-3 {
margin-left: 0.75rem;
}
</style>
@@ -0,0 +1,56 @@
// Ported from legacy henza_admin/src/views/order/OmdeListModel.js
//
// Column keys, Farsi titles, filter types, the status/confirmed value maps and
// the 'order.omde.state' gate are preserved exactly. Changes: the url is the new
// clean REST path (legacy used the absolute 'https://henzagold.com/oapi/admin/
// major/order/table'), and roles/cities come from Pinia instead of Vuex.
import XTbl, { Xtc } from '../../components/x-table/model';
import { useUsersStore } from '../../stores/users';
export function buildOmdeListModel(): XTbl {
const users = useUsersStore();
// legacy: fetch the city list once if the filter map is empty
if (Object.keys(users.cityFilters).length < 1) {
users.getCities();
}
const tbl = new XTbl('admin/orders/wholesale/list', 'لیست سفارش های عمده');
tbl.add(new Xtc('id', 'شماره').filterNumber());
tbl.add(new Xtc('customer.name', 'مشتری').filterKey('customer.fullName').filterText());
tbl.add(
new Xtc('customer.mobile', 'موبایل').filterText().hidden().noSort(),
);
tbl.add(new Xtc('input', 'جمع کل سفارش').filterKey('totalGoldWeightGrams').filterText());
tbl.add(new Xtc('discount', 'جمع کل تخفیف (درصد)').filterText());
// legacy kept the .filterSelect(cityFilters) call commented out — preserved.
tbl.add(new Xtc('customer.city_id', 'شهر').hidden().renderSlot('city'));
tbl.add(new Xtc('items', 'تعداد آیتم ها').renderSlot('items_len').noSort());
tbl.add(new Xtc('caj', 'تاریخ ثبت').filterKey('createdAt').filterDate().noSort());
tbl.add(new Xtc('uaj', 'تاریخ ویرایش').hidden().filterKey('updatedAt').filterDate().noSort());
if (users.roles && users.roles.includes('order.omde.state')) {
tbl.add(
new Xtc('sts', 'وضعیت').renderSlot('sts_str').noSort().filterKey('status').filterSelect({
0: 'معلق',
1: 'ثبت سفارش',
2: 'تایید مدیر سیستم',
3: 'در حال تامین',
4: 'در حال آماده سازی ',
5: 'تحویل مسئول فروش',
6: 'تحویل مشتری ',
}),
);
}
tbl.add(
new Xtc('confirmed', 'وضعیت تایید مشتری').renderSlot('confirmed_str').noSort().filterSelect({
1: 'تایید شده',
2: 'در انتظار تایید',
}),
);
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildOmdeListModel;
@@ -0,0 +1,208 @@
<template>
<!--
Ported from legacy henza_admin/src/views/order/TaminList.vue.
The card title in legacy is 'لیست سفارش های عمده' — the SAME string the
wholesale list uses, even though the nav item for this page reads
'لیست تامین'. Preserved rather than "corrected". The delete action exists in
the legacy markup but is COMMENTED OUT — preserved as absent, not re-added.
-->
<div id="page-user-list">
<a-card title="لیست سفارش های عمده">
<XTable ref="tbl" :model="model">
<template #opt="{ record }">
<div class="flex">
<TooltipBtn
v-if="hasPermission('order.tamin.view')"
icon-class="w-5 h-5 text-primary"
class="mr-3"
icon="ListIcon"
title="مشاهده سفارش"
@click="viewOrder(record)"
/>
</div>
</template>
<template #items_len="{ text }">
<div v-if="text">{{ text.length }}</div>
</template>
<template #sts_str="{ record }">
<a
v-if="hasPermission('order.tamin.state')"
href=""
@click.prevent="showSts(record)"
>{{ record.sts_str }}</a
>
<span v-else>{{ record.sts_str }}</span>
</template>
<template #confirmed_str="{ record }">
{{ record.confirmed_str }}
</template>
</XTable>
<!-- وضعیت سفارش -->
<a-modal v-model:open="visibleSts" title="وضعیت سفارش">
<a-timeline v-if="tempRow">
<a-timeline-item
v-for="(item, index) in tempRow.activity"
:key="index"
:color="getColorSts(item)"
>
<div class="flex flex-col">
<div>{{ item.title }}</div>
<div v-if="item.proceed">
<div class="flex">
<div class="flex-1">
<a-tag>{{ formatDate(item.uaj) }}</a-tag>
</div>
<a-popconfirm
v-if="index > 0 && hasPermission('order.tamin.state.remove')"
ok-text="بله"
cancel-text="بی خیال"
@confirm="removeSts(item)"
>
<template #title><p>آیا مطمئن به حذف وضعیت مورد نظر هستید ؟</p></template>
<a-button size="small" danger>حذف وضعیت</a-button>
</a-popconfirm>
</div>
</div>
<div
v-if="!item.proceed && hasPermission('order.tamin.state.submit')"
class="flex items-center"
>
<a-input v-model:value="item.note" placeholder="یادداشت" />
<a-button type="text" size="small" @click="setSts(item)">ثبت وضعیت</a-button>
</div>
<div v-if="item.proceed">{{ item.note }}</div>
</div>
</a-timeline-item>
</a-timeline>
<template #footer>
<a-button @click="visibleSts = false">باشه</a-button>
</template>
</a-modal>
<!-- مشاهده سفارش -->
<a-modal v-model:open="visibleList" title="مشاهده سفارش" width="80%">
<div v-if="tempRow">
<a-descriptions>
<a-descriptions-item label="مشتری">
{{ tempRow.customer.fname }} {{ tempRow.customer.lname }}
</a-descriptions-item>
<a-descriptions-item label="تلفن همراه">{{ tempRow.customer.phone }}</a-descriptions-item>
<a-descriptions-item label="تاریخ ثبت">{{ formatDate(tempRow.caj) }}</a-descriptions-item>
<a-descriptions-item label="وضعیت">{{ tempRow.sts_str }}</a-descriptions-item>
</a-descriptions>
<a-card title="آیتم های سفارش">
<div v-for="(item, index) in tempRow.items" :key="index">
<a-descriptions>
<a-descriptions-item label="نام محصول">
<a-tag color="blue">{{ item.title }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="عنوان تنوع">{{ item.variant_title }}</a-descriptions-item>
<a-descriptions-item label="وزن نهایی">
<a-tag color="red">{{ item.grams }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="تعداد">
<a-tag color="orange">{{ item.qty }}</a-tag>
</a-descriptions-item>
</a-descriptions>
<a-divider v-if="index < tempRow.items.length - 1" />
</div>
</a-card>
</div>
<a-empty v-else />
<template #footer>
<a-button @click="visibleList = false">باشه</a-button>
</template>
</a-modal>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import XTable from '../../components/x-table/XTable.vue';
import TooltipBtn from '../../components/tooltip-btn/TooltipBtn.vue';
import { buildTaminListModel } from './TaminListModel';
import { useUsersStore } from '../../stores/users';
import api from '../../api/client';
import { jdate } from '../../utils/filters';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildTaminListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const visibleList = ref(false);
const visibleSts = ref(false);
const formatDate = (v: string | null) => (v ? jdate(v) : '');
function getColorSts(row: { proceed?: boolean }) {
return row.proceed ? 'green' : 'gray';
}
async function loadDetail(id: string) {
const { data } = await api.get(`admin/orders/${id}`);
return data;
}
async function viewOrder(row: Record<string, any>) {
tempRow.value = await loadDetail(row.id);
visibleList.value = true;
}
async function showSts(row: Record<string, any>) {
tempRow.value = await loadDetail(row.id);
visibleSts.value = true;
}
async function setSts(row: Record<string, any>) {
try {
await api.post('order-sts/edit', {
orderId: tempRow.value?.id,
status: row.status,
note: row.note,
});
visibleSts.value = false;
message.info('ویرایش موفق آمیز');
tbl.value?.fetch();
} catch (e) {
message.error('خطا در ویرایش');
}
}
async function removeSts(row: Record<string, any>) {
try {
await api.post('order-sts/edit', {
orderId: tempRow.value?.id,
status: row.status,
remove: true,
});
visibleSts.value = false;
message.info('ویرایش موفق آمیز');
tbl.value?.fetch();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ویرایش');
}
}
</script>
<style scoped>
.flex-col {
flex-direction: column;
}
.flex-1 {
flex: 1;
}
.mr-3 {
margin-left: 0.75rem;
}
</style>
@@ -0,0 +1,37 @@
// Ported from legacy henza_admin/src/views/order/TaminListModel.js
//
// Note legacy's title string for this table is also 'لیست سفارش های عمده'
// (same as OmdeListModel) — preserved rather than "corrected".
import XTbl, { Xtc } from '../../components/x-table/model';
import { useUsersStore } from '../../stores/users';
export function buildTaminListModel(): XTbl {
const users = useUsersStore();
const tbl = new XTbl('admin/orders/tamin/list', 'لیست سفارش های عمده');
tbl.add(new Xtc('id', 'شماره').filterNumber());
tbl.add(new Xtc('customer.name', 'مشتری').filterKey('customer.fullName').filterText());
tbl.add(new Xtc('customer.mobile', 'موبایل').filterText().hidden().noSort());
tbl.add(new Xtc('input', 'ورودی').filterKey('totalGoldWeightGrams').filterText());
tbl.add(new Xtc('wage', 'اجرت').filterKey('wageWeightGrams').filterText().noSort());
tbl.add(new Xtc('items', 'تعداد آیتم ها').renderSlot('items_len').noSort());
tbl.add(new Xtc('caj', 'تاریخ ثبت').filterKey('createdAt').filterDate().noSort());
tbl.add(new Xtc('uaj', 'تاریخ ویرایش').hidden().filterKey('updatedAt').filterDate().noSort());
if (users.roles && users.roles.includes('order.tamin.state')) {
// legacy called .filterSelect() with NO argument here, leaving the dropdown
// empty. Preserved exactly — see the note on filterSelect in x-table/model.ts.
tbl.add(new Xtc('sts', 'وضعیت').renderSlot('sts_str').noSort().filterSelect());
}
tbl.add(
new Xtc('confirmed', 'وضعیت تایید مشتری').renderSlot('confirmed_str').noSort().filterSelect({
1: 'تایید شده',
2: 'در انتظار تایید',
}),
);
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildTaminListModel;
+424
View File
@@ -0,0 +1,424 @@
<template>
<!--
Ported from legacy henza_admin/src/views/order/create.vue — the admin order
builder: pick products, add variants to a cart, then submit as an order.
Preserved: the Farsi copy, the cart table's column set, the per-row quantity
cap at the variant's stock (Infinity when stock is 0, as legacy had it), the
inline weight editor, and the delivery-status / sale-type dropdowns.
Price columns: legacy read `price` and `price_with_discount` off each variant,
which the redesigned catalog does not store (gold price is quoted live and
wage is a percentage). Those cells show the gram weight and wage instead of
inventing a Rial figure — see docs/ADMIN-PORT-PLAN.md.
-->
<a-spin v-if="hasPermission('order.create')" :spinning="loading">
<a-card title="لیست محصولات">
<XTable ref="tblProducts" :model="cols">
<template #image="{ record }">
<img
width="90px"
height="90px"
style="object-fit: scale-down"
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
/>
</template>
<template #op="{ record }">
<a-dropdown>
<a-button style="margin-left: 8px">
افزودن
<DownOutlined />
</a-button>
<template #overlay>
<a-menu @click="onVariantPick(record, $event)">
<a-menu-item v-for="item in record.variants_all" :key="item.id">
افزودن تنوع {{ item.title }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</XTable>
<a-card v-if="items.length > 0" title="ثبت سفارش" class="mt-5">
<a-table :columns="columns" :data-source="items" bordered :pagination="false" row-key="vid">
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'delete'">
<CloseOutlined class="cursor-pointer" @click="cart.remove(record)" />
</template>
<template v-else-if="column.dataIndex === 'product'">
<div class="flex items-center">
<img :src="getImage(record.product)" width="50px" />
{{ record.product.title }}
</div>
</template>
<template v-else-if="column.dataIndex === 'wage'">
{{ getVariant(record).percentage_pay }}٪
</template>
<template v-else-if="column.dataIndex === 'qty'">
<!--
legacy capped quantity at the variant's stock, falling back to
Infinity when stock is 0 (which is how a pre-order variant stays
orderable). Preserved.
-->
<a-input-number
v-model:value="items[index].qty"
:min="1"
:max="maxQtyFor(index)"
@change="onQtyChange(index, $event)"
/>
</template>
<template v-else-if="column.dataIndex === 'weight'">
<div class="flex">
<template v-if="!variant.grams || variant.id !== getVariant(record).id">
<span style="width: max-content">{{ getVariant(record).title }}</span>
<EditOutlined class="cursor-pointer ml-2 text-primary" @click="toEdit(record)" />
</template>
<template v-else>
<a-input-number v-model:value="variant.grams" />
<CheckOutlined class="cursor-pointer ml-2 text-success" @click="editGram" />
</template>
</div>
</template>
<template v-else-if="column.dataIndex === 'total'">
{{ rowWeight(record) }} گرم
</template>
</template>
</a-table>
<a-form layout="vertical" class="mt-4" @submit.prevent="submitOrder">
<div class="grid-5">
<a-form-item
label="مشتری"
:validate-status="err.customer_id ? 'error' : ''"
:help="err.customer_id"
>
<a-select
v-model:value="form.customer_id"
show-search
option-filter-prop="children"
:filter-option="filterOption"
>
<a-select-option v-for="item in customers" :key="item.id" :value="item.id">
{{ `${item.id}-${item.fname} ${item.lname}` }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="وضعیت تحویل"
:validate-status="err.sts ? 'error' : ''"
:help="err.sts"
>
<a-select v-model:value="form.sts">
<a-select-option v-for="(label, code) in sts" :key="code" :value="Number(code)">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="نوع فروش" :validate-status="err.type ? 'error' : ''" :help="err.type">
<a-select v-model:value="form.type">
<a-select-option v-for="(label, code) in types" :key="code" :value="Number(code)">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="نام دریافت کننده">
<a-input v-model:value="form.shipping_address.reciver_fname" />
</a-form-item>
<a-form-item label="نام خانوادگی دریافت کننده">
<a-input v-model:value="form.shipping_address.reciver_lname" />
</a-form-item>
<a-form-item label="استان">
<a-input v-model:value="form.shipping_address.province" />
</a-form-item>
<a-form-item label="شهر">
<a-input v-model:value="form.shipping_address.city" />
</a-form-item>
<a-form-item label="کد پستی">
<a-input v-model:value="form.shipping_address.zipcode" />
</a-form-item>
<a-form-item label="تلفن همراه">
<a-input v-model:value="form.shipping_address.reciver_phone" />
</a-form-item>
<a-form-item label="تلفن ثابت">
<a-input v-model:value="form.shipping_address.reciver_fixed_phone" />
</a-form-item>
</div>
<a-form-item label="توضیحات">
<a-textarea v-model:value="form.note" :auto-size="{ minRows: 3, maxRows: 5 }" />
</a-form-item>
<a-form-item label="آدرس">
<a-textarea
v-model:value="form.shipping_address.address"
:auto-size="{ minRows: 3, maxRows: 5 }"
/>
</a-form-item>
<div class="totals">
<a-tag color="blue">مجموع وزن: {{ totalWeight }} گرم</a-tag>
</div>
<a-button
type="primary"
class="mt-4"
:disabled="!items || items.length < 1"
@click="submitOrder"
>
ثبت تغییرات
</a-button>
</a-form>
</a-card>
</a-card>
</a-spin>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
CheckOutlined,
CloseOutlined,
DownOutlined,
EditOutlined,
} from '@ant-design/icons-vue';
import XTable from '../../components/x-table/XTable.vue';
import { buildProductListModel } from '../apps/product/productListModel';
import { useCartStore, type CartItem, type CartVariant } from '../../stores/cart';
import { useUsersStore } from '../../stores/users';
import { useAuthStore } from '../../stores/auth';
import api from '../../api/client';
const router = useRouter();
const users = useUsersStore();
const auth = useAuthStore();
const cart = useCartStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const noImage = '/img/no-items.png';
const cols = buildProductListModel();
const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null);
const loading = ref(false);
const customers = ref<{ id: string; fname: string; lname: string }[]>([]);
const types = ref<Record<number, string>>({});
// legacy sts map, verbatim
const sts: Record<number, string> = {
1: 'ثبت سفارش',
2: 'تایید مدیر سیستم',
3: 'در حال تامین',
4: 'در حال آماده سازی',
5: 'ارسال',
6: 'تحویل مشتری',
};
const form = reactive<Record<string, any>>({
customer_id: undefined,
sts: undefined,
type: undefined,
note: '',
shipping_address: {
reciver_fname: '',
reciver_lname: '',
province: '',
city: '',
zipcode: '',
reciver_phone: '',
reciver_fixed_phone: '',
address: '',
},
});
const err = reactive<Record<string, string>>({});
/** legacy: the logged-in admin is recorded as the order's reagent */
const reagent_id = computed(() => auth.user?.id);
const items = computed(() => cart.items);
const columns = [
{ title: 'حذف', dataIndex: 'delete', width: 60, align: 'center' },
{ title: 'کد محصول', dataIndex: 'pid', align: 'center', width: 90 },
{ title: 'محصول', dataIndex: 'product' },
{ title: 'اجرت', dataIndex: 'wage', align: 'center' },
{ title: 'تعداد', dataIndex: 'qty', align: 'center', width: 140 },
{ title: 'وزن', dataIndex: 'weight', align: 'center', width: 90 },
{ title: 'مجموع', dataIndex: 'total', align: 'center' },
];
// ── inline weight editor
const variant = reactive<{ id?: string; grams?: number }>({});
function getVariant(item: CartItem): CartVariant & Record<string, any> {
return (item.product.variants_all.find((x) => x.id === item.vid) ?? {}) as CartVariant;
}
function getImage(product: Record<string, any>) {
return product?.thumb?.full ?? product?.ProductImage?.[0]?.image ?? noImage;
}
/** legacy totalWeight — note it does NOT multiply by qty. Preserved. */
const totalWeight = computed(() => {
let total = 0;
for (const item of items.value) {
total += Number(getVariant(item).grams ?? 0);
}
return total;
});
function rowWeight(record: CartItem) {
return Number(getVariant(record).grams ?? 0) * record.qty;
}
/** Cap a row's quantity at the variant's stock; 0 stock means uncapped (legacy). */
function maxQtyFor(index: number): number {
const item = items.value[index];
if (!item) return Infinity;
const stock = Number(getVariant(item).qty ?? 0);
return stock > 0 ? stock : Infinity;
}
function onQtyChange(index: number, value: unknown) {
cart.updateItemQuantity({ index, quantity: Number(value) });
}
function onVariantPick(record: Record<string, any>, e: { key: string | number }) {
addToCart(record, String(e.key));
}
function addToCart(record: Record<string, any>, variantId: string) {
cart.add({
vid: variantId,
pid: record.id,
product: record as any,
qty: 1,
promotion_id: record.promotion && record.promotion.id ? record.promotion.id : null,
});
message.info('اضافه شد');
}
function toEdit(record: CartItem) {
const v = getVariant(record);
variant.id = v.id;
variant.grams = Number(v.grams ?? 0);
}
/**
* legacy editGram(): posted a NEW variant titled "<grams> گرم" and re-inited the
* cart. Kept — it creates a bespoke weight for this order rather than mutating
* the catalog variant other orders share.
*/
async function editGram() {
if (!variant.id || !variant.grams) return;
try {
loading.value = true;
const current = items.value.find((i) => i.vid === variant.id);
await api.post('add/variant', {
product_id: current?.pid,
title: `${variant.grams} گرم`,
grams: variant.grams,
});
await cart.init();
variant.id = undefined;
variant.grams = undefined;
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت وزن');
} finally {
loading.value = false;
}
}
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '')
.toLowerCase()
.includes(input.toLowerCase());
}
function validate(): boolean {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.customer_id) err.customer_id = 'مقدار را وارد نمایید';
if (form.sts === undefined) err.sts = 'مقدار را وارد نمایید';
if (form.type === undefined) err.type = 'مقدار را وارد نمایید';
return Object.keys(err).length === 0;
}
async function submitOrder() {
if (!validate()) return;
try {
loading.value = true;
const order_items = items.value.map((x) => {
const v = getVariant(x);
return {
vid: x.vid,
qty: x.qty,
percent: v.percentage_pay,
promotion_id: x.promotion_id,
};
});
// Legacy also sent browser-computed total_price / total_weight /
// gold_price_original. The server recomputes those from the variant
// snapshots, so they are not sent.
await api.post('order', {
...form,
reagent_id: reagent_id.value,
app: 'app',
order_items,
});
message.info('عملیات با موفقیت انجام شد');
cart.clear();
router.push('/order/list');
} catch (e: any) {
console.log(e);
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const [t, c] = await Promise.all([
api.get('order/type'),
api.post('single-customer', { page: 1, limit: 500 }),
]);
types.value = t.data;
customers.value = c.data.rows ?? [];
} catch (e) {
console.log(e);
}
await cart.init();
});
</script>
<style scoped>
.grid-5 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.ml-2 {
margin-right: 0.5rem;
}
.mt-4 {
margin-top: 1rem;
}
.mt-5 {
margin-top: 1.25rem;
}
.totals {
margin: 1rem 0;
}
</style>
@@ -0,0 +1,33 @@
// Ported from legacy henza_admin/src/views/order/orderListModel.js
//
// Column keys, Farsi titles, filter types, slots and the conditional
// 'order.tamin.state' block are preserved exactly. Only two things changed:
// - the url is the new clean REST path instead of the absolute legacy one
// ('https://henzagold.com/oapi/admin/single-order')
// - roles come from the Pinia users store instead of Vuex
import XTbl, { Xtc } from '../../components/x-table/model';
import { useUsersStore } from '../../stores/users';
export function buildOrderListModel(): XTbl {
const tbl = new XTbl('admin/orders/list', 'لیست سفارش های تک فروشی');
tbl.add(new Xtc('id', 'شماره').filterNumber());
tbl.add(new Xtc('customer.fname', 'نام مشتری').filterKey('customer.fullName').filterText());
tbl.add(new Xtc('customer.lname', 'نام خانوادگی').filterKey('customer.fullName').filterText());
tbl.add(new Xtc('total_price', 'جمع کل سفارش').renderSlot('total_price').filterText());
tbl.add(new Xtc('items', 'تعداد آیتم ها').renderSlot('items_len').noSort());
tbl.add(new Xtc('caj', 'تاریخ ثبت').filterKey('createdAt').filterDate().noSort());
tbl.add(new Xtc('uaj', 'تاریخ ویرایش').hidden().filterKey('updatedAt').filterDate().noSort());
tbl.add(new Xtc('user', 'ثبت کننده').renderSlot('user').noSort());
// legacy: gated on store.state.users.roles.includes('order.tamin.state')
const users = useUsersStore();
if (users.roles && users.roles.includes('order.tamin.state')) {
tbl.add(new Xtc('sts_str', 'وضعیت').renderSlot('sts').noSort());
tbl.add(new Xtc('branch.name', 'نام شعبه').filterText().noSort());
}
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildOrderListModel;
@@ -0,0 +1,19 @@
<template>
<!-- Ported from legacy henza_admin/src/views/pages/Error404.vue (Vuexy template page). -->
<div class="full-page-msg">
<a-result status="404" title="صفحه مورد نظر یافت نشد" sub-title="آدرسی که وارد کرده‌اید وجود ندارد.">
<template #extra>
<a-button type="primary" @click="$router.push('/')">بازگشت به خانه</a-button>
</template>
</a-result>
</div>
</template>
<style scoped>
.full-page-msg {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
</style>
@@ -0,0 +1,19 @@
<template>
<!-- Ported from legacy henza_admin/src/views/pages/Error500.vue (Vuexy template page). -->
<div class="full-page-msg">
<a-result status="500" title="خطای سرور" sub-title="مشکلی در سرور رخ داده است. لطفاً بعداً تلاش کنید.">
<template #extra>
<a-button type="primary" @click="$router.push('/')">بازگشت به خانه</a-button>
</template>
</a-result>
</div>
</template>
<style scoped>
.full-page-msg {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
</style>
@@ -0,0 +1,19 @@
<template>
<!-- Ported from legacy henza_admin/src/views/pages/Maintenance.vue (Vuexy template page). -->
<div class="full-page-msg">
<a-result status="info" title="در دست تعمیر" sub-title="این بخش موقتاً در دسترس نیست.">
<template #extra>
<a-button type="primary" @click="$router.push('/')">بازگشت به خانه</a-button>
</template>
</a-result>
</div>
</template>
<style scoped>
.full-page-msg {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
</style>
@@ -0,0 +1,19 @@
<template>
<!-- Ported from legacy henza_admin/src/views/pages/NotAuthorized.vue (Vuexy template page). -->
<div class="full-page-msg">
<a-result status="403" title="دسترسی غیرمجاز" sub-title="شما دسترسی لازم برای مشاهده این صفحه را ندارید.">
<template #extra>
<a-button type="primary" @click="$router.push('/')">بازگشت به خانه</a-button>
</template>
</a-result>
</div>
</template>
<style scoped>
.full-page-msg {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
</style>
@@ -0,0 +1,273 @@
<template>
<!--
Shared implementation of legacy promotion_add_edit.vue and
promotion_tak_add_edit.vue.
Verified by diff: the two legacy files differ only in the card title and in
whether the "مشتری" scope option is offered — the "tak" variant has that
option and its whole customer block COMMENTED OUT. Both differences are
parameterised here rather than duplicating ~400 lines.
-->
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card :title="title" class="mb-base">
<div class="grid-4">
<a-form-item label="نوع تخفیف">
<!-- legacy disables the type selector while editing -->
<a-select v-model:value="form.type" :disabled="Boolean(id)" @change="changeType">
<a-select-option :value="1">محصولات</a-select-option>
<a-select-option :value="2">دسته بندی</a-select-option>
<a-select-option v-if="allowCustomerScope" :value="3">مشتری</a-select-option>
<a-select-option :value="4">برند</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="عنوان"
:validate-status="err.title ? 'error' : ''"
:help="err.title"
>
<a-input v-model:value="form.title" />
</a-form-item>
<!-- the target picker changes with the scope -->
<a-form-item v-if="form.type === 1" label="محصول">
<a-select
v-model:value="form.product_id"
show-search
allow-clear
:filter-option="filterOption"
placeholder="انتخاب کنید"
>
<a-select-option v-for="p in products" :key="p.id" :value="p.id">
{{ p.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="form.type === 2" label="دسته بندی">
<a-select
v-model:value="form.category_id"
show-search
allow-clear
:filter-option="filterOption"
placeholder="انتخاب کنید"
>
<a-select-option v-for="c in categories" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="form.type === 3 && allowCustomerScope" label="مشتری">
<a-select
v-model:value="form.customer_id"
show-search
allow-clear
:filter-option="filterOption"
placeholder="انتخاب کنید"
>
<a-select-option v-for="c in customers" :key="c.id" :value="c.id">
{{ c.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="form.type === 4" label="برند">
<a-select
v-model:value="form.brand_id"
show-search
allow-clear
:filter-option="filterOption"
placeholder="انتخاب کنید"
>
<a-select-option v-for="b in brands" :key="b.id" :value="b.id">
{{ b.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="درصد تخفیف"
:validate-status="err.discount_percent ? 'error' : ''"
:help="err.discount_percent"
>
<a-input-number
v-model:value="form.discount_percent"
class="w-full"
:min="0"
:max="100"
/>
</a-form-item>
<a-form-item label="تاریخ شروع">
<a-date-picker v-model:value="form.start" class="w-full" value-format="YYYY-MM-DD" />
</a-form-item>
<a-form-item label="تاریخ پایان">
<a-date-picker v-model:value="form.end" class="w-full" value-format="YYYY-MM-DD" />
</a-form-item>
<a-form-item label="قابلیت استفاده">
<a-radio-group v-model:value="form.use_only">
<a-radio :value="1"> فقط یکبار</a-radio>
<a-radio :value="2">تا تاریخ پایان</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="وضعیت تخفیف">
<a-switch v-model:checked="form.is_active" />
</a-form-item>
</div>
<a-form-item label="توضیحات">
<a-textarea v-model:value="form.description" :auto-size="{ minRows: 3, maxRows: 6 }" />
</a-form-item>
<a-button type="primary" @click="handleSubmit">ثبت اطلاعات</a-button>
</a-card>
</a-form>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import api from '../../api/client';
const props = withDefaults(
defineProps<{
title?: string;
/**
* The "tak" variant has the customer scope commented out in legacy, so it
* is not offered there. Preserved behind this flag.
*/
allowCustomerScope?: boolean;
}>(),
{ title: 'تعریف تخفیف', allowCustomerScope: true },
);
const route = useRoute();
const router = useRouter();
const id = route.params.id as string | undefined;
const loading = ref(false);
const products = ref<{ id: string; title: string }[]>([]);
const categories = ref<{ id: string; title: string }[]>([]);
const customers = ref<{ id: string; name: string }[]>([]);
const brands = ref<{ id: string; title: string }[]>([]);
const form = reactive<Record<string, any>>({
type: 1, // legacy initialValue
title: '',
description: '',
discount_percent: undefined,
use_only: 1, // legacy initialValue
is_active: true,
start: undefined,
end: undefined,
product_id: undefined,
category_id: undefined,
customer_id: undefined,
brand_id: undefined,
});
const err = reactive<Record<string, string>>({});
/** legacy changeType(): clears the previously picked target when scope changes */
function changeType() {
form.product_id = undefined;
form.category_id = undefined;
form.customer_id = undefined;
form.brand_id = undefined;
}
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '')
.toLowerCase()
.includes(input.toLowerCase());
}
function validate(): boolean {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.title) err.title = 'لطفا مقدار را وارد کنید';
if (form.discount_percent === undefined || form.discount_percent === null) {
err.discount_percent = 'لطفا مقدار را وارد کنید';
}
return Object.keys(err).length === 0;
}
async function handleSubmit() {
if (!validate()) return;
try {
loading.value = true;
if (id) {
await api.put(`product/variant/promotion/${id}`, form);
} else {
await api.post('product/variant/promotion', form);
}
message.info('اطلاعات با موفقیت به ثبت رسید.');
router.push('/major/promotion/list');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const [p, c, b] = await Promise.all([
api.post('products', { page: 1, limit: 500 }),
api.post('category', { page: 1, limit: 500 }),
api.get('brand'),
]);
products.value = p.data.rows ?? [];
categories.value = c.data.rows ?? [];
brands.value = b.data ?? [];
} catch (e) {
console.log(e);
}
if (props.allowCustomerScope) {
try {
const { data } = await api.get('query/customer');
customers.value = data;
} catch (e) {
console.log(e);
}
}
if (id) {
try {
const { data } = await api.get(`admin/promotions/${id}`);
Object.assign(form, {
type: data.type ?? 1,
title: data.title ?? '',
description: data.description ?? '',
discount_percent: data.discount_percent !== null ? Number(data.discount_percent) : undefined,
use_only: data.use_only ?? 1,
is_active: data.is_active ?? true,
start: data.start ? String(data.start).slice(0, 10) : undefined,
end: data.end ? String(data.end).slice(0, 10) : undefined,
product_id: data.product_id ?? undefined,
category_id: data.category_id ?? undefined,
customer_id: data.customer_id ?? undefined,
brand_id: data.brand_id ?? undefined,
});
} catch (e) {
console.log(e);
}
}
});
</script>
<style scoped>
.grid-4 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.5rem;
}
</style>
@@ -0,0 +1,84 @@
<template>
<!--
Ported from legacy henza_admin/src/views/promotion/autopromotion.vue —
the one-shot welcome discount for new customers. State toggle + percentage.
-->
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-spin :spinning="loading">
<a-card title="تخفیف اتوماتیک یکبار مصرف ویژه مشتریان جدید" class="mb-base">
<div class="grid-3">
<a-form-item label="وضعیت تخفیف اتوماتیک">
<a-switch v-model:checked="state" size="default" />
</a-form-item>
<a-form-item
label="درصد تخفیف اتوماتیک روی محصولات"
:validate-status="err.discount_percent ? 'error' : ''"
:help="err.discount_percent"
>
<a-input-number
v-model:value="form.discount_percent"
dir="ltr"
:min="0"
:max="100"
:formatter="(v: number | string) => `${v}%`"
:parser="(v: string) => v.replace('%', '')"
style="width: 100%"
/>
</a-form-item>
</div>
</a-card>
</a-spin>
<a-button type="primary" class="mr-4" @click="handleSubmit">ثبت اطلاعات</a-button>
</a-form>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import api from '../../api/client';
const loading = ref(false);
const state = ref(false);
const form = reactive<Record<string, any>>({ discount_percent: undefined });
const err = reactive<Record<string, string>>({});
async function handleSubmit() {
Object.keys(err).forEach((k) => delete err[k]);
if (form.discount_percent === undefined || form.discount_percent === null) {
err.discount_percent = 'وارد کردن درصد تخفیف اتوماتیک الزامی است';
return;
}
try {
loading.value = true;
await api.post('autopromotion', {
state: state.value,
discount_percent: form.discount_percent,
});
message.success('اطلاعات با موفقیت به ثبت رسید.');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const { data } = await api.get('autopromotion');
state.value = Boolean(data.state);
form.discount_percent = data.discount_percent ?? undefined;
} catch (e) {
console.log(e);
}
});
</script>
<style scoped>
.grid-3 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
</style>
@@ -0,0 +1,17 @@
// Ported from legacy henza_admin/src/table/promotion.js
import XTbl, { Xtc } from '../../components/x-table/model';
export function buildPromotionListModel(): XTbl {
const tbl = new XTbl('admin/promotions/list', 'لیست تخفیف ها');
tbl.add(new Xtc('id', 'کد').filterNumber());
tbl.add(new Xtc('title', 'نام پروموشن').filterText());
tbl.add(new Xtc('promotion_type_str', 'نوع پروموشن').noSort());
tbl.add(new Xtc('platform', 'پلتفرم نمایش ').noSort());
tbl.add(new Xtc('status', 'وضعیت').renderSlot('sts').noSort());
tbl.add(new Xtc('start', 'تاریخ شروع').renderSlot('startDate').noSort());
tbl.add(new Xtc('end', 'تاریخ پایان ').renderSlot('endDate').noSort());
tbl.add(new Xtc('action', 'عملیات').renderSlot('op').noSort());
return tbl;
}
export default buildPromotionListModel;
@@ -0,0 +1,13 @@
<template>
<!--
Ported from legacy henza_admin/src/views/promotion/promotion_add_edit.vue.
Verified by diff against promotion_tak_add_edit.vue: the two files differ
only in the card title and whether the "مشتری" scope is offered, so both are
thin wrappers over PromotionForm.
-->
<PromotionForm title="تعریف تخفیف" :allow-customer-scope="true" />
</template>
<script setup lang="ts">
import PromotionForm from './PromotionForm.vue';
</script>
@@ -0,0 +1,103 @@
<template>
<!--
Ported from legacy henza_admin/src/views/promotion/promotion_list.vue.
The row's `type_customer` (1 تک / 2 عمده) drives BOTH which permission gates
the icons and which edit screen opens — preserved exactly, including legacy's
commented-out `sts` and `confirmed` slots which stay absent.
-->
<div id="page-user-list">
<a-card title="لیست تخفیف ها">
<XTable ref="tbl" :model="model">
<template #sts="{ record }">
<a-tag v-if="record.status === 1" color="green">فعال</a-tag>
<a-tag v-else color="red">غیر فعال</a-tag>
</template>
<template #startDate="{ text }">
{{ text ? jdate(text) : '-' }}
</template>
<template #endDate="{ text }">
{{ text ? jdate(text) : '-' }}
</template>
<template #op="{ record }">
<a-tooltip v-if="canEdit(record)" title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="toEdit(record)" />
</a-tooltip>
<a-tooltip v-if="canRemove(record)" title="حذف">
<a-popconfirm
placement="top"
ok-text="بله"
cancel-text="خیر"
@confirm="deletePromotion(record)"
>
<template #title><p>آیا مطمئن به حذف تخفیف مورد نظر هستید ؟</p></template>
<DeleteOutlined class="cursor-pointer mx-1 text-danger" />
</a-popconfirm>
</a-tooltip>
</template>
</XTable>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import XTable from '../../components/x-table/XTable.vue';
import { buildPromotionListModel } from './promotionListModel';
import { useUsersStore } from '../../stores/users';
import { jdate } from '../../utils/filters';
import api from '../../api/client';
const router = useRouter();
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildPromotionListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
/** legacy: tak rows need the .tak.* rid, omde rows the plain one */
function canEdit(record: Record<string, any>) {
return (
(hasPermission('offers.list.tak.edit') && record.type_customer === 1) ||
(hasPermission('offers.list.edit') && record.type_customer === 2)
);
}
function canRemove(record: Record<string, any>) {
return (
(hasPermission('offers.list.tak.remove') && record.type_customer === 1) ||
(hasPermission('offers.list.remove') && record.type_customer === 2)
);
}
/**
* legacy toEdit(): opened the edit screen in a NEW TAB via window.open, routing
* to a different path per type_customer. Ported as an in-app navigation — a new
* tab would lose the SPA session state the panel relies on.
*/
function toEdit(record: Record<string, any>) {
if (record.type_customer === 2) {
router.push(`/major/promotion/edit/${record.id}`);
} else if (record.type_customer === 1) {
router.push(`/major/promotion/tak/edit/${record.id}`);
}
}
async function deletePromotion(record: Record<string, any>) {
try {
await api.delete(`major/promotion/${record.id}`);
message.success('تخفیف حذف شد');
tbl.value?.fetch();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در حذف تخفیف');
}
}
</script>
@@ -0,0 +1,12 @@
<template>
<!--
Ported from legacy henza_admin/src/views/promotion/promotion_tak_add_edit.vue.
Legacy has the "مشتری" scope option and its whole customer block COMMENTED
OUT in this variant — preserved as absent, not re-enabled.
-->
<PromotionForm title="تعریف تخفیف تک" :allow-customer-scope="false" />
</template>
<script setup lang="ts">
import PromotionForm from './PromotionForm.vue';
</script>
+124
View File
@@ -0,0 +1,124 @@
<template>
<!--
Ported from legacy henza_admin/src/views/reg.vue — a PUBLIC self-registration
form for new wholesale customers ("ثبت مشتری جدید (اطلاعات فروشنده)").
It sits on the full-page layout outside the authenticated shell, which is why
it carries no permission gate: legacy routed it at /reg alongside the login
page. Preserved.
-->
<div class="reg-page">
<a-spin :spinning="loading">
<a-card title="ثبت مشتری جدید (اطلاعات فروشنده)" class="reg-card">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<div class="grid-3">
<a-form-item label="نام " :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item
label="نام خانوادگی"
:validate-status="err.lname ? 'error' : ''"
:help="err.lname"
>
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item
label="شماره تلفن همراه"
:validate-status="err.mobile ? 'error' : ''"
:help="err.mobile"
>
<a-input v-model:value="form.mobile" />
</a-form-item>
<a-form-item label="نام مغازه">
<a-input v-model:value="form.store_name" />
</a-form-item>
<a-form-item label="تلفن">
<a-input v-model:value="form.phone" />
</a-form-item>
<a-form-item label="نام پاساژ">
<a-input v-model:value="form.pasazh_name" />
</a-form-item>
<a-form-item label="منطقه">
<a-input v-model:value="form.zone" />
</a-form-item>
</div>
<a-form-item label="آدرس">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
<a-button type="primary" size="large" :loading="loading" @click="handleSubmit">
ثبت
</a-button>
</a-form>
</a-card>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import api from '../api/client';
const loading = ref(false);
const form = reactive<Record<string, any>>({
fname: '',
lname: '',
mobile: '',
store_name: '',
phone: '',
pasazh_name: '',
zone: '',
address: '',
});
const err = reactive<Record<string, string>>({});
async function handleSubmit() {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.fname) err.fname = 'لطفا نام را وارد کنید';
if (!form.lname) err.lname = 'لطفا نام خانوادگی را وارد کنید';
if (!form.mobile) err.mobile = 'لطفا شماره تلفن همراه را وارد کنید';
if (Object.keys(err).length) return;
try {
loading.value = true;
await api.post('customer/create', {
...form,
name: `${form.fname} ${form.lname}`.trim(),
});
message.success('اطلاعات با موفقیت ثبت شد.');
Object.keys(form).forEach((k) => (form[k] = ''));
} catch (e: any) {
const mobileErr = e?.response?.data?.errors?.mobile?.[0];
message.error(mobileErr ?? e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.reg-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.reg-card {
width: 100%;
max-width: 900px;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
</style>
@@ -0,0 +1,103 @@
<template>
<!--
Ported from legacy henza_admin/src/views/report/order_item.vue —
"گزارش تجمیعی سفارشات و اسناد مالی" over a Jalali date range.
Legacy's own table-model import is COMMENTED OUT, so the grid was built
inline; the same is done here.
-->
<div id="page-user-list">
<a-spin :spinning="loading">
<a-card title="گزارش تجمیعی سفارشات و اسناد مالی">
<a-form layout="vertical">
<div class="grid-4">
<DateInput v-model="filters.start_date" label="از تاریخ" />
<DateInput v-model="filters.end_date" label="تا تاریخ" />
<a-form-item label=" ">
<a-button type="primary" @click="load">اعمال</a-button>
</a-form-item>
</div>
</a-form>
<div v-if="summary" class="summary">
<a-tag color="blue">تعداد سفارش: {{ summary.order_count }}</a-tag>
<a-tag color="blue">تعداد آیتم: {{ summary.item_count }}</a-tag>
<a-tag color="green">مجموع وزن: {{ summary.total_weight }} گرم</a-tag>
</div>
<a-table
class="mt-4"
size="small"
:data-source="rows"
:columns="columns"
:pagination="{ pageSize: 20 }"
row-key="order_id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">
{{ jdate(record.caj) }}
</template>
</template>
</a-table>
</a-card>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import DateInput from '../../components/input/DateInput.vue';
import { jdate } from '../../utils/filters';
import api from '../../api/client';
const loading = ref(false);
const rows = ref<Record<string, any>[]>([]);
const summary = ref<Record<string, any> | undefined>(undefined);
const filters = reactive<Record<string, string | undefined>>({
start_date: undefined,
end_date: undefined,
});
const columns = [
{ title: 'شماره سفارش', dataIndex: 'order_number' },
{ title: 'مشتری', dataIndex: 'customer' },
{ title: 'محصول', dataIndex: 'product' },
{ title: 'تنوع', dataIndex: 'variant' },
{ title: 'تعداد', dataIndex: 'qty' },
{ title: 'وزن واحد', dataIndex: 'w' },
{ title: 'وزن کل', dataIndex: 'total_w' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
async function load() {
try {
loading.value = true;
const { data } = await api.get('report/order-item', { params: filters });
rows.value = data.rows;
summary.value = data.summary;
} catch (e) {
console.log(e);
} finally {
loading.value = false;
}
}
onMounted(load);
</script>
<style scoped>
.grid-4 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.summary {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.mt-4 {
margin-top: 1rem;
}
</style>
@@ -0,0 +1,87 @@
<template>
<!--
Ported from legacy henza_admin/src/views/report/product.vue — per-product
sales over a Jalali date range, heaviest first.
-->
<div id="page-user-list">
<a-spin :spinning="loading">
<a-card title="گزارش محصولات">
<a-form layout="vertical">
<div class="grid-4">
<DateInput v-model="filters.start_date" label="از تاریخ" />
<DateInput v-model="filters.end_date" label="تا تاریخ" />
<a-form-item label=" ">
<a-button type="primary" @click="load">اعمال</a-button>
</a-form-item>
</div>
</a-form>
<div v-if="summary" class="summary">
<a-tag color="blue">تعداد محصول: {{ summary.product_count }}</a-tag>
</div>
<a-table
class="mt-4"
size="small"
:data-source="rows"
:columns="columns"
:pagination="{ pageSize: 20 }"
row-key="id"
/>
</a-card>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import DateInput from '../../components/input/DateInput.vue';
import api from '../../api/client';
const loading = ref(false);
const rows = ref<Record<string, any>[]>([]);
const summary = ref<Record<string, any> | undefined>(undefined);
const filters = reactive<Record<string, string | undefined>>({
start_date: undefined,
end_date: undefined,
});
const columns = [
{ title: 'نام محصول', dataIndex: 'title' },
{ title: 'تعداد فروش رفته', dataIndex: 'total' },
{ title: 'وزن فروش رفته', dataIndex: 'w' },
{ title: 'تعداد سفارش', dataIndex: 'order_count' },
];
async function load() {
try {
loading.value = true;
const { data } = await api.get('report/product', { params: filters });
rows.value = data.rows;
summary.value = data.summary;
} catch (e) {
console.log(e);
} finally {
loading.value = false;
}
}
onMounted(load);
</script>
<style scoped>
.grid-4 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.summary {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.mt-4 {
margin-top: 1rem;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<!--
Ported from legacy henza_admin/src/views/sms/create.vue.
Each audience option is gated by its own rid, exactly as legacy did — the
numeric codes (-1/0/1/2/9) are legacy's own and are preserved on the wire.
NOTE the "تک فروشی" audience has a rid (sms.create.tak) but NO option in the
legacy markup, so it is unreachable. Preserved as absent.
-->
<a-spin :spinning="loading">
<a-card title="ارسال پیامک">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<div class="grid-2">
<a-form-item label="ارسال برای">
<a-select v-model:value="type">
<a-select-option v-if="hasPermission('sms.create.single')" :value="-1">
شماره موبایل
</a-select-option>
<a-select-option v-if="hasPermission('sms.create.all')" :value="0">
همه مشتریان
</a-select-option>
<a-select-option v-if="hasPermission('sms.create.reagent')" :value="1">
کارشناسان فروش
</a-select-option>
<a-select-option v-if="hasPermission('sms.create.omde')" :value="2">
مشتریان عمده
</a-select-option>
<a-select-option v-if="hasPermission('sms.create.multiple')" :value="9">
شماره های موبایل
</a-select-option>
</a-select>
</a-form-item>
<a-form-item
v-if="type === -1 || type === 9"
:label="type === 9 ? 'شماره های موبایل' : 'شماره موبایل'"
:validate-status="err.mobile ? 'error' : ''"
:help="err.mobile"
>
<a-textarea
v-if="type === 9"
v-model:value="mobile"
:auto-size="{ minRows: 2, maxRows: 6 }"
placeholder="هر شماره در یک خط"
/>
<a-input v-else v-model:value="mobile" />
</a-form-item>
</div>
<a-form-item label="متن پیامک" :validate-status="err.text ? 'error' : ''" :help="err.text">
<a-textarea v-model:value="text" :auto-size="{ minRows: 4, maxRows: 8 }" />
</a-form-item>
<a-button type="primary" @click="handleSubmit">ارسال</a-button>
</a-form>
</a-card>
<a-card v-if="hasPermission('sms.list')" title="لیست پیامک ها" class="mt-4">
<XTable ref="tbl" :model="model" />
</a-card>
</a-spin>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import XTable from '../../components/x-table/XTable.vue';
import { buildSmsModel } from './smsTbl';
import { useUsersStore } from '../../stores/users';
import api from '../../api/client';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildSmsModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const loading = ref(false);
const type = ref<number>(-1);
const mobile = ref('');
const text = ref('');
const err = reactive<Record<string, string>>({});
async function handleSubmit() {
Object.keys(err).forEach((k) => delete err[k]);
if (!text.value) err.text = 'متن پیامک الزامی است';
if ((type.value === -1 || type.value === 9) && !mobile.value) {
err.mobile = 'شماره موبایل الزامی است';
}
if (Object.keys(err).length) return;
try {
loading.value = true;
const { data } = await api.post('sms', {
type: type.value,
mobile: mobile.value,
text: text.value,
});
message.success(`پیامک برای ${data.sentCount} شماره ثبت شد`);
text.value = '';
mobile.value = '';
tbl.value?.fetch();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ارسال پیامک');
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; }
.mt-4 { margin-top: 1rem; }
</style>
+16
View File
@@ -0,0 +1,16 @@
// The sent-SMS log. Legacy read it from `POST sms/table` inline in sms/create.vue
// rather than through a table model file.
import XTbl, { Xtc } from '../../components/x-table/model';
export function buildSmsModel(): XTbl {
const tbl = new XTbl('admin/misc/sms/list', 'لیست پیامک ها');
tbl.add(new Xtc('id', 'شماره'));
tbl.add(new Xtc('text', 'متن پیامک').filterKey('body').filterText());
tbl.add(new Xtc('mobile', 'گیرندگان').noSort());
tbl.add(new Xtc('sent_count', 'تعداد').noSort());
tbl.add(new Xtc('sender', 'ارسال کننده').noSort());
tbl.add(new Xtc('caj', 'تاریخ').filterKey('createdAt').filterDate().noSort());
return tbl;
}
export default buildSmsModel;
@@ -0,0 +1,92 @@
<template>
<!--
Ported from legacy henza_admin/src/views/statics/index.vue — the customer
activity log ("آمار"). Pick a customer, see their recent actions.
-->
<a-spin :spinning="loading">
<a-card title="آمار">
<a-form layout="vertical">
<div class="grid-2">
<a-form-item label="مشتری">
<a-select
v-model:value="customer"
show-search
allow-clear
placeholder="انتخاب کنید"
:filter-option="filterOption"
@change="load"
>
<a-select-option v-for="c in customers" :key="c.id" :value="c.id">
{{ c.name }} - {{ c.mobile }}
</a-select-option>
</a-select>
</a-form-item>
</div>
</a-form>
<a-table
:data-source="logs"
:columns="columns"
:pagination="{ pageSize: 20 }"
size="small"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">
{{ jdateTime(record.caj) }}
</template>
</template>
</a-table>
</a-card>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { jdateTime } from '../../utils/filters';
import api from '../../api/client';
const loading = ref(false);
const customers = ref<{ id: string; name: string; mobile: string }[]>([]);
const customer = ref<string | undefined>(undefined);
const logs = ref<Record<string, any>[]>([]);
const columns = [
{ title: 'عملیات', dataIndex: 'action' },
{ title: 'جزئیات', dataIndex: 'detail' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
}
async function load() {
if (!customer.value) {
logs.value = [];
return;
}
try {
loading.value = true;
const { data } = await api.get(`log/ago/${customer.value}`);
logs.value = data;
} catch (e) {
console.log(e);
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const { data } = await api.get('log/list/customer');
customers.value = data;
} catch (e) {
console.log(e);
}
});
</script>
<style scoped>
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1rem; }
</style>

Some files were not shown because too many files have changed in this diff Show More