This commit is contained in:
2026-09-19 16:59:15 +03:30
parent 663d0a36aa
commit 0d113611cb
198 changed files with 2491 additions and 944 deletions
+17
View File
@@ -146,6 +146,23 @@ body {
display: none;
}
/* Horizontal carousels: smooth touch scroll, no visible scrollbar */
.scroll-x {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scroll-behavior: smooth;
scrollbar-width: none;
-ms-overflow-style: none;
}
.scroll-x::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
a {
color: var(--color-primary);
text-decoration: none;
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import BaseButton from '@/components/ui/BaseButton.vue'
const props = defineProps<{
open: boolean
loading?: boolean
error?: string
}>()
const emit = defineEmits<{
close: []
submit: [address: string]
}>()
const address = ref('')
watch(
() => props.open,
(isOpen) => {
if (isOpen) address.value = ''
},
)
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && props.open) emit('close')
}
watch(
() => props.open,
(isOpen) => {
document.body.style.overflow = isOpen ? 'hidden' : ''
if (isOpen) {
window.addEventListener('keydown', onKeydown)
} else {
window.removeEventListener('keydown', onKeydown)
}
},
{ immediate: true },
)
onBeforeUnmount(() => {
document.body.style.overflow = ''
window.removeEventListener('keydown', onKeydown)
})
function submit() {
emit('submit', address.value)
}
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="dialog-overlay address-modal-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="address-modal-title"
@click.self="emit('close')"
>
<div class="dialog card address-modal">
<h3 id="address-modal-title" class="address-modal__title">تکمیل آدرس</h3>
<p class="address-modal__hint">
برای ارسال کالیته فیزیکی، آدرس محل فعالیت خود را وارد کنید. این آدرس در اطلاعات کاربری شما
ذخیره می‌شود.
</p>
<label class="address-modal__field">
<span class="form-label">آدرس</span>
<textarea
v-model="address"
class="address-modal__textarea"
rows="3"
maxlength="500"
placeholder="آدرس کامل محل فعالیت"
/>
</label>
<p v-if="error" class="address-modal__error">{{ error }}</p>
<div class="dialog-actions address-modal__actions">
<BaseButton variant="outline" :disabled="loading" @click="emit('close')">
انصراف
</BaseButton>
<BaseButton :disabled="loading" @click="submit">
{{ loading ? 'در حال ثبت...' : 'ثبت درخواست' }}
</BaseButton>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.address-modal {
padding: 20px;
}
.address-modal__title {
margin: 0 0 8px;
font-size: 1.0625rem;
font-weight: 700;
}
.address-modal__hint {
margin: 0 0 14px;
font-size: 0.875rem;
color: var(--color-text-muted);
line-height: 1.7;
}
.address-modal__field {
display: flex;
flex-direction: column;
gap: 6px;
}
.address-modal__textarea {
width: 100%;
min-height: 96px;
padding: 12px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font: inherit;
font-size: 0.9375rem;
line-height: 1.6;
color: var(--color-text);
resize: vertical;
}
.address-modal__textarea:focus {
outline: none;
border-color: var(--color-primary);
}
.address-modal__error {
margin: 8px 0 0;
color: var(--color-danger);
font-size: 0.8125rem;
}
.address-modal__actions {
margin-top: 16px;
}
@media (min-width: 768px) {
.address-modal__actions {
flex-direction: row;
justify-content: flex-end;
}
}
</style>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number
color?: string
}>(),
{ size: 22, color: 'currentColor' },
)
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
:stroke="color"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="3" y="8" width="18" height="8" rx="1.5" />
<path d="M7 8v3" />
<path d="M10 8v4" />
<path d="M13 8v3" />
<path d="M16 8v4" />
</svg>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number
color?: string
}>(),
{ size: 22, color: 'currentColor' },
)
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
:stroke="color"
stroke-width="1.6"
stroke-linecap="round"
aria-hidden="true"
>
<path d="M6 4v16" />
<path d="M10 4v16" />
<path d="M14 4v16" />
<path d="M18 4v16" />
<path d="M4 8h16" />
<path d="M4 12h16" />
<path d="M4 16h16" />
</svg>
</template>
@@ -0,0 +1,327 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { CatalogProductCard } from '@/types/crm'
import { formatNumber } from '@/utils/format'
import { colorSwatchStyle } from '@/utils/colorSwatch'
import BaseButton from '@/components/ui/BaseButton.vue'
const props = defineProps<{
open: boolean
product: CatalogProductCard | null
rollMeters: number
minRolls: number
basePrice?: number
loading?: boolean
error?: string
}>()
const emit = defineEmits<{
close: []
submit: [rollsByColor: number[]]
}>()
const rollsByColor = ref<number[]>([])
watch(
() => [props.open, props.product?.colors.length] as const,
([isOpen]) => {
if (!isOpen || !props.product) return
rollsByColor.value = props.product.colors.map(() => 0)
},
)
const totalRolls = computed(() =>
rollsByColor.value.reduce((sum, qty) => sum + (qty > 0 ? qty : 0), 0),
)
const totalMeters = computed(() => totalRolls.value * props.rollMeters)
const estimatedPrice = computed(() => {
if (!props.basePrice || totalMeters.value <= 0) return null
return props.basePrice * totalMeters.value
})
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && props.open) emit('close')
}
watch(
() => props.open,
(isOpen) => {
document.body.style.overflow = isOpen ? 'hidden' : ''
if (isOpen) {
window.addEventListener('keydown', onKeydown)
} else {
window.removeEventListener('keydown', onKeydown)
}
},
{ immediate: true },
)
onBeforeUnmount(() => {
document.body.style.overflow = ''
window.removeEventListener('keydown', onKeydown)
})
function adjustRoll(index: number, delta: number) {
const next = [...rollsByColor.value]
next[index] = Math.max(0, (next[index] ?? 0) + delta)
rollsByColor.value = next
}
function submit() {
emit('submit', [...rollsByColor.value])
}
</script>
<template>
<Teleport to="body">
<div
v-if="open && product"
class="dialog-overlay product-order-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="product-order-title"
@click.self="emit('close')"
>
<div class="dialog card product-order-modal">
<header class="product-order-modal__head">
<h3 id="product-order-title" class="product-order-modal__title">ثبت سفارش</h3>
<button type="button" class="product-order-modal__close" aria-label="بستن" @click="emit('close')">
×
</button>
</header>
<div class="product-order-modal__table-head" aria-hidden="true">
<span>رنگ</span>
<span>تعداد</span>
</div>
<ul class="product-order-modal__rows">
<li v-for="(color, idx) in product.colors" :key="idx" class="product-order-row">
<div class="product-order-row__color">
<span
class="product-order-row__swatch"
:style="colorSwatchStyle(color, product.colorImages?.[idx] || product.imageUrls?.[idx])"
:title="color"
/>
<span v-if="color" class="product-order-row__label">{{ color }}</span>
</div>
<div class="product-order-row__stepper">
<button
type="button"
class="product-order-row__btn"
aria-label="کاهش"
:disabled="loading || (rollsByColor[idx] ?? 0) <= 0"
@click="adjustRoll(idx, -1)"
>
−
</button>
<span class="product-order-row__qty">{{ formatNumber(rollsByColor[idx] ?? 0) }}</span>
<button
type="button"
class="product-order-row__btn"
aria-label="افزایش"
:disabled="loading"
@click="adjustRoll(idx, 1)"
>
+
</button>
</div>
</li>
</ul>
<p v-if="error" class="product-order-modal__error">{{ error }}</p>
<footer class="product-order-modal__footer">
<div class="product-order-modal__price">
<span>قیمت</span>
<strong v-if="estimatedPrice != null">
{{ formatNumber(estimatedPrice) }} تومان
</strong>
<strong v-else-if="totalRolls > 0">
{{ formatNumber(totalMeters) }} متر
</strong>
<strong v-else>—</strong>
</div>
<p v-if="minRolls > 0" class="product-order-modal__hint">
حداقل سفارش: {{ formatNumber(minRolls) }} طاقه
</p>
<BaseButton class="product-order-modal__submit" :disabled="loading" @click="submit">
{{ loading ? 'در حال ثبت...' : 'ثبت' }}
</BaseButton>
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.product-order-modal {
width: 100%;
max-width: 420px;
padding: 18px 18px 16px;
}
.product-order-modal__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.product-order-modal__title {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
}
.product-order-modal__close {
width: 32px;
height: 32px;
border: none;
background: transparent;
color: var(--color-text-muted);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
}
.product-order-modal__table-head {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
padding: 0 4px 8px;
color: var(--color-text-muted);
font-size: 0.8125rem;
font-weight: 600;
}
.product-order-modal__rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
max-height: min(52vh, 360px);
overflow-y: auto;
}
.product-order-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: 14px;
background: var(--color-elevated);
}
.product-order-row__color {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.product-order-row__swatch {
width: 28px;
height: 28px;
border-radius: 999px;
border: 1px solid var(--color-border);
flex-shrink: 0;
}
.product-order-row__label {
font-size: 0.8125rem;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-order-row__stepper {
display: inline-flex;
align-items: center;
gap: 8px;
}
.product-order-row__btn {
width: 32px;
height: 32px;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-surface);
color: var(--color-text);
font-size: 1.125rem;
line-height: 1;
cursor: pointer;
}
.product-order-row__btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.product-order-row__qty {
min-width: 1.5rem;
text-align: center;
font-size: 0.9375rem;
font-weight: 700;
}
.product-order-modal__error {
margin: 10px 0 0;
color: var(--color-danger);
font-size: 0.8125rem;
}
.product-order-modal__footer {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: 8px;
}
.product-order-modal__price {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
font-size: 0.875rem;
}
.product-order-modal__price strong {
font-size: 1rem;
color: var(--color-text);
}
.product-order-modal__hint {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.product-order-modal__submit {
width: 100%;
margin-top: 4px;
}
@media (max-width: 767px) {
.product-order-overlay {
align-items: flex-end;
padding: 0;
}
.product-order-modal {
max-width: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
</style>
+165
View File
@@ -0,0 +1,165 @@
<script setup lang="ts">
import type { SimpleTimelineStep } from '@/utils/simpleTimeline'
const props = defineProps<{
steps: SimpleTimelineStep[]
currentIndex: number
timestamps?: Record<string, string | null | undefined>
}>()
function stepState(index: number): 'done' | 'current' | 'pending' {
if (index < props.currentIndex) return 'done'
if (index === props.currentIndex) return 'current'
return 'pending'
}
function timestampFor(stepId: string) {
return props.timestamps?.[stepId] ?? null
}
</script>
<template>
<div class="tracking-timeline">
<div
v-for="(step, index) in steps"
:key="step.id"
class="tracking-timeline__item"
:class="`tracking-timeline__item--${stepState(index)}`"
>
<div class="tracking-timeline__marker-wrap">
<div class="tracking-timeline__line" aria-hidden="true" />
<div class="tracking-timeline__marker">
<span v-if="stepState(index) === 'done'" class="tracking-timeline__check">✓</span>
</div>
</div>
<div
class="tracking-timeline__content"
:class="{ 'tracking-timeline__content--current': stepState(index) === 'current' }"
>
<strong class="tracking-timeline__label">{{ step.label }}</strong>
<span v-if="timestampFor(step.id)" class="tracking-timeline__time">
{{ timestampFor(step.id) }}
</span>
</div>
</div>
</div>
</template>
<style scoped>
.tracking-timeline {
display: flex;
flex-direction: column;
}
.tracking-timeline__item {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
column-gap: 12px;
align-items: start;
min-height: 44px;
}
.tracking-timeline__marker-wrap {
position: relative;
display: flex;
justify-content: center;
align-self: stretch;
width: 28px;
}
.tracking-timeline__line {
position: absolute;
top: 22px;
bottom: 0;
left: 50%;
width: 2px;
transform: translateX(-50%);
background: var(--color-border);
z-index: 0;
}
.tracking-timeline__item:last-child .tracking-timeline__line {
display: none;
}
.tracking-timeline__item--done .tracking-timeline__line {
background: var(--color-success);
}
.tracking-timeline__item--current .tracking-timeline__line {
background: color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
}
.tracking-timeline__marker {
position: relative;
z-index: 1;
width: 22px;
height: 22px;
margin-top: 2px;
border-radius: 50%;
background: var(--color-border);
border: 2px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tracking-timeline__item--done .tracking-timeline__marker {
background: var(--color-success);
border-color: var(--color-success);
}
.tracking-timeline__item--current .tracking-timeline__marker {
background: var(--color-primary);
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 28%, transparent);
}
.tracking-timeline__item--pending .tracking-timeline__marker {
background: var(--color-elevated);
border-color: var(--color-border);
}
.tracking-timeline__check {
color: var(--color-surface);
font-size: 0.75rem;
font-weight: 700;
line-height: 1;
}
.tracking-timeline__content {
min-width: 0;
padding: 2px 0 14px;
}
.tracking-timeline__content--current {
padding: 10px 14px 12px;
border-radius: 12px;
background: var(--color-primary-soft);
border: 1px solid var(--color-border);
}
.tracking-timeline__label {
display: block;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.45;
color: var(--color-text-muted);
margin-bottom: 2px;
}
.tracking-timeline__item--done .tracking-timeline__label,
.tracking-timeline__item--current .tracking-timeline__label {
color: var(--color-text);
}
.tracking-timeline__time {
display: block;
margin-top: 4px;
font-size: 0.75rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
</style>
+98
View File
@@ -0,0 +1,98 @@
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useCatalogRequestsStore } from '@/stores/catalogRequests'
import type { CatalogProductCard } from '@/types/crm'
const SUCCESS_MESSAGE =
'درخواست دریافت فیزیکی کالیته برای شما ثبت شد و به زودی همکاران ما برای شما خواهند آورد'
export function useCatalogRequest() {
const auth = useAuthStore()
const requestsStore = useCatalogRequestsStore()
const requesting = ref(false)
const successMessage = ref('')
const addressModalOpen = ref(false)
const pendingProduct = ref<CatalogProductCard | null>(null)
const addressError = ref('')
function closeAddressModal() {
addressModalOpen.value = false
pendingProduct.value = null
addressError.value = ''
}
async function createRequest(product: CatalogProductCard, address: string) {
const result = await requestsStore.create({
items: [
{
productId: product.id,
productName: product.name,
productCode: product.code,
quantity: 1,
},
],
note: `آدرس: ${address}`,
})
if (!result.success) {
addressError.value = result.message
return false
}
successMessage.value = SUCCESS_MESSAGE
window.setTimeout(() => {
successMessage.value = ''
}, 6000)
return true
}
async function requestCatalog(product: CatalogProductCard) {
addressError.value = ''
const savedAddress = auth.user?.address?.trim()
if (savedAddress) {
requesting.value = true
try {
await createRequest(product, savedAddress)
} finally {
requesting.value = false
}
return
}
pendingProduct.value = product
addressModalOpen.value = true
}
async function submitAddressAndRequest(address: string) {
const trimmed = address.trim()
if (!trimmed) {
addressError.value = 'آدرس الزامی است.'
return false
}
const product = pendingProduct.value
if (!product) return false
addressError.value = ''
requesting.value = true
try {
const updated = await auth.updateProfile({ address: trimmed })
if (!updated) {
addressError.value = 'ذخیره آدرس ناموفق بود.'
return false
}
const ok = await createRequest(product, trimmed)
if (ok) closeAddressModal()
return ok
} finally {
requesting.value = false
}
}
return {
requesting,
successMessage,
addressModalOpen,
addressError,
requestCatalog,
submitAddressAndRequest,
closeAddressModal,
}
}
+17 -12
View File
@@ -65,13 +65,7 @@ const router = createRouter({
{
path: '/catalogs/:catalogId',
name: 'catalog-detail',
component: () => import('@/views/catalogs/CatalogDetailView.vue'),
meta: { requiresAuth: true },
},
{
path: '/catalog-request',
name: 'catalog-request',
component: () => import('@/views/catalogs/CatalogRequestView.vue'),
component: () => import('@/views/catalogs/CatalogProductRedirectView.vue'),
meta: { requiresAuth: true },
},
{
@@ -80,6 +74,12 @@ const router = createRouter({
component: () => import('@/views/catalogs/CatalogRequestsView.vue'),
meta: { requiresAuth: true },
},
{
path: '/catalog-requests/latest-status',
name: 'catalog-request-latest-status',
component: () => import('@/views/catalogs/LatestCatalogRequestStatusView.vue'),
meta: { requiresAuth: true },
},
{
path: '/favorites',
name: 'favorites',
@@ -90,7 +90,7 @@ const router = createRouter({
path: '/customers',
name: 'customers',
component: () => import('@/views/customers/CustomersView.vue'),
meta: { requiresAuth: true },
meta: { requiresAuth: true, agentOnly: true },
},
{
path: '/customers/new',
@@ -103,7 +103,7 @@ const router = createRouter({
path: '/customer',
name: 'customer',
component: () => import('@/views/customers/CustomerDetailView.vue'),
meta: { requiresAuth: true },
meta: { requiresAuth: true, agentOnly: true },
},
{
path: '/meeting-report',
@@ -120,13 +120,13 @@ const router = createRouter({
path: '/reminders',
name: 'reminders',
component: () => import('@/views/reminders/RemindersView.vue'),
meta: { requiresAuth: true },
meta: { requiresAuth: true, agentOnly: true },
},
{
path: '/reminder',
name: 'reminder',
component: () => import('@/views/reminders/ReminderDetailView.vue'),
meta: { requiresAuth: true, hideNav: true },
meta: { requiresAuth: true, agentOnly: true, hideNav: true },
},
{
path: '/companies/:companyId/catalogs',
@@ -157,6 +157,12 @@ const router = createRouter({
component: () => import('@/views/OrdersView.vue'),
meta: { requiresAuth: true },
},
{
path: '/orders/latest-status',
name: 'order-latest-status',
component: () => import('@/views/orders/LatestOrderStatusView.vue'),
meta: { requiresAuth: true },
},
{
path: '/order',
name: 'order-query',
@@ -264,7 +270,6 @@ router.beforeEach((to) => {
return { name: 'home' }
}
// صفحات CRM برای همه قابل مشاهده‌اند (طبق طراحی موکاپ)؛ API برای غیرواسطه خطا می‌دهد
if (to.meta.agentOnly) {
const user = getCachedUser()
if (user?.appRole !== 'agent') {
+3 -2
View File
@@ -136,7 +136,7 @@ export async function fetchMe(): Promise<User | null> {
}
export async function updateProfile(
data: Partial<Pick<User, 'firstName' | 'lastName' | 'nationalId' | 'avatarUrl'>>,
data: Partial<Pick<User, 'firstName' | 'lastName' | 'nationalId' | 'avatarUrl' | 'address'>>,
): Promise<User | null> {
try {
const updated = await apiRequest<User>('/auth/profile', {
@@ -150,7 +150,8 @@ export async function updateProfile(
data.avatarUrl !== undefined &&
data.firstName === undefined &&
data.lastName === undefined &&
data.nationalId === undefined
data.nationalId === undefined &&
data.address === undefined
const current = getCachedUser()
if (!avatarOnly || !current) throw e
const merged: User = { ...current }
+20
View File
@@ -0,0 +1,20 @@
import { apiRequest } from './http'
export type ProductSuggestionDto = {
id: string
productId: string
productName: string
productCode: string
catalogName?: string
catalogCode?: string
color?: string
subtitle?: string
imageUrl?: string
isNew?: boolean
note?: string
createdAt: string
}
export async function fetchMySuggestions(): Promise<ProductSuggestionDto[]> {
return apiRequest<ProductSuggestionDto[]>('/suggestions/me')
}
+1 -1
View File
@@ -108,7 +108,7 @@ export const useAuthStore = defineStore('auth', () => {
}
async function updateProfile(
data: Partial<Pick<User, 'firstName' | 'lastName' | 'nationalId' | 'avatarUrl'>>,
data: Partial<Pick<User, 'firstName' | 'lastName' | 'nationalId' | 'avatarUrl' | 'address'>>,
) {
if (!user.value) return null
const updated = await authUpdateProfile(data)
+24 -5
View File
@@ -192,6 +192,8 @@ export const useCartStore = defineStore('cart', () => {
catalogCode: string,
meterage: number,
company: Company,
notes?: string,
accumulate = false,
): { success: boolean; message?: string } {
const cart = getOrCreateCart(company)
const existing = cart.items.find((i) => i.productId === product.id)
@@ -199,7 +201,16 @@ export const useCartStore = defineStore('cart', () => {
if (existing) {
const index = items.findIndex((i) => i.id === existing.id)
items[index] = { ...existing, meterage }
const mergedNotes = notes
? [existing.notes, notes].filter(Boolean).join(' | ')
: existing.notes
const nextMeterage = accumulate ? existing.meterage + meterage : meterage
items[index] = {
...existing,
meterage: nextMeterage,
color: product.color || existing.color,
notes: mergedNotes || undefined,
}
} else {
const item: CartItem = {
id: `local-item-${product.id}-${Date.now()}`,
@@ -209,6 +220,7 @@ export const useCartStore = defineStore('cart', () => {
productCode: product.code,
color: product.color,
meterage,
notes: notes || undefined,
companyId: company.id,
companyName: company.name,
}
@@ -230,6 +242,8 @@ export const useCartStore = defineStore('cart', () => {
product: Product,
catalogCode: string,
meterage: number,
notes?: string,
accumulate = false,
): Promise<{ success: boolean; message?: string }> {
if (!catalogStore.companies.length) {
await catalogStore.loadCompanies()
@@ -242,9 +256,11 @@ export const useCartStore = defineStore('cart', () => {
: 0
const existing = cart.items.find((i) => i.productId === product.id)
const previousMeterage = existing?.meterage ?? 0
const nextMeterage =
existing && accumulate ? previousMeterage + meterage : meterage
const validation = validateMeterage(
meterage,
nextMeterage,
product,
currentCompanyMeterage,
companyMax,
@@ -258,20 +274,23 @@ export const useCartStore = defineStore('cart', () => {
if (!company) {
return { success: false, message: 'شرکت محصول یافت نشد.' }
}
return addItemLocal(product, catalogCode, meterage, company)
return addItemLocal(product, catalogCode, meterage, company, notes, accumulate)
}
try {
let serverCart: ApiCart
const mergedNotes = notes
? [existing?.notes, notes].filter(Boolean).join(' | ')
: existing?.notes
if (existing) {
serverCart = await apiRequest<ApiCart>(`/carts/items/${existing.id}`, {
method: 'PATCH',
body: { meterage },
body: { meterage: nextMeterage, notes: mergedNotes || undefined },
})
} else {
serverCart = await apiRequest<ApiCart>('/carts/items', {
method: 'POST',
body: { productId: product.id, meterage },
body: { productId: product.id, meterage: nextMeterage, notes: notes || undefined },
})
}
applyServerCart(serverCart)
+8
View File
@@ -27,7 +27,10 @@ export type CatalogRequestStatus =
export type CatalogRequest = {
id: string
createdAt: string
updatedAt?: string
status: CatalogRequestStatus
/** Raw status from API for timeline mapping */
serverStatus?: string
note?: string
items: CatalogRequestItem[]
}
@@ -72,6 +75,7 @@ const STATUS_LABELS: Record<string, string> = {
function mapServerRow(row: {
id: string
createdAt: string
updatedAt?: string
status: string
note?: string
items: Array<{
@@ -84,7 +88,9 @@ function mapServerRow(row: {
return {
id: row.id,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
status: SERVER_TO_UI[row.status] ?? (row.status as CatalogRequestStatus),
serverStatus: row.status,
note: row.note,
items: row.items.map((i) => ({
productId: i.productId,
@@ -117,6 +123,7 @@ export const useCatalogRequestsStore = defineStore('catalogRequests', () => {
Array<{
id: string
createdAt: string
updatedAt?: string
status: string
note?: string
items: Array<{
@@ -151,6 +158,7 @@ export const useCatalogRequestsStore = defineStore('catalogRequests', () => {
const row = await apiRequest<{
id: string
createdAt: string
updatedAt?: string
status: string
note?: string
items: Array<{
+1
View File
@@ -45,6 +45,7 @@ export interface User {
levelProgress?: number
businessName?: string
avatarUrl?: string
address?: string
}
export interface Company {
+62
View File
@@ -0,0 +1,62 @@
import type { Router } from 'vue-router'
import type { CatalogProductCard } from '@/types/crm'
import { demoProductsFor, findMockCalite } from '@/data/caliteCollections'
import { groupCatalogPatterns } from '@/utils/catalogGallery'
import { useCatalogStore } from '@/stores/catalog'
import { useCrmStore } from '@/stores/crm'
function pickPrimaryProductId(products: CatalogProductCard[]): string | null {
if (!products.length) return null
const patterns = groupCatalogPatterns(products)
const first = patterns[0]?.items[0] ?? products[0]
return first?.id ?? null
}
export async function resolveCatalogPrimaryProductId(
catalogId: string,
knownProducts?: CatalogProductCard[],
): Promise<string | null> {
const fromKnown = knownProducts?.filter((item) => item.catalogId === catalogId) ?? []
if (fromKnown.length) {
return pickPrimaryProductId(fromKnown)
}
const catalogStore = useCatalogStore()
const crm = useCrmStore()
let catalog = catalogStore.getCatalog(catalogId)
if (!catalog) {
const companies = await catalogStore.loadCompanies()
await Promise.all(companies.map((company) => catalogStore.loadCatalogs(company.id)))
catalog = catalogStore.getCatalog(catalogId)
}
if (catalog) {
const products = await crm.loadBrowseProducts({
catalogId: catalog.id,
companyId: catalog.companyId,
})
const productId = pickPrimaryProductId(products)
if (productId) return productId
}
const mock = findMockCalite(catalogId)
if (mock) {
return pickPrimaryProductId(demoProductsFor(mock))
}
return null
}
export async function navigateToCatalogProduct(
router: Router,
catalogId: string,
knownProducts?: CatalogProductCard[],
) {
const productId = await resolveCatalogPrimaryProductId(catalogId, knownProducts)
if (productId) {
await router.replace({ path: '/product', query: { id: productId } })
return
}
await router.replace({ name: 'catalogs' })
}
+7 -17
View File
@@ -19,7 +19,7 @@ export interface NavItem {
/** Mobile bottom navigation — center FAB for سفارش */
export const MAIN_NAV_ITEMS: NavItem[] = [
{ name: 'home', path: '/', label: 'خانه', icon: IconHome },
{ name: 'catalogs', path: '/catalogs', label: 'کالیته‌ها', icon: IconCatalog },
{ name: 'products', path: '/products', label: 'محصولات', icon: IconCatalog },
{ name: 'order-fab', path: '/cart', label: 'ثبت سفارش', icon: IconPlus },
{ name: 'orders', path: '/orders', label: 'سفارش‌ها', icon: IconOrders },
{ name: 'profile', path: '/profile', label: 'پروفایل', icon: IconProfile },
@@ -31,7 +31,7 @@ export const MAIN_NAV_ITEMS: NavItem[] = [
*/
export const DESKTOP_NAV_ITEMS: NavItem[] = [
{ name: 'home', path: '/', label: 'خانه', icon: IconHome },
{ name: 'catalogs', path: '/catalogs', label: 'کالیته‌ها', icon: IconCatalog },
{ name: 'products', path: '/products', label: 'محصولات', icon: IconCatalog },
{ name: 'cart', path: '/cart', label: 'ثبت سفارش', icon: IconCart },
{ name: 'orders', path: '/orders', label: 'سفارش‌ها', icon: IconOrders },
{ name: 'checks', path: '/profile/checks', label: 'لیست چک‌ها', icon: IconCheckBook },
@@ -45,14 +45,8 @@ export function getOrderingPath(_activeCompanyId?: string | null): string {
return SHOP_BROWSE_PATH
}
const CATALOG_PATHS = [
'/catalogs',
'/catalog-request',
'/catalog-requests',
]
/** Shop browse flow: empty cart → categories → products → cart */
const ORDER_BROWSE_PATHS = ['/categories', '/products', '/product', '/favorites']
/** Shop browse: categories → products → detail → favorites */
const PRODUCTS_BROWSE_PATHS = ['/categories', '/products', '/product', '/favorites']
const SETTINGS_PATHS = ['/profile/display', '/profile/edit', '/profile/security']
@@ -66,15 +60,11 @@ export function isNavItemActive(name: string, path: string, currentPath: string)
if (name === 'home') {
return currentPath === '/'
}
if (name === 'catalogs') {
return matchesPathPrefix(currentPath, CATALOG_PATHS)
if (name === 'products') {
return matchesPathPrefix(currentPath, PRODUCTS_BROWSE_PATHS)
}
if (name === 'order-fab' || name === 'cart') {
return (
currentPath === '/cart' ||
currentPath.startsWith('/cart/') ||
matchesPathPrefix(currentPath, ORDER_BROWSE_PATHS)
)
return currentPath === '/cart' || currentPath.startsWith('/cart/')
}
if (name === 'checks') {
return matchesPathPrefix(currentPath, ['/profile/checks'])
+68
View File
@@ -0,0 +1,68 @@
import type { CatalogRequest } from '@/stores/catalogRequests'
import type { SimpleTimelineStep } from '@/utils/simpleTimeline'
import { formatDateTime } from '@/utils/format'
export type CatalogServerStatus =
| 'new'
| 'approved'
| 'ready_to_ship'
| 'delivered'
| 'returned'
| 'cancelled'
export const SIMPLE_CATALOG_STATUS_STEPS: SimpleTimelineStep[] = [
{ id: 'registered', label: 'درخواست کالیته ثبت شد' },
{ id: 'ready_to_ship', label: 'آماده ارسال' },
{ id: 'shipped', label: 'ارسال شد' },
{ id: 'returned', label: 'تحویل گرفته شد' },
]
const STATUS_TO_STEP_INDEX: Record<CatalogServerStatus, number> = {
new: 0,
approved: 0,
ready_to_ship: 1,
delivered: 2,
returned: 3,
cancelled: 0,
}
export function resolveCatalogServerStatus(request: CatalogRequest): CatalogServerStatus {
if (request.serverStatus) {
return request.serverStatus as CatalogServerStatus
}
const legacyMap: Record<string, CatalogServerStatus> = {
submitted: 'new',
reviewing: 'approved',
preparing: 'ready_to_ship',
shipped: 'delivered',
delivered: 'returned',
new: 'new',
approved: 'approved',
ready_to_ship: 'ready_to_ship',
returned: 'returned',
cancelled: 'cancelled',
}
return legacyMap[request.status] ?? 'new'
}
export function getSimpleCatalogStatusIndex(request: CatalogRequest): number {
const status = resolveCatalogServerStatus(request)
return STATUS_TO_STEP_INDEX[status] ?? 0
}
export function getSimpleCatalogStatusTimestamps(
request: CatalogRequest,
): Record<string, string | null> {
const status = resolveCatalogServerStatus(request)
const created = formatDateTime(request.createdAt)
const updated = request.updatedAt ? formatDateTime(request.updatedAt) : null
return {
registered: created,
ready_to_ship: ['ready_to_ship', 'delivered', 'returned'].includes(status) ? updated : null,
shipped: ['delivered', 'returned'].includes(status) ? updated : null,
returned: status === 'returned' ? updated : null,
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { Order, OrderStatus } from '@/types'
import type { SimpleTimelineStep } from '@/utils/simpleTimeline'
import { formatDateTime } from '@/utils/format'
export const SIMPLE_ORDER_STATUS_STEPS: SimpleTimelineStep[] = [
{ id: 'registered', label: 'سفارش ثبت شد' },
{ id: 'priced', label: 'قیمت گذاری انجام شد' },
{ id: 'proforma_sent', label: 'پیش فاکتور ارسال شد' },
{ id: 'proforma_confirmed', label: 'پیش فاکتور تایید شد' },
{ id: 'invoice_sent', label: 'فاکتور ارسال شد' },
{ id: 'shipped', label: 'بار ارسال شد' },
{ id: 'delivered', label: 'بار توسط شما تحویل گرفته شد' },
]
const STATUS_TO_STEP_INDEX: Partial<Record<OrderStatus, number>> = {
submitted: 0,
awaiting_review: 1,
pricing: 1,
needs_correction: 1,
awaiting_settlement: 2,
awaiting_settlement_fix: 2,
cash_in_progress: 2,
cash_invoice_issued: 2,
awaiting_cash_payment: 2,
cash_payment_submitted: 2,
check_in_progress: 2,
awaiting_cash_confirmation: 3,
awaiting_check_review: 3,
check_needs_fix: 3,
check_rejected: 3,
checks_approved: 3,
cash_confirmed: 3,
final_approved: 4,
preparing: 4,
ready_to_ship: 4,
shipped: 5,
delivered: 6,
expired: 2,
cancelled: 0,
rejected: 1,
}
const STEP_STATUS_ALIASES: Record<string, OrderStatus[]> = {
registered: ['submitted'],
priced: ['pricing', 'awaiting_review', 'needs_correction'],
proforma_sent: [
'awaiting_settlement',
'awaiting_settlement_fix',
'cash_in_progress',
'cash_invoice_issued',
'awaiting_cash_payment',
'cash_payment_submitted',
'check_in_progress',
],
proforma_confirmed: [
'awaiting_cash_confirmation',
'awaiting_check_review',
'check_needs_fix',
'check_rejected',
'checks_approved',
'cash_confirmed',
],
invoice_sent: ['final_approved', 'preparing', 'ready_to_ship'],
shipped: ['shipped'],
delivered: ['delivered'],
}
export function getSimpleOrderStatusIndex(status: OrderStatus): number {
return STATUS_TO_STEP_INDEX[status] ?? 0
}
export function getSimpleOrderStatusTimestamps(order: Order): Record<string, string | null> {
const result: Record<string, string | null> = {}
for (const step of SIMPLE_ORDER_STATUS_STEPS) {
if (step.id === 'registered') {
result[step.id] = formatDateTime(order.createdAt)
continue
}
const aliases = STEP_STATUS_ALIASES[step.id] ?? []
const entry = [...order.statusHistory]
.reverse()
.find((item) => aliases.includes(item.toStatus))
result[step.id] = entry ? formatDateTime(entry.changedAt) : null
}
return result
}
+4
View File
@@ -0,0 +1,4 @@
export type SimpleTimelineStep = {
id: string
label: string
}
+2 -1
View File
@@ -7,6 +7,7 @@ import SearchField from '@/components/ui/SearchField.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
import { navigateToCatalogProduct } from '@/utils/catalogNavigation'
const route = useRoute()
const router = useRouter()
@@ -32,7 +33,7 @@ onMounted(load)
watch(companyId, load)
function openCatalog(catalogId: string) {
router.push({ name: 'catalog-detail', params: { catalogId } })
void navigateToCatalogProduct(router, catalogId)
}
function getProductCount(catalogId: string) {
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import { navigateToCatalogProduct } from '@/utils/catalogNavigation'
const route = useRoute()
const router = useRouter()
onMounted(() => {
void navigateToCatalogProduct(router, String(route.params.catalogId ?? ''))
})
</script>
<template>
<div class="page">
<PageSkeleton variant="detail" />
</div>
</template>
-314
View File
@@ -1,314 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import PageHeader from '@/components/layout/PageHeader.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import { useCrmStore } from '@/stores/crm'
import { useCatalogRequestsStore } from '@/stores/catalogRequests'
import type { CatalogProductCard } from '@/types/crm'
import { formatNumber } from '@/utils/format'
const route = useRoute()
const router = useRouter()
const crm = useCrmStore()
const requestsStore = useCatalogRequestsStore()
const loading = ref(true)
const saving = ref(false)
const error = ref<string | null>(null)
const note = ref('')
const products = ref<CatalogProductCard[]>([])
const quantities = ref<Record<string, number>>({})
const preselectId = computed(() => String(route.query.productId ?? ''))
const catalogId = computed(() => String(route.query.catalogId ?? ''))
const selectedItems = computed(() =>
products.value
.filter((p) => (quantities.value[p.id] ?? 0) > 0)
.map((p) => ({
productId: p.id,
productName: p.name,
productCode: p.code,
quantity: quantities.value[p.id] ?? 0,
})),
)
onMounted(async () => {
loading.value = true
error.value = null
try {
const list = await crm.loadBrowseProducts(
catalogId.value ? { catalogId: catalogId.value } : undefined,
)
const selected = preselectId.value
products.value = selected
? [...list].sort((a, b) => Number(b.id === selected) - Number(a.id === selected))
: list
if (selected) {
quantities.value = { ...quantities.value, [selected]: 1 }
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'بارگذاری محصولات ناموفق بود.'
} finally {
loading.value = false
}
})
function setQty(id: string, value: number) {
quantities.value = {
...quantities.value,
[id]: Math.max(0, Math.floor(value) || 0),
}
}
async function submit() {
saving.value = true
error.value = null
try {
const result = await requestsStore.create({
items: selectedItems.value,
note: note.value,
})
if (!result.success) {
error.value = result.message
return
}
router.push('/catalog-requests')
} finally {
saving.value = false
}
}
</script>
<template>
<div class="page catalog-request-page">
<PageHeader class="mobile-only" title="درخواست کالیته" show-back centered @back="router.back()" />
<header class="desktop-page__head desktop-only">
<h1 class="desktop-page__title">درخواست کالیته</h1>
<p class="desktop-page__subtitle">محصولات و تعداد اقلام موردنظر را مشخص کنید</p>
</header>
<div class="page__scroll page__scroll--with-nav body">
<p class="hint">
{{ preselectId ? 'این محصول برای درخواست کالیته انتخاب شده است. در صورت نیاز اقلام دیگر را هم اضافه کنید.' : 'محصولات و تعداد اقلام موردنظر را مشخص کنید.' }}
</p>
<PageSkeleton v-if="loading" variant="form" />
<p v-else-if="error" class="error">{{ error }}</p>
<template v-else>
<div class="request-layout">
<div class="request-products">
<article
v-for="item in products"
:key="item.id"
class="row"
:class="{ 'row--selected': preselectId === item.id }"
>
<div>
<strong>{{ item.name }}</strong>
<span>کد {{ item.code }}</span>
</div>
<input
class="qty"
type="number"
min="0"
:value="quantities[item.id] ?? 0"
@input="setQty(item.id, Number(($event.target as HTMLInputElement).value))"
/>
</article>
</div>
<aside class="request-sidebar desktop-summary-panel">
<h2 class="desktop-summary-panel__title">خلاصه درخواست</h2>
<div class="desktop-summary-panel__row">
<span>اقلام انتخاب‌شده</span>
<strong>{{ formatNumber(selectedItems.length) }}</strong>
</div>
<label class="note">
توضیحات
<textarea v-model="note" rows="3" placeholder="آدرس یا توضیحات اختیاری" />
</label>
<button type="button" class="submit" :disabled="saving" @click="submit">
{{ saving ? 'در حال ثبت...' : 'ثبت درخواست کالیته' }}
</button>
<button type="button" class="linkish" @click="router.push('/catalog-requests')">
وضعیت درخواست‌های قبلی
</button>
</aside>
</div>
<div class="request-mobile-footer mobile-only">
<label class="note">
توضیحات
<textarea v-model="note" rows="3" placeholder="آدرس یا توضیحات اختیاری" />
</label>
<p class="summary">اقلام انتخاب‌شده: {{ formatNumber(selectedItems.length) }}</p>
<button type="button" class="submit" :disabled="saving" @click="submit">
{{ saving ? 'در حال ثبت...' : 'ثبت درخواست کالیته' }}
</button>
<button type="button" class="linkish" @click="router.push('/catalog-requests')">
وضعیت درخواست‌های قبلی
</button>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.body {
padding: 12px var(--page-padding) 24px;
display: flex;
flex-direction: column;
gap: 10px;
}
.hint {
margin: 0;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.error {
color: #b42318;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 12px;
box-shadow: var(--shadow-sm);
}
.row--selected {
outline: 2px solid var(--color-primary);
}
.row strong {
display: block;
font-size: 0.9rem;
}
.row span {
color: var(--color-text-muted);
font-size: 0.78rem;
}
.qty {
width: 64px;
height: 40px;
border: 1px solid var(--color-border);
border-radius: 10px;
text-align: center;
font: inherit;
color: var(--color-text);
}
.note {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.note textarea {
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 10px 12px;
font: inherit;
resize: vertical;
color: var(--color-text);
}
.summary {
margin: 4px 0 0;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.submit {
border: none;
border-radius: 12px;
min-height: 48px;
background: var(--color-primary);
color: #fff;
font-weight: 700;
cursor: pointer;
}
.submit:disabled {
opacity: 0.7;
}
.linkish {
border: none;
background: transparent;
color: var(--color-primary);
font-weight: 700;
cursor: pointer;
padding: 8px;
}
.request-layout {
display: flex;
flex-direction: column;
gap: 10px;
}
.request-sidebar {
display: none;
}
.request-mobile-footer {
display: flex;
flex-direction: column;
gap: 10px;
}
@media (min-width: 768px) {
.catalog-request-page {
max-width: var(--content-max-width);
margin-inline: auto;
padding: 20px var(--page-padding) 28px;
box-sizing: border-box;
}
.body {
padding: 0;
}
.request-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
gap: 20px;
align-items: start;
}
.request-sidebar {
display: flex;
flex-direction: column;
gap: 12px;
position: sticky;
top: 16px;
}
.request-sidebar .submit {
margin-top: 4px;
}
.request-mobile-footer {
display: none;
}
.row {
box-shadow: var(--shadow-card);
border: 1px solid var(--color-border);
}
}
</style>
+2 -6
View File
@@ -20,19 +20,15 @@ onMounted(() => store.hydrate())
<h1 class="desktop-page__title">وضعیت کالیته‌ها</h1>
<p class="desktop-page__subtitle">پیگیری درخواست‌های کالیته ثبت‌شده</p>
</div>
<button type="button" class="desk-new-btn" @click="router.push('/catalog-request')">
<button type="button" class="desk-new-btn" @click="router.push('/categories')">
درخواست کالیته جدید
</button>
</header>
<div class="page__scroll page__scroll--with-nav body">
<button type="button" class="new-btn mobile-only" @click="router.push('/catalog-request')">
<button type="button" class="new-btn mobile-only" @click="router.push('/categories')">
درخواست کالیته جدید
</button>
<button type="button" class="new-btn" @click="router.push('/catalog-request')">
درخواست کالیته جدید
</button>
<p v-if="!store.sorted.length" class="empty">هنوز درخواستی ثبت نکرده‌اید.</p>
<article v-for="item in store.sorted" :key="item.id" class="card">
+3 -2
View File
@@ -18,6 +18,7 @@ import {
} from '@/utils/catalogGallery'
import { MOCK_CALITE_CARDS } from '@/data/caliteCollections'
import { formatNumber } from '@/utils/format'
import { navigateToCatalogProduct } from '@/utils/catalogNavigation'
const route = useRoute()
const router = useRouter()
@@ -56,7 +57,7 @@ onMounted(async () => {
products.value = browse
const focusId = String(route.query.id ?? '')
if (focusId && catalogs.value.some((item) => item.id === focusId)) {
router.replace({ name: 'catalog-detail', params: { catalogId: focusId } })
await navigateToCatalogProduct(router, focusId, products.value)
}
} catch {
catalogs.value = []
@@ -66,7 +67,7 @@ onMounted(async () => {
})
function openCatalog(id: string) {
router.push({ name: 'catalog-detail', params: { catalogId: id } })
void navigateToCatalogProduct(router, id, products.value)
}
function focusSearch() {
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import PageHeader from '@/components/layout/PageHeader.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import SimpleStatusTimeline from '@/components/ui/SimpleStatusTimeline.vue'
import { useCatalogRequestsStore } from '@/stores/catalogRequests'
import {
getSimpleCatalogStatusIndex,
getSimpleCatalogStatusTimestamps,
SIMPLE_CATALOG_STATUS_STEPS,
} from '@/utils/simpleCatalogStatus'
import { formatNumber } from '@/utils/format'
const router = useRouter()
const store = useCatalogRequestsStore()
const loading = ref(true)
const latestRequest = computed(() => (store.sorted.length ? store.sorted[0] : null))
const currentIndex = computed(() =>
latestRequest.value ? getSimpleCatalogStatusIndex(latestRequest.value) : 0,
)
const timestamps = computed(() =>
latestRequest.value ? getSimpleCatalogStatusTimestamps(latestRequest.value) : {},
)
onMounted(async () => {
loading.value = true
await store.hydrate()
loading.value = false
})
function goToCategories() {
router.push('/categories')
}
</script>
<template>
<div class="page latest-status-page">
<PageHeader
class="mobile-only"
title="آخرین درخواست کالیته"
show-back
centered
@back="router.push('/')"
/>
<header class="desktop-page__head desktop-only">
<div>
<h1 class="desktop-page__title">آخرین درخواست کالیته</h1>
<p class="desktop-page__subtitle">پیگیری مراحل آخرین درخواست کالیته</p>
</div>
</header>
<div class="page__scroll page__scroll--with-nav body">
<PageSkeleton v-if="loading" />
<template v-else-if="latestRequest">
<article class="summary-card">
<p class="summary-card__label">درخواست</p>
<strong class="summary-card__value">{{ latestRequest.id.slice(-8) }}</strong>
<p class="summary-card__meta">
{{ formatNumber(latestRequest.items.length) }} قلم کالیته
</p>
</article>
<SimpleStatusTimeline
:steps="SIMPLE_CATALOG_STATUS_STEPS"
:current-index="currentIndex"
:timestamps="timestamps"
/>
<button type="button" class="detail-link" @click="router.push('/catalog-requests')">
مشاهده همه درخواست‌ها
</button>
</template>
<button v-else type="button" class="empty-cta" @click="goToCategories">
شما هیچ کالیته ای درخواست نکرده اید، جهت درخواست کالیته و مشاهده فیزیکی پارچه ها کلیک کنید
</button>
</div>
</div>
</template>
<style scoped>
.body {
padding: 12px var(--page-padding) 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.summary-card {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 14px 16px;
box-shadow: var(--shadow-sm);
}
.summary-card__label {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.summary-card__value {
display: block;
margin-top: 4px;
font-size: 1.05rem;
}
.summary-card__meta {
margin: 6px 0 0;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.detail-link {
align-self: center;
border: none;
background: none;
color: var(--color-primary);
font-weight: 700;
font-size: 0.875rem;
cursor: pointer;
padding: 8px 4px;
}
.empty-cta {
margin: 48px auto 0;
max-width: 340px;
border: none;
background: none;
color: var(--color-text-muted);
font-size: 0.9rem;
line-height: 1.6;
text-align: center;
cursor: pointer;
padding: 12px;
}
.empty-cta:hover {
color: var(--color-primary);
}
@media (min-width: 1024px) {
.body {
max-width: 560px;
margin-inline: auto;
width: 100%;
padding-top: 8px;
}
.summary-card {
border: 1px solid var(--color-border);
}
}
</style>
+22 -16
View File
@@ -40,11 +40,11 @@ onMounted(async () => {
})
function openCategory(id: string) {
router.push({ path: '/products', query: { categoryId: id } })
router.push({ path: '/products', query: { categoryId: id, filterAxis: 'usage' } })
}
function openSeason(id: string) {
router.push({ path: '/products', query: { season: id } })
router.push({ path: '/products', query: { season: id, filterAxis: 'season' } })
}
</script>
@@ -68,7 +68,7 @@ function openSeason(id: string) {
<header class="desktop-page__head desktop-only">
<h1 class="desktop-page__title">دسته‌بندی‌ها</h1>
<p class="desktop-page__subtitle">دسته موردنظر خود را انتخاب کنید و محصولات را ببینید</p>
<p class="desktop-page__subtitle">دسته موردنظر خود را انتخاب کنید</p>
</header>
<div class="page__scroll page__scroll--with-nav cat-scroll">
@@ -78,7 +78,7 @@ function openSeason(id: string) {
<section>
<h2 class="block-title">انتخاب بر اساس کاربرد</h2>
<p class="block-sub">دسته موردنظر خود را انتخاب کنید</p>
<div class="usage-grid">
<div class="usage-grid scroll-x">
<button
v-for="item in usageCategories"
:key="item.id"
@@ -162,8 +162,8 @@ function openSeason(id: string) {
class="feature-card feature-card--link"
role="button"
tabindex="0"
@click="router.push('/catalog-request')"
@keydown.enter="router.push('/catalog-request')"
@click="router.push('/products')"
@keydown.enter="router.push('/products')"
>
<span class="feature-card__icon" aria-hidden="true">
<IconCatalog :size="22" />
@@ -238,17 +238,22 @@ function openSeason(id: string) {
}
.usage-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
display: flex;
flex-wrap: nowrap;
gap: 8px;
margin-inline: calc(-1 * var(--page-padding));
padding-inline: var(--page-padding);
padding-bottom: 4px;
scroll-snap-type: x proximity;
}
.usage-card {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
height: 100%;
flex: 0 0 148px;
width: 148px;
scroll-snap-align: start;
appearance: none;
-webkit-appearance: none;
border: none;
@@ -532,8 +537,14 @@ function openSeason(id: string) {
}
.usage-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-inline: 0;
padding-inline: 0;
}
.usage-card {
flex-basis: 168px;
width: 168px;
}
.usage-card__body strong {
@@ -572,9 +583,4 @@ function openSeason(id: string) {
}
}
@media (min-width: 1200px) {
.usage-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
</style>
+166 -10
View File
@@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth'
import { useCrmStore } from '@/stores/crm'
import { useFavoritesStore } from '@/stores/favorites'
import { fetchAgentProfile, fetchBanners, type AppBannerDto } from '@/services/agentApi'
import { fetchMySuggestions, type ProductSuggestionDto } from '@/services/suggestionsApi'
import IconBell from '@/components/icons/IconBell.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import levelMedalIcon from '@/assets/images/level-medal.webp'
@@ -30,6 +31,7 @@ const favorites = useFavoritesStore()
const loading = ref(true)
const error = ref<string | null>(null)
const newest = ref<CatalogProductCard[]>([])
const suggestions = ref<ProductSuggestionDto[]>([])
const favError = ref<string | null>(null)
const agentLevel = ref<{
label: string
@@ -47,17 +49,17 @@ type QuickAction = {
}
const customerActions: QuickAction[] = [
{ id: 'qa-club', label: 'باشگاه مشتریان', to: '/profile', image: quickClubCrownIcon },
{ id: 'qa-cat', label: 'دسته بندی', to: '/categories', image: quickCategoriesGridIcon },
{ id: 'qa-new', label: 'محصولات جدید', to: '/products', image: quickNewProductsIcon },
{ id: 'qa-swatch', label: 'درخواست کالیته', to: '/catalog-request', image: quickCatalogRequestIcon },
{ id: 'qa-cat', label: 'دسته بندی', to: '/categories', image: quickCategoriesGridIcon },
{ id: 'qa-club', label: 'آخرین وضعیت سفارش', to: '/orders/latest-status', image:'/' },
{ id: 'qa-swatch', label: 'آخرین درخواست کالیته', to: '/catalog-requests/latest-status', image: quickCatalogRequestIcon },
]
const agentActions: QuickAction[] = [
{ id: 'qa-customers', label: 'باشگاه مشتریان', to: '/customers', image: quickClubCrownIcon },
{ id: 'qa-new', label: 'محصولات جدید', to: '/products', image: quickNewProductsIcon },
{ id: 'qa-cat', label: 'دسته بندی', to: '/categories', image: quickCategoriesGridIcon },
{ id: 'qa-swatch', label: 'درخواست کالیته', to: '/catalog-request', image: quickCatalogRequestIcon },
{ id: 'qa-customers', label: 'آخرین وضعیت سفارش', to: '/orders/latest-status', image: '/' },
{ id: 'qa-swatch', label: 'آخرین درخواست کالیته', to: '/catalog-requests/latest-status', image: quickCatalogRequestIcon },
]
const actions = computed(() => (auth.isAgent ? agentActions : customerActions))
@@ -167,6 +169,12 @@ onMounted(async () => {
} catch {
// سطح از /auth/me؛ عدم دیپلوی agent API خانه را خراب نکند
}
} else {
try {
suggestions.value = await fetchMySuggestions()
} catch {
suggestions.value = []
}
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'بارگذاری خانه ناموفق بود.'
@@ -299,7 +307,7 @@ async function toggleFavorite(productId: string) {
</button>
</section>
<section class="level-card" aria-label="سطح عضویت">
<section v-if="auth.isAgent" class="level-card" aria-label="سطح عضویت">
<div class="level-card__top">
<div class="level-card__identity">
<img
@@ -339,6 +347,38 @@ async function toggleFavorite(productId: string) {
</div>
</section>
<section v-else class="suggest-card" aria-label="پیشنهاد اختصاصی">
<div class="suggest-card__head">
<h3 class="suggest-card__title">پیشنهاد اختصاصی ما برای شما</h3>
<p v-if="!suggestions.length" class="suggest-card__empty">
هنوز پیشنهاد اختصاصی برای شما ثبت نشده است.
</p>
</div>
<div v-if="suggestions.length" class="suggest-card__row scroll-x">
<article
v-for="(item, index) in suggestions"
:key="item.id"
class="suggest-card__item"
@click="go(`/product?id=${item.productId}`)"
>
<div
class="suggest-card__image"
:class="`suggest-card__image--${index % 3}`"
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
>
<span v-if="item.isNew" class="suggest-card__badge">جدید</span>
</div>
<div class="suggest-card__body">
<strong class="suggest-card__name">{{ item.productName }}</strong>
<span class="suggest-card__sub">
{{ item.subtitle || item.catalogName || item.productCode }}
</span>
<p v-if="item.note" class="suggest-card__note">{{ item.note }}</p>
</div>
</article>
</div>
</section>
<section class="section">
<div class="section__head">
<h3 class="section__title">جدیدترین محصولات</h3>
@@ -347,7 +387,7 @@ async function toggleFavorite(productId: string) {
<IconChevronLeft :size="14" />
</button>
</div>
<div class="product-row">
<div class="product-row scroll-x">
<article
v-for="(item, index) in newest"
:key="item.id"
@@ -777,6 +817,110 @@ async function toggleFavorite(productId: string) {
white-space: nowrap;
}
.suggest-card {
background: var(--color-surface);
border-radius: 18px;
padding: 16px;
border: 1px solid var(--color-border);
min-width: 0;
}
.suggest-card__head {
margin-bottom: 12px;
}
.suggest-card__title {
margin: 0;
font-size: 0.95rem;
font-weight: 800;
color: var(--color-accent-navy);
}
.suggest-card__empty {
margin: 8px 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
line-height: 1.5;
}
.suggest-card__row {
display: flex;
flex-wrap: nowrap;
gap: 10px;
margin-inline: -16px;
padding-inline: 16px;
padding-bottom: 4px;
scroll-snap-type: x proximity;
}
.suggest-card__item {
flex: 0 0 132px;
width: 132px;
scroll-snap-align: start;
cursor: pointer;
}
.suggest-card__image {
position: relative;
height: 92px;
border-radius: 14px;
background: linear-gradient(145deg, #e8ecf0, #d5dbe3);
background-size: cover;
background-position: center;
}
.suggest-card__image--0 {
background-color: #e7dfd6;
}
.suggest-card__image--1 {
background-color: #dfe8ea;
}
.suggest-card__image--2 {
background-color: #ebe4df;
}
.suggest-card__badge {
position: absolute;
top: 8px;
right: 8px;
padding: 2px 8px;
border-radius: 999px;
background: var(--color-primary);
color: #fff;
font-size: 0.62rem;
font-weight: 700;
}
.suggest-card__body {
padding: 8px 2px 0;
}
.suggest-card__name {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
font-size: 0.78rem;
line-height: 1.35;
margin-bottom: 2px;
}
.suggest-card__sub {
display: block;
font-size: 0.68rem;
color: var(--color-text-muted);
line-height: 1.35;
}
.suggest-card__note {
margin: 4px 0 0;
font-size: 0.65rem;
color: var(--color-primary);
line-height: 1.35;
}
.section__head {
display: flex;
align-items: center;
@@ -803,12 +947,10 @@ async function toggleFavorite(productId: string) {
.product-row {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 0;
margin-inline: calc(-1 * var(--page-padding));
padding-inline: var(--page-padding);
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scroll-snap-type: x proximity;
}
.product-card {
@@ -977,6 +1119,20 @@ async function toggleFavorite(productId: string) {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
}
.suggest-card {
grid-area: level;
padding: 20px 22px;
align-self: start;
min-width: 0;
width: 100%;
}
.suggest-card__row {
margin-inline: -22px;
padding-inline: 22px;
}
.quick-grid {
+165
View File
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import PageHeader from '@/components/layout/PageHeader.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import SimpleStatusTimeline from '@/components/ui/SimpleStatusTimeline.vue'
import { useOrderStore } from '@/stores/order'
import { useAuthStore } from '@/stores/auth'
import {
getSimpleOrderStatusIndex,
getSimpleOrderStatusTimestamps,
SIMPLE_ORDER_STATUS_STEPS,
} from '@/utils/simpleOrderStatus'
import { formatCurrency } from '@/utils/format'
const router = useRouter()
const orderStore = useOrderStore()
const auth = useAuthStore()
const loading = ref(true)
const latestOrder = computed(() => {
const list = orderStore.userOrders(auth.user?.id ?? '')
return list.length ? list[0] : null
})
const currentIndex = computed(() =>
latestOrder.value ? getSimpleOrderStatusIndex(latestOrder.value.status) : 0,
)
const timestamps = computed(() =>
latestOrder.value ? getSimpleOrderStatusTimestamps(latestOrder.value) : {},
)
onMounted(async () => {
loading.value = true
await orderStore.hydrate()
loading.value = false
})
function goToCategories() {
router.push('/categories')
}
</script>
<template>
<div class="page latest-status-page">
<PageHeader
class="mobile-only"
title="آخرین وضعیت سفارش"
show-back
centered
@back="router.push('/')"
/>
<header class="desktop-page__head desktop-only">
<div>
<h1 class="desktop-page__title">آخرین وضعیت سفارش</h1>
<p class="desktop-page__subtitle">پیگیری مراحل آخرین سفارش ثبت‌شده</p>
</div>
</header>
<div class="page__scroll page__scroll--with-nav body">
<PageSkeleton v-if="loading" />
<template v-else-if="latestOrder">
<article class="summary-card">
<p class="summary-card__label">سفارش شماره</p>
<strong class="summary-card__value">{{ latestOrder.orderNumber }}</strong>
<p v-if="latestOrder.finalAmount" class="summary-card__meta">
{{ formatCurrency(latestOrder.finalAmount) }}
</p>
</article>
<SimpleStatusTimeline
:steps="SIMPLE_ORDER_STATUS_STEPS"
:current-index="currentIndex"
:timestamps="timestamps"
/>
<button type="button" class="detail-link" @click="router.push(`/orders/${latestOrder.id}`)">
مشاهده جزئیات سفارش
</button>
</template>
<button v-else type="button" class="empty-cta" @click="goToCategories">
سفارشی ثبت نکرده اید. لطفا جهت مشاهده محصولات کلیک کنید
</button>
</div>
</div>
</template>
<style scoped>
.body {
padding: 12px var(--page-padding) 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.summary-card {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 14px 16px;
box-shadow: var(--shadow-sm);
}
.summary-card__label {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.summary-card__value {
display: block;
margin-top: 4px;
font-size: 1.05rem;
}
.summary-card__meta {
margin: 6px 0 0;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.detail-link {
align-self: center;
border: none;
background: none;
color: var(--color-primary);
font-weight: 700;
font-size: 0.875rem;
cursor: pointer;
padding: 8px 4px;
}
.empty-cta {
margin: 48px auto 0;
max-width: 320px;
border: none;
background: none;
color: var(--color-text-muted);
font-size: 0.9rem;
line-height: 1.6;
text-align: center;
cursor: pointer;
padding: 12px;
}
.empty-cta:hover {
color: var(--color-primary);
}
@media (min-width: 1024px) {
.body {
max-width: 560px;
margin-inline: auto;
width: 100%;
padding-top: 8px;
}
.summary-card {
border: 1px solid var(--color-border);
}
}
</style>
+201 -52
View File
@@ -15,12 +15,17 @@ import quickCatalogRequestIcon from '@/assets/images/quick-catalog-request.webp'
import IconHeart from '@/components/icons/IconHeart.vue'
import IconShare from '@/components/icons/IconShare.vue'
import IconShoppingBag from '@/components/icons/IconShoppingBag.vue'
import IconRuler from '@/components/icons/IconRuler.vue'
import IconWeave from '@/components/icons/IconWeave.vue'
import { useGoBack } from '@/composables/useGoBack'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import {
CATALOG_SEASON_CATEGORIES,
CATALOG_USAGE_CATEGORIES,
} from '@/data/catalogCategories'
import { useCatalogRequest } from '@/composables/useCatalogRequest'
import AddressCompletionModal from '@/components/catalogs/AddressCompletionModal.vue'
import ProductOrderModal from '@/components/products/ProductOrderModal.vue'
const USAGE_CHIP_LABEL: Record<string, string> = {
suit: 'پارچه کت‌شلواری',
@@ -49,6 +54,17 @@ const selectedColor = ref(0)
const rolls = ref(1)
const adding = ref(false)
const showAllSpecs = ref(false)
const orderModalOpen = ref(false)
const orderModalError = ref('')
const {
requesting: catalogRequesting,
successMessage: catalogSuccessMessage,
addressModalOpen,
addressError: catalogAddressError,
requestCatalog,
submitAddressAndRequest,
closeAddressModal,
} = useCatalogRequest()
const rollMeters = computed(() => product.value?.rollMeters ?? 50)
const currentGalleryImage = computed(() => {
@@ -149,7 +165,7 @@ async function load() {
onMounted(load)
watch(productId, load)
function toCartProduct(p: CatalogProductCard): Product {
function toCartProduct(p: CatalogProductCard, colorIndex = selectedColor.value): Product {
const roll = p.rollMeters || 50
const step = p.orderMultiplier && p.orderMultiplier > 0 ? p.orderMultiplier : roll
const min = p.minMeterage && p.minMeterage > 0 ? p.minMeterage : roll
@@ -159,7 +175,7 @@ function toCartProduct(p: CatalogProductCard): Product {
catalogId: p.catalogId || '',
name: p.name,
code: p.code,
color: p.color || p.colors[selectedColor.value] || '—',
color: p.color || p.colors[colorIndex] || '—',
minMeterage: min,
orderMultiplier: step,
basePrice: p.basePrice,
@@ -174,21 +190,63 @@ function snapMeterage(meters: number, step: number, min: number) {
return Math.max(snapped, minSnapped)
}
async function addToOrder() {
function openOrderModal() {
if (!product.value) return
const cartProduct = toCartProduct(product.value)
orderModalError.value = ''
orderModalOpen.value = true
}
function closeOrderModal() {
orderModalOpen.value = false
orderModalError.value = ''
}
async function submitOrderModal(rollsByColor: number[]) {
const p = product.value
if (!p) return
const selections = p.colors
.map((color, idx) => ({ color, idx, rolls: rollsByColor[idx] ?? 0 }))
.filter((row) => row.rolls > 0)
if (!selections.length) {
orderModalError.value = 'حداقل یک رنگ با تعداد انتخاب کنید.'
return
}
const totalRolls = selections.reduce((sum, row) => sum + row.rolls, 0)
const totalMeters = totalRolls * rollMeters.value
if (totalMeters < minOrderMeters.value) {
orderModalError.value = `حداقل سفارش ${formatNumber(minRolls.value)} طاقه است.`
return
}
const firstSelection = selections[0]!
const cartProduct = toCartProduct(p, firstSelection.idx)
const meterage = snapMeterage(
totalMeters,
cartProduct.orderMultiplier,
cartProduct.minMeterage,
)
const notes = selections
.map((row) => `${row.color || `رنگ ${row.idx + 1}`}: ${formatNumber(row.rolls)} طاقه`)
.join('، ')
adding.value = true
orderModalError.value = ''
try {
const meterage = snapMeterage(
orderMeters.value,
cartProduct.orderMultiplier,
cartProduct.minMeterage,
)
await cartStore.addItem(
const result = await cartStore.addItem(
cartProduct,
product.value.catalogCode || product.value.code,
p.catalogCode || p.code,
meterage,
notes,
true,
)
if (!result.success) {
orderModalError.value = result.message ?? 'افزودن به سفارش ناموفق بود.'
return
}
closeOrderModal()
} finally {
adding.value = false
}
@@ -208,10 +266,23 @@ function shareProduct() {
void navigator.clipboard?.writeText(url)
}
function requestPhysicalCatalog() {
if (!product.value || catalogRequesting.value) return
void requestCatalog(product.value)
}
function onAddressSubmit(address: string) {
void submitAddressAndRequest(address)
}
</script>
<template>
<div v-if="product" class="page product-detail product-page">
<p v-if="catalogSuccessMessage" class="catalog-success-banner" role="status">
{{ catalogSuccessMessage }}
</p>
<header class="pd-header mobile-only">
<button type="button" class="icon-btn" aria-label="بازگشت" @click="goBack">
<IconChevronBack :size="20" />
@@ -273,21 +344,36 @@ function shareProduct() {
<div class="product-page__layout">
<div class="product-page__main">
<div class="gallery">
<div
class="gallery__main"
:style="
currentGalleryImage
? {
backgroundImage: `url(${currentGalleryImage})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
: colorSwatchStyle(currentColorName)
"
>
<span class="gallery__count">
{{ formatNumber(selectedColor + 1) }}/{{ formatNumber(Math.max(product.colors.length, 1)) }}
</span>
<div class="gallery__stage">
<div
class="gallery__main"
:style="
currentGalleryImage
? {
backgroundImage: `url(${currentGalleryImage})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
: colorSwatchStyle(currentColorName)
"
/>
<div
v-if="product.colors.length > 1"
class="gallery__dots"
role="tablist"
aria-label="تصاویر محصول"
>
<button
v-for="(color, idx) in product.colors"
:key="idx"
type="button"
class="gallery__dot"
:class="{ 'gallery__dot--active': selectedColor === idx }"
:aria-label="color || `تصویر ${idx + 1}`"
:aria-selected="selectedColor === idx"
@click="selectedColor = idx"
/>
</div>
</div>
<div class="gallery__thumbs">
<button
@@ -313,9 +399,7 @@ function shareProduct() {
<div class="specs">
<div class="spec-cell">
<span class="spec-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<rect x="4" y="8" width="16" height="8" rx="2" />
</svg>
<IconRuler color="#98a2ad" />
</span>
<span>عرض</span>
<strong>{{ formatNumber(product.widthCm) }} سانتی‌متر</strong>
@@ -338,10 +422,7 @@ function shareProduct() {
</div>
<div class="spec-cell">
<span class="spec-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="12" cy="12" r="8" />
<path d="M12 8a6 6 0 0 1 0 8" />
</svg>
<IconWeave color="#98a2ad" />
</span>
<span>نوع بافت</span>
<strong>{{ product.weaveType || '—' }}</strong>
@@ -395,7 +476,7 @@ function shareProduct() {
</section>
<section class="pd-section">
<h2 class="section-title">متراژ هر ساقه (طاقه)</h2>
<h2 class="section-title">متراژ هر طاقه</h2>
<div class="roll-field">{{ formatNumber(product.rollMeters) }} متر</div>
</section>
</div>
@@ -413,18 +494,19 @@ function shareProduct() {
<strong>{{ formatNumber(minRolls) }} طاقه</strong>
</div>
<div class="cart-summary-panel__btn">
<button type="button" class="btn-primary" :disabled="adding" @click="addToOrder">
<button type="button" class="btn-primary" @click="openOrderModal">
<IconCart :size="18" color="#ffffff" />
{{ adding ? 'در حال افزودن...' : 'افزودن به سفارش' }}
افزودن به سفارش
</button>
</div>
<button
type="button"
class="btn-secondary pd-desk-catalog-btn"
@click="router.push({ path: '/catalog-request', query: { productId: product.id } })"
:disabled="catalogRequesting"
@click="requestPhysicalCatalog"
>
<img class="btn-secondary__icon" :src="quickCatalogRequestIcon" alt="" aria-hidden="true" />
درخواست کالیته
{{ catalogRequesting ? 'در حال ثبت...' : 'درخواست کالیته' }}
</button>
</div>
</aside>
@@ -433,17 +515,42 @@ function shareProduct() {
<footer class="pd-footer mobile-only sticky-footer">
<div class="pd-footer__primary">
<button type="button" class="btn-primary" :disabled="adding" @click="addToOrder">
<button type="button" class="btn-primary" @click="openOrderModal">
<IconCart :size="18" color="#ffffff" />
{{ adding ? 'در حال افزودن...' : 'افزودن به سفارش' }}
افزودن به سفارش
</button>
<p class="min-note">حداقل سفارش: {{ formatNumber(minRolls) }} طاقه</p>
</div>
<button type="button" class="btn-secondary" @click="router.push({ path: '/catalog-request', query: { productId: product.id } })">
<button
type="button"
class="btn-secondary"
:disabled="catalogRequesting"
@click="requestPhysicalCatalog"
>
<img class="btn-secondary__icon" :src="quickCatalogRequestIcon" alt="" aria-hidden="true" />
درخواست کالیته
{{ catalogRequesting ? 'در حال ثبت...' : 'درخواست کالیته' }}
</button>
</footer>
<AddressCompletionModal
:open="addressModalOpen"
:loading="catalogRequesting"
:error="catalogAddressError"
@close="closeAddressModal"
@submit="onAddressSubmit"
/>
<ProductOrderModal
:open="orderModalOpen"
:product="product"
:roll-meters="rollMeters"
:min-rolls="minRolls"
:base-price="product.basePrice"
:loading="adding"
:error="orderModalError"
@close="closeOrderModal"
@submit="submitOrderModal"
/>
</div>
<div v-else-if="loading" class="page empty">
@@ -457,6 +564,17 @@ function shareProduct() {
</template>
<style scoped>
.catalog-success-banner {
margin: 0;
padding: 12px var(--page-padding);
background: var(--color-success-light);
color: var(--color-success);
font-size: 0.875rem;
font-weight: 600;
line-height: 1.6;
text-align: center;
}
.product-detail {
--pd-card-border: 1px solid #eeeef1;
--pd-card-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 6px 16px rgba(16, 24, 40, 0.05);
@@ -492,24 +610,47 @@ function shareProduct() {
.pd-scroll {
flex: 1;
padding-left: 1rem;
padding-right: 1rem;
padding-bottom: 120px;
}
.gallery__main {
.gallery__stage {
position: relative;
}
.gallery__main {
border-radius: var(--radius-lg);
height: 260px;
background: linear-gradient(160deg, #a8b5a0, #6f8570);
}
.gallery__count {
.gallery__dots {
position: absolute;
bottom: 12px;
inset-inline-start: 12px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border-radius: 6px;
padding: 4px 10px;
font-size: 0.75rem;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 6px;
z-index: 2;
}
.gallery__dot {
width: 6px;
height: 6px;
border: none;
padding: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.45);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
cursor: pointer;
transition: width 0.35s ease, background 0.35s ease;
}
.gallery__dot--active {
width: 16px;
background: #fff;
}
.gallery__thumbs {
@@ -909,12 +1050,16 @@ button.tag--link:hover {
padding-bottom: 0;
}
.gallery__main {
height: 360px;
.gallery__stage {
border-radius: var(--radius-lg);
overflow: hidden;
}
.gallery__main {
height: 360px;
border-radius: 0;
}
.gallery__thumbs {
margin-inline: 0;
}
@@ -949,5 +1094,9 @@ button.tag--link:hover {
.gallery__main {
height: 420px;
}
.gallery__dots {
bottom: 16px;
}
}
</style>
+87 -24
View File
@@ -26,6 +26,7 @@ const categoryId = computed(() => String(route.query.categoryId ?? ''))
const catalogId = computed(() => String(route.query.catalogId ?? ''))
const companyId = computed(() => String(route.query.companyId ?? ''))
const season = computed(() => String(route.query.season ?? '') as SeasonKind | '')
const filterAxis = computed(() => String(route.query.filterAxis ?? ''))
const q = computed(() => String(route.query.q ?? ''))
const searchDraft = ref(String(route.query.q ?? ''))
const products = ref<CatalogProductCard[]>([])
@@ -72,7 +73,7 @@ const title = computed(() => {
return crm.seasonCategories.find((s) => s.id === season.value)?.title ?? 'محصولات'
}
if (q.value) return `جستجو: ${q.value}`
return 'همه محصولات'
return 'محصولات'
})
function splitCategoryTitle(fullTitle: string) {
@@ -159,6 +160,17 @@ const bannerStyle = computed(() =>
const showSearch = computed(() => !categoryId.value && !catalogId.value)
/** Cross-filter chips: usage entry → season chips; season entry → usage chips. */
const crossChipKind = computed<'season' | 'usage'>(() => {
if (filterAxis.value === 'usage' || categoryId.value) return 'season'
if (filterAxis.value === 'season' || season.value) return 'usage'
return 'season'
})
const crossChipAriaLabel = computed(() =>
crossChipKind.value === 'season' ? 'فصل' : 'دسته کاربرد',
)
const sortedProducts = computed(() => {
const needle = q.value.trim().toLowerCase()
let list = [...products.value]
@@ -186,6 +198,7 @@ const sortAriaLabel = computed(() =>
function applySearch(value: string) {
searchDraft.value = value
const axis = resolveFilterAxis(categoryId.value, season.value)
router.replace({
path: '/products',
query: {
@@ -193,6 +206,7 @@ function applySearch(value: string) {
...(catalogId.value ? { catalogId: catalogId.value } : {}),
...(companyId.value ? { companyId: companyId.value } : {}),
...(season.value ? { season: season.value } : {}),
...(axis ? { filterAxis: axis } : {}),
...(value.trim() ? { q: value.trim() } : {}),
},
})
@@ -229,13 +243,32 @@ watch(q, (value) => {
})
function setSeason(value: string) {
const nextCategory = categoryId.value
const axis = nextCategory ? 'usage' : value ? 'season' : undefined
router.replace({
path: '/products',
query: {
...(categoryId.value ? { categoryId: categoryId.value } : {}),
...(nextCategory ? { categoryId: nextCategory } : {}),
...(catalogId.value ? { catalogId: catalogId.value } : {}),
...(companyId.value ? { companyId: companyId.value } : {}),
...(value ? { season: value } : {}),
...(axis ? { filterAxis: axis } : {}),
...(q.value ? { q: q.value } : {}),
},
})
}
function setCategory(value: string) {
const nextSeason = season.value
const axis = nextSeason ? 'season' : value ? 'usage' : undefined
router.replace({
path: '/products',
query: {
...(value ? { categoryId: value } : {}),
...(catalogId.value ? { catalogId: catalogId.value } : {}),
...(companyId.value ? { companyId: companyId.value } : {}),
...(nextSeason ? { season: nextSeason } : {}),
...(axis ? { filterAxis: axis } : {}),
...(q.value ? { q: q.value } : {}),
},
})
@@ -303,10 +336,18 @@ onClickOutside(seasonRootDesk, () => {
seasonSelectOpen.value = false
})
function resolveFilterAxis(nextCategory: string, nextSeason: string) {
if (nextCategory) return 'usage'
if (nextSeason) return 'season'
return undefined
}
function buildProductsQuery(nextCategory: string, nextSeason: string) {
const axis = resolveFilterAxis(nextCategory, nextSeason)
return {
...(nextCategory ? { categoryId: nextCategory } : {}),
...(nextSeason ? { season: nextSeason } : {}),
...(axis ? { filterAxis: axis } : {}),
...(companyId.value ? { companyId: companyId.value } : {}),
...(q.value ? { q: q.value } : {}),
}
@@ -627,12 +668,12 @@ onUnmounted(() => {
</Teleport>
<div class="page__scroll page__scroll--with-nav products-scroll">
<SearchField
<!-- <SearchField
v-if="showSearch"
:model-value="searchDraft"
placeholder="جستجو نام یا کد محصول..."
@update:model-value="applySearch"
/>
/>-->
<p v-if="error">{{ error }}</p>
<PageSkeleton v-else-if="loading" variant="grid" />
<template v-else>
@@ -661,26 +702,48 @@ onUnmounted(() => {
</span>
</section>
<div class="chips-row">
<div class="chips" role="tablist" aria-label="فصل">
<button
type="button"
class="chip"
:class="{ 'chip--active': !season }"
@click="setSeason('')"
>
همه
</button>
<button
v-for="item in crm.seasonCategories"
:key="item.id"
type="button"
class="chip"
:class="{ 'chip--active': season === item.id }"
@click="setSeason(item.id)"
>
{{ item.title }}
</button>
<div v-if="showFilter" class="chips-row scroll-x">
<div class="chips" role="tablist" :aria-label="crossChipAriaLabel">
<template v-if="crossChipKind === 'season'">
<button
type="button"
class="chip"
:class="{ 'chip--active': !season }"
@click="setSeason('')"
>
همه
</button>
<button
v-for="item in crm.seasonCategories"
:key="item.id"
type="button"
class="chip"
:class="{ 'chip--active': season === item.id }"
@click="setSeason(item.id)"
>
{{ item.title }}
</button>
</template>
<template v-else>
<button
type="button"
class="chip"
:class="{ 'chip--active': !categoryId }"
@click="setCategory('')"
>
همه
</button>
<button
v-for="item in crm.usageCategories"
:key="item.id"
type="button"
class="chip"
:class="{ 'chip--active': categoryId === item.id }"
@click="setCategory(item.id)"
>
{{ item.title }}
</button>
</template>
</div>
</div>
+46 -2
View File
@@ -20,6 +20,7 @@ const auth = useAuthStore()
const firstName = ref(auth.user?.firstName ?? '')
const lastName = ref(auth.user?.lastName ?? '')
const nationalId = ref(auth.user?.nationalId ?? '')
const address = ref(auth.user?.address ?? '')
const avatarUrl = ref<string | null>(auth.user?.avatarUrl ?? null)
const fileInput = ref<HTMLInputElement | null>(null)
const fieldErrors = ref<FieldErrors>({})
@@ -96,12 +97,17 @@ async function save() {
return
}
const updated = await auth.updateProfile({
const payload: Parameters<typeof auth.updateProfile>[0] = {
firstName: firstName.value.trim(),
lastName: lastName.value.trim(),
nationalId: nationalId.value || undefined,
avatarUrl: avatarUrl.value ?? '',
})
}
if (auth.isAgent) {
payload.address = address.value.trim()
}
const updated = await auth.updateProfile(payload)
if (!updated) {
formError.value = 'ذخیره پروفایل ناموفق بود.'
return
@@ -184,6 +190,19 @@ async function save() {
:error="fieldErrors.nationalId"
@update:model-value="clearField('nationalId')"
/>
<div v-if="auth.isAgent" class="form-group profile-edit__full">
<label class="form-label" for="profile-address">آدرس</label>
<textarea
id="profile-address"
v-model="address"
class="profile-edit__textarea"
rows="3"
maxlength="500"
placeholder="آدرس کامل محل فعالیت"
@input="clearField('address')"
/>
<p v-if="fieldErrors.address" class="input-error-text">{{ fieldErrors.address }}</p>
</div>
</div>
<div class="profile-edit__actions">
@@ -351,4 +370,29 @@ async function save() {
font-size: 0.875rem;
color: var(--color-success);
}
.profile-edit__textarea {
width: 100%;
min-height: 96px;
padding: 12px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font: inherit;
font-size: 0.9375rem;
line-height: 1.6;
color: var(--color-text);
resize: vertical;
transition: border-color 0.15s;
}
.profile-edit__textarea:focus {
outline: none;
border-color: var(--color-primary);
}
.input-error-text {
color: var(--color-danger);
font-size: 0.8125rem;
margin: 4px 0 0;
}
</style>
@@ -41,16 +41,26 @@ const profile = ref<AgentProfileDto | null>(null)
const sampleProduct = ref<CatalogProductCard>(SAMPLE_PRODUCT)
const loading = ref(false)
/** مطابق موکاپ ۱۱ — بدون gate نقش */
const quickActions = [
const agentQuickActions = [
{ label: 'ثبت مشتری جدید', to: '/customers', icon: 'vuesax-linear:add' },
{ label: 'سفارش‌ها', to: '/orders', icon: 'vuesax-linear:document-text' },
{ label: 'مشتریان من', to: '/customers', icon: 'vuesax-linear:people' },
{ label: 'گزارش‌ها', to: '/reminders', icon: 'vuesax-linear:chart-2' },
{ label: 'درخواست کالیته', to: '/catalog-request', icon: 'vuesax-linear:category' },
{ label: 'درخواست کالیته', to: '/categories', icon: 'vuesax-linear:category' },
{ label: 'قیمت‌گذاری', to: '/categories', icon: 'vuesax-linear:money-4' },
] as const
const customerQuickActions = [
{ label: 'سفارش‌ها', to: '/orders', icon: 'vuesax-linear:document-text' },
{ label: 'دسته‌بندی', to: '/categories', icon: 'vuesax-linear:category-2' },
{ label: 'درخواست کالیته', to: '/categories', icon: 'vuesax-linear:category' },
{ label: 'علاقه‌مندی‌ها', to: '/favorites', icon: 'vuesax-linear:heart' },
] as const
const quickActions = computed(() =>
auth.isAgent ? agentQuickActions : customerQuickActions,
)
const recentOrders = computed(() =>
[...orderStore.orders]
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
@@ -78,6 +88,7 @@ const recentOrders = computed(() =>
const displayName = computed(() => auth.fullName || 'کاربر')
const code = computed(() => profile.value?.code || auth.user?.agentCode || '—')
const codeLabel = computed(() => (auth.isAgent ? 'کد واسطه' : 'کد مشتری'))
const levelLabel = computed(
() => profile.value?.levelLabel || auth.user?.levelLabel || 'برنزی',
)
@@ -104,10 +115,12 @@ onMounted(async () => {
loading.value = true
try {
void notificationStore.hydrate()
try {
profile.value = await fetchAgentProfile()
} catch {
profile.value = null
if (auth.isAgent) {
try {
profile.value = await fetchAgentProfile()
} catch {
profile.value = null
}
}
try {
const products = await crm.loadBrowseProducts()
@@ -165,8 +178,8 @@ onMounted(async () => {
</div>
<div class="hero__meta">
<h2>{{ displayName }}</h2>
<p>کد واسطه: {{ code }}</p>
<span class="level-pill">
<p v-if="auth.isAgent">{{ codeLabel }}: {{ code }}</p>
<span v-if="auth.isAgent" class="level-pill">
<svg class="level-pill__crown" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
@@ -186,7 +199,7 @@ onMounted(async () => {
<div class="profile-sheet">
<PageSkeleton v-if="loading" variant="profile" />
<template v-else>
<section class="panel">
<section v-if="auth.isAgent" class="panel">
<div class="panel__head">
<h3>خلاصه عملکرد</h3>
<span class="panel__period">
@@ -238,7 +251,7 @@ onMounted(async () => {
</div>
</section>
<section class="loyalty">
<section v-if="auth.isAgent" class="loyalty">
<div class="loyalty__row">
<div class="loyalty__crown" aria-hidden="true">
<svg viewBox="0 0 48 48" width="40" height="40">