diff --git a/.env.example b/.env.example index b93b615..eade3c7 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/DELIVERY.md b/DELIVERY.md index 0e0d043..92ca24f 100644 --- a/DELIVERY.md +++ b/DELIVERY.md @@ -196,7 +196,7 @@ container. | `JWT_EXPIRES_IN` | `12h` | Admin token lifetime. | | `JWT_CUSTOMER_EXPIRES_IN` | `30d` | Customer token lifetime. | | `UPLOAD_DIR` | `/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. diff --git a/DOCKER.md b/DOCKER.md index 2515837..22d0e85 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -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`. diff --git a/apps/user-app-v2/dist/img/no-items.png b/apps/admin-panel/public/img/no-items.png similarity index 100% rename from apps/user-app-v2/dist/img/no-items.png rename to apps/admin-panel/public/img/no-items.png diff --git a/apps/admin-panel/src/api/endpoints.ts b/apps/admin-panel/src/api/endpoints.ts index 0beff76..4937469 100644 --- a/apps/admin-panel/src/api/endpoints.ts +++ b/apps/admin-panel/src/api/endpoints.ts @@ -89,6 +89,7 @@ const EXACT: Record = { // ── 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', diff --git a/apps/admin-panel/src/utils/orderStatus.ts b/apps/admin-panel/src/utils/orderStatus.ts new file mode 100644 index 0000000..f9a2f3b --- /dev/null +++ b/apps/admin-panel/src/utils/orderStatus.ts @@ -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 = { + SUSPENDED: 'معلق', + PLACED: 'ثبت سفارش', + ADMIN_APPROVED: 'تایید مدیر', + SOURCING: 'در حال تامین', + PREPARING: 'آماده‌سازی', + DELIVERED_TO_AGENT: 'تحویل نماینده', + DELIVERED_TO_CUSTOMER: 'تحویل مشتری', + CANCELLED: 'لغو شده', +}; + +export const ORDER_STATUS_BLURBS: Record = { + SUSPENDED: 'سفارش به‌دلیل سقف اعتبار معلق شد', + PLACED: 'سفارش ثبت و دریافت شد', + ADMIN_APPROVED: 'سفارش توسط مدیر تایید شد', + SOURCING: 'کالا در حال تامین است', + PREPARING: 'سفارش در حال آماده‌سازی است', + DELIVERED_TO_AGENT: 'تحویل به نماینده فروش انجام شد', + DELIVERED_TO_CUSTOMER: 'سفارش به مشتری تحویل شد', + CANCELLED: 'سفارش لغو شده است', +}; + +export const ORDER_STATUS_COLORS: Record = { + 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 = { + 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; +} diff --git a/apps/admin-panel/src/views/apps/product/AddProduct.vue b/apps/admin-panel/src/views/apps/product/AddProduct.vue index 89b5efb..c3374b2 100644 --- a/apps/admin-panel/src/views/apps/product/AddProduct.vue +++ b/apps/admin-panel/src/views/apps/product/AddProduct.vue @@ -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 @@

دسته‌بندی

-

دسته، برند و سینی‌های محصول

+

دسته، برند، سینی و برچسب‌های محصول

@@ -268,7 +269,7 @@ - + {{ b.title }} @@ -277,7 +278,7 @@ - + + + + +
@@ -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([]); const form = reactive>({ title: '', @@ -537,6 +550,7 @@ const form = reactive>({ 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, diff --git a/apps/admin-panel/src/views/apps/product/ProductList.vue b/apps/admin-panel/src/views/apps/product/ProductList.vue index 00ecec3..43f0bcc 100644 --- a/apps/admin-panel/src/views/apps/product/ProductList.vue +++ b/apps/admin-panel/src/views/apps/product/ProductList.vue @@ -29,6 +29,13 @@ - + + @@ -82,6 +89,12 @@ {{ tempRow.category?.title || '-' }} + + + - + {{ tempRow.show_type_str }} {{ 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 { diff --git a/apps/admin-panel/src/views/apps/product/productListModel.ts b/apps/admin-panel/src/views/apps/product/productListModel.ts index ade7a1b..3143136 100644 --- a/apps/admin-panel/src/views/apps/product/productListModel.ts +++ b/apps/admin-panel/src/views/apps/product/productListModel.ts @@ -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', 'نوع نمایش') diff --git a/apps/admin-panel/src/views/customer/omde/omdeListModel.ts b/apps/admin-panel/src/views/customer/omde/omdeListModel.ts index 8249020..bb3a505 100644 --- a/apps/admin-panel/src/views/customer/omde/omdeListModel.ts +++ b/apps/admin-panel/src/views/customer/omde/omdeListModel.ts @@ -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() diff --git a/apps/admin-panel/src/views/customer/reagentList/ReagentListModel.ts b/apps/admin-panel/src/views/customer/reagentList/ReagentListModel.ts index cac3fe9..8c8cce5 100644 --- a/apps/admin-panel/src/views/customer/reagentList/ReagentListModel.ts +++ b/apps/admin-panel/src/views/customer/reagentList/ReagentListModel.ts @@ -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() diff --git a/apps/admin-panel/src/views/order/List.vue b/apps/admin-panel/src/views/order/List.vue index 014ee57..28627f8 100644 --- a/apps/admin-panel/src/views/order/List.vue +++ b/apps/admin-panel/src/views/order/List.vue @@ -51,103 +51,60 @@ - - - -
-
{{ item.title }}
-
-
-
- {{ formatDate(item.uaj) }} -
- - - حذف وضعیت - -
-
-
- - ثبت وضعیت -
-
{{ item.note }}
-
-
-
+ + - - -
- - - {{ tempRow.customer.fname }} {{ tempRow.customer.lname }} - - - {{ tempRow.customer.phone }} - - {{ formatDate(tempRow.caj) }} - {{ tempRow.sts_str }} - - - -
- - - {{ item.title }} - - {{ item.variant_title }} - - - {{ item.grams }} - - - - {{ item.qty }} - - -
{{ item.note }}
-
-
- -
-
-
- + + + @@ -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 } | null>(null); const tempRow = ref | 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 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) { // 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) { try { @@ -238,21 +192,122 @@ async function removeSts(row: Record) { } } -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) { tempRow.value = await loadDetail(row.id); + detailSnapshot.value = JSON.stringify(tempRow.value); + editMode.value = false; visibleList.value = true; } diff --git a/apps/admin-panel/src/views/order/OmdeList.vue b/apps/admin-panel/src/views/order/OmdeList.vue index 2ded720..f38d74e 100644 --- a/apps/admin-panel/src/views/order/OmdeList.vue +++ b/apps/admin-panel/src/views/order/OmdeList.vue @@ -55,81 +55,61 @@ - - - -
-
{{ item.title }}
-
-
-
- {{ formatDate(item.uaj) }} -
- - - حذف وضعیت - -
-
-
- - ثبت وضعیت -
-
{{ item.note }}
-
-
-
+ + - - -
- - - {{ tempRow.customer.fname }} {{ tempRow.customer.lname }} - - {{ tempRow.customer.phone }} - {{ formatDate(tempRow.caj) }} - {{ tempRow.sts_str }} - - - -
- - - {{ item.title }} - - {{ item.variant_title }} - - {{ item.grams }} - - - {{ item.qty }} - - - -
-
-
- + + + @@ -137,14 +117,15 @@ + + diff --git a/apps/admin-panel/src/views/order/OrderStatusTimeline.vue b/apps/admin-panel/src/views/order/OrderStatusTimeline.vue new file mode 100644 index 0000000..0cf9009 --- /dev/null +++ b/apps/admin-panel/src/views/order/OrderStatusTimeline.vue @@ -0,0 +1,396 @@ + + + + + + diff --git a/apps/admin-panel/src/views/order/TaminList.vue b/apps/admin-panel/src/views/order/TaminList.vue index 5f807de..216da37 100644 --- a/apps/admin-panel/src/views/order/TaminList.vue +++ b/apps/admin-panel/src/views/order/TaminList.vue @@ -42,78 +42,28 @@ - - - -
-
{{ item.title }}
-
-
-
- {{ formatDate(item.uaj) }} -
- - - حذف وضعیت - -
-
-
- - ثبت وضعیت -
-
{{ item.note }}
-
-
-
+ + - -
- - - {{ tempRow.customer.fname }} {{ tempRow.customer.lname }} - - {{ tempRow.customer.phone }} - {{ formatDate(tempRow.caj) }} - {{ tempRow.sts_str }} - - - -
- - - {{ item.title }} - - {{ item.variant_title }} - - {{ item.grams }} - - - {{ item.qty }} - - - -
-
-
- + +