feat: complete admin panel and backend admin api migration
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 */
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user