Remove user-app v1 and ship pending order, auth, and app fixes.
Drop the duplicate customer app from the monorepo and keep user-app-v2 as the only app; include order pricing, admin cart, OTP/SMS, and dashboard cache updates. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,6 +12,9 @@
|
||||
<MenuUnfoldOutlined v-if="collapsed" />
|
||||
<MenuFoldOutlined v-else />
|
||||
</a-button>
|
||||
<a-button type="text" class="nav-back" title="بازگشت" aria-label="بازگشت" @click="goBack">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<span class="page-title">{{ pageTitle }}</span>
|
||||
<a-breadcrumb v-if="breadcrumb" class="breadcrumb">
|
||||
<a-breadcrumb-item v-for="(crumb, i) in breadcrumb" :key="i">
|
||||
@@ -75,6 +78,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
ArrowRightOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
@@ -104,6 +108,22 @@ const displayName = computed(() => auth.user?.name ?? auth.user?.fullName ?? 'ک
|
||||
const initial = computed(() => displayName.value.trim().charAt(0) || 'ک');
|
||||
const canSeePermissions = computed(() => Boolean(users.hasPermission('user.permission')));
|
||||
|
||||
function goBack() {
|
||||
const parent = breadcrumb.value
|
||||
?.slice()
|
||||
.reverse()
|
||||
.find((c) => c.url && !c.active);
|
||||
if (parent?.url) {
|
||||
router.push(parent.url);
|
||||
return;
|
||||
}
|
||||
if (window.history.length > 1) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
router.push('/');
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
const left = await auth.logout();
|
||||
if (!left) router.push('/auth/login');
|
||||
@@ -133,6 +153,10 @@ async function onLogout() {
|
||||
.nav-right {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-back {
|
||||
color: #1c2d56;
|
||||
font-size: 16px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
color: #1c2d56;
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface CartVariant {
|
||||
price_with_discount?: number;
|
||||
grams?: number;
|
||||
title?: string;
|
||||
/** Fixed rial making charge on the variant (legacy rial_pay). */
|
||||
rial_pay?: number | null;
|
||||
percentage_pay?: number | null;
|
||||
}
|
||||
|
||||
export interface CartProduct {
|
||||
@@ -38,6 +41,11 @@ export interface CartItem {
|
||||
product: CartProduct;
|
||||
qty: number;
|
||||
promotion_id?: string | null;
|
||||
/**
|
||||
* Operator's per-unit rial charge for this line. Unset means "use the
|
||||
* variant's rial_pay"; 0 means the operator cleared it.
|
||||
*/
|
||||
rial_input?: number | null;
|
||||
}
|
||||
|
||||
export const useCartStore = defineStore('cart', {
|
||||
@@ -82,6 +90,15 @@ export const useCartStore = defineStore('cart', {
|
||||
this.setItems(this.items);
|
||||
},
|
||||
|
||||
/** Keeps an edited rial charge on the line across quantity changes and reloads. */
|
||||
updateItemRial(payload: { index: number; rial: number | null }) {
|
||||
const item = this.items[payload.index];
|
||||
if (!item) return;
|
||||
const n = payload.rial == null ? 0 : Number(payload.rial);
|
||||
item.rial_input = Number.isFinite(n) ? n : 0;
|
||||
this.setItems(this.items);
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.items = [];
|
||||
this.setItems(this.items);
|
||||
@@ -112,6 +129,7 @@ export const useCartStore = defineStore('cart', {
|
||||
product,
|
||||
qty: cartItem.qty,
|
||||
promotion_id: product.promotion && product.promotion.id ? product.promotion.id : null,
|
||||
...(cartItem.rial_input != null ? { rial_input: Number(cartItem.rial_input) } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,17 @@
|
||||
{{ record.product.title }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'rial'">
|
||||
<a-input-number
|
||||
:value="lineRial(record)"
|
||||
:min="0"
|
||||
:step="50000"
|
||||
style="width: 140px"
|
||||
:formatter="rialFormatter"
|
||||
:parser="rialParser"
|
||||
@change="onRialChange(index, $event)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'qty'">
|
||||
<a-input-number
|
||||
v-model:value="items[index].qty"
|
||||
@@ -146,11 +157,16 @@ const items = computed(() => cart.items);
|
||||
const columns = [
|
||||
{ title: 'حذف', dataIndex: 'delete', width: 60, align: 'center' },
|
||||
{ title: 'محصول', dataIndex: 'product' },
|
||||
{ title: 'اجرت ریالی', dataIndex: 'rial', align: 'center', width: 160 },
|
||||
{ title: 'تعداد', dataIndex: 'qty', align: 'center', width: 140 },
|
||||
{ title: 'وزن', dataIndex: 'weight', align: 'center', width: 90 },
|
||||
{ title: 'مجموع', dataIndex: 'total', align: 'center' },
|
||||
];
|
||||
|
||||
const rialFormatter = (val: string | number | undefined) =>
|
||||
val ? `${val}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',') : '';
|
||||
const rialParser = (val: string | undefined) => (val ? val.replace(/,/g, '') : '');
|
||||
|
||||
function getVariant(item: CartItem): CartVariant & Record<string, any> {
|
||||
return (item.product.variants_all.find((x) => x.id === item.vid) ?? {}) as CartVariant;
|
||||
}
|
||||
@@ -171,6 +187,20 @@ function onQtyChange(index: number, value: unknown) {
|
||||
cart.updateItemQuantity({ index, quantity: Number(value) });
|
||||
}
|
||||
|
||||
/** Unset on the line means the variant's rial_pay; 0 means the operator cleared it. */
|
||||
function lineRial(item: CartItem): number {
|
||||
if (item.rial_input != null) {
|
||||
const n = Number(item.rial_input);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
const n = Number(getVariant(item).rial_pay ?? 0);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function onRialChange(index: number, value: number | string | null) {
|
||||
cart.updateItemRial({ index, rial: value == null || value === '' ? 0 : Number(value) });
|
||||
}
|
||||
|
||||
function onVariantPick(record: Record<string, any>, e: { key: string | number }) {
|
||||
cart.add({
|
||||
vid: String(e.key),
|
||||
@@ -209,7 +239,7 @@ async function submitOrder() {
|
||||
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 };
|
||||
return { vid: x.vid, qty: x.qty, percent: v.percentage_pay, rial_input: lineRial(x) };
|
||||
});
|
||||
|
||||
await api.post('order', {
|
||||
|
||||
@@ -132,6 +132,35 @@
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- دکمه سفارشی — یک دکمه با عنوان و لینک، قابل فعال/غیرفعال -->
|
||||
<a-tab-pane key="custom-button" tab="دکمه سفارشی">
|
||||
<a-form layout="vertical" class="custom-btn-form" @submit.prevent="saveCustomButton">
|
||||
<a-form-item label="وضعیت">
|
||||
<a-switch
|
||||
v-model:checked="customButton.enabled"
|
||||
checked-children="فعال"
|
||||
un-checked-children="غیرفعال"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="عنوان دکمه">
|
||||
<a-input
|
||||
v-model:value="customButton.title"
|
||||
placeholder="مثلاً پشتیبانی تلگرام"
|
||||
style="max-width: 360px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="لینک">
|
||||
<a-input
|
||||
v-model:value="customButton.link"
|
||||
placeholder="https://..."
|
||||
style="max-width: 360px"
|
||||
dir="ltr"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-button type="primary" @click="saveCustomButton">ذخیره دکمه</a-button>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- سینیها — super admin only -->
|
||||
<a-tab-pane v-if="users.isSuperAdmin" key="collections" tab="سینی ها">
|
||||
<div class="row">
|
||||
@@ -218,6 +247,9 @@ const tagsInputValue = ref('');
|
||||
// ── mobile slider (legacy key: mobile_main_slider)
|
||||
const sliders = ref<{ image: string; link: string }[]>([]);
|
||||
|
||||
// ── custom button (Setting key: custom_button)
|
||||
const customButton = ref({ enabled: false, title: '', link: '' });
|
||||
|
||||
// ── collections
|
||||
const collections = ref<{ id: string; title: string }[]>([]);
|
||||
const newCollection = ref('');
|
||||
@@ -315,6 +347,27 @@ async function saveSliders() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCustomButton() {
|
||||
const title = customButton.value.title.trim();
|
||||
const link = customButton.value.link.trim();
|
||||
if (customButton.value.enabled && (!title || !link)) {
|
||||
message.warning('برای فعالسازی، عنوان و لینک را وارد کنید');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put('const/custom_button', {
|
||||
val: {
|
||||
enabled: customButton.value.enabled,
|
||||
title,
|
||||
link,
|
||||
},
|
||||
});
|
||||
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 });
|
||||
@@ -363,9 +416,20 @@ onMounted(async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
await Promise.all([loadBrands(), users.isSuperAdmin ? loadCollections() : Promise.resolve()]);
|
||||
const [t, s] = await Promise.all([api.get('const/__list__tags'), api.get('const/slider')]);
|
||||
const [t, s, btn] = await Promise.all([
|
||||
api.get('const/__list__tags'),
|
||||
api.get('const/slider'),
|
||||
api.get('const/custom_button'),
|
||||
]);
|
||||
tags.value = Array.isArray(t.data) ? t.data : [];
|
||||
sliders.value = Array.isArray(s.data) ? s.data : [];
|
||||
if (btn.data && typeof btn.data === 'object' && !Array.isArray(btn.data)) {
|
||||
customButton.value = {
|
||||
enabled: Boolean(btn.data.enabled),
|
||||
title: String(btn.data.title ?? ''),
|
||||
link: String(btn.data.link ?? ''),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
} finally {
|
||||
@@ -446,6 +510,9 @@ onMounted(async () => {
|
||||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.custom-btn-form {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
/* the add-row input fills what the button leaves instead of a fixed 260px */
|
||||
|
||||
@@ -128,6 +128,9 @@
|
||||
/>
|
||||
<a-tag v-else color="orange">{{ item.qty }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="Number(item.flatRialCharge) > 0" label="اجرت ریالی">
|
||||
<a-tag>{{ Number(item.flatRialCharge).toLocaleString('fa-IR') }} ریال</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="توضیحات">
|
||||
<div>{{ item.note }}</div>
|
||||
</a-descriptions-item>
|
||||
|
||||
@@ -121,6 +121,9 @@
|
||||
<a-descriptions-item label="تعداد">
|
||||
<a-tag color="orange">{{ item.qty }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="Number(item.flatRialCharge) > 0" label="اجرت ریالی">
|
||||
<a-tag>{{ Number(item.flatRialCharge).toLocaleString('fa-IR') }} ریال</a-tag>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-divider v-if="index < tempRow.items.length - 1" />
|
||||
</div>
|
||||
|
||||
@@ -108,6 +108,9 @@
|
||||
<a-descriptions-item label="تعداد">
|
||||
<a-tag color="orange">{{ item.qty }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="Number(item.flatRialCharge) > 0" label="اجرت ریالی">
|
||||
<a-tag>{{ Number(item.flatRialCharge).toLocaleString('fa-IR') }} ریال</a-tag>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-divider v-if="index < tempRow.items.length - 1" />
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
|
||||
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.
|
||||
wage is a percentage). Those cells show the gram weight and percent wage.
|
||||
The fixed rial making charge (rial_pay) is its own editable column and is
|
||||
sent as rial_input.
|
||||
-->
|
||||
<a-spin v-if="hasPermission('order.create')" :spinning="loading">
|
||||
<a-card title="لیست محصولات" class="page-card">
|
||||
@@ -67,6 +68,18 @@
|
||||
{{ getVariant(record).percentage_pay }}٪
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'rial'">
|
||||
<a-input-number
|
||||
:value="lineRial(record)"
|
||||
:min="0"
|
||||
:step="50000"
|
||||
style="width: 140px"
|
||||
:formatter="rialFormatter"
|
||||
:parser="rialParser"
|
||||
@change="onRialChange(index, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'qty'">
|
||||
<!--
|
||||
legacy capped quantity at the variant's stock, falling back to
|
||||
@@ -262,11 +275,16 @@ const columns = [
|
||||
{ title: 'کد محصول', dataIndex: 'pid', align: 'center', width: 90 },
|
||||
{ title: 'محصول', dataIndex: 'product' },
|
||||
{ title: 'اجرت', dataIndex: 'wage', align: 'center' },
|
||||
{ title: 'اجرت ریالی', dataIndex: 'rial', align: 'center', width: 160 },
|
||||
{ title: 'تعداد', dataIndex: 'qty', align: 'center', width: 140 },
|
||||
{ title: 'وزن', dataIndex: 'weight', align: 'center', width: 90 },
|
||||
{ title: 'مجموع', dataIndex: 'total', align: 'center' },
|
||||
];
|
||||
|
||||
const rialFormatter = (val: string | number | undefined) =>
|
||||
val ? `${val}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',') : '';
|
||||
const rialParser = (val: string | undefined) => (val ? val.replace(/,/g, '') : '');
|
||||
|
||||
// ── inline weight editor
|
||||
const variant = reactive<{ id?: string; grams?: number }>({});
|
||||
|
||||
@@ -303,6 +321,20 @@ function onQtyChange(index: number, value: unknown) {
|
||||
cart.updateItemQuantity({ index, quantity: Number(value) });
|
||||
}
|
||||
|
||||
/** Unset on the line means the variant's rial_pay; 0 means the operator cleared it. */
|
||||
function lineRial(item: CartItem): number {
|
||||
if (item.rial_input != null) {
|
||||
const n = Number(item.rial_input);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
const n = Number(getVariant(item).rial_pay ?? 0);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function onRialChange(index: number, value: number | string | null) {
|
||||
cart.updateItemRial({ index, rial: value == null || value === '' ? 0 : Number(value) });
|
||||
}
|
||||
|
||||
function onVariantPick(record: Record<string, any>, e: { key: string | number }) {
|
||||
addToCart(record, String(e.key));
|
||||
}
|
||||
@@ -374,6 +406,7 @@ async function submitOrder() {
|
||||
qty: x.qty,
|
||||
percent: v.percentage_pay,
|
||||
promotion_id: x.promotion_id,
|
||||
rial_input: lineRial(x),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user