diff --git a/src/composables/useCartPage.ts b/src/composables/useCartPage.ts index af80055..ecfdaf5 100644 --- a/src/composables/useCartPage.ts +++ b/src/composables/useCartPage.ts @@ -15,6 +15,7 @@ export function useCartPage() { const items = computed(() => cartStore.activeCart?.items ?? []) const isEmpty = computed(() => items.value.length === 0) const companyName = computed(() => cartStore.companyNamesLabel || cartStore.activeCart?.companyName || '') + const companySummaries = computed(() => cartStore.companySummaries) async function removeItem(itemId: string) { const item = items.value.find((entry) => entry.id === itemId) @@ -40,17 +41,25 @@ export function useCartPage() { submitting.value = true submitError.value = '' try { - // همگام‌سازی سبد با سرور قبل از ثبت تا productId/companyId کهنه نباشد await cartStore.hydrate() if (!cartStore.activeCart?.items.length) { submitError.value = 'سبد خرید خالی است یا با سرور همگام نشد.' return } - const order = await orderStore.submitOrder(cartStore.activeCart) + const created = await orderStore.submitOrder(cartStore.activeCart) await cartStore.hydrate() - router.replace(`/orders/${order.id}/success`) + if (created.length === 1) { + router.replace(`/orders/${created[0]!.id}/success`) + return + } + // چند شرکت → چند سفارش؛ به لیست سفارش‌ها برو + router.replace({ + path: '/orders', + query: { submitted: String(created.length) }, + }) } catch (e) { submitError.value = e instanceof Error ? e.message : 'ثبت سفارش ناموفق بود.' + await cartStore.hydrate() } finally { submitting.value = false } @@ -90,6 +99,7 @@ export function useCartPage() { items, isEmpty, companyName, + companySummaries, showFullDialog, submitting, submitError, diff --git a/src/composables/useOrderList.ts b/src/composables/useOrderList.ts index 88f9a46..e7ea934 100644 --- a/src/composables/useOrderList.ts +++ b/src/composables/useOrderList.ts @@ -1,5 +1,5 @@ import { computed, ref, watch, onMounted } from 'vue' -import { useRouter } from 'vue-router' +import { useRouter, useRoute } from 'vue-router' import { useOrderStore } from '@/stores/order' import { useAuthStore } from '@/stores/auth' import { useCatalogStore } from '@/stores/catalog' @@ -12,22 +12,43 @@ import { matchesMobileOrderTab, type DesktopOrderFilterId, } from '@/utils/orderStatusGroups' +import { toPersianDigits } from '@/utils/format' const DESKTOP_PAGE_SIZE = 5 const MOBILE_PAGE_SIZE = 5 export function useOrderList() { const router = useRouter() + const route = useRoute() const orderStore = useOrderStore() const authStore = useAuthStore() const catalogStore = useCatalogStore() const currentPage = ref(1) const mobilePage = ref(1) + const submitSuccessMessage = ref('') onMounted(async () => { await Promise.all([orderStore.hydrate(), catalogStore.loadCompanies()]) }) + watch( + () => route.query.submitted, + (submitted) => { + const count = Number(submitted) + if (!Number.isFinite(count) || count < 1) { + submitSuccessMessage.value = '' + return + } + submitSuccessMessage.value = + count === 1 + ? 'پیش‌سفارش با موفقیت ثبت شد.' + : `${toPersianDigits(String(count))} پیش‌سفارش (برای شرکت‌های مختلف) ثبت شد.` + const { submitted: _submitted, ...rest } = route.query + void router.replace({ query: rest }) + }, + { immediate: true }, + ) + const tab = computed({ get: () => (router.currentRoute.value.query.tab as string) ?? 'all', set: (v: string) => router.replace({ query: { ...router.currentRoute.value.query, tab: v } }), @@ -202,6 +223,7 @@ export function useOrderList() { mobilePage, companySlug, iconVariant, + submitSuccessMessage, goToOrder, setPage, setMobilePage, diff --git a/src/stores/cart.ts b/src/stores/cart.ts index 49c0796..7a2aa41 100644 --- a/src/stores/cart.ts +++ b/src/stores/cart.ts @@ -45,30 +45,70 @@ export const useCartStore = defineStore('cart', () => { const itemCount = computed(() => activeCart.value?.items.length ?? 0) + const catalogStore = useCatalogStore() + const companyNamesLabel = computed(() => { if (!activeCart.value?.items.length) return '' return uniqueCompanyNames(activeCart.value.items) }) - const catalogStore = useCatalogStore() + const companySummaries = computed(() => { + const cart = activeCart.value + if (!cart?.items.length) return [] as { + companyId: string + companyName: string + meterage: number + maxMeterage: number + remaining: number + }[] + const byCompany = new Map() + for (const item of cart.items) { + const current = byCompany.get(item.companyId) + if (current) { + current.meterage += item.meterage + } else { + byCompany.set(item.companyId, { + companyName: item.companyName || 'شرکت', + meterage: item.meterage, + }) + } + } + + return [...byCompany.entries()].map(([companyId, info]) => { + const max = + catalogStore.getCompany(companyId)?.maxMeteragePerInvoice ?? DEFAULT_MAX_METERAGE + return { + companyId, + companyName: info.companyName, + meterage: info.meterage, + maxMeterage: max, + remaining: Math.max(0, max - info.meterage), + } + }) + }) + + /** سقف نمایشی: مجموع سقف شرکت‌های داخل سبد (هر شرکت جداگانه محدود می‌شود). */ const maxMeterage = computed(() => { - if (!activeCart.value?.items.length) { + if (!companySummaries.value.length) { const selected = selectedCompanyId.value ? catalogStore.getCompany(selectedCompanyId.value) : null return selected?.maxMeteragePerInvoice ?? DEFAULT_MAX_METERAGE } - - const companyIds = [...new Set(activeCart.value.items.map((item) => item.companyId))] - return companyIds.reduce((sum, id) => { - const company = catalogStore.getCompany(id) - return sum + (company?.maxMeteragePerInvoice ?? DEFAULT_MAX_METERAGE) - }, 0) + return companySummaries.value.reduce((sum, row) => sum + row.maxMeterage, 0) }) - const remainingCapacity = computed(() => Math.max(0, maxMeterage.value - totalMeterage.value)) - const isFull = computed(() => totalMeterage.value >= maxMeterage.value) + const remainingCapacity = computed(() => + companySummaries.value.length + ? companySummaries.value.reduce((sum, row) => sum + row.remaining, 0) + : Math.max(0, maxMeterage.value - totalMeterage.value), + ) + + /** وقتی حداقل یک شرکت به سقف خودش رسیده باشد. */ + const isFull = computed(() => + companySummaries.value.some((row) => row.meterage >= row.maxMeterage), + ) function applyServerCart(serverCart: ApiCart) { const cart: Cart = { @@ -178,8 +218,8 @@ export const useCartStore = defineStore('cart', () => { replaceActiveCart({ id: cart.id || 'local', - companyId: company.id, - companyName: company.name, + companyId: items[0]?.companyId || company.id, + companyName: uniqueCompanyNames(items) || company.name, items, notes: cart.notes, }) @@ -349,6 +389,7 @@ export const useCartStore = defineStore('cart', () => { totalMeterage, itemCount, companyNamesLabel, + companySummaries, maxMeterage, remainingCapacity, isFull, diff --git a/src/stores/order.ts b/src/stores/order.ts index 7e28c56..3fe71bf 100644 --- a/src/stores/order.ts +++ b/src/stores/order.ts @@ -117,8 +117,9 @@ export const useOrderStore = defineStore('order', () => { /** * سفارش بدون روش تسویه ثبت می‌شود؛ مشتری بعد از قیمت‌گذاری مدیریت * روش تسویه را انتخاب می‌کند. + * سبد چندشرکتی: برای هر شرکت یک سفارش جدا ساخته می‌شود. */ - async function submitOrder(cart: Cart): Promise { + async function submitOrder(cart: Cart): Promise { if (!cart.items.length) { throw new Error('سبد خرید خالی است.') } @@ -126,43 +127,50 @@ export const useOrderStore = defineStore('order', () => { throw new Error('سبد خرید با سرور همگام نیست. لطفاً دوباره محصولات را به سبد اضافه کنید.') } - // شرکت را از خود آیتم‌ها بگیر؛ هدر سبد گاهی با شرکت واقعی آیتم‌ها ناسازگار است. const companyIds = [ ...new Set(cart.items.map((item) => item.companyId).filter(Boolean)), ] if (!companyIds.length) { throw new Error('شرکت سبد مشخص نیست. سبد را خالی کنید و دوباره اضافه کنید.') } - if (companyIds.length > 1) { - throw new Error( - 'سبد شامل چند شرکت است. فعلاً فقط اقلام یک شرکت را نگه دارید و ثبت کنید.', - ) - } - - const companyId = companyIds[0]! - const itemsForOrder = cart.items.filter((item) => item.companyId === companyId) - - const body = { - companyId, - notes: cart.notes, - items: itemsForOrder.map((item) => ({ - productId: item.productId, - meterage: Number(item.meterage), - notes: item.notes, - })), - } + const createdOrders: Order[] = [] try { - const order = await apiRequest('/orders', { - method: 'POST', - body, - }) - orders.value.unshift(order) - clearCheckDraft() - return order + for (const companyId of companyIds) { + const itemsForOrder = cart.items.filter((item) => item.companyId === companyId) + if (!itemsForOrder.length) continue + + const order = await apiRequest('/orders', { + method: 'POST', + body: { + companyId, + notes: cart.notes, + items: itemsForOrder.map((item) => ({ + productId: item.productId, + meterage: Number(item.meterage), + notes: item.notes, + })), + }, + }) + orders.value.unshift(order) + createdOrders.push(order) + } } catch (e) { + if (createdOrders.length) { + // بخشی ثبت شده؛ سفارش‌های موفق را نگه می‌داریم و خطا را با توضیح برمی‌گردانیم + clearCheckDraft() + const names = createdOrders.map((o) => o.orderNumber).join('، ') + throw new Error( + e instanceof ApiError + ? `${e.message} (سفارش‌های ثبت‌شده: ${names})` + : `ثبت بخشی از سفارش‌ها ناموفق بود. ثبت‌شده: ${names}`, + ) + } throw new Error(e instanceof ApiError ? e.message : 'ثبت سفارش ناموفق بود.') } + + clearCheckDraft() + return createdOrders } /** انتخاب روش تسویه پس از صدور فاکتور. */ diff --git a/src/views/OrdersView.vue b/src/views/OrdersView.vue index 3e89d3d..5695f33 100644 --- a/src/views/OrdersView.vue +++ b/src/views/OrdersView.vue @@ -25,6 +25,7 @@ const { mobilePage, companySlug, iconVariant, + submitSuccessMessage, goToOrder, setPage, setMobilePage, @@ -49,39 +50,66 @@ const desktopFilterTabs: { id: DesktopOrderFilterId; label: string }[] = [ + + diff --git a/src/views/cart/CartView.vue b/src/views/cart/CartView.vue index e79132b..e956e38 100644 --- a/src/views/cart/CartView.vue +++ b/src/views/cart/CartView.vue @@ -13,6 +13,7 @@ const { items, isEmpty, companyName, + companySummaries, showFullDialog, submitting, submitError, @@ -40,6 +41,7 @@ function goToOrdering() { class="mobile-only" :items="items" :company-name="companyName" + :company-summaries="companySummaries" :total-meterage="cartStore.totalMeterage" :max-meterage="cartStore.maxMeterage" :remaining-capacity="cartStore.remainingCapacity" @@ -55,6 +57,7 @@ function goToOrdering() { class="desktop-only" :items="items" :company-name="companyName" + :company-summaries="companySummaries" :total-meterage="cartStore.totalMeterage" :max-meterage="cartStore.maxMeterage" :remaining-capacity="cartStore.remainingCapacity" @@ -70,7 +73,7 @@ function goToOrdering() {

سقف متراژ تکمیل شد

- سقف متراژ این فاکتور تکمیل شده است. آیا مایلید ادامه محصولات را در فاکتور جدید ثبت کنید؟ + سقف متراژ حداقل یک شرکت در سبد پر شده است. می‌توانید همین پیش‌سفارش‌ها را ثبت کنید یا سبد را خالی کرده و فاکتور جدید بسازید.

diff --git a/src/views/cart/components/DesktopCartPage.vue b/src/views/cart/components/DesktopCartPage.vue index e877ace..cdbdc0f 100644 --- a/src/views/cart/components/DesktopCartPage.vue +++ b/src/views/cart/components/DesktopCartPage.vue @@ -16,6 +16,13 @@ import type { CartItem } from '@/types' defineProps<{ items: CartItem[] companyName: string + companySummaries?: { + companyId: string + companyName: string + meterage: number + maxMeterage: number + remaining: number + }[] totalMeterage: number maxMeterage: number remainingCapacity: number @@ -156,6 +163,17 @@ const quickLinks = [ {{ formatMeterage(totalMeterage) }}
+
+ {{ row.companyName }} + + {{ formatMeterage(row.meterage) }} + / {{ formatMeterage(row.maxMeterage) }} + +
@@ -176,7 +194,7 @@ const quickLinks = [ {{ submitting ? 'در حال ثبت…' : 'ثبت پیش‌سفارش' }}

- قیمت‌گذاری توسط مدیریت انجام می‌شود؛ پس از صدور فاکتور روش تسویه را انتخاب می‌کنید. + اقلام همه شرکت‌ها در یک سبد هستند؛ با ثبت، برای هر شرکت یک پیش‌سفارش جدا ساخته می‌شود.

@@ -483,6 +501,17 @@ const quickLinks = [ border-bottom: 1px solid var(--color-border); } +.cart-summary-card__row--sub { + padding-block: 6px; + font-size: 0.8125rem; + color: var(--color-text-muted); +} + +.cart-summary-card__cap { + font-weight: 500; + color: var(--color-text-muted); +} + .cart-summary-card__row:last-child { border-bottom: none; } diff --git a/src/views/cart/components/MobileCartPage.vue b/src/views/cart/components/MobileCartPage.vue index 5def7a4..de4e25b 100644 --- a/src/views/cart/components/MobileCartPage.vue +++ b/src/views/cart/components/MobileCartPage.vue @@ -9,6 +9,13 @@ import type { CartItem } from '@/types' defineProps<{ items: CartItem[] companyName: string + companySummaries?: { + companyId: string + companyName: string + meterage: number + maxMeterage: number + remaining: number + }[] totalMeterage: number maxMeterage: number remainingCapacity: number @@ -67,10 +74,23 @@ const emit = defineEmits<{ @@ -250,10 +269,22 @@ const emit = defineEmits<{ .cart-mobile-summary__row { display: flex; justify-content: space-between; + align-items: center; + gap: 12px; padding: 8px 0; font-size: 0.875rem; } +.cart-mobile-summary__row--sub { + color: var(--color-text-muted); + font-size: 0.8125rem; +} + +.cart-mobile-summary__cap { + font-weight: 500; + color: var(--color-text-muted); +} + .cart-mobile-summary__row:not(:last-child) { border-bottom: 1px solid var(--color-border); }