feat: order revision, rial wage from variant, product tags; drop user-app-v2

Admin order edit with category chips and timeline; customer checkout charges variant rialWage; product tags; retire the unused user-app-v2 tree.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
saeed
2026-10-28 14:29:12 +03:30
co-authored by Cursor
parent 4e09a66fbf
commit feb5727c0e
429 changed files with 3942 additions and 30302 deletions
-1
View File
@@ -20,7 +20,6 @@ DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGR
API_PORT=3000
ADMIN_PANEL_PORT=5180
USER_APP_PORT=5181
USER_APP_V2_PORT=5182
# API Configuration (kept for any tooling that still reads bare PORT)
PORT=3000
+4 -4
View File
@@ -196,7 +196,7 @@ container.
| `JWT_EXPIRES_IN` | `12h` | Admin token lifetime. |
| `JWT_CUSTOMER_EXPIRES_IN` | `30d` | Customer token lifetime. |
| `UPLOAD_DIR` | `<api>/storage/uploads` | Where uploaded files are written. |
| `ADMIN_PANEL_PORT`, `USER_APP_PORT`, `USER_APP_V2_PORT` | 5180–5182 | Dev servers only. |
| `ADMIN_PANEL_PORT`, `USER_APP_PORT` | 5180–5181 | Dev servers only. |
| `RUN_MIGRATIONS` | `true` | Set `false` to skip the boot-time migration run. |
| `RUN_SEED` | `true` | Set `false` to skip seeding. |
| `ACCOUNTING_SECRET_KEY` | *(derived from `JWT_SECRET`)* | Encrypts the accounting-software token the admin enters (D27). Set its own long random value; changing it later means re-entering the token. |
@@ -240,7 +240,7 @@ pnpm dev # all apps via turbo
```
Ports come from the repo-root `.env` (single source of truth):
API 3000, admin-panel 5180, user-app 5181, user-app-v2 5182.
API 3000, admin-panel 5180, user-app 5181.
### Build
@@ -263,8 +263,8 @@ docker compose up -d --build
> success. During this delivery's compose run that bundle was four days old and
> the container was missing every route added since. Tracked as **DEPLOY-03**.
Services: `db` (Postgres 15), `api` (port 3000), `admin-panel`, `user-app`,
`user-app-v2` (each nginx on port 80). The apps expose no host ports —
Services: `db` (Postgres 15), `api` (port 3000), `admin-panel`, `user-app`
(each nginx on port 80). The apps expose no host ports —
Dokploy/Traefik routes to them by domain. Uploads persist in the `api_uploads`
volume; without it every previously uploaded image 404s after a redeploy.
+1 -3
View File
@@ -8,7 +8,7 @@ Dokploy only builds thin images that copy those files (no `pnpm install` on the
```bash
pnpm install
pnpm build:release
git add -f apps/api/release apps/admin-panel/dist apps/user-app/dist apps/user-app-v2/dist
git add -f apps/api/release apps/admin-panel/dist apps/user-app/dist
git commit -m "chore: refresh release artifacts"
git push
```
@@ -20,7 +20,6 @@ What this produces:
| API | `apps/api/release/app.js` (+ Prisma under `release/vendor`) | `docker.arvancloud.ir/node:20-bookworm` |
| Admin | `apps/admin-panel/dist/` | `docker.arvancloud.ir/nginx:latest` |
| User | `apps/user-app/dist/` | `docker.arvancloud.ir/nginx:latest` |
| User v2 | `apps/user-app-v2/dist/` | `docker.arvancloud.ir/nginx:latest` |
## Dokploy Environment
@@ -46,7 +45,6 @@ different Compose project name for each application.
|---|---|---|
| `admin-panel` | admin.example.com | **80** |
| `user-app` | app.example.com | **80** |
| `user-app-v2` | app-v2.example.com | **80** |
| `api` | (optional) | **3000** |
Frontends call `/api` on their own domain; nginx proxies to `api:3000`.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

+1
View File
@@ -89,6 +89,7 @@ const EXACT: Record<string, string> = {
// ── products (Batch 6) ────────────────────────────────────────────
products: 'admin/products/list',
'product/create': 'admin/products/create',
'product/categories': 'admin/products/categories',
'get/variants': 'admin/products/variants/list',
'change/percent/count': 'admin/products/change-percent/count',
'change/all': 'admin/products/change-percent/apply',
+75
View File
@@ -0,0 +1,75 @@
// Mirrors apps/user-app/src/utils/orderStatus.ts — same labels, blurbs and
// colors so the admin status modal feels like the customer timeline sheet.
import type { Component } from 'vue';
import {
CarOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
FileDoneOutlined,
GiftOutlined,
InboxOutlined,
PauseCircleOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons-vue';
export const ORDER_STATUS_LABELS: Record<string, string> = {
SUSPENDED: 'معلق',
PLACED: 'ثبت سفارش',
ADMIN_APPROVED: 'تایید مدیر',
SOURCING: 'در حال تامین',
PREPARING: 'آماده‌سازی',
DELIVERED_TO_AGENT: 'تحویل نماینده',
DELIVERED_TO_CUSTOMER: 'تحویل مشتری',
CANCELLED: 'لغو شده',
};
export const ORDER_STATUS_BLURBS: Record<string, string> = {
SUSPENDED: 'سفارش به‌دلیل سقف اعتبار معلق شد',
PLACED: 'سفارش ثبت و دریافت شد',
ADMIN_APPROVED: 'سفارش توسط مدیر تایید شد',
SOURCING: 'کالا در حال تامین است',
PREPARING: 'سفارش در حال آماده‌سازی است',
DELIVERED_TO_AGENT: 'تحویل به نماینده فروش انجام شد',
DELIVERED_TO_CUSTOMER: 'سفارش به مشتری تحویل شد',
CANCELLED: 'سفارش لغو شده است',
};
export const ORDER_STATUS_COLORS: Record<string, { font: string; background: string }> = {
SUSPENDED: { font: '#A16207', background: '#A162071F' },
PLACED: { font: '#3B82F6', background: '#3B82F61F' },
ADMIN_APPROVED: { font: '#6366F1', background: '#6366F11F' },
SOURCING: { font: '#EAB308', background: '#EAB3081F' },
PREPARING: { font: '#F97316', background: '#F973161F' },
DELIVERED_TO_AGENT: { font: '#06B6D4', background: '#06B6D41F' },
DELIVERED_TO_CUSTOMER: { font: '#22C55E', background: '#22C55E1F' },
CANCELLED: { font: '#DC2626', background: '#DC26261F' },
};
export const ORDER_STATUS_ICONS: Record<string, Component> = {
SUSPENDED: PauseCircleOutlined,
PLACED: FileDoneOutlined,
ADMIN_APPROVED: SafetyCertificateOutlined,
SOURCING: InboxOutlined,
PREPARING: GiftOutlined,
DELIVERED_TO_AGENT: CarOutlined,
DELIVERED_TO_CUSTOMER: CheckCircleOutlined,
CANCELLED: CloseCircleOutlined,
};
const FALLBACK_COLOR = { font: '#64748B', background: '#64748B1F' };
export function orderStatusColor(status: string) {
return ORDER_STATUS_COLORS[status] ?? FALLBACK_COLOR;
}
export function orderStatusLabel(status: string) {
return ORDER_STATUS_LABELS[status] ?? status;
}
export function orderStatusBlurb(status: string) {
return ORDER_STATUS_BLURBS[status] ?? '';
}
export function orderStatusIcon(status: string) {
return ORDER_STATUS_ICONS[status] ?? FileDoneOutlined;
}
@@ -9,8 +9,9 @@
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, and the variant colour/rial-wage fields.
delivery_type, message_long_delivery, offer_day, has_warranty, is_exist.
Tags are wired again: chosen from Setting `__list__tags` and stored on
Product.tags; the storefront shows them on list cards only.
The image uploaders ARE rendered (unlike the note above once claimed):
Product.images is a real ProductImage[] relation and `file/create` is a real,
@@ -256,7 +257,7 @@
<span class="pf-section__icon"><TagsOutlined /></span>
<div>
<h3>دسته‌بندی</h3>
<p>دسته، برند و سینی‌های محصول</p>
<p>دسته، برند، سینی و برچسب‌های محصول</p>
</div>
</div>
@@ -268,7 +269,7 @@
</a-select>
</a-form-item>
<a-form-item label="برند" :class="{ 'mb-0': !users.isSuperAdmin }">
<a-form-item label="برند">
<a-select v-model:value="form.brand_id" allow-clear show-search option-filter-prop="label" placeholder="انتخاب کنید">
<a-select-option v-for="b in brands" :key="b.id" :value="b.id" :label="b.title">
{{ b.title }}
@@ -277,7 +278,7 @@
</a-form-item>
<!-- legacy "سینی ها" — the Collection grouping -->
<a-form-item v-if="users.isSuperAdmin" label="سینی ها" class="mb-0">
<a-form-item v-if="users.isSuperAdmin" label="سینی ها">
<a-select
v-model:value="form.ciny_ids"
mode="multiple"
@@ -290,6 +291,17 @@
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="برچسب ها" class="mb-0">
<a-select
v-model:value="form.tags"
mode="multiple"
allow-clear
option-filter-prop="label"
placeholder="انتخاب از فهرست برچسب‌ها"
:options="tagOptions.map((t) => ({ value: t, label: t }))"
/>
</a-form-item>
</section>
<section class="pf-section">
@@ -529,6 +541,7 @@ const activeGalleryTab = ref<'site' | 'mobile'>('site');
const brands = ref<{ id: string; title: string }[]>([]);
const categories = ref<{ id: string; title: string }[]>([]);
const collections = ref<{ id: string; title: string }[]>([]);
const tagOptions = ref<string[]>([]);
const form = reactive<Record<string, any>>({
title: '',
@@ -537,6 +550,7 @@ const form = reactive<Record<string, any>>({
category_id: undefined,
brand_id: undefined,
ciny_ids: [] as string[],
tags: [] as string[],
body_html: '',
is_active: true,
indicator_id: null as string | null,
@@ -715,11 +729,12 @@ async function handleSubmit() {
onMounted(async () => {
try {
const [b, c, col, colorsRes] = await Promise.all([
const [b, c, col, colorsRes, tagsRes] = await Promise.all([
api.get('brand'),
api.post('category'),
users.isSuperAdmin ? api.get('admin/products/collections') : Promise.resolve({ data: [] }),
api.get('admin/products/colors').catch(() => ({ data: [] })),
api.get('const/__list__tags').catch(() => ({ data: [] })),
]);
brands.value = b.data;
categories.value = (c.data.rows ?? c.data).map((x: any) => ({
@@ -729,6 +744,7 @@ onMounted(async () => {
}));
collections.value = col.data;
colorOptions.value = colorsRes.data ?? [];
tagOptions.value = Array.isArray(tagsRes.data) ? tagsRes.data.map(String) : [];
} catch (e) {
console.log(e);
}
@@ -746,6 +762,7 @@ onMounted(async () => {
category_id: data.category?.id ?? undefined,
brand_id: data.brand?.id ?? undefined,
ciny_ids: data.ciny_ids ?? [],
tags: Array.isArray(data.tags) ? data.tags : [],
body_html: data.body_html ?? '',
is_active: data.is_active ?? true,
indicator_id: data.indicator_id ?? null,
@@ -29,6 +29,13 @@
<span v-else>-</span>
</template>
<template #tags="{ record }">
<div v-if="record.tags?.length" class="product-tags">
<a-tag v-for="tag in record.tags" :key="tag">{{ tag }}</a-tag>
</div>
<span v-else class="text-muted">—</span>
</template>
<template #show_type="{ record }">
{{ record.show_type_str }}
</template>
@@ -82,6 +89,12 @@
<a-descriptions-item label="دسته بندی">
{{ tempRow.category?.title || '-' }}
</a-descriptions-item>
<a-descriptions-item label="برچسب‌ها">
<template v-if="tempRow.tags?.length">
<a-tag v-for="tag in tempRow.tags" :key="tag">{{ tag }}</a-tag>
</template>
<span v-else>-</span>
</a-descriptions-item>
<a-descriptions-item v-if="users.isSuperAdmin" label="نوع نمایش">{{ tempRow.show_type_str }}</a-descriptions-item>
<a-descriptions-item label="تعداد تنوع">
{{ tempRow.variants_all?.length ?? tempRow.variants_count ?? 0 }}
@@ -184,6 +197,16 @@ function toEdit(id: string) {
border-radius: 4px;
}
.product-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.text-muted {
color: rgba(0, 0, 0, 0.35);
}
@media (max-width: 575px) {
/* the actions sit in the card header on phones; a fixed 12rem would crowd the title */
.w-48 {
@@ -15,6 +15,7 @@ export function buildProductListModel(withShowType = true): XTbl {
// 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('tags', 'برچسب‌ها').renderSlot('tags').noSort());
if (withShowType) {
tbl.add(
new Xtc('show_type', 'نوع نمایش')
@@ -35,7 +35,7 @@ export function buildOmdeListModel(source: OmdeSourceKey): XTbl {
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('mobile', 'موبایل').filterText().noSort());
tbl.add(
new Xtc('city_id', 'شهر')
.hidden()
@@ -18,7 +18,7 @@ export function buildReagentListModel(): XTbl {
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('mobile', 'موبایل').filterText().noSort());
tbl.add(
new Xtc('city_id', 'شهر')
.hidden()
+167 -112
View File
@@ -51,103 +51,60 @@
</XTable>
<!-- وضعیت سفارش -->
<a-modal v-model:open="visibleSts" title="وضعیت سفارش" wrap-class-name="detail-modal">
<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>
<a-modal
v-model:open="visibleSts"
title="وضعیت سفارش"
width="480px"
wrap-class-name="detail-modal"
>
<OrderStatusTimeline
v-if="tempRow"
:activity="tempRow.activity || []"
:can-submit="hasPermission('order.list.sts.submit')"
:can-remove="hasPermission('order.list.sts.remove')"
@submit="setSts"
@remove="removeSts"
/>
<template #footer>
<a-button @click="visibleSts = false">باشه</a-button>
<a-button @click="visibleSts = false">بستن</a-button>
</template>
</a-modal>
<!-- مشاهده سفارش -->
<a-modal v-model:open="visibleList" title="مشاهده سفارش" width="80%" wrap-class-name="detail-modal">
<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 />
<!-- مشاهده / ویرایش سفارش -->
<a-modal
v-model:open="visibleList"
title="مشاهده سفارش"
:width="editMode && canEditOrder ? 1120 : 760"
wrap-class-name="detail-modal order-detail-modal"
:body-style="{ maxHeight: '78vh', overflowY: 'auto' }"
>
<OrderDetailBody
:order="tempRow"
:edit-mode="editMode && canEditOrder"
v-model:add-variant-id="addVariantId"
v-model:add-qty="addQty"
@remove-line="removeLine"
@add-line="onAddLine"
/>
<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 class="flex justify-between flex-wrap gap-2">
<a-button @click="closeDetail">بستن</a-button>
<div class="flex gap-2 flex-wrap">
<template v-if="tempRow?.revisionStatus === 'AWAITING_CUSTOMER' && canEditOrder">
<a-button @click="discardEdit">لغو پیشنهاد</a-button>
<a-button type="primary" danger @click="forceApplyEdit">اعمال اجباری</a-button>
</template>
<template v-else-if="canEditOrder">
<a-button v-if="!editMode" type="primary" @click="editMode = true">شروع ویرایش</a-button>
<template v-else>
<a-button @click="cancelEdit">انصراف</a-button>
<a-button type="primary" danger :loading="saving" @click="submitEdit">
ارسال برای تأیید مشتری
</a-button>
</template>
</template>
</div>
</div>
</template>
</a-modal>
@@ -160,11 +117,13 @@ 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 OrderDetailBody from './OrderDetailBody.vue';
import OrderStatusTimeline from './OrderStatusTimeline.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';
import { priceUnit } from '../../utils/filters';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
@@ -173,17 +132,16 @@ const model = buildOrderListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const detailSnapshot = ref<string>('');
const visibleList = ref(false);
const visibleSts = ref(false);
const editMode = ref(false);
const saving = ref(false);
const addVariantId = ref('');
const addQty = ref(1);
const is_branch = computed(() => isBranch());
const formatDate = (v: string | null) => (v ? jdate(v) : '');
function getColorSts(row: { proceed?: boolean }) {
return row.proceed ? 'green' : 'gray';
}
const canEditOrder = computed(() => hasPermission('order.list.view.edit'));
async function showSts(row: Record<string, any>) {
// legacy: branch orders have no editable status timeline
@@ -200,12 +158,8 @@ async function loadDetail(id: string) {
/**
* Both handlers receive one ACTIVITY entry from the timeline
* (`{status, title, proceed, uaj, note}` — admin-orders.service.ts:249-258),
* which carries no order id. Posting that object straight through left
* `orderId` missing and UpdateOrderStatusDto rejected every call with 400, so
* neither "ثبت وضعیت" nor "حذف وضعیت" worked on this screen at all. The id
* comes from `tempRow`, the order loaded when the dialog opened. OmdeList.vue
* already built its body this way (OmdeList.vue:187).
* (`{status, title, proceed, uaj, note}`), which carries no order id.
* The id comes from `tempRow`, the order loaded when the dialog opened.
*/
async function setSts(row: Record<string, any>) {
try {
@@ -238,21 +192,122 @@ async function removeSts(row: Record<string, any>) {
}
}
async function editRow() {
function closeDetail() {
visibleList.value = false;
editMode.value = false;
}
function cancelEdit() {
if (detailSnapshot.value) {
try {
tempRow.value = JSON.parse(detailSnapshot.value);
} catch {
/* keep */
}
}
editMode.value = false;
}
function removeLine(index: number) {
if (!tempRow.value?.items) return;
if (tempRow.value.items.length <= 1) {
message.warning('حداقل یک آیتم باید در سفارش بماند');
return;
}
tempRow.value.items.splice(index, 1);
}
function onAddLine(payload: {
variantId: string;
qty: number;
title?: string;
variantTitle?: string;
grams?: number;
wagePercent?: number;
flatRialCharge?: number;
image?: string | null;
}) {
const vid = payload.variantId.trim();
if (!vid) {
message.warning('یک تنوع انتخاب کنید');
return;
}
if (!tempRow.value) return;
tempRow.value.items.push({
productVariantId: vid,
title: payload.title ?? 'آیتم جدید',
variant_title: payload.variantTitle ?? vid,
qty: payload.qty || 1,
grams: payload.grams ?? 0,
flatRialCharge: payload.flatRialCharge ?? 0,
wagePercent: payload.wagePercent,
rash: payload.grams ?? 0,
image: payload.image ?? null,
});
addVariantId.value = '';
addQty.value = 1;
}
async function submitEdit() {
if (!tempRow.value?.id) return;
const items = (tempRow.value.items ?? [])
.filter((it: any) => it.productVariantId)
.map((it: any) => ({
productVariantId: it.productVariantId,
quantity: Number(it.qty) || 1,
grams: Number(it.grams) || undefined,
flatRialCharge: Number(it.flatRialCharge) || 0,
rashWeightGrams: it.rash != null ? Number(it.rash) : undefined,
wagePercent: it.wagePercent != null ? Number(it.wagePercent) : undefined,
discountPercent: it.discountPercent != null ? Number(it.discountPercent) : undefined,
}));
if (!items.length) {
message.error('حداقل یک آیتم لازم است');
return;
}
saving.value = true;
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('ویرایش موفق آمیز');
await api.post('admin/orders/edit/propose', { orderId: tempRow.value.id, items });
message.success('پیشنهاد ویرایش ثبت شد؛ پیامک برای مشتری ارسال می‌شود');
editMode.value = false;
tempRow.value = await loadDetail(tempRow.value.id);
detailSnapshot.value = JSON.stringify(tempRow.value);
tbl.value?.fetch();
} catch (e) {
message.error('خطا در ویرایش');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت ویرایش');
} finally {
saving.value = false;
}
}
async function forceApplyEdit() {
if (!tempRow.value?.id) return;
try {
await api.post('admin/orders/edit/force-apply', { orderId: tempRow.value.id });
message.success('ویرایش به‌صورت اجباری اعمال شد');
tempRow.value = await loadDetail(tempRow.value.id);
tbl.value?.fetch();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در اعمال');
}
}
async function discardEdit() {
if (!tempRow.value?.id) return;
try {
await api.post('admin/orders/edit/discard', { orderId: tempRow.value.id });
message.success('پیشنهاد ویرایش لغو شد');
tempRow.value = await loadDetail(tempRow.value.id);
tbl.value?.fetch();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در لغو');
}
}
async function viewOrder(row: Record<string, any>) {
tempRow.value = await loadDetail(row.id);
detailSnapshot.value = JSON.stringify(tempRow.value);
editMode.value = false;
visibleList.value = true;
}
+175 -76
View File
@@ -55,81 +55,61 @@
</XTable>
<!-- وضعیت سفارش -->
<a-modal v-model:open="visibleSts" title="وضعیت سفارش" wrap-class-name="detail-modal">
<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>
<a-modal
v-model:open="visibleSts"
title="وضعیت سفارش"
width="480px"
wrap-class-name="detail-modal"
>
<OrderStatusTimeline
v-if="tempRow"
:activity="tempRow.activity || []"
:can-submit="hasPermission('order.omde.state.submit')"
:can-remove="hasPermission('order.omde.state.remove')"
@submit="setSts"
@remove="removeSts"
/>
<template #footer>
<a-button @click="visibleSts = false">باشه</a-button>
<a-button @click="visibleSts = false">بستن</a-button>
</template>
</a-modal>
<!-- مشاهده سفارش -->
<a-modal v-model:open="visibleList" title="مشاهده سفارش" width="80%" wrap-class-name="detail-modal">
<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 />
<!-- مشاهده / ویرایش سفارش (عمده — همان فلو revision اپ کاربر) -->
<a-modal
v-model:open="visibleList"
title="مشاهده سفارش"
:width="editMode && canEditOrder ? 1120 : 760"
wrap-class-name="detail-modal order-detail-modal"
:body-style="{ maxHeight: '78vh', overflowY: 'auto' }"
>
<OrderDetailBody
:order="tempRow"
:edit-mode="editMode && canEditOrder"
v-model:add-variant-id="addVariantId"
v-model:add-qty="addQty"
@remove-line="removeLine"
@add-line="onAddLine"
/>
<template #footer>
<a-button @click="visibleList = false">باشه</a-button>
<div class="flex justify-between flex-wrap gap-2">
<a-button @click="closeDetail">بستن</a-button>
<div class="flex gap-2 flex-wrap">
<template v-if="tempRow?.revisionStatus === 'AWAITING_CUSTOMER' && canEditOrder">
<a-button @click="discardEdit">لغو پیشنهاد</a-button>
<a-button type="primary" danger @click="forceApplyEdit">اعمال اجباری</a-button>
</template>
<template v-else-if="canEditOrder">
<a-button v-if="!editMode" type="primary" @click="editMode = true">شروع ویرایش</a-button>
<template v-else>
<a-button @click="cancelEdit">انصراف</a-button>
<a-button type="primary" danger :loading="saving" @click="submitEdit">
ارسال برای تأیید مشتری
</a-button>
</template>
</template>
</div>
</div>
</template>
</a-modal>
</a-card>
@@ -137,14 +117,15 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
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 { buildOmdeListModel } from './OmdeListModel';
import OrderDetailBody from './OrderDetailBody.vue';
import OrderStatusTimeline from './OrderStatusTimeline.vue';
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));
@@ -153,20 +134,21 @@ const model = buildOmdeListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const detailSnapshot = ref('');
const visibleList = ref(false);
const visibleSts = ref(false);
const editMode = ref(false);
const saving = ref(false);
const addVariantId = ref('');
const addQty = ref(1);
const formatDate = (v: string | null) => (v ? jdate(v) : '');
const canEditOrder = computed(() => hasPermission('order.omde.view.edit'));
/** 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;
@@ -174,9 +156,123 @@ async function loadDetail(id: string) {
async function viewOrder(row: Record<string, any>) {
tempRow.value = await loadDetail(row.id);
detailSnapshot.value = JSON.stringify(tempRow.value);
editMode.value = false;
visibleList.value = true;
}
function closeDetail() {
visibleList.value = false;
editMode.value = false;
}
function cancelEdit() {
if (detailSnapshot.value) {
try {
tempRow.value = JSON.parse(detailSnapshot.value);
} catch {
/* keep */
}
}
editMode.value = false;
}
function removeLine(index: number) {
if (!tempRow.value?.items) return;
if (tempRow.value.items.length <= 1) {
message.warning('حداقل یک آیتم باید در سفارش بماند');
return;
}
tempRow.value.items.splice(index, 1);
}
function onAddLine(payload: {
variantId: string;
qty: number;
title?: string;
variantTitle?: string;
grams?: number;
wagePercent?: number;
flatRialCharge?: number;
image?: string | null;
}) {
const vid = payload.variantId.trim();
if (!vid) {
message.warning('یک تنوع انتخاب کنید');
return;
}
if (!tempRow.value) return;
tempRow.value.items.push({
productVariantId: vid,
title: payload.title ?? 'آیتم جدید',
variant_title: payload.variantTitle ?? vid,
qty: payload.qty || 1,
grams: payload.grams ?? 0,
flatRialCharge: payload.flatRialCharge ?? 0,
wagePercent: payload.wagePercent,
rash: payload.grams ?? 0,
image: payload.image ?? null,
});
addVariantId.value = '';
addQty.value = 1;
}
async function submitEdit() {
if (!tempRow.value?.id) return;
const items = (tempRow.value.items ?? [])
.filter((it: any) => it.productVariantId)
.map((it: any) => ({
productVariantId: it.productVariantId,
quantity: Number(it.qty) || 1,
grams: Number(it.grams) || undefined,
flatRialCharge: Number(it.flatRialCharge) || 0,
rashWeightGrams: it.rash != null ? Number(it.rash) : undefined,
wagePercent: it.wagePercent != null ? Number(it.wagePercent) : undefined,
discountPercent: it.discountPercent != null ? Number(it.discountPercent) : undefined,
}));
if (!items.length) {
message.error('حداقل یک آیتم لازم است');
return;
}
saving.value = true;
try {
await api.post('admin/orders/edit/propose', { orderId: tempRow.value.id, items });
message.success('پیشنهاد ویرایش ثبت شد؛ پیامک برای مشتری ارسال می‌شود');
editMode.value = false;
tempRow.value = await loadDetail(tempRow.value.id);
detailSnapshot.value = JSON.stringify(tempRow.value);
tbl.value?.fetch();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت ویرایش');
} finally {
saving.value = false;
}
}
async function forceApplyEdit() {
if (!tempRow.value?.id) return;
try {
await api.post('admin/orders/edit/force-apply', { orderId: tempRow.value.id });
message.success('ویرایش به‌صورت اجباری اعمال شد');
tempRow.value = await loadDetail(tempRow.value.id);
tbl.value?.fetch();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در اعمال');
}
}
async function discardEdit() {
if (!tempRow.value?.id) return;
try {
await api.post('admin/orders/edit/discard', { orderId: tempRow.value.id });
message.success('پیشنهاد ویرایش لغو شد');
tempRow.value = await loadDetail(tempRow.value.id);
tbl.value?.fetch();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در لغو');
}
}
async function showSts(row: Record<string, any>) {
tempRow.value = await loadDetail(row.id);
visibleSts.value = true;
@@ -231,6 +327,9 @@ function deleteOrder(record: Record<string, any>) {
.flex-1 {
flex: 1;
}
.justify-between {
justify-content: space-between;
}
.mr-3 {
margin-left: 0.75rem;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,396 @@
<!--
Admin status rail — same language as the customer app's tlSheet.
Rail (markers / stem / current) stays; actions stay the same logically
(ثبت for every pending, حذف for proceeded except first) but sit quietly
in one line so the list does not feel stacked and noisy.
-->
<template>
<div v-if="activity.length" class="ost">
<header class="ost__banner">
<span class="ost__chip">وضعیت</span>
<span class="ost__dash" aria-hidden="true" />
<span
v-if="current"
class="ost__badge"
:style="{
background: orderStatusColor(current.status).background,
color: orderStatusColor(current.status).font,
}"
>
<component :is="orderStatusIcon(current.status)" />
{{ nodeTitle(current) }}
</span>
</header>
<ol class="ost__rail">
<li
v-for="(node, index) in nodes"
:key="node.status || index"
class="ost__node"
:class="[`is-${node.state}`]"
>
<div class="ost__axis" aria-hidden="true">
<span
v-if="node.state === 'current'"
class="ost__halo"
:style="{ color: orderStatusColor(node.status).font }"
/>
<span class="ost__marker" :style="markerStyle(node)">
<component :is="orderStatusIcon(node.status)" />
</span>
<span
v-if="index < nodes.length - 1"
class="ost__stem"
:style="node.state === 'pending' ? undefined : { background: orderStatusColor(node.status).font }"
/>
</div>
<div class="ost__main">
<div class="ost__row">
<div class="ost__copy">
<div class="ost__status">{{ nodeTitle(node) }}</div>
<p v-if="node.proceed && node.note" class="ost__note">{{ node.note }}</p>
</div>
<div class="ost__meta">
<time v-if="node.proceed && node.uaj" class="ost__when" dir="ltr">{{ formatWhen(node.uaj) }}</time>
<span v-else-if="node.proceed" class="ost__when is-wait">—</span>
<span v-else class="ost__when is-wait">در انتظار</span>
<a-popconfirm
v-if="node.proceed && canRemove && index > 0"
ok-text="بله"
cancel-text="بی‌خیال"
placement="topLeft"
@confirm="emit('remove', node)"
>
<template #title><p>آیا مطمئن به حذف وضعیت مورد نظر هستید ؟</p></template>
<a-button
danger
ghost
size="small"
class="ost__iconBtn"
aria-label="حذف وضعیت"
>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
</div>
</div>
<div v-if="!node.proceed && canSubmit" class="ost__submit">
<a-input
v-model:value="node.note"
size="small"
placeholder="یادداشت"
allow-clear
@pressEnter="emit('submit', node)"
/>
<a-button size="small" type="primary" @click="emit('submit', node)">ثبت وضعیت</a-button>
</div>
</div>
</li>
</ol>
</div>
<a-empty v-else description="مسیری برای این سفارش ثبت نشده است." />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import moment from 'moment-jalaali';
import { DeleteOutlined } from '@ant-design/icons-vue';
import {
orderStatusColor,
orderStatusIcon,
orderStatusLabel,
} from '../../utils/orderStatus';
export type ActivityItem = {
status: string;
title?: string;
proceed?: boolean;
uaj?: string | null;
note?: string;
};
type NodeState = 'done' | 'current' | 'pending';
type TimelineNode = ActivityItem & { state: NodeState };
const props = defineProps<{
activity: ActivityItem[];
canSubmit?: boolean;
canRemove?: boolean;
}>();
const emit = defineEmits<{
(e: 'submit', item: ActivityItem): void;
(e: 'remove', item: ActivityItem): void;
}>();
const activity = computed(() => props.activity ?? []);
const lastProceedIdx = computed(() => {
let idx = -1;
activity.value.forEach((item, i) => {
if (item.proceed) idx = i;
});
return idx;
});
const nodes = computed((): TimelineNode[] =>
activity.value.map((item, index) => {
let state: NodeState = 'pending';
if (item.proceed) {
state = index === lastProceedIdx.value ? 'current' : 'done';
}
return { ...item, state };
}),
);
const current = computed(() => nodes.value.find((n) => n.state === 'current') ?? null);
function nodeTitle(node: ActivityItem) {
return orderStatusLabel(node.status) || node.title || node.status;
}
function formatWhen(v: string) {
return moment(v).format('jYYYY/jMM/jDD - HH:mm');
}
function markerStyle(node: TimelineNode) {
if (node.state === 'pending') return undefined;
const c = orderStatusColor(node.status);
return { color: c.font, borderColor: c.font };
}
</script>
<style scoped>
.ost__banner {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.ost__chip {
flex: 0 0 auto;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid #f0f0f0;
background: #fff;
box-shadow: 0 1px 2px rgba(20, 22, 26, 0.04);
font-size: 11px;
font-weight: 600;
color: rgba(0, 0, 0, 0.55);
}
.ost__dash {
flex: 1 1 auto;
height: 0;
border-top: 1.5px dashed #e5e7eb;
min-width: 16px;
}
.ost__badge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 11px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.ost__badge :deep(.anticon) {
font-size: 13px;
}
.ost__rail {
list-style: none;
margin: 0;
padding: 0 2px;
}
.ost__node {
display: flex;
gap: 12px;
align-items: stretch;
min-height: 52px;
}
.ost__axis {
flex: 0 0 40px;
width: 40px;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.ost__halo {
position: absolute;
top: 0;
left: 50%;
width: 40px;
height: 40px;
margin-left: -20px;
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.ost__halo::before {
content: '';
position: absolute;
inset: -14px;
border-radius: 50%;
background: radial-gradient(
circle,
color-mix(in srgb, currentColor 32%, transparent) 0%,
color-mix(in srgb, currentColor 12%, transparent) 38%,
transparent 70%
);
filter: blur(1.5px);
opacity: 0;
animation: ostHalo 4.8s ease-in-out infinite;
}
@keyframes ostHalo {
0%,
100% {
opacity: 0;
}
50% {
opacity: 0.7;
}
}
@media (prefers-reduced-motion: reduce) {
.ost__halo::before {
animation: none;
opacity: 0.35;
}
}
.ost__marker {
flex: 0 0 40px;
width: 40px;
height: 40px;
border-radius: 50%;
border: 1.5px solid currentColor;
background: #fff;
display: grid;
place-items: center;
position: relative;
z-index: 1;
font-size: 16px;
}
.ost__node.is-pending .ost__marker {
color: #9ca3af;
border-color: #9ca3af;
border-style: dashed;
}
.ost__stem {
flex: 1 1 auto;
width: 1.5px;
margin: 4px 0;
background: #e5e7eb;
min-height: 12px;
}
.ost__node.is-pending .ost__stem {
width: 2px;
background-image: repeating-linear-gradient(180deg, #e5e7eb 0 4px, transparent 4px 8px);
background-color: transparent;
}
.ost__main {
flex: 1;
min-width: 0;
padding-top: 8px;
padding-bottom: 12px;
}
.ost__node:last-child .ost__main {
padding-bottom: 2px;
}
.ost__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 32px;
}
.ost__copy {
min-width: 0;
flex: 1;
}
.ost__status {
font-size: 13px;
font-weight: 700;
color: rgba(0, 0, 0, 0.88);
line-height: 1.35;
}
.ost__note {
margin: 2px 0 0;
font-size: 11px;
color: rgba(0, 0, 0, 0.4);
line-height: 1.35;
}
.ost__meta {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 6px;
}
.ost__when {
font-size: 11px;
font-weight: 500;
color: rgba(0, 0, 0, 0.45);
font-variant-numeric: tabular-nums;
white-space: nowrap;
direction: ltr;
unicode-bidi: isolate;
}
.ost__when.is-wait {
opacity: 0.85;
}
.ost__node.is-pending .ost__status,
.ost__node.is-pending .ost__when {
color: rgba(0, 0, 0, 0.35);
}
.ost__iconBtn {
width: 28px;
height: 28px;
padding: 0;
display: inline-grid;
place-items: center;
border-radius: 8px;
}
.ost__submit {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.ost__submit .ant-input {
flex: 1;
min-width: 0;
}
</style>
+19 -74
View File
@@ -42,78 +42,28 @@
</XTable>
<!-- وضعیت سفارش -->
<a-modal v-model:open="visibleSts" title="وضعیت سفارش" wrap-class-name="detail-modal">
<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>
<a-modal
v-model:open="visibleSts"
title="وضعیت سفارش"
width="480px"
wrap-class-name="detail-modal"
>
<OrderStatusTimeline
v-if="tempRow"
:activity="tempRow.activity || []"
:can-submit="hasPermission('order.tamin.state.submit')"
:can-remove="hasPermission('order.tamin.state.remove')"
@submit="setSts"
@remove="removeSts"
/>
<template #footer>
<a-button @click="visibleSts = false">باشه</a-button>
<a-button @click="visibleSts = false">بستن</a-button>
</template>
</a-modal>
<!-- مشاهده سفارش -->
<a-modal v-model:open="visibleList" title="مشاهده سفارش" width="80%" wrap-class-name="detail-modal">
<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 />
<a-modal v-model:open="visibleList" title="مشاهده سفارش" width="720px" wrap-class-name="detail-modal">
<OrderDetailBody :order="tempRow" />
<template #footer>
<a-button @click="visibleList = false">باشه</a-button>
@@ -128,10 +78,11 @@ 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 OrderDetailBody from './OrderDetailBody.vue';
import OrderStatusTimeline from './OrderStatusTimeline.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));
@@ -143,12 +94,6 @@ 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;
+5 -2
View File
@@ -67,11 +67,14 @@ export default defineConfig(({ mode }) => {
// stuck dev server is obvious rather than moving to a random port.
strictPort: true,
proxy: {
'/api': `http://localhost:${apiPort}`,
// 127.0.0.1, not localhost: on Windows localhost often prefers ::1, and
// another process can bind only IPv6 :3000 while our API is on IPv4 —
// the proxy then hits the wrong app and every /api call 404s.
'/api': `http://127.0.0.1:${apiPort}`,
// Uploaded files (ProductImage.url etc.) are served by the API's own
// static handler, not this dev server — without this they 404 in dev
// even though production nginx (nginx.conf) already proxies them.
'/uploads': `http://localhost:${apiPort}`,
'/uploads': `http://127.0.0.1:${apiPort}`,
},
},
};
@@ -0,0 +1,2 @@
-- Product labels from Setting `__list__tags`, assigned per product in admin.
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,29 @@
-- Admin order edit + customer confirm (Order.revisionStatus / OrderRevision).
CREATE TYPE "OrderRevisionStatus" AS ENUM ('NONE', 'AWAITING_CUSTOMER');
ALTER TABLE "Order" ADD COLUMN "revisionStatus" "OrderRevisionStatus" NOT NULL DEFAULT 'NONE';
CREATE TABLE "OrderRevision" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"items" JSONB NOT NULL,
"totalGoldWeightGrams" DECIMAL(14,4) NOT NULL,
"wageWeightGrams" DECIMAL(14,4) NOT NULL,
"flatRialCharge" DECIMAL(20,2) NOT NULL DEFAULT 0,
"proposedByAdminUserId" TEXT,
"note" TEXT,
"customerDecision" TEXT,
"customerDecidedAt" TIMESTAMP(3),
"appliedAt" TIMESTAMP(3),
"discardedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrderRevision_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Order_revisionStatus_idx" ON "Order"("revisionStatus");
CREATE INDEX "OrderRevision_orderId_idx" ON "OrderRevision"("orderId");
CREATE INDEX "OrderRevision_createdAt_idx" ON "OrderRevision"("createdAt");
ALTER TABLE "OrderRevision" ADD CONSTRAINT "OrderRevision_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+46
View File
@@ -693,6 +693,9 @@ model Product {
/// Legacy Product.show_type: 1 site, 2 mobile, 3 both.
showType Int @default(3)
isActive Boolean @default(true)
/// Labels from Setting `__list__tags`, assigned on the product form. The
/// storefront shows them on list cards only (not the product detail page).
tags String[] @default([])
createdAt DateTime @default(now())
/// Stock of the linked کارساخته as last read from the accounting software
@@ -800,6 +803,12 @@ model ProductVariant {
// Orders — stock orders (debt-bearing)
// ─────────────────────────────────────────────────────────────────────────
/// Customer-confirm state for an admin-proposed order edit (not a pipeline status).
enum OrderRevisionStatus {
NONE
AWAITING_CUSTOMER
}
model Order {
id String @id @default(cuid())
orderNumber String @unique
@@ -811,6 +820,9 @@ model Order {
channel OrderChannel @default(RETAIL)
status OrderStatus @default(PLACED)
/// Pending admin edit awaiting customer confirm (or admin force/discard).
revisionStatus OrderRevisionStatus @default(NONE)
/// Total gold weight incl. wage across all items ("allGram" / order.w).
totalGoldWeightGrams Decimal @db.Decimal(14, 4)
/// Pure making-charge weight component (totalGoldWeightGrams minus raw weight sum).
@@ -843,14 +855,48 @@ model Order {
items OrderItem[]
statusEvents OrderStatusEvent[]
ledgerEntries LedgerEntry[]
revisions OrderRevision[]
convertedFromPreOrder PreOrder?
@@index([customerId])
@@index([status])
@@index([revisionStatus])
@@index([salesAgentId])
@@index([referrerCustomerId])
}
/// Proposed line-item edit for an Order. Live until applied or discarded.
/// While pending, Order.revisionStatus = AWAITING_CUSTOMER and charged figures
/// stay on the current Order until the customer confirms (or admin force-applies).
model OrderRevision {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
/// Proposed lines: [{ productVariantId, quantity, rashWeightGramsSnapshot,
/// wagePercentSnapshot, discountPercentSnapshot, computedWeightGrams, flatRialCharge }]
items Json
totalGoldWeightGrams Decimal @db.Decimal(14, 4)
wageWeightGrams Decimal @db.Decimal(14, 4)
flatRialCharge Decimal @default(0) @db.Decimal(20, 2)
proposedByAdminId String? @map("proposedByAdminUserId")
note String?
/// null | ACCEPTED | REJECTED — reject leaves AWAITING_CUSTOMER for admin decision.
customerDecision String?
customerDecidedAt DateTime?
appliedAt DateTime?
discardedAt DateTime?
createdAt DateTime @default(now())
@@index([orderId])
@@index([createdAt])
}
model OrderItem {
id String @id @default(cuid())
orderId String
+7 -25
View File
@@ -38,9 +38,6 @@ const chk = (cond, msg) => {
};
const step = (t) => console.log('\n' + t);
/** Differences found between the two apps, reported at the end. */
const divergences = [];
const createdOrders = [];
let token = null;
@@ -92,19 +89,16 @@ function loadCartRules(app) {
}
(async () => {
// ── 0. BOTH APPS' CART RULES ────────────────────────────────────────────
step('0 — CART STOCK RULES, in each app\'s own source');
// ── 0. USER-APP CART RULES ──────────────────────────────────────────────
step('0 — CART STOCK RULES, in user-app source');
const v1 = loadCartRules('user-app');
const v2 = loadCartRules('user-app-v2');
for (const [name, m] of [['user-app', v1], ['user-app-v2', v2]]) {
chk(m.hasCapQty, `${name}: capQty() exists`);
chk(m.hasMaxQty, `${name}: maxQty() exists`);
chk(m.hasAddGuard, `${name}: addToCart refuses an un-orderable variant`);
chk(m.hasQtyClamp, `${name}: updateFeild clamps a qty change`);
}
chk(v1.hasCapQty, 'user-app: capQty() exists');
chk(v1.hasMaxQty, 'user-app: maxQty() exists');
chk(v1.hasAddGuard, 'user-app: addToCart refuses an un-orderable variant');
chk(v1.hasQtyClamp, 'user-app: updateFeild clamps a qty change');
if (v1.capQty && v2.capQty) {
if (v1.capQty) {
const cases = [
{ want: 10, variant: { qty: 3 }, expect: 3, label: 'want 10 of 3 in stock -> 3' },
{ want: 2, variant: { qty: 5 }, expect: 2, label: 'want 2 of 5 in stock -> 2' },
@@ -114,17 +108,10 @@ function loadCartRules(app) {
];
for (const c of cases) {
const a = v1.capQty(c.want, c.variant);
const b = v2.capQty(c.want, c.variant);
chk(a === c.expect, `user-app: ${c.label} (got ${a})`);
chk(b === c.expect, `user-app-v2: ${c.label} (got ${b})`);
if (a !== b) divergences.push(`capQty(${c.want}, ${JSON.stringify(c.variant)}): v1=${a} v2=${b}`);
}
}
const sameStore = v1.src === v2.src;
chk(sameStore, 'the two cart stores are byte-identical');
if (!sameStore) divergences.push('cartStore.ts differs between the apps');
// ── 1. LOGIN WITH THE MASTER CODE ───────────────────────────────────────
step('1 — LOGIN with the delivery master code');
const customer = await prisma.customer.findFirst({
@@ -281,11 +268,6 @@ function loadCartRules(app) {
console.log('\nFailures:');
failures.forEach((f) => console.log(' - ' + f));
}
console.log(
divergences.length
? `\nDIVERGENCES between the apps:\n${divergences.map((d) => ' - ' + d).join('\n')}`
: '\nNo behavioural divergence between user-app and user-app-v2.',
);
console.log('='.repeat(60));
await prisma.$disconnect();
@@ -107,6 +107,51 @@ export class AdminOrdersController {
return this.orders.setStatus(dto, admin.id);
}
/** Stages where admin may propose an edit — from Setting `order.edit.editableStatuses`. */
@Get('edit/editable-statuses')
@RequirePermission('order.list', 'order.omde', 'order.tamin', 'order.list.view.edit')
editableStatuses() {
return this.orders.editableStatuses();
}
/** Propose a full line-item edit; customer must confirm (SMS sent). */
@Post('edit/propose')
@HttpCode(200)
@RequirePermission('order.list.view.edit', 'order.omde.view.edit', 'order.tamin.view.edit')
proposeEdit(
@Body()
body: {
orderId: string;
note?: string;
items: {
productVariantId: string;
quantity: number;
grams?: number;
flatRialCharge?: number;
rashWeightGrams?: number;
wagePercent?: number;
discountPercent?: number;
}[];
},
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.orders.proposeEdit(body, admin.id);
}
@Post('edit/force-apply')
@HttpCode(200)
@RequirePermission('order.list.view.edit', 'order.omde.view.edit', 'order.tamin.view.edit')
forceApplyEdit(@Body() body: { orderId: string }, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.orders.forceApplyEdit(body.orderId, admin.id);
}
@Post('edit/discard')
@HttpCode(200)
@RequirePermission('order.list.view.edit', 'order.omde.view.edit', 'order.tamin.view.edit')
discardEdit(@Body() body: { orderId: string }, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.orders.discardEdit(body.orderId, admin.id);
}
@Get(':id')
@RequirePermission('order.list', 'order.omde', 'order.tamin')
findOne(@Param('id') id: string) {
@@ -1,10 +1,11 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
import { OrderChannel, OrderRevisionStatus, OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
import { OrdersService } from '../orders/orders.service';
import { OrderRevisionService } from '../orders/order-revision.service';
import { LedgerService } from '../accounting/ledger.service';
import type { UpdateOrderStatusDto } from './update-status.dto';
@@ -36,12 +37,35 @@ const STATUS_FLOW: OrderStatus[] = [
OrderStatus.DELIVERED_TO_CUSTOMER,
];
/** Variant photo preferred, else the product's first gallery image. */
const ORDER_ITEM_INCLUDE = {
productVariant: {
include: {
image: true,
product: {
include: {
images: { orderBy: { sortOrder: 'asc' as const }, take: 1 },
},
},
},
},
} satisfies Prisma.OrderItemInclude;
type OrderWithItems = Prisma.OrderGetPayload<{
include: {
customer: true;
branch: true;
items: { include: typeof ORDER_ITEM_INCLUDE };
};
}>;
@Injectable()
export class AdminOrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly orders: OrdersService,
private readonly revisions: OrderRevisionService,
private readonly accounting: AccountingHooksService,
private readonly ledger: LedgerService,
) {}
@@ -93,7 +117,7 @@ export class AdminOrdersService {
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
items: { include: ORDER_ITEM_INCLUDE },
},
}),
this.prisma.order.count({ where: scoped }),
@@ -108,23 +132,23 @@ export class AdminOrdersService {
* Legacy field names are preserved (`sts_str`, `caj`, `total_price`, `items`)
* so the ported template bindings keep working unchanged.
*/
private toRow(o: Prisma.OrderGetPayload<{
include: {
customer: true;
branch: true;
items: { include: { productVariant: { include: { product: true } } } };
};
}>) {
private toRow(o: OrderWithItems & { revisionStatus?: OrderRevisionStatus }) {
const awaiting = o.revisionStatus === OrderRevisionStatus.AWAITING_CUSTOMER;
return {
id: o.id,
orderNumber: o.orderNumber,
status: o.status,
sts_str: STATUS_LABELS[o.status],
revisionStatus: o.revisionStatus ?? OrderRevisionStatus.NONE,
// Legacy Omde/Tamin «تایید مشتری» column (1 = ok / 2 = waiting).
confirmed: awaiting ? 2 : 1,
confirmed_str: awaiting ? 'در انتظار تایید' : '—',
caj: o.createdAt,
uaj: o.updatedAt,
total_price: o.flatRialCharge,
totalGoldWeightGrams: o.totalGoldWeightGrams,
wageWeightGrams: o.wageWeightGrams,
flatRialCharge: o.flatRialCharge,
isSuspendedOverCreditLimit: o.isSuspendedOverCreditLimit,
deliveredAt: o.deliveredAt,
branch_id: o.branchId,
@@ -140,11 +164,18 @@ export class AdminOrdersService {
},
items: o.items.map((it) => ({
id: it.id,
productVariantId: it.productVariantId,
title: it.productVariant.product.title,
variant_title: it.productVariant.title,
qty: it.quantity,
grams: it.computedWeightGrams,
rash: it.rashWeightGramsSnapshot,
wagePercent: it.wagePercentSnapshot,
discountPercent: it.discountPercentSnapshot,
flatRialCharge: it.flatRialCharge,
// Same preference as the customer app: variant photo, else product cover.
image: it.productVariant.image?.url ?? it.productVariant.product.images[0]?.url ?? null,
note: '',
})),
};
}
@@ -198,7 +229,7 @@ export class AdminOrdersService {
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
items: { include: ORDER_ITEM_INCLUDE },
},
}),
this.prisma.order.count({ where: scoped }),
@@ -242,16 +273,29 @@ export class AdminOrdersService {
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
items: { include: ORDER_ITEM_INCLUDE },
statusEvents: { include: { actorAdmin: true }, orderBy: { createdAt: 'asc' } },
},
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
const reached = new Set(order.statusEvents.map((e) => e.toStatus));
const pending = await this.revisions.pendingForOrder(id);
let pendingRevision = pending ? this.revisions.shapeRevision(pending) : null;
if (pendingRevision && pending?.customerDecision === 'REJECTED') {
// Surface reject for the admin decision UI.
(pendingRevision as { confirmed_str?: string }).confirmed_str = 'رد شده توسط مشتری';
}
return {
...this.toRow(order),
confirmed_str:
order.revisionStatus === OrderRevisionStatus.AWAITING_CUSTOMER
? pending?.customerDecision === 'REJECTED'
? 'رد شده توسط مشتری'
: 'در انتظار تایید'
: '—',
pendingRevision,
// legacy `activity`: one entry per step, `proceed` marking those reached
activity: STATUS_FLOW.map((s) => {
const event = order.statusEvents.find((e) => e.toStatus === s);
@@ -266,6 +310,40 @@ export class AdminOrdersService {
};
}
editableStatuses() {
return this.revisions.editableStatuses();
}
proposeEdit(
body: {
orderId: string;
note?: string;
items: {
productVariantId: string;
quantity: number;
grams?: number;
flatRialCharge?: number;
rashWeightGrams?: number;
wagePercent?: number;
discountPercent?: number;
}[];
},
adminId: string,
) {
if (!body.orderId) throw new BadRequestException('سفارش مشخص نشده است');
return this.revisions.propose(body.orderId, body.items ?? [], adminId, body.note);
}
forceApplyEdit(orderId: string, adminId: string) {
if (!orderId) throw new BadRequestException('سفارش مشخص نشده است');
return this.revisions.forceApply(orderId, adminId);
}
discardEdit(orderId: string, adminId: string) {
if (!orderId) throw new BadRequestException('سفارش مشخص نشده است');
return this.revisions.discard(orderId, adminId);
}
/** Farsi labels for the status dropdowns. */
statusOptions() {
return Object.entries(STATUS_LABELS).map(([value, title]) => ({ value, title }));
@@ -91,6 +91,12 @@ export class AdminProductsController {
return this.products.brands();
}
@Get('categories')
@RequirePermission('product.list', 'order.list.view.edit', 'order.omde.view.edit', 'order.tamin.view.edit')
categories() {
return this.products.categoriesForPicker();
}
/** Brands as an XTable grid — the exclusion picker needs a selectable table. */
@Post('brands-table')
@HttpCode(200)
@@ -42,6 +42,7 @@ export class AdminProductsService {
'showType',
'isActive',
'createdAt',
'categoryId',
'brand.title',
'category.title',
// legacy filter keys
@@ -117,6 +118,7 @@ export class AdminProductsService {
show_type_str: SHOW_TYPE_LABELS[p.showType] ?? '-',
is_active: p.isActive,
description: p.description,
tags: p.tags ?? [],
brand: p.brand ? { id: p.brand.id, title: p.brand.title } : null,
// Legacy nested this as category.collection.title in the grid model.
category: p.category
@@ -187,6 +189,7 @@ export class AdminProductsService {
brand_id: p.brandId,
cats: p.categoryId ? [p.categoryId] : [],
ciny_ids: p.collections.map((c) => c.collectionId),
tags: p.tags ?? [],
variants_all: p.variants.map((v) => this.variantRow(v)),
// The 4 image slots the product form edits, split back out by role —
// mirrors the shape AddProduct.vue posts (indicator_id / mobile_pic /
@@ -245,7 +248,7 @@ export class AdminProductsService {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['title', 'sku'],
searchable: ['title', 'sku', 'product.title'],
allowed: ['id', 'title', 'sku', 'qty', 'rashWeightGrams', 'isActive', 'product.title'],
defaultOrderBy: { title: 'asc' },
});
@@ -411,6 +414,17 @@ export class AdminProductsService {
* for the two featured slots and one URL array each for the two galleries;
* this flattens all four into the role-tagged rows ProductImage.create wants.
*/
/** Tags posted by the product form — trimmed unique strings, empty dropped. */
private normalizeTags(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const out: string[] = [];
for (const item of raw) {
const t = String(item ?? '').trim();
if (t && !out.includes(t)) out.push(t);
}
return out;
}
private imageCreateData(data: Record<string, unknown>): { url: string; sortOrder: number; role: Prisma.ProductImageCreateInput['role'] }[] {
const rows: { url: string; sortOrder: number; role: Prisma.ProductImageCreateInput['role'] }[] = [];
@@ -459,6 +473,7 @@ export class AdminProductsService {
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
brandId: (data.brand_id as string) || null,
categoryId: cats[0] ?? null,
tags: this.normalizeTags(data.tags),
collections: cinyIds.length
? { create: cinyIds.map((collectionId) => ({ collectionId })) }
: undefined,
@@ -525,6 +540,9 @@ export class AdminProductsService {
const catId = (data.cats as string[])[0];
patch.category = catId ? { connect: { id: catId } } : { disconnect: true };
}
if (data.tags !== undefined) {
patch.tags = this.normalizeTags(data.tags);
}
await this.prisma.product.update({ where: { id }, data: patch });
@@ -728,6 +746,15 @@ export class AdminProductsService {
return this.prisma.brand.findMany({ orderBy: { title: 'asc' } });
}
/** Categories that currently have at least one active product — order picker chips. */
async categoriesForPicker() {
return this.prisma.category.findMany({
where: { products: { some: { isActive: true } } },
orderBy: { title: 'asc' },
select: { id: true, title: true },
});
}
/** Brands in the XTable envelope, for the promotion-exclusion picker. */
async brandsTable(query: TableQueryDto) {
this.table.assertNotExport(query);
@@ -98,7 +98,12 @@ describe('CatalogService — paging and filtering', () => {
const out = await service.findProducts({ page: 1, limit: 20, search: 'انگشتر' });
expect(out.total).toBe(42);
expect(count).toHaveBeenCalledWith({
where: expect.objectContaining({ title: { contains: 'انگشتر', mode: 'insensitive' } }),
where: expect.objectContaining({
OR: [
{ title: { contains: 'انگشتر', mode: 'insensitive' } },
{ productCode: { contains: 'انگشتر', mode: 'insensitive' } },
],
}),
});
});
@@ -161,6 +166,16 @@ describe('CatalogService — paging and filtering', () => {
const { service, findMany } = setup();
await service.findProducts({ page: 1, search: ' ' });
expect(lastWhere(findMany).title).toBeUndefined();
expect(lastWhere(findMany).OR).toBeUndefined();
});
it('matches productCode as well as title', async () => {
const { service, findMany } = setup();
await service.findProducts({ page: 1, search: 'HG-12' });
expect(lastWhere(findMany).OR).toEqual([
{ title: { contains: 'HG-12', mode: 'insensitive' } },
{ productCode: { contains: 'HG-12', mode: 'insensitive' } },
]);
});
});
@@ -138,9 +138,13 @@ export class CatalogService {
const where: Prisma.ProductWhereInput = { isActive: true, images: { some: {} } };
if (query.search?.trim()) {
// `contains`, as the apps' own title filter did. Case-insensitive is a
// no-op for Persian but matches how every other search in this API reads.
where.title = { contains: query.search.trim(), mode: 'insensitive' };
// Title OR product code — same as the app search box (SearchView).
// Case-insensitive is a no-op for Persian but matches other API searches.
const term = query.search.trim();
where.OR = [
{ title: { contains: term, mode: 'insensitive' } },
{ productCode: { contains: term, mode: 'insensitive' } },
];
}
if (query.categoryId) where.categoryId = query.categoryId;
// Apps' filter sheet allows several brands at once. Accept a single id or
@@ -248,6 +252,9 @@ export class CatalogService {
out.push({
...product,
// Explicit so list cards can show admin-assigned labels even if a
// future select() omits the field from the spread.
tags: product.tags ?? [],
variants: mappedVariants,
});
}
@@ -1,7 +1,8 @@
import { Body, Controller, Get, Headers, HttpCode, Ip, Post, Query, Res } from '@nestjs/common';
import { Body, Controller, Get, Headers, HttpCode, Ip, Param, Post, Query, Res } from '@nestjs/common';
import type { Response } from 'express';
import { CustomerAuthService } from './customer-auth.service';
import { CustomersService } from '../customers/customers.service';
import { OrderRevisionService } from '../orders/order-revision.service';
import { RequestOtpDto } from './dto/request-otp.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { RegisterDto } from './dto/register.dto';
@@ -27,6 +28,7 @@ export class CustomerAuthController {
constructor(
private readonly auth: CustomerAuthService,
private readonly customers: CustomersService,
private readonly revisions: OrderRevisionService,
) {}
/** Step 1. Always answers `{ sent: true }` — it never reveals whether the number is registered. */
@@ -117,6 +119,58 @@ export class CustomerAuthController {
myLedgerEntries(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.customers.getLedgerEntries(me.id);
}
/** Inbox: admin broadcast / direct messages for this customer. */
@CustomerAuth()
@Get('me/notifications')
myNotifications(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.customers.getNotifications(me.id);
}
@CustomerAuth()
@Get('me/notifications/unread-count')
myUnreadNotificationCount(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.customers.unreadNotificationCount(me.id).then((count) => ({ count }));
}
@CustomerAuth()
@Post('me/notifications/read-all')
@HttpCode(200)
markAllNotificationsRead(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.customers.markAllNotificationsRead(me.id);
}
@CustomerAuth()
@Post('me/notifications/:id/read')
@HttpCode(200)
markNotificationRead(
@CurrentCustomer() me: AuthenticatedCustomer,
@Param('id') id: string,
) {
return this.customers.markNotificationRead(me.id, id);
}
/** Accept an admin-proposed order edit. */
@CustomerAuth()
@Post('me/orders/:id/revision/confirm')
@HttpCode(200)
confirmOrderRevision(
@CurrentCustomer() me: AuthenticatedCustomer,
@Param('id') id: string,
) {
return this.revisions.confirm(id, me.id);
}
/** Reject — stays awaiting until an admin force-applies or discards. */
@CustomerAuth()
@Post('me/orders/:id/revision/reject')
@HttpCode(200)
rejectOrderRevision(
@CurrentCustomer() me: AuthenticatedCustomer,
@Param('id') id: string,
) {
return this.revisions.reject(id, me.id);
}
}
function readCookie(header: string | undefined, name: string): string | null {
@@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { PrismaModule } from '../../prisma/prisma.module';
import { CustomersModule } from '../customers/customers.module';
import { OrdersModule } from '../orders/orders.module';
import { CustomerAuthController } from './customer-auth.controller';
import { CustomerAuthService } from './customer-auth.service';
import { CustomerJwtStrategy } from './customer-jwt.strategy';
@@ -17,7 +18,7 @@ import { CustomerJwtStrategy } from './customer-jwt.strategy';
* No APP_GUARD is registered — customer routes opt in with @CustomerAuth().
*/
@Module({
imports: [PrismaModule, PassportModule, JwtModule.register({}), CustomersModule],
imports: [PrismaModule, PassportModule, JwtModule.register({}), CustomersModule, OrdersModule],
controllers: [CustomerAuthController],
providers: [CustomerAuthService, CustomerJwtStrategy],
exports: [CustomerAuthService],
@@ -1,4 +1,4 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { AccountingSyncService } from '../accounting-sync/accounting-sync.service';
@@ -131,6 +131,8 @@ export class CustomersService {
select: {
id: true,
title: true,
// Prefer the variant's own gallery pick; fall back to product photo.
image: { select: { url: true } },
product: {
select: {
id: true,
@@ -158,7 +160,14 @@ export class CustomersService {
const [result, count] = await Promise.all([
this.prisma.order.findMany({
where,
include,
include: {
...include,
revisions: {
where: { appliedAt: null, discardedAt: null },
orderBy: { createdAt: 'desc' },
take: 1,
},
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
@@ -166,7 +175,41 @@ export class CustomersService {
this.prisma.order.count({ where }),
]);
return { result, count, page, limit };
return {
result: result.map((row) => {
const { revisions, ...order } = row as typeof row & {
revisions?: Array<{
id: string;
items: unknown;
totalGoldWeightGrams: unknown;
wageWeightGrams: unknown;
flatRialCharge: unknown;
note: string | null;
customerDecision: string | null;
createdAt: Date;
}>;
};
const pending = revisions?.[0];
return {
...order,
pendingRevision: pending
? {
id: pending.id,
items: pending.items,
totalGoldWeightGrams: pending.totalGoldWeightGrams,
wageWeightGrams: pending.wageWeightGrams,
flatRialCharge: pending.flatRialCharge,
note: pending.note,
customerDecision: pending.customerDecision,
createdAt: pending.createdAt,
}
: null,
};
}),
count,
page,
limit,
};
}
getLedgerEntries(customerId: string) {
@@ -175,4 +218,53 @@ export class CustomersService {
orderBy: { createdAt: 'desc' },
});
}
/**
* In-app inbox for this customer: rows from NotificationRecipient (broadcast
* or single-customer messages created in the admin panel).
*/
async getNotifications(customerId: string) {
const rows = await this.prisma.notificationRecipient.findMany({
where: { customerId },
orderBy: { notification: { createdAt: 'desc' } },
take: 50,
include: {
notification: { select: { id: true, title: true, body: true, createdAt: true } },
},
});
return rows.map((r) => ({
id: r.id,
notificationId: r.notificationId,
title: r.notification.title,
body: r.notification.body,
createdAt: r.notification.createdAt,
readAt: r.readAt,
isRead: Boolean(r.readAt),
}));
}
unreadNotificationCount(customerId: string) {
return this.prisma.notificationRecipient.count({
where: { customerId, readAt: null },
});
}
async markNotificationRead(customerId: string, recipientId: string) {
const row = await this.prisma.notificationRecipient.findUnique({ where: { id: recipientId } });
if (!row) throw new NotFoundException('پیام یافت نشد');
if (row.customerId !== customerId) throw new ForbiddenException();
if (row.readAt) return row;
return this.prisma.notificationRecipient.update({
where: { id: recipientId },
data: { readAt: new Date() },
});
}
async markAllNotificationsRead(customerId: string) {
const result = await this.prisma.notificationRecipient.updateMany({
where: { customerId, readAt: null },
data: { readAt: new Date() },
});
return { updated: result.count };
}
}
@@ -31,9 +31,9 @@ describe('CustomersService.getOrders — items carry their product', () => {
const variant = include.items.include.productVariant;
expect(variant.select.title).toBe(true);
expect(variant.select.image).toEqual({ select: { url: true } });
expect(variant.select.product.select.title).toBe(true);
// images[0] is the product's main photo, so it must be ordered and limited
// to one — the apps read `images[0].url` and nothing else.
// Product cover is the fallback when the variant has no own image.
expect(variant.select.product.select.images).toMatchObject({
orderBy: { sortOrder: 'asc' },
take: 1,
@@ -83,7 +83,7 @@ describe('CustomersService.getOrders — items carry their product', () => {
count.mockResolvedValue(41);
await expect(service.getOrders('c1', { page: 2, limit: 20 })).resolves.toEqual({
result: [{ id: 'o1' }],
result: [{ id: 'o1', pendingRevision: null }],
count: 41,
page: 2,
limit: 20,
+1 -1
View File
@@ -270,7 +270,7 @@ export class IamService {
/**
* Admin return URL only. Must NOT fall back to the customer/app URL: that
* sent admins to user-app-v2 after password login whenever
* sent admins into the customer app after password login whenever
* `IAM_ADMIN_BROWSER_RETURN_URL` was left blank but the customer return was set.
*/
private adminBrowserReturnUrl(): string | null {
@@ -0,0 +1,356 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { OrderRevisionStatus, OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { LedgerService } from '../accounting/ledger.service';
import { SmsService } from '../sms/sms.service';
import { brandName } from '../../common/brand';
/** Setting key: JSON array of OrderStatus values that allow proposeEdit. */
export const ORDER_EDIT_STATUSES_KEY = 'order.edit.editableStatuses';
const DEFAULT_EDITABLE: OrderStatus[] = [
OrderStatus.PLACED,
OrderStatus.ADMIN_APPROVED,
OrderStatus.SOURCING,
OrderStatus.PREPARING,
];
export interface ProposedLineInput {
productVariantId: string;
quantity: number;
/** Final line weight (گرم) — admin may override. */
grams?: number;
rashWeightGrams?: number;
wagePercent?: number;
discountPercent?: number;
flatRialCharge?: number;
}
export interface ProposedLineStored {
productVariantId: string;
quantity: number;
rashWeightGramsSnapshot: string;
wagePercentSnapshot: string;
discountPercentSnapshot: string;
computedWeightGrams: string;
flatRialCharge: string;
}
@Injectable()
export class OrderRevisionService {
private readonly logger = new Logger(OrderRevisionService.name);
constructor(
private readonly prisma: PrismaService,
private readonly ledger: LedgerService,
private readonly sms: SmsService,
) {}
async editableStatuses(): Promise<OrderStatus[]> {
const row = await this.prisma.setting.findUnique({ where: { key: ORDER_EDIT_STATUSES_KEY } });
if (!row?.value) {
await this.prisma.setting.upsert({
where: { key: ORDER_EDIT_STATUSES_KEY },
create: { key: ORDER_EDIT_STATUSES_KEY, value: DEFAULT_EDITABLE },
update: {},
});
return [...DEFAULT_EDITABLE];
}
const raw = row.value;
const list = Array.isArray(raw) ? raw : (raw as { statuses?: unknown })?.statuses;
if (!Array.isArray(list)) return [...DEFAULT_EDITABLE];
const allowed = new Set(Object.values(OrderStatus));
const parsed = list
.map((s) => String(s))
.filter((s): s is OrderStatus => allowed.has(s as OrderStatus));
return parsed.length ? parsed : [...DEFAULT_EDITABLE];
}
async pendingForOrder(orderId: string) {
return this.prisma.orderRevision.findFirst({
where: { orderId, appliedAt: null, discardedAt: null },
orderBy: { createdAt: 'desc' },
});
}
/**
* Admin proposes a full replace of line items. Does not mutate Order items
* until the customer confirms (or admin force-applies).
*/
async propose(
orderId: string,
lines: ProposedLineInput[],
adminId: string,
note?: string,
) {
if (!lines?.length) throw new BadRequestException('حداقل یک آیتم برای سفارش لازم است');
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { customer: true },
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
if (order.status === OrderStatus.CANCELLED) {
throw new BadRequestException('سفارش لغو‌شده قابل ویرایش نیست');
}
const editable = await this.editableStatuses();
if (!editable.includes(order.status)) {
throw new BadRequestException('در این مرحله از سفارش امکان ویرایش وجود ندارد');
}
const existingPending = await this.pendingForOrder(orderId);
if (existingPending) {
throw new BadRequestException(
'یک پیشنهاد ویرایش در انتظار تأیید مشتری است. ابتدا آن را اعمال یا لغو کنید.',
);
}
const computed = await this.computeLines(lines);
const totals = this.sumTotals(computed);
const revision = await this.prisma.$transaction(async (tx) => {
const rev = await tx.orderRevision.create({
data: {
orderId,
items: computed as unknown as Prisma.InputJsonValue,
totalGoldWeightGrams: totals.totalGoldWeightGrams,
wageWeightGrams: totals.wageWeightGrams,
flatRialCharge: totals.flatRialCharge,
proposedByAdminId: adminId,
note: note?.trim() || null,
},
});
await tx.order.update({
where: { id: orderId },
data: { revisionStatus: OrderRevisionStatus.AWAITING_CUSTOMER },
});
return rev;
});
this.notifyCustomerEdit(order.orderNumber, order.customer.mobile);
return this.shapeRevision(revision);
}
/** Customer accepts → apply lines to the live order. */
async confirm(orderId: string, customerId: string) {
const order = await this.requireOwnedPending(orderId, customerId);
const pending = await this.pendingForOrder(orderId);
if (!pending) throw new BadRequestException('پیشنهاد ویرایشی در انتظار نیست');
await this.applyRevision(pending.id, { customerDecision: 'ACCEPTED' });
return this.prisma.order.findUnique({
where: { id: order.id },
include: { items: true, statusEvents: true },
});
}
/**
* Customer rejects → stay AWAITING_CUSTOMER until admin force-applies or discards.
*/
async reject(orderId: string, customerId: string) {
const order = await this.requireOwnedPending(orderId, customerId);
const pending = await this.pendingForOrder(orderId);
if (!pending) throw new BadRequestException('پیشنهاد ویرایشی در انتظار نیست');
await this.prisma.orderRevision.update({
where: { id: pending.id },
data: { customerDecision: 'REJECTED', customerDecidedAt: new Date() },
});
return { orderId: order.id, revisionStatus: OrderRevisionStatus.AWAITING_CUSTOMER, decision: 'REJECTED' };
}
/** Admin applies the pending proposal without waiting for the customer. */
async forceApply(orderId: string, _adminId: string) {
const pending = await this.pendingForOrder(orderId);
if (!pending) throw new BadRequestException('پیشنهاد ویرایشی در انتظار نیست');
await this.applyRevision(pending.id, { customerDecision: pending.customerDecision ?? 'ACCEPTED' });
return this.pendingSummary(orderId);
}
/** Admin drops the proposal; live order unchanged. */
async discard(orderId: string, _adminId: string) {
const pending = await this.pendingForOrder(orderId);
if (!pending) throw new BadRequestException('پیشنهاد ویرایشی در انتظار نیست');
await this.prisma.$transaction(async (tx) => {
await tx.orderRevision.update({
where: { id: pending.id },
data: { discardedAt: new Date() },
});
await tx.order.update({
where: { id: orderId },
data: { revisionStatus: OrderRevisionStatus.NONE },
});
});
return { success: true };
}
shapeRevision(r: {
id: string;
orderId: string;
items: Prisma.JsonValue;
totalGoldWeightGrams: Prisma.Decimal;
wageWeightGrams: Prisma.Decimal;
flatRialCharge: Prisma.Decimal;
note: string | null;
customerDecision: string | null;
customerDecidedAt: Date | null;
appliedAt: Date | null;
discardedAt: Date | null;
createdAt: Date;
}) {
return {
id: r.id,
orderId: r.orderId,
items: r.items,
totalGoldWeightGrams: r.totalGoldWeightGrams.toString(),
wageWeightGrams: r.wageWeightGrams.toString(),
flatRialCharge: r.flatRialCharge.toString(),
note: r.note,
customerDecision: r.customerDecision,
customerDecidedAt: r.customerDecidedAt,
appliedAt: r.appliedAt,
discardedAt: r.discardedAt,
createdAt: r.createdAt,
};
}
private async pendingSummary(orderId: string) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { items: true },
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
return order;
}
private async requireOwnedPending(orderId: string, customerId: string) {
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
if (!order) throw new NotFoundException('سفارش یافت نشد');
if (order.customerId !== customerId) throw new ForbiddenException();
if (order.revisionStatus !== OrderRevisionStatus.AWAITING_CUSTOMER) {
throw new BadRequestException('این سفارش منتظر تأیید ویرایش نیست');
}
return order;
}
private async computeLines(lines: ProposedLineInput[]): Promise<ProposedLineStored[]> {
const out: ProposedLineStored[] = [];
for (const line of lines) {
const qty = Math.floor(Number(line.quantity));
if (!line.productVariantId || !Number.isFinite(qty) || qty < 1) {
throw new BadRequestException('آیتم سفارش نامعتبر است');
}
const variant = await this.prisma.productVariant.findUnique({
where: { id: line.productVariantId },
});
if (!variant) throw new NotFoundException(`تنوع ${line.productVariantId} یافت نشد`);
const rash = new Prisma.Decimal(
line.rashWeightGrams != null ? line.rashWeightGrams : variant.rashWeightGrams,
);
const wage = new Prisma.Decimal(
line.wagePercent != null ? line.wagePercent : variant.defaultWagePercent,
);
const discount = new Prisma.Decimal(line.discountPercent ?? 0);
const computed =
line.grams != null
? new Prisma.Decimal(line.grams)
: rash.mul(wage.sub(discount).div(100).add(1));
const flat = new Prisma.Decimal(line.flatRialCharge ?? variant.rialWage ?? 0);
out.push({
productVariantId: variant.id,
quantity: qty,
rashWeightGramsSnapshot: rash.toString(),
wagePercentSnapshot: wage.toString(),
discountPercentSnapshot: discount.toString(),
computedWeightGrams: computed.toString(),
flatRialCharge: flat.toString(),
});
}
return out;
}
private sumTotals(lines: ProposedLineStored[]) {
let totalGold = new Prisma.Decimal(0);
let totalRash = new Prisma.Decimal(0);
let flat = new Prisma.Decimal(0);
for (const line of lines) {
totalGold = totalGold.add(new Prisma.Decimal(line.computedWeightGrams).mul(line.quantity));
totalRash = totalRash.add(new Prisma.Decimal(line.rashWeightGramsSnapshot).mul(line.quantity));
flat = flat.add(new Prisma.Decimal(line.flatRialCharge).mul(line.quantity));
}
return {
totalGoldWeightGrams: totalGold,
wageWeightGrams: totalGold.sub(totalRash),
flatRialCharge: flat,
};
}
private async applyRevision(
revisionId: string,
opts: { customerDecision: string },
) {
await this.prisma.$transaction(async (tx) => {
const rev = await tx.orderRevision.findUnique({ where: { id: revisionId } });
if (!rev || rev.appliedAt || rev.discardedAt) {
throw new BadRequestException('پیشنهاد ویرایش قابل اعمال نیست');
}
const items = rev.items as unknown as ProposedLineStored[];
if (!Array.isArray(items) || !items.length) {
throw new BadRequestException('آیتم‌های پیشنهاد نامعتبر است');
}
await tx.orderItem.deleteMany({ where: { orderId: rev.orderId } });
await tx.orderItem.createMany({
data: items.map((it) => ({
orderId: rev.orderId,
productVariantId: it.productVariantId,
quantity: it.quantity,
rashWeightGramsSnapshot: new Prisma.Decimal(it.rashWeightGramsSnapshot),
wagePercentSnapshot: new Prisma.Decimal(it.wagePercentSnapshot),
discountPercentSnapshot: new Prisma.Decimal(it.discountPercentSnapshot),
computedWeightGrams: new Prisma.Decimal(it.computedWeightGrams),
flatRialCharge: new Prisma.Decimal(it.flatRialCharge),
})),
});
await tx.order.update({
where: { id: rev.orderId },
data: {
totalGoldWeightGrams: rev.totalGoldWeightGrams,
wageWeightGrams: rev.wageWeightGrams,
flatRialCharge: rev.flatRialCharge,
revisionStatus: OrderRevisionStatus.NONE,
},
});
await tx.orderRevision.update({
where: { id: rev.id },
data: {
appliedAt: new Date(),
customerDecision: opts.customerDecision,
customerDecidedAt: new Date(),
},
});
await this.ledger.syncOrderDebt(rev.orderId, tx);
});
}
private notifyCustomerEdit(orderNumber: string, mobile: string) {
const body = `سفارش شما به شماره ${orderNumber} ویرایش شده و منتظر تأیید شماست. جزئیات را در اپ ببینید. ${brandName()}`;
void this.sms.send(mobile, body).catch((e: unknown) => {
this.logger.error(`Order-edit SMS to ${mobile} failed: ${String(e)}`);
});
}
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { OrdersService } from './orders.service';
import { OrderRevisionService } from './order-revision.service';
import { OrdersController } from './orders.controller';
import { CatalogModule } from '../catalog/catalog.module';
import { AccountingModule } from '../accounting/accounting.module';
@@ -9,7 +10,7 @@ import { SmsModule } from '../sms/sms.module';
@Module({
imports: [CatalogModule, AccountingModule, PricingModule, SmsModule],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
providers: [OrdersService, OrderRevisionService],
exports: [OrdersService, OrderRevisionService],
})
export class OrdersModule {}
@@ -124,7 +124,14 @@ describe('OrdersService.createOrder — server-side making charge', () => {
customer: { findUnique: jest.fn(async () => ({ id: 'c1', canPlaceOrders: true, referredByAgentId: null, goldDebtGrams: new Prisma.Decimal(0), goldCreditLimitGrams: new Prisma.Decimal(1000) })) },
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
};
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
const catalog = {
getVariantOrThrow: jest.fn(async () => ({
id: 'v1',
rashWeightGrams: '10',
// Variant اجرت ریالی — charged on the customer path, ignored from the body.
rialWage: '500000',
})),
};
const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never);
return { service, created };
}
@@ -135,7 +142,7 @@ describe('OrdersService.createOrder — server-side making charge', () => {
const { service, created } = build();
await service.createOrder(tampered, 'c1', { placedBy: 'customer' });
expect(created[0].totalGoldWeightGrams.toString()).toBe('11');
expect(created[0].flatRialCharge.toString()).toBe('0');
expect(created[0].flatRialCharge.toString()).toBe('500000');
});
it('keeps the numbers an operator entered in the panel', async () => {
@@ -149,10 +156,13 @@ describe('OrdersService.createOrder — server-side making charge', () => {
* MON-08 step 3 (owner ruling): «تست مقایسه‌ای که نشان دهد نتیجه برای کاربر
* عادی یکسان است». An unmodified app sends what the server derives
* (DECISIONS §"Report — MON-08"), so deriving server-side must not move the
* total for an ordinary customer.
* total for an ordinary customer. For flat Rial the honest app mirrors the
* variant's rialWage (same figure the cart shows).
*/
it('gives an honest app exactly the total it sent', async () => {
const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10, discountPercent: 0, flatRialCharge: 0 }] } as never;
const honest = {
items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10, discountPercent: 0, flatRialCharge: 500000 }],
} as never;
const a = build();
await a.service.createOrder(honest, 'c1', { placedBy: 'customer' });
const b = build();
@@ -169,7 +179,7 @@ describe('OrdersService.createOrder — server-side making charge', () => {
const lines = warn.mock.calls.map((c) => String(c[0])).join('\n');
expect(lines).toContain('wagePercent sent=0 used=10');
expect(lines).toContain('discountPercent sent=50 used=0');
expect(lines).toContain('flatRialCharge sent=999 used=0');
expect(lines).toContain('flatRialCharge sent=999 used=500000');
} finally {
warn.mockRestore();
}
@@ -211,7 +221,9 @@ describe('OrdersService.createOrder — server-side making charge', () => {
it('stays quiet when the app sent the right figures', async () => {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
try {
const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }] } as never;
const honest = {
items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10, flatRialCharge: 500000 }],
} as never;
await build().service.createOrder(honest, 'c1', { placedBy: 'customer' });
const pricing = warn.mock.calls.filter((c) => String(c[0]).includes('Order pricing'));
expect(pricing).toHaveLength(0);
+13 -8
View File
@@ -98,7 +98,13 @@ export class OrdersService {
if (quotes && !quote) throw new NotFoundException(`Variant ${variant.id} not found`);
const wagePercent = new Prisma.Decimal(quote ? quote.wagePercentSnapshot : item.wagePercent);
const discountPercent = new Prisma.Decimal(quote ? quote.discountPercentSnapshot : (item.discountPercent ?? 0));
if (quote) this.warnOnPriceMismatch(variant.id, item, wagePercent, discountPercent);
// Customer path: flat Rial comes from the variant (اجرت ریالی), never
// from the request body — same MON-08 posture as wage/discount. Operator
// path keeps the panel-entered figure.
const flatRialCharge = quotes
? new Prisma.Decimal(variant.rialWage ?? 0)
: new Prisma.Decimal(item.flatRialCharge ?? 0);
if (quote) this.warnOnPriceMismatch(variant.id, item, wagePercent, discountPercent, flatRialCharge);
const netPercent = wagePercent.sub(discountPercent);
const computedWeightGrams = rashWeight.mul(netPercent.div(100).add(1));
@@ -109,8 +115,7 @@ export class OrdersService {
wagePercentSnapshot: wagePercent,
discountPercentSnapshot: discountPercent,
computedWeightGrams,
// Legacy app: rial_pay is always 0 (cart.vue); only an operator sets one.
flatRialCharge: new Prisma.Decimal(quotes ? 0 : (item.flatRialCharge ?? 0)),
flatRialCharge,
};
}),
);
@@ -216,15 +221,15 @@ export class OrdersService {
* mismatch means a stale app version, a promotion that expired between the
* product page and checkout, or a tampered request.
*
* `flatRialCharge` is included because the app hardcodes 0 (cart.vue:179) and
* only an operator ever sets one — anything non-zero from a customer is a
* client that should not be sending it.
* Flat Rial is the variant's `rialWage` (اجرت ریالی); the client's number is
* ignored and logged when it differs.
*/
private warnOnPriceMismatch(
variantId: string,
sent: CreateOrderItemInputDto,
wagePercent: Prisma.Decimal,
discountPercent: Prisma.Decimal,
flatRialCharge: Prisma.Decimal,
) {
const differs: string[] = [];
if (!wagePercent.eq(sent.wagePercent)) {
@@ -233,8 +238,8 @@ export class OrdersService {
if (!discountPercent.eq(sent.discountPercent ?? 0)) {
differs.push(`discountPercent sent=${sent.discountPercent ?? 0} used=${discountPercent.toString()}`);
}
if ((sent.flatRialCharge ?? 0) !== 0) {
differs.push(`flatRialCharge sent=${sent.flatRialCharge} used=0`);
if (!flatRialCharge.eq(sent.flatRialCharge ?? 0)) {
differs.push(`flatRialCharge sent=${sent.flatRialCharge ?? 0} used=${flatRialCharge.toString()}`);
}
if (differs.length) {
this.logger.warn(
+2 -1
View File
@@ -13,7 +13,8 @@ import { Prisma } from '@prisma/client';
* its category or its brand (getPromotion; customer-type off)
* final % = percent_pay − discount (setOrder :558)
* weight = rash × (1 + final/100) × qty
* rial charge = 0 (cart.vue: rial_pay: 0)
* rial charge = variant.rialWage × qty (اجرت ریالی; charged in OrdersService,
* not by this percent formula)
*
* The admin «اجرت» screen may change the numbers AND the structure: each term
* has a coefficient (0 switches it off), the promotion can be the largest or
-5
View File
@@ -1,5 +0,0 @@
*
!dist/
!dist/**
!nginx.conf
!Dockerfile
-8
View File
@@ -1,8 +0,0 @@
FROM nginx:latest
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.rfx[data-v-a2468dec]{position:fixed;top:0;right:0;bottom:0;left:0;z-index:4000;display:grid;place-items:center;pointer-events:none;transition:opacity .12s ease}.rfx--blocking[data-v-a2468dec]{pointer-events:all}.rfx__veil[data-v-a2468dec]{position:absolute;top:0;right:0;bottom:0;left:0;background:#0c060a94;backdrop-filter:blur(22px) saturate(140%);-webkit-backdrop-filter:blur(22px) saturate(140%)}.rfx__stage[data-v-a2468dec]{position:relative;z-index:1;display:flex;flex-direction:column;align-items:center;gap:22px;padding:28px 32px;will-change:transform}.rfx__logo[data-v-a2468dec]{width:140px;height:140px;-o-object-fit:contain;object-fit:contain;filter:drop-shadow(0 22px 44px rgba(0,0,0,.5)) drop-shadow(0 0 36px rgba(124,0,53,.4));animation:rfx-breathe-a2468dec 1.8s ease-in-out infinite}.rfx__spin[data-v-a2468dec]{display:grid;place-items:center;width:48px;height:48px;border-radius:50%;background:#ffffff14;border:1px solid rgba(255,255,255,.14);box-shadow:0 10px 28px -12px #0000008c}@keyframes rfx-breathe-a2468dec{0%,to{transform:scale(1)}50%{transform:scale(1.045)}}@media (prefers-reduced-motion: reduce){.rfx[data-v-a2468dec]{transition:none}.rfx__veil[data-v-a2468dec]{backdrop-filter:none;-webkit-backdrop-filter:none;background:#0c060ac7}.rfx__logo[data-v-a2468dec]{animation:none;filter:drop-shadow(0 12px 24px rgba(0,0,0,.4))}}.apr[data-v-b5b12b8c] .van-pull-refresh__track{transform:none!important;transition:none!important}.apr[data-v-b5b12b8c] .van-pull-refresh__head{display:none}
@@ -1 +0,0 @@
import{d as u,b as i,c,f as r,e,_ as h,u as v,p as o,C as b,E as k,F as f,r as w,m as n,t as d,g as x,I as _,R as g}from"./index-D8i4h-PU.js";import{P as y}from"./PageBar-BBMNYL0j.js";const C=u({__name:"ToggleSwitch",props:{on:{type:Boolean}},setup(p){return(a,l)=>(i(),c("span",{class:r(["tsw",{"is-on":p.on}]),"aria-hidden":"true"},[...l[0]||(l[0]=[e("span",{class:"tsw__knob"},null,-1)])],2))}}),B=h(C,[["__scopeId","data-v-a25f88d8"]]),T={class:"ap"},S={class:"ap__section"},V={class:"ap__themes",role:"radiogroup","aria-labelledby":"ap-theme"},I=["aria-checked","onClick"],L={class:"ap__themeLabel"},M={class:"ap__check","aria-hidden":"true"},z={key:0,class:"ap__note"},E={class:"ap__section"},N=["aria-checked"],$={class:"ap__rowIcon","aria-hidden":"true"},A=u({__name:"AppearanceView",setup(p){const a=v(),l=[{value:"light",label:"روشن",icon:"vuesax-linear:sun-1"},{value:"dark",label:"تیره",icon:"vuesax-linear:moon"},{value:"system",label:"مطابق سیستم",icon:"vuesax-linear:monitor-mobbile"}];return(P,s)=>{const m=k("container");return i(),c("div",T,[o(y,{title:"تنظیمات ظاهری","back-to":"userPage::hasSubRoute"}),o(m,{class:"ap__body"},{default:b(()=>[e("section",S,[s[2]||(s[2]=e("header",{class:"ap__head"},[e("h2",{id:"ap-theme",class:"ap__title"},"پوسته برنامه"),e("p",{class:"ap__sub"},"ظاهر روشن یا تیره، یا هماهنگ با تنظیمات گوشی شما")],-1)),e("div",V,[(i(),c(f,null,w(l,t=>e("button",{key:t.value,type:"button",role:"radio",class:r(["ap__theme",{"is-on":n(a).theme===t.value}]),"aria-checked":n(a).theme===t.value,onClick:R=>n(a).setTheme(t.value)},[e("span",{class:r(["ap__preview",`ap__preview--${t.value}`]),"aria-hidden":"true"},[...s[1]||(s[1]=[e("span",{class:"ap__pvBar"},null,-1),e("span",{class:"ap__pvCard"},null,-1),e("span",{class:"ap__pvLine"},null,-1),e("span",{class:"ap__pvLine ap__pvLine--short"},null,-1),e("span",{class:"ap__pvBtn"},null,-1)])],2),e("span",L,[o(_,{icon:t.icon,size:"15"},null,8,["icon"]),g(" "+d(t.label),1)]),e("span",M,[o(_,{icon:"vuesax-linear:tick-circle",size:"18"})])],10,I)),64))]),n(a).theme==="system"?(i(),c("p",z," الان "+d(n(a).resolvedTheme==="dark"?"تیره":"روشن")+" است، مطابق تنظیمات گوشی. ",1)):x("",!0)]),e("section",E,[s[4]||(s[4]=e("header",{class:"ap__head"},[e("h2",{class:"ap__title"},"انیمیشن‌ها"),e("p",{class:"ap__sub"},"برای سرعت بیشتر روی گوشی‌های ضعیف‌تر")],-1)),e("button",{type:"button",role:"switch",class:"ap__row","aria-checked":n(a).reduceMotion,onClick:s[0]||(s[0]=t=>n(a).setReduceMotion(!n(a).reduceMotion))},[e("span",$,[o(_,{icon:"vuesax-linear:flash",size:"18"})]),s[3]||(s[3]=e("span",{class:"ap__rowText"},[e("b",null,"حالت سبک و سریع"),e("small",null," حرکت‌های سنگین، افکت‌های شیشه‌ای و اسلاید خودکار خاموش می‌شوند؛ بازخورد کوتاه لمس‌ها و جابه‌جایی‌های سریع باقی می‌مانند تا برنامه خشک و بی‌روح نشود. ")],-1)),o(B,{on:n(a).reduceMotion},null,8,["on"])],8,N)])]),_:1})])}}}),H=h(A,[["__scopeId","data-v-f5d35ecf"]]);export{H as default};
@@ -1 +0,0 @@
.tsw[data-v-a25f88d8]{position:relative;flex:0 0 auto;display:inline-block;width:46px;height:28px;border-radius:999px;transition:background-color .22s ease}.tsw__knob[data-v-a25f88d8]{position:absolute;top:3px;inset-inline-start:3px;width:22px;height:22px;border-radius:50%;box-shadow:0 2px 6px -1px #14161a59;transition:transform .26s cubic-bezier(.32,.72,0,1),background-color .22s ease}.tsw.is-on .tsw__knob[data-v-a25f88d8]{transform:translate(-18px)}.tsw[data-v-a25f88d8]{background:var(--c-hairline)}.tsw__knob[data-v-a25f88d8]{background:#fff}.tsw.is-on[data-v-a25f88d8]{background:var(--c-wine)}.tsw.is-on .tsw__knob[data-v-a25f88d8]{background:#fff}.ap[data-v-f5d35ecf]{min-height:100vh}.ap__body[data-v-f5d35ecf]{padding-top:76px;padding-bottom:90px;display:flex;flex-direction:column;gap:26px}.ap__title[data-v-f5d35ecf],.ap__sub[data-v-f5d35ecf],.ap__note[data-v-f5d35ecf]{margin:0}.ap__themes[data-v-f5d35ecf]{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.ap__theme[data-v-f5d35ecf]{position:relative;display:flex;flex-direction:column;align-items:center;gap:10px;cursor:pointer;font-family:inherit;-webkit-tap-highlight-color:transparent;transition:transform .15s ease,border-color .2s ease,box-shadow .2s ease,background-color .2s ease}.ap__theme[data-v-f5d35ecf]:active{transform:scale(.97)}.ap__themeLabel[data-v-f5d35ecf]{display:inline-flex;align-items:center;gap:5px;white-space:nowrap}.ap__check[data-v-f5d35ecf]{position:absolute;top:8px;inset-inline-end:8px;display:grid;place-items:center;opacity:0;transform:scale(.6);transition:opacity .18s ease,transform .22s cubic-bezier(.32,.72,0,1)}.ap__theme.is-on .ap__check[data-v-f5d35ecf]{opacity:1;transform:none}.ap__preview[data-v-f5d35ecf]{position:relative;width:100%;aspect-ratio:3 / 4;overflow:hidden;display:flex;flex-direction:column;gap:6%;padding:10% 12%;--pv-bg: #ffffff;--pv-bar: #f4f5f6;--pv-card: #ecedef;--pv-line: #dadce0;--pv-btn: #14161a;background:var(--pv-bg)}.ap__preview--dark[data-v-f5d35ecf]{--pv-bg: #16171a;--pv-bar: #1e2024;--pv-card: #2b2e34;--pv-line: #3a3e45;--pv-btn: #f1f2f4}.ap__preview--system[data-v-f5d35ecf]:after{content:"";position:absolute;top:0;right:0;bottom:0;left:0;background:#16171a;clip-path:polygon(100% 0,100% 100%,0 100%);opacity:.94}.ap__pvBar[data-v-f5d35ecf]{height:9%;border-radius:4px;background:var(--pv-bar)}.ap__pvCard[data-v-f5d35ecf]{flex:1;border-radius:7px;background:var(--pv-card)}.ap__pvLine[data-v-f5d35ecf]{height:6%;border-radius:3px;background:var(--pv-line)}.ap__pvLine--short[data-v-f5d35ecf]{width:60%}.ap__pvBtn[data-v-f5d35ecf]{height:11%;border-radius:999px;background:var(--pv-btn)}.ap__row[data-v-f5d35ecf]{width:100%;display:flex;align-items:center;gap:12px;cursor:pointer;font-family:inherit;text-align:start;-webkit-tap-highlight-color:transparent;transition:transform .15s ease,border-color .2s ease}.ap__row[data-v-f5d35ecf]:active{transform:scale(.985)}.ap__rowIcon[data-v-f5d35ecf]{flex:0 0 auto;display:grid;place-items:center}.ap__rowText[data-v-f5d35ecf]{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}.ap[data-v-f5d35ecf]{background:var(--c-page)}.ap__head[data-v-f5d35ecf]{margin-bottom:12px}.ap__title[data-v-f5d35ecf]{font-size:16px;font-weight:700;color:var(--c-ink)}.ap__sub[data-v-f5d35ecf]{margin-top:4px;font-size:11.5px;color:var(--c-inkFaint)}.ap__note[data-v-f5d35ecf]{margin-top:10px;font-size:11.5px;color:var(--c-inkMuted)}.ap__theme[data-v-f5d35ecf]{padding:10px 10px 12px;border:0;border-radius:22px;background:var(--c-hairlineSoft);color:var(--c-inkMuted);box-shadow:inset 0 0 0 1.5px transparent}.ap__theme.is-on[data-v-f5d35ecf]{color:var(--c-ink);box-shadow:inset 0 0 0 1.5px var(--c-wine)}.ap__preview[data-v-f5d35ecf]{border-radius:14px;box-shadow:0 0 0 1px #14161a0f}.ap__themeLabel[data-v-f5d35ecf]{font-size:12px;font-weight:700}.ap__check[data-v-f5d35ecf]{width:22px;height:22px;border-radius:50%;color:#fff;background:var(--c-wine)}.ap__row[data-v-f5d35ecf]{padding:13px 14px;border:1px solid var(--c-hairline);border-radius:17px;background:var(--c-canvas)}.ap__row[data-v-f5d35ecf]:active{border-color:rgb(var(--c-wine-rgb) / 20%)}.ap__rowIcon[data-v-f5d35ecf]{width:38px;height:38px;border-radius:12px;color:var(--c-wine);background:var(--c-wineWash)}.ap__rowText b[data-v-f5d35ecf]{font-size:12.5px;font-weight:700;color:var(--c-ink)}.ap__rowText small[data-v-f5d35ecf]{font-size:10.5px;line-height:1.9;color:var(--c-inkFaint)}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.cho__barExtra[data-v-65173363]{padding:0 16px 12px}.cho__body[data-v-65173363]{padding-top:132px;padding-bottom:90px}.cho__list[data-v-65173363]{display:flex;flex-direction:column;padding-top:8px}.cho__card[data-v-65173363]{padding:18px 2px;background:transparent;border:none;border-bottom:1px solid var(--c-hairline);cursor:pointer}.cho__card[data-v-65173363]:last-child{border-bottom:none}.cho__card[data-v-65173363]:active{opacity:.75}.cho__top[data-v-65173363]{display:flex;justify-content:space-between;gap:12px}.cho__right[data-v-65173363]{display:flex;flex-direction:column;align-items:flex-end;gap:8px}.cho__num[data-v-65173363]{font-size:15px;font-weight:700;margin-bottom:10px}.cho__muted[data-v-65173363]{color:var(--c-inkFaint);font-weight:500;margin-left:4px}.cho__meta[data-v-65173363]{display:flex;align-items:baseline;gap:4px}.cho__strong[data-v-65173363]{font-size:15px;font-weight:700}.cho__unit[data-v-65173363]{font-size:12px;color:var(--c-inkFaint)}.cho__status[data-v-65173363]{padding:5px 14px;border-radius:999px;font-size:11.5px;font-weight:600;color:#06b6d4;background:#06b6d41f;white-space:nowrap}.cho__divider[data-v-65173363]{height:10px}.cho__bottom[data-v-65173363]{display:flex;justify-content:space-between;align-items:center}.cho__customer[data-v-65173363]{font-size:13.5px;font-weight:600;color:var(--c-inkMuted)}.cho__more[data-v-65173363],.cho__sentinel[data-v-65173363]{padding:12px 0;text-align:center;font-size:12px;color:var(--c-inkFaint)}.cho__empty[data-v-65173363]{margin-top:8px;padding:34px 20px;border:1px dashed var(--c-hairline);border-radius:18px;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--c-inkFaint)}.cho__empty p[data-v-65173363]{margin:0;font-size:12.5px}.cho__retry[data-v-65173363]{padding:8px 18px;border-radius:999px;border:1px solid var(--c-hairline);background:var(--c-canvas);font-family:inherit;font-size:12px;font-weight:600;cursor:pointer}.cho__detail[data-v-65173363]{padding:20px 16px 24px}.cho__detailTitle[data-v-65173363]{margin:0 0 16px;font-size:17px;font-weight:700}.cho__rows[data-v-65173363]{margin:0;display:flex;flex-direction:column}.cho__row[data-v-65173363]{display:flex;justify-content:space-between;align-items:center;padding:13px 2px;border-bottom:1px solid var(--c-hairline)}.cho__row[data-v-65173363]:last-child{border-bottom:none}.cho__row dt[data-v-65173363]{font-size:13px;color:var(--c-inkFaint)}.cho__row dd[data-v-65173363]{margin:0;font-size:14px;font-weight:600}.cho__close[data-v-65173363]{margin-top:18px;width:100%;padding:13px;border:none;border-radius:14px;background:var(--c-wine);color:#fff;font-family:inherit;font-size:14px;font-weight:700;cursor:pointer}
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.coll[data-v-24baa11f]{background:var(--c-page);min-height:100vh}.coll__body[data-v-24baa11f]{padding-top:76px;padding-bottom:90px}.coll__grid[data-v-24baa11f]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.coll__more[data-v-24baa11f]{display:flex;justify-content:center;padding:18px 0 4px}.coll__empty[data-v-24baa11f]{margin-top:8px;padding:34px 20px;border:1px dashed var(--c-hairline);border-radius:18px;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--c-inkFaint)}.coll__empty p[data-v-24baa11f]{margin:0;font-size:12.5px}
@@ -1 +0,0 @@
import{d as b,W as B,a as M,o as V,c as n,p as t,C as f,N as g,i,b as o,m as s,F as N,r as S,g as v,I as q,e as E,h as F,E as L,Y as O,j,_ as z}from"./index-D8i4h-PU.js";import{P as A}from"./PageBar-BBMNYL0j.js";import{S as H}from"./SectionHead-LafpWeqY.js";import{P as R}from"./ProductCard-C0a9p2bL.js";import{u as T}from"./usePagedProducts-Bcf85uEP.js";const U={class:"coll"},W={key:0,class:"coll__grid"},Y={key:2,class:"coll__empty"},$=b({__name:"CollectionView",setup(D){const u=O(),{rows:a,loading:y,loadingMore:h,hasMore:d,resetAndLoad:k,loadMore:x}=T(),_=i(()=>y.value&&a.value.length===0),w=i(()=>u.query.page||"مجموعه"),C=i(()=>String(u.params.id??"")),l=F(null);let e;function m(){return{collectionId:C.value,sort:"newest"}}function I(){e==null||e.disconnect(),l.value&&(e=new IntersectionObserver(c=>{c.some(r=>r.isIntersecting)&&d.value&&x(m())},{rootMargin:"400px"}),e.observe(l.value))}return B(l,I),M(()=>e==null?void 0:e.disconnect()),V(async()=>{try{await k(m())}catch(c){console.log(c)}}),(c,r)=>{const P=L("container");return o(),n("div",U,[t(A,{title:w.value,"back-to":"search"},null,8,["title"]),t(g,{spinning:_.value,tip:"درحال دریافت اطلاعات"},{default:f(()=>[t(P,{class:"coll__body"},{default:f(()=>[t(H,{title:"محصولات این مجموعه",subtitle:"برای دیدن جزئیات، روی محصول ضربه بزنید"}),s(a).length?(o(),n("div",W,[(o(!0),n(N,null,S(s(a),p=>(o(),j(R,{key:p.id,value:p},null,8,["value"]))),128))])):v("",!0),s(d)||s(h)?(o(),n("div",{key:1,ref_key:"sentinel",ref:l,class:"coll__more"},[t(g,{spinning:!0,indicator:!0,fixed:!1,tip:"",cls:"my-[10px]"})],512)):!_.value&&!s(a).length?(o(),n("section",Y,[t(q,{icon:"vuesax-linear:box",size:"26"}),r[0]||(r[0]=E("p",null,"در این مجموعه محصولی ثبت نشده است.",-1))])):v("",!0)]),_:1})]),_:1},8,["spinning"])])}}}),Z=z($,[["__scopeId","data-v-24baa11f"]]);export{Z as default};
@@ -1 +0,0 @@
.siu__input[data-v-3a42e270]{display:none}.siu__box[data-v-3a42e270]{position:relative;width:100%;padding:18px 2px;display:flex;flex-direction:row-reverse;align-items:center;justify-content:flex-end;gap:12px;border:none;border-bottom:1px solid var(--c-hairline);background:transparent;font-family:inherit;text-align:right;cursor:pointer;overflow:hidden}.siu__box[data-v-3a42e270]:disabled{cursor:progress}.siu__box[data-v-3a42e270]:active{opacity:.75}.is-done .siu__box[data-v-3a42e270]{border-bottom-color:var(--c-green)}.is-error .siu__box[data-v-3a42e270]{border-bottom-color:var(--c-red)}.siu__preview[data-v-3a42e270]{width:46px;height:46px;flex:0 0 auto;-o-object-fit:cover;object-fit:cover;border-radius:12px;order:-1}.siu__icon[data-v-3a42e270]{display:flex;flex:0 0 auto;align-items:center;justify-content:center;width:46px;height:46px;border-radius:12px;background:var(--c-canvasSoft);color:var(--c-inkMuted);order:-1}.siu__text[data-v-3a42e270]{flex:1;font-size:13.5px;font-weight:600;color:var(--c-ink)}.siu__badge[data-v-3a42e270]{flex:0 0 auto;display:flex;color:var(--c-green)}.siu__error[data-v-3a42e270]{margin:7px 2px 0;font-size:12px;color:var(--c-red)}.cpf__body[data-v-978e9d9e]{padding-top:84px;padding-bottom:110px}.cpf__lead[data-v-978e9d9e]{margin:4px 0 6px;font-size:13px;color:var(--c-inkMuted)}.cpf__form[data-v-978e9d9e]{display:flex;flex-direction:column}.cpf__error[data-v-978e9d9e]{margin:12px 2px 0;font-size:12.5px;color:var(--c-red)}.cpf__submit[data-v-978e9d9e]{position:fixed;left:16px;right:16px;bottom:20px;z-index:20;max-width:736px;margin:0 auto;padding:15px;border:none;border-radius:999px;background:var(--c-wine);color:#fff;font-family:inherit;font-size:15px;font-weight:700;cursor:pointer}.cpf__submit[data-v-978e9d9e]:disabled{opacity:.6;cursor:progress}
@@ -1 +0,0 @@
import{d as S,b as r,c,f as I,e as d,p as n,I as w,t as h,g as x,h as o,v as $,_ as B,C,N as P,T as D,aS as U,O as V,E as z,D as E,$ as N,U as R,K as T}from"./index-D8i4h-PU.js";import{P as j}from"./PageBar-BBMNYL0j.js";const K=["disabled"],L=["src"],M={key:1,class:"siu__icon"},O={class:"siu__text"},Y={key:2,class:"siu__badge"},A={key:0,class:"siu__error"},Q=8*1024*1024,W=S({__name:"SingleImageUploader",props:{uploadText:{},purpose:{}},emits:["done"],setup(g,{emit:b}){const a=g,_=b,l=o(null),i=o(!1),t=o(null),u=o(null),m=o(null);async function s(v){var f;const e=v.target,p=(f=e.files)==null?void 0:f[0];if(e.value="",!!p){if(t.value=null,p.size>Q){t.value="حجم فایل بیش از حد مجاز است (۸ مگابایت)";return}i.value=!0;try{const k=await $.uploadDocument(p,a.purpose);u.value=k.id,m.value=p.type.startsWith("image/")?URL.createObjectURL(p):null,_("done",k.id)}catch{t.value="بارگذاری انجام نشد. دوباره تلاش کنید.",u.value=null,m.value=null}finally{i.value=!1}}}return(v,e)=>(r(),c("div",{class:I(["siu",{"is-done":!!u.value,"is-error":!!t.value}])},[d("input",{ref_key:"input",ref:l,type:"file",accept:"image/jpeg,image/png,image/webp,application/pdf",class:"siu__input",onChange:s},null,544),d("button",{type:"button",class:"siu__box",disabled:i.value,onClick:e[0]||(e[0]=p=>{var f;return(f=l.value)==null?void 0:f.click()})},[m.value?(r(),c("img",{key:0,src:m.value,class:"siu__preview",alt:""},null,8,L)):(r(),c("span",M,[n(w,{icon:i.value?"vuesax-linear:refresh":"vuesax-linear:gallery-add",size:"26"},null,8,["icon"])])),d("span",O,h(i.value?"در حال بارگذاری…":u.value?"تغییر تصویر":g.uploadText),1),u.value&&!i.value?(r(),c("span",Y,[n(w,{icon:"vuesax-linear:tick-circle",size:"16"})])):x("",!0)],8,K),t.value?(r(),c("p",A,h(t.value),1)):x("",!0)],2))}}),y=B(W,[["__scopeId","data-v-3a42e270"]]),X={class:"cpf"},q={key:0,class:"cpf__error"},F=["disabled"],G=S({__name:"CompleteProfileView",setup(g){const b=E(),a=V({business_license_image_id:null,shop_image_id:null,ncode_image_id:null}),_=o(!1),l=o(!1),i=o(null);async function t(){if(i.value=null,!a.business_license_image_id||!a.shop_image_id||!a.ncode_image_id){N.warning("لطفا تمامی عکس های خواسته شده را آپلود کنید");return}_.value=!0;try{await $.completeProfile({business_license_image_id:a.business_license_image_id,shop_image_id:a.shop_image_id,ncode_image_id:a.ncode_image_id}),R().invalidate(T.profile),l.value=!0}catch{i.value="ثبت اطلاعات انجام نشد. دوباره تلاش کنید."}finally{_.value=!1}}function u(){l.value=!1,b.replace({name:"home"})}return(m,s)=>{const v=z("container");return r(),c("div",X,[n(j,{title:"تکمیل پروفایل","back-to":"userPage::hasSubRoute"}),n(P,{spinning:_.value,tip:"در حال ثبت اطلاعات"},{default:C(()=>[n(v,{class:"cpf__body"},{default:C(()=>[s[4]||(s[4]=d("p",{class:"cpf__lead"}," برای تکمیل پروفایل، سه مدرک زیر را بارگذاری کنید. ",-1)),d("form",{class:"cpf__form",onSubmit:U(t,["prevent"])},[n(y,{"upload-text":"بارگذاری عکس جواز کسب",purpose:"business-license",onDone:s[0]||(s[0]=e=>a.business_license_image_id=e)}),n(y,{"upload-text":"بارگذاری عکس سردر مغازه",purpose:"shop-image",onDone:s[1]||(s[1]=e=>a.shop_image_id=e)}),n(y,{"upload-text":"بارگذاری عکس کارت ملی",purpose:"national-card",onDone:s[2]||(s[2]=e=>a.ncode_image_id=e)}),i.value?(r(),c("p",q,h(i.value),1)):x("",!0),d("button",{type:"submit",class:"cpf__submit",disabled:_.value}," بارگذاری اطلاعات ",8,F)],32)]),_:1})]),_:1},8,["spinning"]),n(D,{show:l.value,"onUpdate:show":s[3]||(s[3]=e=>l.value=e),art:"success",title:"تکمیل اطلاعات شما با موفقیت انجام شد.",description:"مدارک شما با موفقیت بارگذاری شد.","confirm-text":"متوجه شدم","close-on-click-overlay":!1,onConfirm:u},null,8,["show"])])}}}),Z=B(G,[["__scopeId","data-v-978e9d9e"]]);export{Z as default};
@@ -1 +0,0 @@
.cch__barExtra[data-v-bd288253]{padding:0 16px 12px}.cch__body[data-v-bd288253]{padding-top:132px;padding-bottom:90px}.cch__list[data-v-bd288253]{display:flex;flex-direction:column;padding-top:8px}.cch__card[data-v-bd288253]{padding:18px 2px;background:transparent;border:none;border-bottom:1px solid var(--c-hairline);cursor:pointer}.cch__card[data-v-bd288253]:last-child{border-bottom:none}.cch__card[data-v-bd288253]:active{opacity:.75}.cch__head[data-v-bd288253]{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px}.cch__name[data-v-bd288253]{margin:0;font-size:15.5px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cch__state[data-v-bd288253]{flex:0 0 auto;padding:4px 12px;border-radius:999px;font-size:11.5px;font-weight:600}.cch__state.is-on[data-v-bd288253]{color:var(--c-green);background:rgb(var(--c-green-rgb) / 14%)}.cch__state.is-off[data-v-bd288253]{color:var(--c-red);background:rgb(var(--c-red-rgb) / 12%)}.cch__facts[data-v-bd288253]{display:flex;flex-direction:column;gap:7px}.cch__fact[data-v-bd288253]{display:flex;align-items:center;gap:7px;font-size:13px;font-weight:600;color:var(--c-inkFaint)}.cch__empty[data-v-bd288253]{margin-top:8px;padding:34px 20px;border:1px dashed var(--c-hairline);border-radius:18px;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--c-inkFaint)}.cch__empty p[data-v-bd288253]{margin:0;font-size:12.5px}.cch__retry[data-v-bd288253]{padding:8px 18px;border-radius:999px;border:1px solid var(--c-hairline);background:var(--c-canvas);font-family:inherit;font-size:12px;font-weight:600;cursor:pointer}.cch__detail[data-v-bd288253]{padding:20px 16px 24px}.cch__detailTitle[data-v-bd288253]{margin:0 0 16px;font-size:17px;font-weight:700}.cch__rows[data-v-bd288253]{margin:0;display:flex;flex-direction:column}.cch__row[data-v-bd288253]{display:flex;justify-content:space-between;align-items:center;padding:13px 2px;border-bottom:1px solid var(--c-hairline)}.cch__row[data-v-bd288253]:last-child{border-bottom:none}.cch__row dt[data-v-bd288253]{font-size:13px;color:var(--c-inkFaint)}.cch__row dd[data-v-bd288253]{margin:0;font-size:14px;font-weight:600}.cch__enter[data-v-bd288253]{margin-top:18px;width:100%;padding:13px;border:none;border-radius:14px;background:var(--c-wine);color:#fff;font-family:inherit;font-size:14px;font-weight:700;cursor:pointer}.cch__enter[data-v-bd288253]:disabled{opacity:.6;cursor:default}.cch__close[data-v-bd288253]{margin-top:10px;width:100%;padding:13px;border:1px solid rgb(var(--c-ink-rgb) / 35%);border-radius:14px;background:transparent;color:var(--c-ink);font-family:inherit;font-size:14px;font-weight:700;cursor:pointer}
@@ -1 +0,0 @@
import{d as A,S as D,Q as P,o as R,c,p as n,C as _,N as B,m as E,h as i,v as F,b as r,e as s,F as I,r as U,I as v,t as o,g as b,$ as L,E as M,D as O,f as Q,_ as T}from"./index-D8i4h-PU.js";import{A as j}from"./AppPullRefresh-DK9DbvVA.js";import{P as q}from"./PageBar-BBMNYL0j.js";import{S as G}from"./SearchInput-BYt0pCew.js";import{D as H}from"./index-BMHplzPz.js";import"./KeyCode-C7e-XTEK.js";import"./classCallCheck-BwcnXnXD.js";import"./raf-nDFcuFT6.js";const J={class:"cch"},K={class:"cch__barExtra"},W={key:0,class:"cch__list"},X=["onClick"],Y={class:"cch__head"},Z={class:"cch__name"},ss={class:"cch__facts"},es={class:"cch__fact"},ts={dir:"ltr"},as={class:"cch__fact"},os={dir:"ltr"},ls={key:0,class:"cch__fact"},ns={key:1,class:"cch__empty"},cs={key:2,class:"cch__empty"},is={key:0,class:"cch__detail"},rs={class:"cch__detailTitle"},ds={class:"cch__rows"},us={class:"cch__row"},_s={dir:"ltr"},vs={class:"cch__row"},hs={dir:"ltr"},ps={class:"cch__row"},fs={class:"cch__row"},ms={class:"cch__row"},bs={dir:"ltr"},ys={class:"cch__row"},gs={dir:"ltr"},ws={class:"cch__row"},Cs=["disabled"],ks=A({__name:"CustomerChoiceView",setup(xs){const C=O(),k=D(),x=P(),h=i(!1),d=i([]),p=i(!1),y=i(!1),f=i(!1),g=i(null),u=i(!1),l=i(null);function w(t){const e=Number(t??0);return Number.isFinite(e)?e.toFixed(3):"0.000"}async function m(){p.value=!0,f.value=!1;try{d.value=await F.getExpertCustomers(g.value)}catch{f.value=!0,d.value=[]}finally{p.value=!1}}async function S(t){g.value=t!=null&&t.trim()?t.trim():null,await m()}async function V(){try{await m()}finally{y.value=!1}}function z(t){l.value=t,u.value=!0}async function N(t){h.value=!0;try{x.clear(),k.setActingAs({id:t.id,name:t.name??t.fullName??t.mobile??""}),u.value=!1,await C.push({name:"userPage::hasSubRoute"}),L.success(`وارد حساب ${t.name??""} شدید`)}finally{h.value=!1}}return R(m),(t,e)=>{const $=M("container");return r(),c("div",J,[n(q,{title:"لیست مشتریان","back-to":"userPage::hasSubRoute"},{below:_(()=>[s("div",K,[n(G,{onSearch:S,"place-holder":"جستجو"})])]),_:1}),n(B,{spinning:p.value&&!d.value.length,tip:"درحال دریافت اطلاعات"},{default:_(()=>[n(j,{modelValue:y.value,"onUpdate:modelValue":e[0]||(e[0]=a=>y.value=a),onRefresh:V},{default:_(()=>[n($,{class:"cch__body"},{default:_(()=>[d.value.length?(r(),c("div",W,[(r(!0),c(I,null,U(d.value,a=>(r(),c("article",{key:a.id,class:"cch__card",onClick:Ss=>z(a)},[s("div",Y,[s("h3",Z,o(a.name),1),s("span",{class:Q(["cch__state",a.active===1?"is-on":"is-off"])},o(a.active===1?"فعال":"غیرفعال"),3)]),s("div",ss,[s("div",es,[n(v,{icon:"vuesax-linear:mobile",size:"15"}),s("span",ts,o(a.mobile||"—"),1)]),s("div",as,[n(v,{icon:"vuesax-linear:call",size:"15"}),s("span",os,o(a.phone||"—"),1)]),a.store_name?(r(),c("div",ls,[n(v,{icon:"vuesax-linear:shop",size:"15"}),s("span",null,o(a.store_name),1)])):b("",!0)])],8,X))),128))])):!p.value&&!f.value?(r(),c("section",ns,[n(v,{icon:"vuesax-linear:profile-2user",size:"26"}),s("p",null,o(g.value?"مشتری با این مشخصات پیدا نشد.":"هنوز مشتری‌ای به شما معرفی نشده است."),1)])):b("",!0),f.value?(r(),c("section",cs,[n(v,{icon:"vuesax-linear:danger",size:"26"}),e[4]||(e[4]=s("p",null,"دریافت اطلاعات با خطا روبه‌رو شد.",-1)),s("button",{type:"button",class:"cch__retry",onClick:m},"تلاش دوباره")])):b("",!0)]),_:1})]),_:1},8,["modelValue"])]),_:1},8,["spinning"]),n(E(H),{visible:u.value,"onUpdate:visible":e[3]||(e[3]=a=>u.value=a),placement:"bottom",height:"auto",closable:!1,"body-style":{padding:"0"}},{default:_(()=>[l.value?(r(),c("div",is,[s("h2",rs,o(l.value.name),1),s("dl",ds,[s("div",us,[e[5]||(e[5]=s("dt",null,"موبایل",-1)),s("dd",_s,o(l.value.mobile||"—"),1)]),s("div",vs,[e[6]||(e[6]=s("dt",null,"تلفن",-1)),s("dd",hs,o(l.value.phone||"—"),1)]),s("div",ps,[e[7]||(e[7]=s("dt",null,"نام مغازه",-1)),s("dd",null,o(l.value.store_name||"—"),1)]),s("div",fs,[e[8]||(e[8]=s("dt",null,"نام پاساژ",-1)),s("dd",null,o(l.value.pasazh_name||"—"),1)]),s("div",ms,[e[9]||(e[9]=s("dt",null,"سقف اعتبار",-1)),s("dd",bs,o(w(l.value.gram_level))+" گرم",1)]),s("div",ys,[e[10]||(e[10]=s("dt",null,"بدهی گرمی",-1)),s("dd",gs,o(w(l.value.remain))+" گرم",1)]),s("div",ws,[e[11]||(e[11]=s("dt",null,"وضعیت",-1)),s("dd",null,o(l.value.active===1?"فعال":"غیرفعال"),1)])]),s("button",{type:"button",class:"cch__enter",disabled:h.value,onClick:e[1]||(e[1]=a=>N(l.value))},o(h.value?"در حال ورود…":"ورود به حساب این مشتری"),9,Cs),s("button",{type:"button",class:"cch__close",onClick:e[2]||(e[2]=a=>u.value=!1)},"بستن")])):b("",!0)]),_:1},8,["visible"])])}}}),Bs=T(ks,[["__scopeId","data-v-bd288253"]]);export{Bs as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.favs[data-v-44c1c9b1]{background:var(--c-page);min-height:100vh}.favs__body[data-v-44c1c9b1]{padding-top:76px;padding-bottom:90px}.favs__grid[data-v-44c1c9b1]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.favs__empty[data-v-44c1c9b1]{margin-top:8px;padding:38px 20px;border:1px dashed var(--c-hairline);border-radius:18px;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--c-inkFaint)}.favs__empty p[data-v-44c1c9b1]{margin:0;font-size:12.5px}.favs__emptyCta[data-v-44c1c9b1]{margin-top:4px;padding:9px 18px;border:0;cursor:pointer;border-radius:999px;font-family:inherit;font-size:12.5px;font-weight:700;color:#fff;background:var(--c-wine)}
@@ -1 +0,0 @@
import{d as g,aT as h,o as y,W as b,c as r,p as a,C as v,N as k,h as _,v as C,b as s,F as f,e as u,r as P,I as B,m as F,g as w,D as x,i as V,E as I,j as N,_ as S}from"./index-D8i4h-PU.js";import{P as $}from"./PageBar-BBMNYL0j.js";import{S as j}from"./SectionHead-LafpWeqY.js";import{P as E}from"./ProductCard-C0a9p2bL.js";const R={class:"favs"},z={class:"favs__grid"},D={key:1,class:"favs__empty"},H=g({__name:"FavoritesView",setup(L){const p=x(),i=h(),o=_(!1),n=_([]),l=V(()=>n.value.filter(t=>i.isFavorite(t.id)));async function d(){const t=i.ids;if(t.length===0){n.value=[];return}o.value=n.value.length===0;try{n.value=await C.getProductsByIds(t)}catch(e){console.log(e)}finally{o.value=!1}}return y(d),b(()=>i.ids.join(","),d),(t,e)=>{const m=I("container");return s(),r("div",R,[a($,{title:"علاقه‌مندی","back-to":"userPage::hasSubRoute"}),a(k,{spinning:o.value,tip:"در حال دریافت اطلاعات"},{default:v(()=>[a(m,{class:"favs__body"},{default:v(()=>[l.value.length?(s(),r(f,{key:0},[a(j,{title:"ذخیره‌شده‌ها",subtitle:`${l.value.length} محصول`},null,8,["subtitle"]),u("div",z,[(s(!0),r(f,null,P(l.value,c=>(s(),N(E,{key:c.id,value:c},null,8,["value"]))),128))])],64)):o.value?w("",!0):(s(),r("section",D,[a(B,{icon:"vuesax-linear:heart",size:"26"}),e[1]||(e[1]=u("p",null,"هنوز محصولی را به علاقه‌مندی‌ها اضافه نکرده‌اید.",-1)),u("button",{type:"button",class:"favs__emptyCta",onClick:e[0]||(e[0]=c=>F(p).push({name:"home"}))}," دیدن محصولات ")]))]),_:1})]),_:1},8,["spinning"])])}}}),A=S(H,[["__scopeId","data-v-44c1c9b1"]]);export{A as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
import{d as c,S as i,o as m,v as r,c as l,D as _,b as p}from"./index-D8i4h-PU.js";const g={style:{"min-height":"100vh",display:"grid","place-items":"center"}},h=c({__name:"IamCallback",setup(u){const a=_(),s=i();function o(){sessionStorage.setItem("gb_iam_sso_skip","1");const e=sessionStorage.getItem("gb_iam_return");sessionStorage.removeItem("gb_iam_return");const t=e&&e.startsWith("/")&&!e.startsWith("//")?e:"/";return a.replace(t)}return m(async()=>{const e=new URLSearchParams(window.location.search);if(sessionStorage.removeItem("gb_iam_sso_checked"),e.get("error")||!e.get("code")||!e.get("state")){await o();return}try{const t=await r.ssoCallback(e.get("code"),e.get("state"));s.login(t.token,t.customer.id,t.customer.mobile);try{const n=await r.getExpertIdentity();s.setSalesExpert(n.isSalesExpert)}catch{s.setSalesExpert(!1)}await a.replace({name:"home"})}catch{await o()}}),(e,t)=>(p(),l("div",g,"در حال ورود..."))}});export{h as default};
-1
View File
@@ -1 +0,0 @@
import{af as d,i as f,at as g,d as P,ah as p,P as C,W as R,a3 as T,z as h,a as m,p as x,aR as y}from"./index-D8i4h-PU.js";var E=!1;try{var c=Object.defineProperty({},"passive",{get:function(){E=!0}});window.addEventListener("testPassive",null,c),window.removeEventListener("testPassive",null,c)}catch{}var j=Symbol("TriggerContextKey"),N=function(e){return e?d(j,{setPortal:function(){},popPortal:!1}):{setPortal:function(){},popPortal:!1}},v=Symbol("PortalContextKey"),D=function(e){var i=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{inTriggerContext:!0};g(v,{inTriggerContext:i.inTriggerContext,shouldRender:f(function(){var n=e||{},u=n.sPopupVisible,t=n.popupRef,l=n.forceRender,r=n.autoDestroy,s=!1;return(u||t||l)&&(s=!0),!u&&r&&(s=!1),s})})},I=function(){D({},{inTriggerContext:!1});var e=d(v,{shouldRender:f(function(){return!1}),inTriggerContext:!1});return{shouldRender:f(function(){return e.shouldRender.value||e.inTriggerContext===!1})}};const b=P({compatConfig:{MODE:3},name:"Portal",inheritAttrs:!1,props:{getContainer:p.func.isRequired,didUpdate:Function},setup:function(e,i){var n=i.slots,u=!0,t,l=I(),r=l.shouldRender;C(function(){u=!1,r.value&&(t=e.getContainer())});var s=R(r,function(){r.value&&!t&&(t=e.getContainer()),t&&s()});return T(function(){h(function(){if(r.value){var o;(o=e.didUpdate)===null||o===void 0||o.call(e,e)}})}),m(function(){t&&t.parentNode&&t.parentNode.removeChild(t)}),function(){if(!r.value)return null;if(u){var o;return(o=n.default)===null||o===void 0?void 0:o.call(n)}return t?x(y,{to:t},n):null}}});var w={ESC:27,PAGE_UP:33,PAGE_DOWN:34,END:35,HOME:36,LEFT:37,UP:38,RIGHT:39,DOWN:40};export{w as K,b as P,N as a,E as s,D as u};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
.ant-empty .ant-empty-description{font-size:16px!important;font-weight:600;margin-top:-36px}.empty{min-height:calc(100vh - 350px);vertical-align:middle}.newest[data-v-fe755016]{background:var(--c-page);min-height:100vh}.newest__scroll[data-v-fe755016]{height:calc(100vh - 61px)}.newest__body[data-v-fe755016]{padding-top:76px;padding-bottom:90px}.newest__grid[data-v-fe755016]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.newest__empty[data-v-fe755016]{margin-top:8px;padding:34px 20px;border:1px dashed var(--c-hairline);border-radius:18px;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--c-inkFaint)}.newest__empty p[data-v-fe755016]{margin:0;font-size:12.5px}
-1
View File
@@ -1 +0,0 @@
import{d as m,b as n,c as r,p as o,m as c,H as x,J as P,L as k,n as S,M,N as v,j as y,g as h,h as p,O as B,P as C,C as f,F as E,r as L,I as b,e as A,E as N,_ as I}from"./index-D8i4h-PU.js";import{P as V}from"./PageBar-BBMNYL0j.js";import{S as $}from"./SectionHead-LafpWeqY.js";import{P as F}from"./ProductCard-C0a9p2bL.js";import{u as H,C as T}from"./usePagedProducts-Bcf85uEP.js";const j={class:"flex justify-center items-center empty"},z=m({__name:"EmptyState",props:{description:{},image:{}},setup(e){return(t,s)=>(n(),r("div",j,[o(c(x),{description:"موردی یافت نشد",image:e.image,class:"h-[200px] w-[200px] opacity-70"},null,8,["image"])]))}}),D="/assets/favoritEmpty-kbtPAdi5.svg",G=m({__name:"ScrollFetchSearch",props:{loading:{type:Boolean},flag:{type:Boolean},padding:{}},emits:["fetch"],setup(e,{emit:t}){const s=t,i=P(),a=p(null);return k(a,()=>{s("fetch")},{distance:50}),(l,d)=>(n(),r("main",{class:"flex flex-col flex-1 overflow-auto no-scrollbar w-[100vw] max-w-screen-md m-auto",style:S({paddingTop:e.padding||c(i).headerHeight+"px"}),ref_key:"scrollArea",ref:a},[M(l.$slots,"default"),o(v,{cls:"my-[10px]",spinning:e.loading,indicator:!0,tip:"درحال دریافت اطلاعات"},null,8,["spinning"]),e.flag?(n(),y(z,{key:0,image:c(D)},null,8,["image"])):h("",!0)],4))}});function O(){const e=H(),t=B({totalPages:0,productList:[],flag:!1});function s(){t.productList=e.rows.value,t.totalPages=Math.ceil(e.total.value/T)||0,t.flag=e.total.value<1&&!e.loading.value}return{productsData:t,getProducts:async(a,l=!1,d=!1)=>{if(l){if(!e.hasMore.value||e.loadingMore.value)return;a.value=!0;try{await e.loadMore({sort:"newest"}),s()}catch(u){console.log(u)}finally{a.value=!1}return}a.value=!0;try{await e.resetAndLoad({sort:"newest"}),s()}catch(u){console.log(u)}finally{a.value=!1}},hasMore:e.hasMore}}const J={class:"newest"},Z={key:0,class:"newest__grid"},q={key:1,class:"newest__empty"},K=m({__name:"NewestView",setup(e){const{productsData:t,getProducts:s,hasMore:i}=O(),a=p(!1),l=p(!1);C(async()=>{await s(a,!1)});const d=async()=>{l.value||!i.value||await s(l,!0)};return(u,_)=>{const w=N("container");return n(),r("div",J,[o(V,{title:"جدیدترین محصولات","back-to":"home"}),o(v,{spinning:a.value,tip:"درحال دریافت اطلاعات"},{default:f(()=>[o(G,{class:"newest__scroll",loading:l.value,flag:c(t).flag,padding:"0px",onFetch:d},{default:f(()=>[o(w,{class:"newest__body"},{default:f(()=>[o($,{title:"تازه‌ها",subtitle:"آخرین افزوده‌های مجموعه"}),c(t).productList.length?(n(),r("div",Z,[(n(!0),r(E,null,L(c(t).productList,g=>(n(),y(F,{key:g.id,value:g},null,8,["value"]))),128))])):a.value?h("",!0):(n(),r("section",q,[o(b,{icon:"vuesax-linear:box",size:"26"}),_[0]||(_[0]=A("p",null,"هنوز محصولی در مجموعه ثبت نشده است.",-1))]))]),_:1})]),_:1},8,["loading","flag"])]),_:1},8,["spinning"])])}}}),Y=I(K,[["__scopeId","data-v-fe755016"]]);export{Y as default};
@@ -1 +0,0 @@
import{d as n,c as a,p as o,C as i,E as c,b as _,e,I as r,_ as p}from"./index-D8i4h-PU.js";import{P as f}from"./PageBar-BBMNYL0j.js";const l={class:"notif"},d={class:"notif__empty"},m=n({__name:"NotificationsView",setup(u){return(x,t)=>{const s=c("container");return _(),a("div",l,[o(f,{title:"اعلان‌ها","back-to":"home"}),o(s,{class:"notif__body"},{default:i(()=>[e("section",d,[o(r,{icon:"vuesax-linear:notification",size:"26"}),t[0]||(t[0]=e("p",null,"اعلانی برای نمایش وجود ندارد.",-1))])]),_:1})])}}}),B=p(m,[["__scopeId","data-v-6887fb26"]]);export{B as default};
@@ -1 +0,0 @@
.notif[data-v-6887fb26]{background:var(--c-page);min-height:100vh}.notif__body[data-v-6887fb26]{padding-top:76px;padding-bottom:90px}.notif__empty[data-v-6887fb26]{margin-top:8px;padding:38px 20px;border:1px dashed var(--c-hairline);border-radius:18px;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--c-inkFaint)}.notif__empty p[data-v-6887fb26]{margin:0;font-size:12.5px}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{d as b,bp as u,c as o,e,p as d,I as p,t as k,M as i,b as n,D as h,_ as f}from"./index-D8i4h-PU.js";const m={class:"bar"},B={class:"bar__inner"},v={key:1,class:"bar__spacer","aria-hidden":"true"},g={class:"bar__title"},y={class:"bar__trailing"},A=b({__name:"PageBar",props:{title:{},back:{type:Boolean,default:!0},backTo:{},backAction:{}},setup(t){const a=t,s=h(),l=u();function _(){if(a.backAction){a.backAction();return}if(l.canGoBack){s.back();return}if(a.backTo){s.push({name:a.backTo});return}s.push({name:"home"})}return(r,c)=>(n(),o("header",m,[e("div",B,[t.back?(n(),o("button",{key:0,type:"button",class:"bar__btn","aria-label":"بازگشت",onClick:_},[d(p,{icon:"vuesax-linear:arrow-right-1",size:"20"})])):(n(),o("span",v)),e("h1",g,k(t.title),1),e("div",y,[i(r.$slots,"trailing",{},()=>[c[0]||(c[0]=e("span",{class:"bar__spacer","aria-hidden":"true"},null,-1))],!0)])]),i(r.$slots,"below",{},void 0,!0)]))}}),N=f(A,[["__scopeId","data-v-bc8960e7"]]);export{N as P};
-1
View File
@@ -1 +0,0 @@
.bar[data-v-bc8960e7]{position:fixed;top:var(--aab-h, 0px);left:0;right:0;z-index:20;background:rgb(var(--c-canvas-rgb) / 88%);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);border-bottom:1px solid var(--c-hairline)}.bar__inner[data-v-bc8960e7]{max-width:768px;margin:0 auto;padding:11px 16px;display:flex;align-items:center;justify-content:space-between;gap:12px}.bar__title[data-v-bc8960e7]{flex:1 1 auto;min-width:0;margin:0;font-size:13.5px;font-weight:600;color:var(--c-ink);letter-spacing:0;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.bar__btn[data-v-bc8960e7]{position:relative;flex:0 0 auto;width:38px;height:38px;border:0;cursor:pointer;border-radius:12px;display:grid;place-items:center;color:var(--c-ink);background:var(--c-hairlineSoft);transition:background .18s ease}.bar__btn[data-v-bc8960e7]:active{transform:scale(.94)}.bar__trailing[data-v-bc8960e7]{flex:0 0 auto;display:flex;align-items:center;justify-content:flex-end;gap:8px;min-width:38px}.bar__spacer[data-v-bc8960e7]{flex:0 0 auto;display:block;width:38px;height:38px}
-1
View File
@@ -1 +0,0 @@
import{d as B,aT as P,k as z,l as F,b as s,c as t,e as a,m as e,w as N,p as h,I as m,aS as S,f as V,g as _,t as r,R as D,h as I,G as E,_ as O}from"./index-D8i4h-PU.js";const T={class:"pc__art"},q=["alt"],G={key:1,class:"pc__artEmpty","aria-hidden":"true"},L=["aria-label"],M={key:2,class:"pc__flag"},R={key:3,class:"pc__flag pc__flag--soft"},U={class:"pc__title"},$={class:"pc__weight"},j={key:0,class:"pc__meta"},A={key:0,class:"pc__wageOld"},H={class:"pc__wage"},J=B({__name:"ProductCard",props:{value:{},wageBefore:{}},setup(l){const d=l,o=P(),{image:p,id:i,title:u,min_gram:y,wage:f,is_exist:k,qty:w,isPreOrder:x}=z(d.value),g=I(null);function C(){E(d.value,g.value)}function v(n){return n%1===0?String(n):n.toFixed(2)}return(n,c)=>{const b=F("lazy");return s(),t("article",{ref_key:"root",ref:g,class:"pc","data-product-origin":"",onClick:C},[a("div",T,[e(p)?N((s(),t("img",{key:0,alt:e(u)},null,8,q)),[[b,e(p)]]):(s(),t("div",G,[h(m,{icon:"vuesax-linear:diamonds",size:"26"})])),a("button",{type:"button",class:V(["pc__fav",{"is-on":e(o).isFavorite(e(i))}]),"aria-label":e(o).isFavorite(e(i))?"حذف از علاقه‌مندی":"افزودن به علاقه‌مندی",onClick:c[0]||(c[0]=S(K=>e(o).toggle(e(i)),["stop"]))},[h(m,{icon:e(o).isFavorite(e(i))?"vuesax-linear:heart-fill":"vuesax-linear:heart",size:"16"},null,8,["icon"])],10,L),e(k)?e(x)&&e(w)===0?(s(),t("span",R,"پیش‌سفارش")):_("",!0):(s(),t("span",M,"ناموجود"))]),a("h3",U,r(e(u)),1),a("p",$,[D(r(e(y))+" ",1),c[1]||(c[1]=a("span",{class:"pc__weightUnit"},"گرم",-1))]),e(f)!=null?(s(),t("div",j,[l.wageBefore!=null?(s(),t("span",A,"اجرت "+r(v(l.wageBefore))+"٪",1)):_("",!0),a("span",H,r(v(e(f)))+"٪ اجرت",1)])):_("",!0)],512)}}}),W=O(J,[["__scopeId","data-v-2d367664"]]);export{W as P};
-1
View File
@@ -1 +0,0 @@
.pc[data-v-2d367664]{cursor:pointer;border-radius:32px;background:var(--c-hairlineSoft);padding:16px 14px 15px;text-align:center;transition:transform .2s ease,background .2s ease}.pc[data-v-2d367664]:active{transform:scale(.985)}.pc__art[data-v-2d367664]{position:relative;aspect-ratio:1 / 1;display:flex;align-items:center;justify-content:center}.pc__art img[data-v-2d367664]{max-width:82%;max-height:100%;-o-object-fit:contain;object-fit:contain;-o-object-position:center;object-position:center;display:block}html.theme-dark .pc__art img[data-v-2d367664]{background:var(--c-photoMat);border-radius:12px}.pc__artEmpty[data-v-2d367664]{width:100%;height:100%;display:grid;place-items:center;color:var(--c-inkFaint)}.pc__fav[data-v-2d367664]{position:absolute;top:0;left:0;width:30px;height:30px;display:grid;place-items:center;border:0;cursor:pointer;border-radius:50%;color:var(--c-inkFaint);background:rgb(var(--c-canvas-rgb) / 75%);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:color .18s ease,transform .18s ease}.pc__fav.is-on[data-v-2d367664]{color:var(--c-wine);background:var(--c-canvas)}.pc__fav[data-v-2d367664]:active{transform:scale(.9)}.pc__flag[data-v-2d367664]{position:absolute;top:0;right:0;padding:4px 9px;border-radius:999px;font-size:10px;font-weight:700;color:#fff;background:var(--c-wine)}.pc__flag--soft[data-v-2d367664]{background:var(--c-wineSoft)}.pc__title[data-v-2d367664]{margin:12px 0 0;font-size:12px;font-weight:500;color:var(--c-ink);line-height:1.6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.pc__weight[data-v-2d367664]{margin:5px 0 0;font-size:16px;font-weight:700;line-height:1.4;color:var(--c-inkSoft)}.pc__weightUnit[data-v-2d367664]{font-size:12px;font-weight:600;color:var(--c-inkMuted)}.pc__meta[data-v-2d367664]{display:flex;align-items:center;justify-content:center;gap:8px;padding-top:5px;font-size:11.5px}.pc__wageOld[data-v-2d367664]{color:var(--c-inkFaint);text-decoration:line-through}.pc__wage[data-v-2d367664]{color:#e31a1a;font-weight:500;font-size:12px}
-1
View File
@@ -1 +0,0 @@
.prof[data-v-a61d0c61]{background:var(--c-page);min-height:100vh}.prof__body[data-v-a61d0c61]{padding-top:80px;padding-bottom:90px}.prof__identity[data-v-a61d0c61]{display:flex;flex-direction:column;align-items:center;text-align:center;padding:26px 20px 28px;border:1px solid var(--c-hairline);border-radius:20px;background:var(--c-canvasSoft)}.prof__avatar[data-v-a61d0c61]{width:72px;height:72px;border-radius:50%;display:grid;place-items:center;color:var(--c-wine);background:var(--c-wineWash)}.prof__name[data-v-a61d0c61]{margin:14px 0 0;font-size:15.5px;font-weight:800;color:var(--c-ink)}.prof__mobile[data-v-a61d0c61]{margin:6px 0 0;font-size:12.5px;color:var(--c-inkMuted);direction:ltr}.prof__seal[data-v-a61d0c61]{margin-top:12px;padding:5px 12px;border-radius:999px;font-size:11px;font-weight:700}.prof__seal.is-ok[data-v-a61d0c61]{color:var(--c-green);background:rgb(var(--c-green-rgb) / 10%)}.prof__seal.is-pending[data-v-a61d0c61]{color:var(--c-wine);background:var(--c-wineWash)}.prof__block[data-v-a61d0c61]{margin-top:26px}.prof__specs[data-v-a61d0c61]{margin:0;border:1px solid var(--c-hairline);border-radius:18px;overflow:hidden}.prof__spec[data-v-a61d0c61]{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:13px 14px;border-bottom:1px solid var(--c-hairlineSoft)}.prof__spec[data-v-a61d0c61]:last-child{border-bottom:0}.prof__spec dt[data-v-a61d0c61]{margin:0;font-size:12px;color:var(--c-inkFaint)}.prof__spec dd[data-v-a61d0c61]{margin:0;font-size:12.5px;font-weight:700;color:var(--c-ink);text-align:left}.prof__spec dd.is-debt[data-v-a61d0c61]{color:var(--c-wine)}.prof__spec dd.is-credit[data-v-a61d0c61]{color:var(--c-green)}.prof__spec dd small[data-v-a61d0c61]{font-weight:500;font-size:11px}.prof__skeleton[data-v-a61d0c61]{height:92px;background:var(--c-hairlineSoft)}.prof__empty[data-v-a61d0c61]{margin-top:26px;padding:34px 20px;border:1px dashed var(--c-hairline);border-radius:18px;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--c-inkFaint)}.prof__empty p[data-v-a61d0c61]{margin:0;font-size:12.5px}.prof__complete[data-v-a61d0c61]{margin-top:24px;width:100%;padding:15px;border:none;border-radius:999px;background:var(--c-wine);color:#fff;font-family:inherit;font-size:15px;font-weight:700;cursor:pointer}
-1
View File
@@ -1 +0,0 @@
import{d as L,S as w,y as F,h as _,K as g,o as M,c as n,p as r,C as $,E as O,b as i,e,I as k,t as a,g as c,f as v,R as T,m as U,i as m,D as z,_ as A}from"./index-D8i4h-PU.js";import{P as K}from"./PageBar-BBMNYL0j.js";import{S}from"./SectionHead-LafpWeqY.js";const W={class:"prof"},H={class:"prof__identity"},J={class:"prof__avatar","aria-hidden":"true"},Y={class:"prof__name"},j={key:0,class:"prof__mobile"},q={key:0,class:"prof__block"},G={class:"prof__specs"},Q={class:"prof__spec"},X={class:"prof__spec"},Z={class:"prof__spec"},ee={class:"prof__spec"},se={key:1,class:"prof__block"},te={class:"prof__specs"},oe={class:"prof__spec"},le={class:"prof__spec"},ae={key:2,class:"prof__block"},ne={key:3,class:"prof__empty"},ie=L({__name:"ProfileView",setup(re){const b={UNVERIFIED:"احراز نشده",STEP_ONE_SUBMITTED:"مرحله اول ثبت شده",STEP_TWO_SUBMITTED:"مرحله دوم ثبت شده",VERIFIED:"تایید شده",REJECTED:"رد شده"},h=w(),d=F(),o=_(d.peek(g.profile)??null),p=_(!1),C=m(()=>{var s;return((s=o.value)==null?void 0:s.verificationStep)==="VERIFIED"}),R=m(()=>{var t;const s=(t=o.value)==null?void 0:t.verificationStep;return!!s&&s!=="VERIFIED"&&s!=="STEP_TWO_SUBMITTED"}),D=z(),l=_(d.peek(g.balance)??null),f=_(!1),u=s=>Number(s??0),E=s=>u(s)>0?"بدهکار":u(s)<0?"بستانکار":"",y=s=>u(s)>0?"is-debt":u(s)<0?"is-credit":"",V=s=>`${Math.abs(u(s)).toLocaleString("fa-IR",{maximumFractionDigits:3})} گرم`,B=s=>`${Math.abs(u(s)).toLocaleString("fa-IR")} ریال`,N=m(()=>{const s=l.value;if(!(s!=null&&s.at))return"";const t=new Date(s.at).toLocaleString("fa-IR",{dateStyle:"short",timeStyle:"short"});return s.source==="stored"?`آخرین مانده‌ی ثبت‌شده — ${t}`:`از ${s.provider??"حسابداری"} — ${t}`});async function P(){f.value=!l.value;try{l.value=await d.accountingBalance({force:!0})}catch{l.value=null}finally{f.value=!1}}return M(async()=>{if(h.isLoggedIn){P(),p.value=!o.value;try{o.value=await d.me({force:!0})}finally{p.value=!1}}}),(s,t)=>{const x=O("container");return i(),n("div",W,[r(K,{title:"اطلاعات کاربری","back-to":"userPage::hasSubRoute"}),r(x,{class:"prof__body"},{default:$(()=>{var I;return[e("section",H,[e("div",J,[r(k,{icon:"vuesax-linear:user",size:"30"})]),e("h2",Y,a(((I=o.value)==null?void 0:I.fullName)??"—"),1),o.value?(i(),n("p",j,a(o.value.mobile),1)):c("",!0),o.value?(i(),n("span",{key:1,class:v(["prof__seal",C.value?"is-ok":"is-pending"])},a(b[o.value.verificationStep]??o.value.verificationStep),3)):c("",!0)]),o.value?(i(),n("section",q,[r(S,{title:"مشخصات",subtitle:"اطلاعات ثبت‌شده حساب شما"}),e("dl",G,[e("div",Q,[t[1]||(t[1]=e("dt",null,"نام و نام خانوادگی",-1)),e("dd",null,a(o.value.fullName),1)]),e("div",X,[t[2]||(t[2]=e("dt",null,"تلفن همراه",-1)),e("dd",null,a(o.value.mobile),1)]),e("div",Z,[t[3]||(t[3]=e("dt",null,"کد ملی",-1)),e("dd",null,a(o.value.nationalCode||"-"),1)]),e("div",ee,[t[4]||(t[4]=e("dt",null,"وضعیت احراز هویت",-1)),e("dd",null,a(b[o.value.verificationStep]??o.value.verificationStep),1)])])])):c("",!0),l.value&&l.value.source!=="none"?(i(),n("section",se,[r(S,{title:"مانده‌ی حساب",subtitle:N.value},null,8,["subtitle"]),e("dl",te,[e("div",oe,[t[5]||(t[5]=e("dt",null,"مانده‌ی طلا",-1)),e("dd",{class:v(y(l.value.gold))},[T(a(V(l.value.gold))+" ",1),e("small",null,a(E(l.value.gold)),1)],2)]),e("div",le,[t[6]||(t[6]=e("dt",null,"مانده‌ی ریالی",-1)),e("dd",{class:v(y(l.value.rial))},[T(a(B(l.value.rial))+" ",1),e("small",null,a(E(l.value.rial)),1)],2)])])])):f.value?(i(),n("section",ae,[r(S,{title:"مانده‌ی حساب",subtitle:"در حال دریافت از حسابداری…"}),t[7]||(t[7]=e("div",{class:"prof__specs prof__skeleton"},null,-1))])):p.value?c("",!0):(i(),n("section",ne,[r(k,{icon:"vuesax-linear:user",size:"26"}),t[8]||(t[8]=e("p",null,"اطلاعات حساب در دسترس نیست.",-1))])),R.value?(i(),n("button",{key:4,type:"button",class:"prof__complete",onClick:t[0]||(t[0]=ue=>U(D).push({name:"completeProfile"}))}," تکمیل پروفایل ")):c("",!0)]}),_:1})])}}}),pe=A(ie,[["__scopeId","data-v-a61d0c61"]]);export{pe as default};
-1
View File
@@ -1 +0,0 @@
.rate-strip[data-v-d1bea229]{display:flex;align-items:center;gap:10px;padding:10px 14px;border-radius:14px;background:var(--c-heroSurface);color:#fff;box-shadow:0 10px 24px -18px #14161ae6}.rate-strip--compact[data-v-d1bea229]{gap:7px;padding:6px 12px 6px 10px;border-radius:999px;box-shadow:none;background:rgb(var(--c-wine-rgb) / 6%);border:1px solid rgb(var(--c-wine-rgb) / 10%);color:var(--c-primary)}.rate-strip--compact .rate-strip__label[data-v-d1bea229]{font-size:11px;font-weight:500;color:rgb(var(--c-primary-rgb) / 62%)}.rate-strip--compact .rate-strip__num[data-v-d1bea229]{font-size:12.5px;font-weight:700;color:var(--c-primary)}.rate-strip--compact .rate-strip__value[data-v-d1bea229]{margin-inline-start:0}.rate-strip--compact .rate-strip__dot[data-v-d1bea229]{width:5px;height:5px;background:#8d6a46;box-shadow:none;animation:none}.rate-strip--compact .rate-strip__value--muted[data-v-d1bea229]{font-size:11px;color:rgb(var(--c-primary-rgb) / 45%)}.rate-strip--compact .rate-strip__unit[data-v-d1bea229],.rate-strip--compact .rate-strip__time[data-v-d1bea229]{display:none}.rate-strip__label[data-v-d1bea229]{display:flex;align-items:center;gap:7px;font-size:12px;color:#fff;white-space:nowrap}.rate-strip__dot[data-v-d1bea229]{width:6px;height:6px;border-radius:50%;background:var(--c-wineSoft);box-shadow:0 0 #a33c55a6;animation:ratePulse-d1bea229 2.4s ease-out infinite}.rate-strip__value[data-v-d1bea229]{margin-inline-start:auto;display:flex;align-items:baseline;gap:5px}.rate-strip__value--muted[data-v-d1bea229]{font-size:12px;color:#ffffff8c}.rate-strip__num[data-v-d1bea229]{font-size:16px;font-weight:700;letter-spacing:.2px}.rate-strip__unit[data-v-d1bea229]{font-size:10.5px;color:#f3f4f6}.rate-strip__time[data-v-d1bea229]{font-size:10.5px;color:#fff;padding-inline-start:10px;border-inline-start:1px solid rgba(255,255,255,.14);white-space:nowrap}@keyframes ratePulse-d1bea229{0%{box-shadow:0 0 #a33c5599}70%{box-shadow:0 0 0 7px #a33c5500}to{box-shadow:0 0 #a33c5500}}
-1
View File
@@ -1 +0,0 @@
import{p as n,j as d}from"./filters-qWhZyCzD.js";import{d as l,c as a,f as m,e as s,R as u,t as i,m as c,g as _,i as v,b as r,_ as q}from"./index-D8i4h-PU.js";const f={key:0,class:"rate-strip__value"},y={class:"rate-strip__num"},k={key:1,class:"rate-strip__value rate-strip__value--muted"},R={key:2,class:"rate-strip__time"},x=l({__name:"RateStrip",props:{quote:{},compact:{type:Boolean}},setup(t){const o=t,p=v(()=>o.quote?o.quote.pricePerGramRial.split(".")[0]:"");return(B,e)=>(r(),a("div",{class:m(["rate-strip",{"is-empty":!t.quote,"rate-strip--compact":t.compact}])},[e[1]||(e[1]=s("div",{class:"rate-strip__label"},[s("span",{class:"rate-strip__dot","aria-hidden":"true"}),u(" نرخ طلای ۱۸ عیار ")],-1)),t.quote?(r(),a("div",f,[s("span",y,i(c(n)(p.value)),1),e[0]||(e[0]=s("span",{class:"rate-strip__unit"},"ریال / گرم",-1))])):(r(),a("div",k,"در انتظار اعلام نرخ")),t.quote?(r(),a("div",R,i(c(d)(t.quote.quotedAt)),1)):_("",!0)],2))}}),N=q(x,[["__scopeId","data-v-d1bea229"]]);export{N as R};
-1
View File
@@ -1 +0,0 @@
.doc[data-v-21825ac9]{padding:18px 2px;background:transparent;border:none;border-bottom:1px solid var(--c-hairline)}.doc[data-v-21825ac9]:last-child{border-bottom:none}.doc__head[data-v-21825ac9]{display:flex;justify-content:space-between;gap:10px;margin-bottom:10px;font-size:13px;font-weight:600}.doc__muted[data-v-21825ac9]{color:var(--c-inkMuted);font-weight:500;margin-left:4px}.doc__rows[data-v-21825ac9]{margin:0;display:flex;flex-direction:column}.doc__row[data-v-21825ac9]{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:9px 0;border-bottom:1px dashed var(--c-hairlineSoft)}.doc__row[data-v-21825ac9]:last-child{border-bottom:none}.doc__row dt[data-v-21825ac9]{font-size:12.5px;color:var(--c-inkMuted);white-space:nowrap}.doc__row dd[data-v-21825ac9]{margin:0;font-size:13.5px;font-weight:600;text-align:left}.doc__gold[data-v-21825ac9]{display:flex;justify-content:space-between;align-items:center;margin-top:10px;padding-top:10px;border-top:1px dashed var(--c-hairline);font-size:13px;font-weight:700}.doc__stack[data-v-21825ac9]{display:flex;flex-direction:column;align-items:flex-end;gap:2px}.is-in[data-v-21825ac9]{color:var(--c-green)}.is-out[data-v-21825ac9]{color:var(--c-red)}.rep__tabs[data-v-49f65111]{display:flex;gap:24px;padding:0 16px 10px}.rep__tab[data-v-49f65111]{padding:8px 0;border:none;border-bottom:2px solid transparent;background:transparent;font-family:inherit;font-size:14px;font-weight:600;color:var(--c-inkFaint);cursor:pointer}.rep__tab.is-on[data-v-49f65111]{color:var(--c-ink);border-bottom-color:var(--c-wine)}.rep__body[data-v-49f65111]{padding-top:126px;padding-bottom:90px}.rep__list[data-v-49f65111]{display:flex;flex-direction:column;padding-top:8px}.rep__card[data-v-49f65111]{padding:18px 2px;background:transparent;border:none;border-bottom:1px solid var(--c-hairline)}.rep__card[data-v-49f65111]:last-child{border-bottom:none}.rep__head[data-v-49f65111]{display:flex;justify-content:space-between;gap:10px;margin-bottom:12px;font-size:13px;font-weight:600}.rep__muted[data-v-49f65111]{color:var(--c-inkMuted);font-weight:500;margin-left:4px}.rep__pair[data-v-49f65111]{display:flex;justify-content:space-between;align-items:center;padding:8px 0;font-size:13px;color:var(--c-inkMuted)}.rep__pair.is-balance[data-v-49f65111]{padding-top:10px;border-top:1px dashed var(--c-hairline);color:var(--c-ink);font-weight:700}.rep__stack[data-v-49f65111]{display:flex;flex-direction:column;align-items:flex-end;gap:2px;font-weight:600}.is-in[data-v-49f65111]{color:var(--c-green)}.is-out[data-v-49f65111]{color:var(--c-red)}.rep__more[data-v-49f65111],.rep__sentinel[data-v-49f65111]{padding:12px 0;text-align:center;font-size:12px;color:var(--c-inkMuted)}.rep__more[data-v-49f65111]{width:100%;margin-top:6px;border:none;background:transparent;font-family:inherit;font-weight:700;color:var(--c-wine);cursor:pointer}.rep__empty[data-v-49f65111]{margin-top:40px;padding:34px 20px;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--c-inkMuted);font-size:13px}.rep__retry[data-v-49f65111]{padding:8px 18px;border-radius:999px;border:1px solid var(--c-hairline);background:var(--c-canvas);font-family:inherit;font-size:12px;font-weight:600;cursor:pointer}.rep__modal[data-v-49f65111]{padding:20px 16px 24px}.rep__modalEmpty[data-v-49f65111]{padding:24px 0;text-align:center;font-size:13px;color:var(--c-inkMuted)}.rep__close[data-v-49f65111]{margin-top:18px;width:100%;padding:14px;border:none;border-radius:999px;background:var(--c-wine);color:#fff;font-family:inherit;font-size:14px;font-weight:700;cursor:pointer}
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{d as v,b8 as h,W as m,b as t,c as l,p as n,I as r,w as b,b9 as f,e as x,ba as k,g as I,O as w,h as y,_ as C}from"./index-D8i4h-PU.js";const S={class:"si"},V=["placeholder"],B=v({__name:"SearchInput",props:{placeHolder:{}},emits:["search"],setup(i,{emit:d}){const c=d,a=w({clear:!1}),e=y(void 0),s=()=>{c("search",e.value)},u=()=>{e.value=void 0,a.clear=!1,c("search",void 0)},p=h(e,700);return m(p,()=>{e?a.clear=!0:a.clear=!1,s()}),(N,o)=>(t(),l("div",S,[n(r,{icon:"vuesax-linear:search-normal-1",class:"si__icon",size:"18",onClick:s}),b(x("input",{"onUpdate:modelValue":o[0]||(o[0]=_=>e.value=_),class:"si__input",placeholder:i.placeHolder,onKeyup:k(s,["enter"])},null,40,V),[[f,e.value]]),a.clear?(t(),l("button",{key:0,type:"button",class:"si__clear","aria-label":"پاک کردن",onClick:u},[n(r,{icon:"vuesax-linear:add",size:"16"})])):I("",!0)]))}}),D=C(B,[["__scopeId","data-v-8ebd3183"]]);export{D as S};
-1
View File
@@ -1 +0,0 @@
.si[data-v-8ebd3183]{position:relative;display:flex;align-items:center}.si__icon[data-v-8ebd3183]{position:absolute;right:14px;color:var(--c-inkFaint);cursor:pointer}.si__input[data-v-8ebd3183]{width:100%;height:48px;padding:0 44px;font-family:inherit;font-size:13px;color:var(--c-ink);background:var(--c-canvas);border:1px solid var(--c-hairline);border-radius:999px;transition:border-color .18s ease,background .18s ease}.si__input[data-v-8ebd3183]::-moz-placeholder{color:var(--c-inkFaint)}.si__input[data-v-8ebd3183]::placeholder{color:var(--c-inkFaint)}.si__input[data-v-8ebd3183]:focus{outline:none;background:var(--c-canvas);border-color:var(--c-wineSoft)}.si__clear[data-v-8ebd3183]{position:absolute;left:10px;width:26px;height:26px;display:grid;place-items:center;border:0;cursor:pointer;border-radius:50%;color:var(--c-inkFaint);background:var(--c-hairline);transform:rotate(45deg);transition:color .18s ease,background .18s ease}.si__clear[data-v-8ebd3183]:active{transform:rotate(45deg) scale(.92)}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
.sec[data-v-10172dc1]{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;padding-bottom:13px}.sec__title[data-v-10172dc1]{margin:0;font-size:13.5px;font-weight:600;color:var(--c-ink);letter-spacing:-.1px}.sec__sub[data-v-10172dc1]{margin:5px 0 0;font-size:11.5px;color:var(--c-inkFaint)}.sec__action[data-v-10172dc1]{display:inline-flex;align-items:center;gap:4px;border:0;background:none;cursor:pointer;padding:0;font-family:inherit;font-size:11.5px;font-weight:700;color:var(--c-wine);white-space:nowrap}
-1
View File
@@ -1 +0,0 @@
import{d,b as t,c as s,e as o,t as a,g as n,R as r,p as _,I as u,_ as m}from"./index-D8i4h-PU.js";const b={class:"sec"},x={class:"sec__text"},f={class:"sec__title"},h={key:0,class:"sec__sub"},k=d({__name:"SectionHead",props:{title:{},subtitle:{},actionLabel:{}},emits:["action"],setup(e,{emit:i}){const l=i;return(p,c)=>(t(),s("header",b,[o("div",x,[o("h2",f,a(e.title),1),e.subtitle?(t(),s("p",h,a(e.subtitle),1)):n("",!0)]),e.actionLabel?(t(),s("button",{key:0,type:"button",class:"sec__action",onClick:c[0]||(c[0]=v=>l("action"))},[r(a(e.actionLabel)+" ",1),_(u,{icon:"vuesax-linear:arrow-left-1",size:"15"})])):n("",!0)]))}}),N=m(k,[["__scopeId","data-v-10172dc1"]]);export{N as S};
@@ -1 +0,0 @@
import{d as A,S as D,y as N,h as u,K as g,o as w,c as i,p as r,C as E,N as L,j as P,m,g as h,b as l,F as R,e as s,r as M,I as T,t as o,f as p,E as O,_ as B}from"./index-D8i4h-PU.js";import{P as U}from"./PageBar-BBMNYL0j.js";import{S as I}from"./SectionHead-LafpWeqY.js";import{j as S,p as V}from"./filters-qWhZyCzD.js";import{D as Y}from"./index-BMHplzPz.js";import"./KeyCode-C7e-XTEK.js";import"./classCallCheck-BwcnXnXD.js";import"./raf-nDFcuFT6.js";const $={class:"tx"},j={class:"tx__list"},z=["onClick"],F={class:"tx__main"},G={class:"tx__head"},H={class:"tx__kind"},K={key:0,class:"tx__note"},J={class:"tx__foot"},Q={class:"tx__date"},W={key:1,class:"tx__empty"},q={class:"sheet"},X={class:"sheet__specs"},Z={class:"sheet__spec"},ss={class:"sheet__spec"},es={class:"sheet__spec"},ts={class:"sheet__spec"},os={class:"sheet__spec"},ns=A({__name:"TransactionsView",setup(as){const x={ORDER_STOCK:"سفارش",ORDER_PRE_ORDER:"سفارش ساخت",REPAYMENT_MELTED_GOLD:"طلای آب‌شده",REPAYMENT_TRANSFER:"حواله",REPAYMENT_CHEQUE:"چک",REPAYMENT_CASH:"پولی",WALLET_BONUS:"پاداش",ADJUSTMENT:"اصلاحیه"},k=D(),v=N(),_=u(!1),d=u(v.peek(g.ledger)??[]),f=u(!1),n=u(null),c=a=>a.direction==="CREDIT",y=a=>a.currency==="GOLD_GRAM"?`${a.amount} گرم`:`${V(a.amount)} ریال`,C=a=>{n.value=a,f.value=!0};return w(async()=>{if(k.isLoggedIn){_.value=v.peek(g.ledger)===void 0;try{d.value=await v.myLedgerEntries({force:!0})}finally{_.value=!1}}}),(a,e)=>{const b=O("container");return l(),i("div",$,[r(U,{title:"اسناد","back-to":"userPage::hasSubRoute"}),r(L,{spinning:_.value,tip:"درحال دریافت اطلاعات"},{default:E(()=>[r(b,{class:"tx__body"},{default:E(()=>[d.value.length?(l(),i(R,{key:0},[r(I,{title:"گردش حساب",subtitle:`${d.value.length} سند`},null,8,["subtitle"]),s("div",j,[(l(!0),i(R,null,M(d.value,t=>(l(),i("article",{key:t.id,class:"tx__row",onClick:ls=>C(t)},[s("span",{class:p(["tx__mark",c(t)?"is-in":"is-out"]),"aria-hidden":"true"},[r(T,{icon:c(t)?"vuesax-linear:arrow-down":"vuesax-linear:arrow-up",size:"16"},null,8,["icon"])],2),s("div",F,[s("div",G,[s("h3",H,o(x[t.sourceType]??t.sourceType),1),s("span",{class:p(["tx__amount",c(t)?"is-in":"is-out"])},o(y(t)),3)]),t.note?(l(),i("p",K,o(t.note),1)):h("",!0),s("div",J,[s("span",Q,o(m(S)(t.createdAt)),1),s("span",{class:p(["tx__state",{"is-pending":!t.confirmed}])},o(t.confirmed?"تایید شده":"در انتظار تایید"),3)])])],8,z))),128))])],64)):_.value?h("",!0):(l(),i("section",W,[r(T,{icon:"vuesax-linear:receipt-text",size:"26"}),e[1]||(e[1]=s("p",null,"سندی برای حساب شما ثبت نشده است.",-1))]))]),_:1})]),_:1},8,["spinning"]),n.value?(l(),P(m(Y),{key:0,visible:f.value,"onUpdate:visible":e[0]||(e[0]=t=>f.value=t),placement:"bottom",closable:!1,height:"420px",class:"rtl",bodyStyle:{padding:0}},{default:E(()=>[s("div",q,[e[7]||(e[7]=s("h3",{class:"sheet__title"},"جزئیات سند",-1)),s("dl",X,[s("div",Z,[e[2]||(e[2]=s("dt",null,"نوع سند",-1)),s("dd",null,o(x[n.value.sourceType]??n.value.sourceType),1)]),s("div",ss,[e[3]||(e[3]=s("dt",null,"مبلغ",-1)),s("dd",{class:p(c(n.value)?"is-in":"is-out")},o(y(n.value)),3)]),s("div",es,[e[4]||(e[4]=s("dt",null,"وضعیت",-1)),s("dd",null,o(n.value.confirmed?"تایید شده":"در انتظار تایید"),1)]),s("div",ts,[e[5]||(e[5]=s("dt",null,"تاریخ",-1)),s("dd",null,o(m(S)(n.value.createdAt)),1)]),s("div",os,[e[6]||(e[6]=s("dt",null,"توضیحات",-1)),s("dd",null,o(n.value.note||"—"),1)])])])]),_:1},8,["visible"])):h("",!0)])}}}),fs=B(ns,[["__scopeId","data-v-4e8ef6ab"]]);export{fs as default};
@@ -1 +0,0 @@
.tx[data-v-4e8ef6ab]{background:var(--c-page);min-height:100vh}.tx__body[data-v-4e8ef6ab]{padding-top:76px;padding-bottom:90px}.tx__list[data-v-4e8ef6ab]{display:flex;flex-direction:column;gap:10px}.tx__row[data-v-4e8ef6ab]{display:flex;gap:11px;padding:12px;cursor:pointer;background:var(--c-canvas);border:1px solid var(--c-hairline);border-radius:18px;transition:transform .18s ease}.tx__row[data-v-4e8ef6ab]:active{transform:scale(.99)}.tx__mark[data-v-4e8ef6ab]{flex:0 0 36px;width:36px;height:36px;border-radius:50%;display:grid;place-items:center}.tx__mark.is-in[data-v-4e8ef6ab]{color:var(--c-green);background:rgb(var(--c-green-rgb) / 10%)}.tx__mark.is-out[data-v-4e8ef6ab]{color:var(--c-wine);background:var(--c-wineWash)}.tx__main[data-v-4e8ef6ab]{flex:1;min-width:0}.tx__head[data-v-4e8ef6ab]{display:flex;align-items:baseline;justify-content:space-between;gap:10px}.tx__kind[data-v-4e8ef6ab]{margin:0;font-size:13px;font-weight:700;color:var(--c-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tx__amount[data-v-4e8ef6ab]{font-size:12.5px;font-weight:800;white-space:nowrap}.tx__amount.is-in[data-v-4e8ef6ab]{color:var(--c-green)}.tx__amount.is-out[data-v-4e8ef6ab]{color:var(--c-wine)}.tx__note[data-v-4e8ef6ab]{margin:6px 0 0;font-size:11.5px;line-height:1.7;color:var(--c-inkMuted);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.tx__foot[data-v-4e8ef6ab]{display:flex;align-items:center;justify-content:space-between;gap:10px;padding-top:8px}.tx__date[data-v-4e8ef6ab]{font-size:11px;color:var(--c-inkFaint)}.tx__state[data-v-4e8ef6ab]{font-size:10.5px;font-weight:700;color:var(--c-inkMuted)}.tx__state.is-pending[data-v-4e8ef6ab]{color:#916f48}.tx__empty[data-v-4e8ef6ab]{margin-top:8px;padding:34px 20px;border:1px dashed var(--c-hairline);border-radius:18px;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--c-inkFaint)}.tx__empty p[data-v-4e8ef6ab]{margin:0;font-size:12.5px}.sheet[data-v-4e8ef6ab]{padding:18px 16px 22px}.sheet__title[data-v-4e8ef6ab]{margin:0 0 14px;font-size:13.5px;font-weight:800;color:var(--c-ink)}.sheet__specs[data-v-4e8ef6ab]{margin:0;border:1px solid var(--c-hairline);border-radius:16px;overflow:hidden}.sheet__spec[data-v-4e8ef6ab]{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 14px;border-bottom:1px solid var(--c-hairlineSoft)}.sheet__spec[data-v-4e8ef6ab]:last-child{border-bottom:0}.sheet__spec dt[data-v-4e8ef6ab]{margin:0;font-size:12px;color:var(--c-inkFaint)}.sheet__spec dd[data-v-4e8ef6ab]{margin:0;font-size:12.5px;font-weight:700;color:var(--c-ink);text-align:left}.sheet__spec dd.is-in[data-v-4e8ef6ab]{color:var(--c-green)}.sheet__spec dd.is-out[data-v-4e8ef6ab]{color:var(--c-wine)}

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