Files
tehran_taj/apps/admin-panel/src/views/order/create.vue
T

489 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 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">
<XTable ref="tblProducts" :model="cols" mobile-cards>
<template #image="{ record }">
<img
width="90px"
height="90px"
style="object-fit: scale-down"
:src="record.thumb && record.thumb.full ? imageUrl(record.thumb.full, 200) : 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"
:size="layout.isMobile ? 'small' : 'middle'"
:scroll="layout.isSmallScreen ? { x: 'max-content' } : undefined"
>
<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 === '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
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 form-submit"
:disabled="!items || items.length < 1"
@click="submitOrder"
>
ثبت تغییرات
</a-button>
</a-form>
</a-card>
</a-card>
</a-spin>
</template>
<script setup lang="ts">
import { optionText } from '../../utils/select';
import { imageUrl } from '../../utils/image';
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 { useLayoutStore } from '../../stores/layout';
import api from '../../api/client';
const router = useRouter();
const layout = useLayoutStore();
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(users.isSuperAdmin);
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: '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 }>({});
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 imageUrl(product?.thumb?.full ?? product?.ProductImage?.[0]?.image, 200) ?? 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) });
}
/** 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));
}
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 optionText(option)
.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;
// No `percent`: the server prices each line at what the app would charge
// this customer (their percent + the variant's − the largest promotion),
// so the same order costs the same from here as from the app.
const order_items = items.value.map((x) => ({
vid: x.vid,
qty: x.qty,
promotion_id: x.promotion_id,
rial_input: lineRial(x),
}));
// 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;
}
@media (max-width: 575px) {
.grid-5 {
grid-template-columns: 1fr 1fr;
column-gap: 12px;
row-gap: 0;
}
.grid-5 > :first-child {
grid-column: 1 / -1;
}
.mt-5 {
margin-top: 12px;
}
.mt-4 {
margin-top: 12px;
}
.totals {
margin: 0 0 12px;
}
}
</style>