Initialize the user app and load home banners from the API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 17:08:45 +03:30
co-authored by Cursor
commit a7f9c66514
190 changed files with 36160 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useOrderList } from '@/composables/useOrderList'
import type { DesktopOrderFilterId } from '@/utils/orderStatusGroups'
import MobileOrdersPage from '@/views/orders/components/MobileOrdersPage.vue'
import DesktopOrdersPage from '@/views/orders/components/DesktopOrdersPage.vue'
const router = useRouter()
const {
tab,
desktopFilter,
stats,
filteredOrdersMobile,
filteredOrdersDesktop,
paginatedOrders,
paginatedOrdersMobile,
totalPages,
mobileTotalPages,
paginationRange,
mobilePaginationRange,
visiblePages,
mobileVisiblePages,
currentPage,
mobilePage,
companySlug,
iconVariant,
submitSuccessMessage,
goToOrder,
setPage,
setMobilePage,
setDesktopFilter,
viewStatFilter,
} = useOrderList()
const mobileTabs = [
{ id: 'all', label: 'همه' },
{ id: 'active', label: 'در جریان' },
{ id: 'done', label: 'تکمیل شده' },
{ id: 'cancelled', label: 'لغو شده' },
]
const desktopFilterTabs: { id: DesktopOrderFilterId; label: string }[] = [
{ id: 'review', label: 'در انتظار بررسی' },
{ id: 'in_progress', label: 'در حال انجام' },
{ id: 'shipped', label: 'ارسال شده' },
{ id: 'delivered', label: 'تحویل شده' },
{ id: 'cancelled', label: 'لغو شده' },
]
</script>
<template>
<div class="orders-view">
<p v-if="submitSuccessMessage" class="orders-view__success" role="status">
{{ submitSuccessMessage }}
</p>
<MobileOrdersPage
class="mobile-only"
:tab="tab"
:tabs="mobileTabs"
:orders="paginatedOrdersMobile"
:total-count="filteredOrdersMobile.length"
:current-page="mobilePage"
:total-pages="mobileTotalPages"
:pagination-range="mobilePaginationRange"
:visible-pages="mobileVisiblePages"
:company-slug="companySlug"
:icon-variant="iconVariant"
@update:tab="tab = $event"
@select="goToOrder"
@back="router.push('/')"
@set-page="setMobilePage"
/>
<DesktopOrdersPage
class="desktop-only"
:stats="stats"
:desktop-filter="desktopFilter"
:desktop-filter-tabs="desktopFilterTabs"
:filtered-orders="filteredOrdersDesktop"
:paginated-orders="paginatedOrders"
:pagination-range="paginationRange"
:visible-pages="visiblePages"
:current-page="currentPage"
:total-pages="totalPages"
@view-stat-filter="viewStatFilter"
@set-desktop-filter="setDesktopFilter"
@select="goToOrder"
@set-page="setPage"
@new-order="router.push('/companies')"
/>
</div>
</template>
<style scoped>
.orders-view {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.orders-view :deep(.orders-mobile-page) {
flex: 1;
min-height: 0;
}
.orders-view__success {
margin: 0 var(--page-padding) 12px;
padding: 12px 14px;
border-radius: var(--radius-sm);
background: var(--color-success-soft);
border: 1px solid color-mix(in srgb, var(--color-success) 35%, transparent);
color: var(--color-success);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.5;
}
</style>
+119
View File
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import BaseInput from '@/components/ui/BaseInput.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
import { normalizePhone, toPersianDigits } from '@/utils/format'
import { validatePhone } from '@/utils/validation'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
const router = useRouter()
const auth = useAuthStore()
const phone = ref('')
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
const displayPhone = computed(() => (phone.value ? toPersianDigits(phone.value) : ''))
function phoneErrorMessage(value: string): string {
if (!value) return 'شماره موبایل را وارد کنید.'
if (!validatePhone(value)) return 'شماره موبایل معتبر نیست. (مثال: ۰۹۱۲۱۲۳۴۵۶۷)'
return ''
}
function clearPhoneError() {
fieldErrors.value = clearFieldError(fieldErrors.value, 'phone')
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
function onPhoneInput(value: string) {
phone.value = normalizePhone(value)
clearPhoneError()
}
function onPhoneBlur() {
if (!phone.value) return
const message = phoneErrorMessage(phone.value)
if (message) {
fieldErrors.value = { ...fieldErrors.value, phone: message }
}
}
async function submit() {
fieldErrors.value = {}
formError.value = ''
const normalized = normalizePhone(phone.value)
phone.value = normalized
const message = phoneErrorMessage(normalized)
if (message) {
fieldErrors.value = { phone: message }
formError.value = FORM_VALIDATION_SUMMARY
return
}
const result = await auth.requestOtp(normalized, { isRegister: false })
if (result.success) {
router.push({ name: 'otp', query: { phone: normalized, mode: 'login' } })
} else {
formError.value = result.message ?? 'خطا در ارسال کد تأیید.'
}
}
</script>
<template>
<div class="page page-shell auth-page">
<AuthThemeToggle />
<div class="page__scroll flex flex-col justify-center py-6">
<div class="auth-header">
<div class="text-5xl mb-3">🧵</div>
<h1 class="page-title">ورود به سامانه</h1>
<p class="page-subtitle">سامانه سفارش عمده پارچه</p>
</div>
<form class="card" @submit.prevent="submit">
<FormErrorBanner :message="formError" />
<BaseInput
:model-value="displayPhone"
label="شماره موبایل"
type="tel"
inputmode="numeric"
autocomplete="tel"
dir="ltr"
align="center"
maxlength="11"
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
:error="fieldErrors.phone"
hint="کد تأیید به این شماره ارسال می‌شود"
@update:model-value="onPhoneInput"
@blur="onPhoneBlur"
/>
<BaseButton block type="submit" :loading="auth.loading">دریافت کد تأیید</BaseButton>
</form>
<div class="mt-6 w-full max-w-[var(--form-max-width)] text-center">
<p class="m-0 text-sm text-muted">
حساب کاربری ندارید؟
<RouterLink
to="/register"
class="ms-1 font-bold text-primary no-underline hover:underline"
>
ثبت نام کنید
</RouterLink>
</p>
</div>
</div>
</div>
</template>
+363
View File
@@ -0,0 +1,363 @@
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useIntervalFn } from '@vueuse/core'
import { useAuthStore } from '@/stores/auth'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
import IconEditPen from '@/components/icons/IconEditPen.vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import { toPersianDigits, extractNumericString, normalizePhone } from '@/utils/format'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
import { getPendingRegister } from '@/utils/pendingRegister'
const OTP_LENGTH = 4
const RESEND_SECONDS = 60
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const phone = computed(() => {
const raw = route.query.phone
const value = Array.isArray(raw) ? raw[0] : raw
return normalizePhone(typeof value === 'string' ? value : '')
})
const isRegister = computed(() => route.query.mode === 'register')
const digits = ref<string[]>(Array.from({ length: OTP_LENGTH }, () => ''))
const inputRefs = ref<(HTMLInputElement | null)[]>([])
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
const secondsLeft = ref(RESEND_SECONDS)
const code = computed(() => digits.value.join(''))
const canResend = computed(() => secondsLeft.value <= 0 && !auth.loading)
const timerLabel = computed(() => {
const minutes = Math.floor(secondsLeft.value / 60)
const seconds = secondsLeft.value % 60
const formatted = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
return toPersianDigits(formatted)
})
const { pause, resume } = useIntervalFn(
() => {
if (secondsLeft.value <= 1) {
secondsLeft.value = 0
pause()
return
}
secondsLeft.value -= 1
},
1000,
{ immediate: false },
)
function clearCodeError() {
fieldErrors.value = clearFieldError(fieldErrors.value, 'code')
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
function setInputRef(el: unknown, index: number) {
inputRefs.value[index] = el as HTMLInputElement | null
}
function focusInput(index: number) {
const clamped = Math.max(0, Math.min(OTP_LENGTH - 1, index))
void nextTick(() => inputRefs.value[clamped]?.focus())
}
function startTimer() {
secondsLeft.value = RESEND_SECONDS
resume()
}
function fillDigits(value: string, startIndex = 0) {
const numeric = extractNumericString(value).slice(0, OTP_LENGTH - startIndex)
if (!numeric) return startIndex
const next = [...digits.value]
let cursor = startIndex
for (const digit of numeric) {
if (cursor >= OTP_LENGTH) break
next[cursor] = digit
cursor += 1
}
digits.value = next
clearCodeError()
return Math.min(cursor, OTP_LENGTH - 1)
}
function tryAutoSubmit() {
if (code.value.length === OTP_LENGTH && !auth.loading) {
void submit()
}
}
function onDigitInput(index: number, event: Event) {
const target = event.target as HTMLInputElement
const numeric = extractNumericString(target.value)
if (numeric.length > 1) {
const focusAt = fillDigits(numeric, index)
target.value = digits.value[index] ?? ''
focusInput(focusAt)
tryAutoSubmit()
return
}
const digit = numeric.slice(-1)
digits.value[index] = digit
target.value = digit
clearCodeError()
if (digit && index < OTP_LENGTH - 1) {
focusInput(index + 1)
} else if (digit) {
tryAutoSubmit()
}
}
function onDigitKeydown(index: number, event: KeyboardEvent) {
if (event.key === 'Backspace') {
if (digits.value[index]) {
digits.value[index] = ''
clearCodeError()
return
}
if (index > 0) {
digits.value[index - 1] = ''
clearCodeError()
focusInput(index - 1)
}
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
focusInput(index - 1)
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
focusInput(index + 1)
}
}
function onDigitPaste(index: number, event: ClipboardEvent) {
event.preventDefault()
const pasted = event.clipboardData?.getData('text') ?? ''
const focusAt = fillDigits(pasted, index)
focusInput(focusAt)
tryAutoSubmit()
}
function displayDigit(value: string) {
return value ? toPersianDigits(value) : ''
}
async function submit() {
if (auth.loading) return
fieldErrors.value = {}
formError.value = ''
const errors: FieldErrors = {}
if (code.value.length < OTP_LENGTH) {
errors.code = 'کد تأیید را کامل وارد کنید.'
}
if (hasFieldErrors(errors)) {
fieldErrors.value = errors
formError.value = FORM_VALIDATION_SUMMARY
return
}
const result = await auth.confirmOtp(phone.value, extractNumericString(code.value))
if (result.success) {
router.replace('/')
} else {
formError.value = result.message ?? 'کد تأیید نامعتبر است.'
digits.value = Array.from({ length: OTP_LENGTH }, () => '')
focusInput(0)
}
}
async function resend() {
if (!canResend.value) return
formError.value = ''
fieldErrors.value = {}
const pending = isRegister.value ? getPendingRegister() : null
if (isRegister.value && !pending) {
formError.value = 'برای ارسال مجدد کد، نام و نام خانوادگی لازم است. لطفاً دوباره ثبت‌نام را شروع کنید.'
return
}
const result = await auth.requestOtp(phone.value, {
isRegister: isRegister.value,
firstName: pending?.firstName,
lastName: pending?.lastName,
})
if (result.success) {
digits.value = Array.from({ length: OTP_LENGTH }, () => '')
startTimer()
focusInput(0)
} else {
formError.value = result.message ?? 'خطا در ارسال مجدد کد.'
}
}
function changePhone() {
router.replace({ name: isRegister.value ? 'register' : 'login' })
}
onMounted(() => {
if (!phone.value) {
router.replace({ name: 'login' })
return
}
startTimer()
focusInput(0)
})
</script>
<template>
<div class="page page-shell auth-page">
<button
type="button"
class="auth-back"
aria-label="بازگشت"
@click="router.push(isRegister ? '/register' : '/login')"
>
<IconChevronBack :size="20" />
</button>
<AuthThemeToggle />
<div class="page__scroll flex flex-col justify-center py-6">
<div class="auth-header">
<h1 class="page-title">تأیید شماره موبایل</h1>
<p class="page-subtitle">کد ارسال‌شده به {{ toPersianDigits(phone) }} را وارد کنید</p>
</div>
<form class="card" @submit.prevent="submit">
<FormErrorBanner :message="formError" />
<div class="form-group">
<div
class="otp-row flex w-full max-w-full justify-center ltr"
dir="ltr"
role="group"
aria-label="کد تأیید"
>
<input
v-for="(digit, index) in digits"
:id="`otp-${index}`"
:key="index"
:ref="(el) => setInputRef(el, index)"
class="otp-box"
:class="{ 'otp-box--error': fieldErrors.code }"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="1"
:value="displayDigit(digit)"
:aria-label="`رقم ${toPersianDigits(String(index + 1))}`"
@input="onDigitInput(index, $event)"
@keydown="onDigitKeydown(index, $event)"
@paste="onDigitPaste(index, $event)"
/>
</div>
<p v-if="fieldErrors.code" class="input-error-text">{{ fieldErrors.code }}</p>
</div>
<BaseButton block type="submit" :loading="auth.loading">تأیید و ورود</BaseButton>
<BaseButton
block
variant="outline"
type="button"
class="mt-2"
:disabled="!canResend"
@click="resend"
>
<template v-if="canResend">ارسال مجدد کد</template>
<template v-else>ارسال مجدد کد ({{ timerLabel }})</template>
</BaseButton>
<button
type="button"
class="mt-3 mx-auto flex items-center justify-center gap-1.5 border-0 bg-transparent p-0 text-sm text-muted cursor-pointer transition-colors hover:text-text"
@click="changePhone"
>
<span class="ltr tabular-nums" dir="ltr">{{ toPersianDigits(phone) }}</span>
<span>ویرایش شماره</span>
<IconEditPen :size="18" :stroke-width="1.5" />
</button>
</form>
</div>
</div>
</template>
<style scoped>
.otp-row {
gap: clamp(0.25rem, 1.8vw, 0.625rem);
}
.otp-box {
flex: 1 1 0;
width: 0;
min-width: 0;
max-width: 48px;
aspect-ratio: 48 / 52;
height: auto;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font-size: clamp(0.95rem, 4.2vw, 1.25rem);
font-weight: 600;
text-align: center;
caret-color: var(--color-primary);
transition: border-color 0.15s;
}
.otp-box:focus {
outline: none;
border-color: var(--color-primary);
}
.otp-box--error {
border-color: var(--color-danger);
}
.input-error-text {
color: var(--color-danger);
font-size: 0.8125rem;
margin: 4px 0 0;
}
.auth-back {
position: fixed;
top: calc(12px + env(safe-area-inset-top, 0px));
inset-inline-start: 16px;
z-index: 100;
width: 40px;
height: 40px;
border: none;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
color: var(--color-text);
padding: 0;
}
</style>
+203
View File
@@ -0,0 +1,203 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import BaseInput from '@/components/ui/BaseInput.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import { normalizePhone } from '@/utils/format'
import { validatePhone } from '@/utils/validation'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
import {
JOB_CATEGORY_OPTIONS,
setPendingRegister,
type JobCategory,
} from '@/utils/pendingRegister'
const router = useRouter()
const auth = useAuthStore()
const firstName = ref('')
const lastName = ref('')
const phone = ref('')
const jobCategory = ref<JobCategory | ''>('')
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
function clearField(key: string) {
fieldErrors.value = clearFieldError(fieldErrors.value, key)
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
async function submit() {
fieldErrors.value = {}
formError.value = ''
const normalized = normalizePhone(phone.value)
const errors: FieldErrors = {}
if (!firstName.value.trim()) {
errors.firstName = 'نام الزامی است.'
}
if (!lastName.value.trim()) {
errors.lastName = 'نام خانوادگی الزامی است.'
}
if (!validatePhone(normalized)) {
errors.phone = 'شماره موبایل معتبر نیست.'
}
if (!jobCategory.value) {
errors.jobCategory = 'دسته‌بندی شغلی الزامی است.'
}
if (hasFieldErrors(errors)) {
fieldErrors.value = errors
formError.value = FORM_VALIDATION_SUMMARY
return
}
const names = {
firstName: firstName.value.trim(),
lastName: lastName.value.trim(),
jobCategory: jobCategory.value as JobCategory,
}
setPendingRegister(names)
const result = await auth.requestOtp(normalized, {
isRegister: true,
firstName: names.firstName,
lastName: names.lastName,
})
if (result.success) {
router.push({ name: 'otp', query: { phone: normalized, mode: 'register' } })
} else {
formError.value = result.message ?? 'خطا در ارسال کد تأیید.'
}
}
</script>
<template>
<div class="page page-shell auth-page">
<button type="button" class="auth-back" aria-label="بازگشت" @click="router.push('/login')">
<IconChevronBack :size="20" />
</button>
<AuthThemeToggle />
<div class="page__scroll flex flex-col justify-center py-6">
<div class="auth-header">
<div class="text-5xl mb-3">🧵</div>
<h1 class="page-title">ثبت‌نام</h1>
<p class="page-subtitle">ایجاد حساب کاربری جدید</p>
</div>
<div class="card">
<FormErrorBanner :message="formError" />
<BaseInput
v-model="firstName"
label="نام"
placeholder="نام خود را وارد کنید"
:error="fieldErrors.firstName"
@update:model-value="clearField('firstName')"
/>
<BaseInput
v-model="lastName"
label="نام خانوادگی"
placeholder="نام خانوادگی"
:error="fieldErrors.lastName"
@update:model-value="clearField('lastName')"
/>
<BaseInput
v-model="phone"
label="شماره موبایل"
type="tel"
inputmode="tel"
dir="ltr"
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
:error="fieldErrors.phone"
@update:model-value="clearField('phone')"
/>
<label class="job-label">
دسته‌بندی شغلی
<select
v-model="jobCategory"
class="job-select"
:aria-invalid="!!fieldErrors.jobCategory"
@change="clearField('jobCategory')"
>
<option disabled value="">انتخاب کنید</option>
<option v-for="opt in JOB_CATEGORY_OPTIONS" :key="opt.id" :value="opt.id">
{{ opt.label }}
</option>
</select>
<span v-if="fieldErrors.jobCategory" class="job-error">{{ fieldErrors.jobCategory }}</span>
</label>
<BaseButton block :loading="auth.loading" @click="submit">دریافت کد تأیید</BaseButton>
</div>
<div class="mt-6 w-full max-w-[var(--form-max-width)] text-center">
<p class="m-0 text-sm text-muted">
قبلاً ثبت‌نام کرده‌اید؟
<RouterLink
to="/login"
class="ms-1 font-bold text-primary no-underline hover:underline"
>
وارد شوید
</RouterLink>
</p>
</div>
</div>
</div>
</template>
<style scoped>
.auth-back {
position: fixed;
top: calc(12px + env(safe-area-inset-top, 0px));
inset-inline-start: 16px;
z-index: 100;
width: 40px;
height: 40px;
border: none;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
color: var(--color-text);
padding: 0;
}
.job-label {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.job-select {
height: 48px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0 12px;
font: inherit;
background: var(--color-surface);
color: var(--color-text);
}
.job-error {
color: var(--color-danger);
font-size: 0.75rem;
font-weight: 500;
}
</style>
+118
View File
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useCartPage } from '@/composables/useCartPage'
import BaseButton from '@/components/ui/BaseButton.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import MobileCartPage from '@/views/cart/components/MobileCartPage.vue'
import DesktopCartPage from '@/views/cart/components/DesktopCartPage.vue'
const router = useRouter()
const {
cartStore,
items,
isEmpty,
companyName,
companySummaries,
showFullDialog,
submitting,
submitError,
removeItem,
proceedToCheckout,
proceedAnyway,
createNewInvoice,
} = useCartPage()
function goToOrdering() {
const companyId = cartStore.selectedCompanyId || cartStore.activeCart?.companyId
if (companyId) {
router.push(`/companies/${companyId}/catalogs`)
return
}
router.push('/companies')
}
</script>
<template>
<div class="cart-view">
<FormErrorBanner v-if="submitError" :message="submitError" class="cart-view__error" />
<MobileCartPage
class="mobile-only"
:items="items"
:company-name="companyName"
:company-summaries="companySummaries"
:total-meterage="cartStore.totalMeterage"
:max-meterage="cartStore.maxMeterage"
:remaining-capacity="cartStore.remainingCapacity"
:is-empty="isEmpty"
:submitting="submitting"
@back="router.push('/')"
@remove="removeItem"
@checkout="proceedToCheckout"
@select-company="router.push('/companies')"
/>
<DesktopCartPage
class="desktop-only"
:items="items"
:company-name="companyName"
:company-summaries="companySummaries"
:total-meterage="cartStore.totalMeterage"
:max-meterage="cartStore.maxMeterage"
:remaining-capacity="cartStore.remainingCapacity"
:is-empty="isEmpty"
:submitting="submitting"
@remove="removeItem"
@checkout="proceedToCheckout"
@select-company="router.push('/companies')"
@back="goToOrdering"
/>
<div v-if="showFullDialog" class="dialog-overlay" @click.self="showFullDialog = false">
<div class="dialog card">
<h3>سقف متراژ تکمیل شد</h3>
<p class="text-sm">
سقف متراژ حداقل یک شرکت در سبد پر شده است. می‌توانید همین پیش‌سفارش‌ها را ثبت کنید یا سبد را خالی کرده و فاکتور جدید بسازید.
</p>
<div class="dialog-actions">
<BaseButton variant="outline" :loading="submitting" @click="proceedAnyway">
ثبت همین فاکتور
</BaseButton>
<BaseButton @click="createNewInvoice">فاکتور جدید</BaseButton>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.cart-view {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.cart-view__error {
margin: 0 var(--page-padding);
}
.dialog h3 {
margin: 0 0 12px;
}
@media (min-width: 768px) {
.cart-view {
flex: 1 0 auto;
min-height: auto;
overflow: visible;
}
.dialog-actions {
flex-direction: row;
justify-content: flex-end;
}
}
</style>
@@ -0,0 +1,658 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import CheckoutStepper from '@/components/layout/CheckoutStepper.vue'
import IconCart from '@/components/icons/IconCart.vue'
import IconTrash from '@/components/icons/IconTrash.vue'
import IconBuilding from '@/components/icons/IconBuilding.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconOrders from '@/components/icons/IconOrders.vue'
import IconClipboardEmpty from '@/components/icons/IconClipboardEmpty.vue'
import IconLightbulb from '@/components/icons/IconLightbulb.vue'
import IconLayoutGrid from '@/components/icons/IconLayoutGrid.vue'
import IconDiamond from '@/components/icons/IconDiamond.vue'
import { formatMeterage, formatNumber } from '@/utils/format'
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
isEmpty: boolean
submitting?: boolean
}>()
const emit = defineEmits<{
remove: [id: string]
checkout: []
selectCompany: []
back: []
}>()
const router = useRouter()
const quickLinks = [
{
title: 'انتخاب شرکت',
desc: 'شروع سفارش از انتخاب شرکت',
icon: IconBuilding,
tone: 'blue',
action: () => emit('selectCompany'),
},
{
title: 'سفارش‌گذاری',
desc: 'مشاهده کاتالوگ و افزودن کالا',
icon: IconCart,
tone: 'green',
action: () => emit('back'),
},
{
title: 'سفارش‌های من',
desc: 'پیگیری وضعیت سفارش‌ها',
icon: IconOrders,
tone: 'blue',
action: () => router.push('/orders'),
},
]
</script>
<template>
<div class="desktop-page cart-desktop-page">
<CheckoutStepper :current-step="1" />
<header class="cart-desktop-page__head">
<h1 class="cart-desktop-page__title">سبد خرید</h1>
<p class="cart-desktop-page__subtitle">
{{ companyName ? `شرکت‌ها: ${companyName}` : 'سبد خرید مشترک' }}
</p>
</header>
<div class="cart-desktop-page__layout">
<section class="cart-desktop-page__main">
<div v-if="isEmpty" class="cart-empty-card">
<div class="cart-empty-card__visual" aria-hidden="true">
<span class="cart-empty-card__spark cart-empty-card__spark--1">+</span>
<span class="cart-empty-card__spark cart-empty-card__spark--2">·</span>
<span class="cart-empty-card__circle">
<IconCart :size="40" />
</span>
<span class="cart-empty-card__spark cart-empty-card__spark--3">·</span>
</div>
<h2 class="cart-empty-card__heading">سبد خرید شما خالی است</h2>
<p class="cart-empty-card__text">
برای ثبت سفارش جدید، ابتدا شرکت موردنظر را انتخاب کنید و سپس کالیته و محصولات
را به سبد اضافه کنید.
</p>
<div class="cart-empty-card__actions">
<button type="button" class="cart-btn cart-btn--primary" @click="emit('selectCompany')">
<IconBuilding :size="18" />
انتخاب شرکت
</button>
<button type="button" class="cart-btn cart-btn--outline" @click="emit('back')">
<IconChevronLeft :size="16" />
بازگشت به سفارش‌گذاری
</button>
</div>
</div>
<div v-else class="cart-items-card">
<div class="cart-items-card__scroll">
<table class="cart-items-table">
<thead>
<tr>
<th scope="col">محصول</th>
<th scope="col">شرکت</th>
<th scope="col">کد</th>
<th scope="col">متراژ (متر)</th>
<th scope="col">حذف</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.id">
<td>
<strong class="cart-items-table__name">{{ item.productName }}</strong>
</td>
<td>{{ item.companyName || '—' }}</td>
<td>{{ item.productCode }}</td>
<td>{{ formatNumber(item.meterage) }}</td>
<td>
<button
type="button"
class="cart-items-table__delete"
aria-label="حذف"
@click="emit('remove', item.id)"
>
<IconTrash :size="18" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<aside class="cart-desktop-page__sidebar">
<div class="cart-summary-card">
<h2 class="cart-summary-card__title">
<span class="cart-summary-card__title-icon" aria-hidden="true">
<IconClipboardEmpty :size="22" />
</span>
خلاصه سفارش
</h2>
<div class="cart-summary-card__rows">
<div class="cart-summary-card__row">
<span class="cart-summary-card__row-label">
<IconLayoutGrid :size="16" />
تعداد آیتم‌ها
</span>
<strong>{{ formatNumber(items.length) }}</strong>
</div>
<div class="cart-summary-card__row">
<span class="cart-summary-card__row-label">
<IconDiamond :size="16" />
متراژ کل
</span>
<strong>{{ formatMeterage(totalMeterage) }}</strong>
</div>
<div
v-for="row in companySummaries ?? []"
:key="row.companyId"
class="cart-summary-card__row cart-summary-card__row--sub"
>
<span class="cart-summary-card__row-label">{{ row.companyName }}</span>
<strong>
{{ formatMeterage(row.meterage) }}
<span class="cart-summary-card__cap">/ {{ formatMeterage(row.maxMeterage) }}</span>
</strong>
</div>
<div class="cart-summary-card__row cart-summary-card__row--hint">
<span class="cart-summary-card__row-label">
<IconOrders :size="16" />
مبلغ فاکتور
</span>
<span class="cart-summary-card__hint-value">
پس از قیمت‌گذاری مدیریت
</span>
</div>
</div>
<button
type="button"
class="cart-summary-card__cta"
:disabled="isEmpty || submitting"
@click="emit('checkout')"
>
<IconChevronLeft :size="16" />
{{ submitting ? 'در حال ثبت…' : 'ثبت پیش‌سفارش' }}
</button>
<p class="cart-summary-card__note">
اقلام همه شرکت‌ها در یک سبد و یک فاکتور ثبت می‌شوند؛ پس از قیمت‌گذاری، یک‌بار تسویه می‌کنید.
</p>
</div>
<div class="cart-guide-card">
<h3 class="cart-guide-card__title">
<span class="cart-guide-card__title-icon" aria-hidden="true">
<IconLightbulb :size="18" />
</span>
راهنمای گام بعدی
</h3>
<p class="cart-guide-card__text">
پس از انتخاب شرکت و افزودن محصولات، متراژ کل و سقف مجاز فاکتور را بررسی کنید.
<template v-if="!isEmpty">
سقف مجاز: {{ formatMeterage(maxMeterage) }} — باقی‌مانده:
{{ formatMeterage(remainingCapacity) }}.
</template>
سپس برای تکمیل سفارش، گزینه «ادامه و انتخاب تسویه» را بزنید.
</p>
</div>
</aside>
</div>
<nav class="cart-quick-nav" aria-label="دسترسی سریع">
<button
v-for="link in quickLinks"
:key="link.title"
type="button"
class="cart-quick-nav__item"
@click="link.action()"
>
<span class="cart-quick-nav__icon" :class="`cart-quick-nav__icon--${link.tone}`">
<component :is="link.icon" :size="20" />
</span>
<span class="cart-quick-nav__body">
<strong class="cart-quick-nav__title">{{ link.title }}</strong>
<span class="cart-quick-nav__desc">{{ link.desc }}</span>
</span>
<IconChevronLeft :size="16" class="cart-quick-nav__chevron" aria-hidden="true" />
</button>
</nav>
<p class="cart-desktop-page__footer">پارس بافت — سامانه سفارش‌گذاری پارچه</p>
</div>
</template>
<style scoped>
.cart-desktop-page {
width: 100%;
padding-bottom: 24px;
}
.cart-desktop-page__head {
margin-bottom: 20px;
}
.cart-desktop-page__title {
margin: 0 0 6px;
font-size: 1.375rem;
font-weight: 700;
color: var(--color-text);
}
.cart-desktop-page__subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.cart-desktop-page__layout {
direction: ltr;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 20px;
align-items: start;
margin-bottom: 24px;
}
@media (min-width: 1100px) {
.cart-desktop-page__layout {
grid-template-columns: minmax(0, 1fr) 300px;
}
}
.cart-desktop-page__main,
.cart-desktop-page__sidebar {
direction: rtl;
}
.cart-desktop-page__sidebar {
display: flex;
flex-direction: column;
gap: 16px;
}
.cart-empty-card,
.cart-items-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.cart-empty-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 48px 32px 40px;
min-height: 360px;
}
.cart-empty-card__visual {
position: relative;
width: 140px;
height: 100px;
margin-bottom: 24px;
}
.cart-empty-card__circle {
position: absolute;
inset: 12px 24px;
border-radius: 50%;
background: var(--color-primary-soft);
color: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
}
.cart-empty-card__spark {
position: absolute;
color: #93c5fd;
font-size: 1.125rem;
font-weight: 700;
line-height: 1;
}
.cart-empty-card__spark--1 {
top: 8px;
right: 18px;
}
.cart-empty-card__spark--2 {
top: 28px;
left: 8px;
font-size: 1.5rem;
}
.cart-empty-card__spark--3 {
bottom: 16px;
right: 10px;
font-size: 1.25rem;
}
.cart-empty-card__heading {
margin: 0 0 10px;
font-size: 1.0625rem;
font-weight: 700;
color: var(--color-text);
}
.cart-empty-card__text {
margin: 0 0 28px;
max-width: 380px;
font-size: 0.8125rem;
line-height: 1.7;
color: var(--color-text-muted);
}
.cart-empty-card__actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.cart-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 0 22px;
border-radius: var(--radius-md);
font-family: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s, background 0.15s;
}
.cart-btn--primary {
border: none;
background: var(--color-primary);
color: var(--color-surface);
}
.cart-btn--primary:hover {
background: var(--color-primary-dark);
}
.cart-btn--outline {
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-primary);
}
.cart-btn--outline:hover {
background: var(--color-primary-soft);
}
.cart-items-card__scroll {
overflow-x: auto;
}
.cart-items-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
min-width: 520px;
}
.cart-items-table th {
padding: 14px 18px;
text-align: start;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-muted);
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
}
.cart-items-table td {
padding: 16px 18px;
border-bottom: 1px solid var(--color-border);
color: var(--color-text);
vertical-align: middle;
}
.cart-items-table tr:last-child td {
border-bottom: none;
}
.cart-items-table__name {
font-weight: 600;
}
.cart-items-table__delete {
border: none;
background: none;
cursor: pointer;
color: var(--color-text-muted);
padding: 4px;
display: inline-flex;
}
.cart-items-table__delete:hover {
color: var(--color-danger);
}
.cart-summary-card,
.cart-guide-card {
padding: 18px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.cart-summary-card__title,
.cart-guide-card__title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font-size: 0.9375rem;
font-weight: 700;
color: var(--color-text);
}
.cart-summary-card__title-icon,
.cart-guide-card__title-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary);
}
.cart-summary-card__rows {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 16px;
}
.cart-summary-card__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
font-size: 0.8125rem;
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;
}
.cart-summary-card__row-label {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--color-text-muted);
}
.cart-summary-card__row strong {
font-variant-numeric: tabular-nums;
color: var(--color-text);
}
.cart-summary-card__hint-value {
color: var(--color-text-muted);
font-size: 0.75rem;
}
.cart-summary-card__note {
margin: 10px 0 0;
font-size: 0.75rem;
line-height: 1.6;
color: var(--color-text-muted);
}
.cart-summary-card__cta {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: 44px;
border: 1px solid transparent;
border-radius: var(--radius-md);
background: var(--color-primary);
color: var(--color-surface);
font-family: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.cart-summary-card__cta:hover:not(:disabled) {
background: var(--color-primary-dark);
}
.cart-summary-card__cta:disabled {
background: var(--color-elevated);
border-color: var(--color-border);
color: var(--color-text-muted);
cursor: not-allowed;
}
.cart-guide-card__text {
margin: 0;
font-size: 0.75rem;
line-height: 1.75;
color: var(--color-text-muted);
}
.cart-quick-nav {
direction: ltr;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.cart-quick-nav__item {
direction: rtl;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
padding: 16px 18px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
box-shadow: var(--shadow-card);
cursor: pointer;
text-align: right;
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.cart-quick-nav__item:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-md);
}
.cart-quick-nav__icon {
width: 42px;
height: 42px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.cart-quick-nav__icon--blue {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.cart-quick-nav__icon--green {
background: var(--color-success-soft);
color: var(--color-success);
}
.cart-quick-nav__body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.cart-quick-nav__title {
font-size: 0.875rem;
font-weight: 700;
color: var(--color-text);
}
.cart-quick-nav__desc {
font-size: 0.6875rem;
color: var(--color-text-muted);
line-height: 1.4;
}
.cart-quick-nav__chevron {
color: var(--color-text-muted);
flex-shrink: 0;
}
.cart-desktop-page__footer {
margin: 0;
text-align: center;
font-size: 0.75rem;
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,291 @@
<script setup lang="ts">
import BaseButton from '@/components/ui/BaseButton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import IconTrash from '@/components/icons/IconTrash.vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import { formatMeterage, formatNumber } from '@/utils/format'
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
isEmpty: boolean
submitting?: boolean
}>()
const emit = defineEmits<{
back: []
remove: [id: string]
checkout: []
selectCompany: []
}>()
</script>
<template>
<div class="cart-mobile-page">
<header class="cart-mobile-page__nav">
<button type="button" class="nav-btn" aria-label="بازگشت" @click="emit('back')">
<IconChevronBack :size="20" />
</button>
<h1 class="cart-mobile-page__title">سبد خرید</h1>
<button type="button" class="nav-btn nav-btn--ghost" aria-hidden="true" tabindex="-1">
<IconChevronBack :size="18" class="nav-btn__flip" />
</button>
</header>
<div v-if="!isEmpty" class="cart-mobile-page__body">
<p class="cart-mobile-page__company">
{{ companyName ? `شرکت‌ها: ${companyName}` : 'سبد خرید مشترک' }}
</p>
<div class="cart-mobile-page__scroll">
<div class="cart-mobile-table">
<div class="cart-mobile-table__head">
<span>محصول</span>
<span>متراژ (متر)</span>
<span>حذف</span>
</div>
<div v-for="item in items" :key="item.id" class="cart-mobile-table__row">
<div class="col-product">
<strong>{{ item.productName }}</strong>
<span>کد: {{ item.productCode }}</span>
<span v-if="item.companyName" class="col-product__company">{{ item.companyName }}</span>
</div>
<div class="col-meterage">{{ formatNumber(item.meterage) }}</div>
<div class="col-delete">
<button type="button" aria-label="حذف" @click="emit('remove', item.id)">
<IconTrash :size="20" />
</button>
</div>
</div>
</div>
</div>
<div class="cart-mobile-page__footer">
<div class="cart-mobile-summary">
<div class="cart-mobile-summary__row">
<span>جمع متراژ کل</span>
<strong>{{ formatMeterage(totalMeterage) }}</strong>
</div>
<template v-if="companySummaries?.length">
<div
v-for="row in companySummaries"
:key="row.companyId"
class="cart-mobile-summary__row cart-mobile-summary__row--sub"
>
<span>{{ row.companyName }}</span>
<strong>
{{ formatMeterage(row.meterage) }}
<span class="cart-mobile-summary__cap">/ {{ formatMeterage(row.maxMeterage) }}</span>
</strong>
</div>
</template>
<div v-else class="cart-mobile-summary__row">
<span>حداکثر مجاز</span>
<strong>{{ formatMeterage(maxMeterage) }}</strong>
</div>
<div class="cart-mobile-summary__row">
<span>باقی مانده</span>
<strong>{{ formatMeterage(remainingCapacity) }}</strong>
</div>
</div>
<p class="cart-mobile-page__hint">
پس از ثبت، همه اقلام در یک پیش‌سفارش واحد قرار می‌گیرند و مدیریت قیمت‌گذاری می‌کند.
</p>
<BaseButton block :loading="submitting" @click="emit('checkout')">ثبت پیش‌سفارش</BaseButton>
</div>
</div>
<EmptyState v-else variant="cart" message="برای شروع سفارش، یک شرکت انتخاب کنید">
<BaseButton @click="emit('selectCompany')">انتخاب شرکت</BaseButton>
</EmptyState>
</div>
</template>
<style scoped>
.cart-mobile-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
background: var(--color-bg);
color: var(--color-text);
padding: 12px 0 0;
overflow: hidden;
}
.cart-mobile-page__nav {
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
margin-bottom: 12px;
}
.cart-mobile-page__hint {
margin: 0 0 10px;
font-size: 0.75rem;
line-height: 1.6;
color: var(--color-text-muted);
}
.cart-mobile-page__title {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
text-align: center;
color: var(--color-text);
}
.nav-btn {
width: 40px;
height: 40px;
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.nav-btn--ghost {
visibility: hidden;
}
.nav-btn__flip {
transform: scaleX(-1);
}
.cart-mobile-page__body {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.cart-mobile-page__company {
margin: 0 0 16px;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.cart-mobile-page__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.cart-mobile-table {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-elevated);
}
.cart-mobile-table__head,
.cart-mobile-table__row {
display: grid;
grid-template-columns: 1fr 88px 52px;
gap: 8px;
padding: 10px 12px;
align-items: center;
}
.cart-mobile-table__head {
background: var(--color-summary-bg);
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
}
.cart-mobile-table__row {
border-top: 1px solid var(--color-border);
color: var(--color-text);
}
.col-product strong {
display: block;
font-size: 0.875rem;
}
.col-product span {
display: block;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.col-product__company {
margin-top: 2px;
color: var(--color-primary) !important;
font-weight: 600;
}
.col-meterage {
text-align: center;
font-weight: 700;
}
.col-delete {
display: flex;
justify-content: center;
}
.col-delete button {
border: none;
background: none;
cursor: pointer;
color: var(--color-text-muted);
}
.cart-mobile-page__footer {
flex-shrink: 0;
margin: 0;
padding: 12px 0 calc(12px + env(safe-area-inset-bottom, 0));
border-top: 1px solid var(--color-border);
background: var(--color-bg);
}
.cart-mobile-summary {
margin-bottom: 12px;
padding: 14px 16px;
background: var(--color-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
box-shadow: var(--shadow-sm);
}
.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);
}
</style>
+209
View File
@@ -0,0 +1,209 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCatalogStore } from '@/stores/catalog'
import PageHeader from '@/components/layout/PageHeader.vue'
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'
const route = useRoute()
const router = useRouter()
const catalogStore = useCatalogStore()
const loading = ref(true)
const companyId = computed(() => route.params.companyId as string)
const company = computed(() => catalogStore.getCompany(companyId.value))
const search = ref('')
const catalogs = computed(() => catalogStore.fetchCatalogs(companyId.value, search.value))
async function load() {
loading.value = true
if (!catalogStore.getCompany(companyId.value)) {
await catalogStore.loadCompanies()
}
await catalogStore.loadCatalogs(companyId.value)
loading.value = false
}
onMounted(load)
watch(companyId, load)
function openCatalog(catalogId: string) {
router.push(`/companies/${companyId.value}/catalogs/${catalogId}/products`)
}
function getProductCount(catalogId: string) {
const catalog = catalogStore.getCatalog(catalogId)
if (catalog?.productCount != null) return catalog.productCount
return catalogStore.fetchProducts(catalogId).length
}
</script>
<template>
<!-- Mobile -->
<div class="page catalog-mobile-page mobile-only">
<PageHeader
:title="company?.name ?? 'کالیته‌ها'"
subtitle="کالیته مورد نظر را انتخاب کنید"
show-back
centered
@back="router.push('/')"
/>
<SearchField
v-model="search"
placeholder="جستجو بر اساس کد یا نام کالیته..."
class="catalog-page__search"
/>
<div class="page__scroll page__scroll--with-nav catalog-mobile-scroll">
<PageSkeleton v-if="loading" variant="cards" :rows="5" />
<div v-else-if="catalogs.length > 0" class="catalog-mobile-list">
<button
v-for="catalog in catalogs"
:key="catalog.id"
type="button"
class="catalog-card"
@click="openCatalog(catalog.id)"
>
<span class="catalog-card__icon"><IconCatalog :size="22" /></span>
<div class="catalog-card__info">
<h3>کالیته کد {{ catalog.code }}</h3>
<p>{{ catalog.name }}</p>
</div>
<span class="catalog-card__count">{{ getProductCount(catalog.id) }} محصول</span>
</button>
</div>
<EmptyState
v-else
:variant="search ? 'search' : 'catalogs'"
:message="search ? 'با این جستجو کالیته‌ای پیدا نشد' : 'برای این شرکت هنوز کالیته‌ای ثبت نشده است'"
/>
</div>
</div>
<!-- Desktop -->
<div class="desktop-page catalog-desktop-page desktop-only">
<header class="desktop-page__head">
<h1 class="desktop-page__title">{{ company?.name ?? 'کالیته‌ها' }}</h1>
<p class="desktop-page__subtitle">کالیته مورد نظر را انتخاب کنید</p>
</header>
<SearchField
v-model="search"
placeholder="جستجو بر اساس کد یا نام کالیته..."
class="catalog-page__search"
/>
<div v-if="catalogs.length > 0" class="desktop-grid-3">
<button
v-for="catalog in catalogs"
:key="catalog.id"
type="button"
class="catalog-card catalog-card--desktop"
@click="openCatalog(catalog.id)"
>
<span class="catalog-card__icon"><IconCatalog :size="24" /></span>
<div class="catalog-card__info">
<h3>کالیته کد {{ catalog.code }}</h3>
<p>{{ catalog.name }}</p>
</div>
<span class="catalog-card__count">{{ getProductCount(catalog.id) }} محصول</span>
</button>
</div>
<EmptyState
v-else
:variant="search ? 'search' : 'catalogs'"
:message="search ? 'با این جستجو کالیته‌ای پیدا نشد' : 'برای این شرکت هنوز کالیته‌ای ثبت نشده است'"
/>
</div>
</template>
<style scoped>
.catalog-mobile-page {
background: var(--color-bg);
color: var(--color-text);
padding: 12px 0 0;
}
.catalog-page__search {
margin-bottom: 16px;
}
.catalog-mobile-scroll {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.catalog-mobile-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.catalog-card {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-elevated);
box-shadow: var(--shadow-sm);
color: var(--color-text);
text-align: right;
cursor: pointer;
}
.catalog-card--desktop {
flex-direction: column;
align-items: flex-start;
min-height: 140px;
padding: 20px;
}
.catalog-card:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-md);
}
.catalog-card__icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary-light);
border-radius: var(--radius-sm);
color: var(--color-primary);
}
.catalog-card__info {
flex: 1;
}
.catalog-card__info h3 {
margin: 0 0 4px;
font-size: 0.9375rem;
color: var(--color-text);
}
.catalog-card__info p {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.catalog-card__count {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-primary);
}
</style>
+211
View File
@@ -0,0 +1,211 @@
<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 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()
products.value = list
if (preselectId.value) {
quantities.value = { ...quantities.value, [preselectId.value]: 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">
<PageHeader title="درخواست کالیته" show-back centered @back="router.back()" />
<div class="page__scroll page__scroll--with-nav body">
<p class="hint">محصولات و تعداد اقلام موردنظر را مشخص کنید.</p>
<PageSkeleton v-if="loading" variant="form" />
<p v-else-if="error" class="error">{{ error }}</p>
<template v-else>
<article v-for="item in products" :key="item.id" class="row">
<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>
<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>
</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 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;
background: var(--color-bg);
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;
background: var(--color-surface);
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;
}
</style>
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import PageHeader from '@/components/layout/PageHeader.vue'
import { useCatalogRequestsStore } from '@/stores/catalogRequests'
import { formatNumber } from '@/utils/format'
const router = useRouter()
const store = useCatalogRequestsStore()
onMounted(() => store.hydrate())
</script>
<template>
<div class="page">
<PageHeader title="وضعیت کالیته‌ها" show-back centered @back="router.push('/catalogs')" />
<div class="page__scroll page__scroll--with-nav body">
<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">
<div class="card__top">
<strong>درخواست {{ item.id.slice(-6) }}</strong>
<span class="badge">{{ store.statusLabel(item.status) }}</span>
</div>
<p>{{ item.createdAt.slice(0, 10) }}</p>
<p>{{ formatNumber(item.items.length) }} قلم</p>
<ul>
<li v-for="line in item.items" :key="line.productId">
{{ line.productName }} × {{ formatNumber(line.quantity) }}
</li>
</ul>
<p v-if="item.note" class="note">{{ item.note }}</p>
</article>
</div>
</div>
</template>
<style scoped>
.body {
padding: 12px var(--page-padding) 24px;
display: flex;
flex-direction: column;
gap: 10px;
}
.new-btn {
border: none;
border-radius: 12px;
min-height: 48px;
background: var(--color-primary);
color: #fff;
font-weight: 700;
cursor: pointer;
}
.empty {
text-align: center;
color: var(--color-text-muted);
}
.card {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 14px;
box-shadow: var(--shadow-sm);
}
.card__top {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
}
.badge {
border-radius: 999px;
padding: 4px 10px;
font-size: 0.72rem;
background: var(--color-primary-soft);
color: var(--color-primary);
font-weight: 700;
}
.card p {
margin: 6px 0 0;
color: var(--color-text-muted);
font-size: 0.82rem;
}
.card ul {
margin: 8px 0 0;
padding-inline-start: 18px;
font-size: 0.85rem;
}
.note {
color: var(--color-text) !important;
}
</style>
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { nextTick, 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 { useCatalogStore } from '@/stores/catalog'
import type { Catalog } from '@/types'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
const route = useRoute()
const router = useRouter()
const catalogStore = useCatalogStore()
const catalogs = ref<Catalog[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
onMounted(async () => {
try {
const companies = await catalogStore.loadCompanies()
const lists = await Promise.all(companies.map((c) => catalogStore.loadCatalogs(c.id)))
catalogs.value = lists.flat()
const focusId = String(route.query.id ?? '')
if (focusId) {
const hit = catalogs.value.find((c) => c.id === focusId)
if (hit) {
await nextTick()
openCatalog(hit)
}
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'بارگذاری کالیته‌ها ناموفق بود.'
} finally {
loading.value = false
}
})
function openCatalog(item: Catalog) {
router.push({
path: `/companies/${item.companyId}/catalogs/${item.id}/products`,
})
}
function companyName(companyId: string) {
return catalogStore.getCompany(companyId)?.name ?? 'برند'
}
</script>
<template>
<div class="page">
<PageHeader title="کالیته‌ها" show-back centered @back="router.push('/')" />
<div class="page__scroll page__scroll--with-nav catalog-hub">
<button type="button" class="hub-card" @click="router.push('/catalog-request')">
<strong>درخواست کالیته فیزیکی</strong>
<span>ثبت اقلام و پیگیری وضعیت</span>
</button>
<button type="button" class="hub-card" @click="router.push('/catalog-requests')">
<strong>وضعیت درخواست‌ها</strong>
<span>پیگیری کالیته‌های ثبت‌شده</span>
</button>
<button type="button" class="hub-card" @click="router.push('/categories')">
<strong>مشاهده دسته‌بندی‌ها</strong>
<span>انتخاب بر اساس کاربرد و فصل</span>
</button>
<button type="button" class="hub-card" @click="router.push('/companies')">
<strong>کالیته‌های برندها</strong>
<span>مسیر انتخاب شرکت و کالیته</span>
</button>
<PageSkeleton v-if="loading" variant="grid" />
<p v-else-if="error">{{ error }}</p>
<template v-else>
<h2 class="section-title">کالیته‌های موجود</h2>
<button
v-for="item in catalogs"
:key="item.id"
type="button"
class="calite-item"
:class="{ 'calite-item--focus': String(route.query.id ?? '') === item.id }"
@click="openCatalog(item)"
>
<div class="calite-item__text">
<strong>{{ item.name }}</strong>
<span>کد {{ item.code }}</span>
<em>{{ companyName(item.companyId) }}</em>
</div>
<IconChevronLeft :size="18" color="#9aa3a0" />
</button>
<p v-if="!catalogs.length" class="empty-hint">کالیته‌ای یافت نشد.</p>
</template>
</div>
</div>
</template>
<style scoped>
.catalog-hub {
padding: 12px var(--page-padding) 24px;
display: flex;
flex-direction: column;
gap: 10px;
}
.section-title {
margin: 8px 0 0;
font-size: 0.95rem;
}
.hub-card,
.calite-item {
border: none;
text-align: start;
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 14px;
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
cursor: pointer;
width: 100%;
}
.calite-item {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.calite-item--focus {
outline: 2px solid var(--color-primary);
}
.calite-item__text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.calite-item strong {
display: block;
font-size: 0.95rem;
font-weight: 700;
line-height: 1.5;
color: var(--color-text);
}
.hub-card span,
.calite-item span,
.calite-item em {
display: block;
color: var(--color-text-muted);
font-size: 0.8rem;
line-height: 1.5;
font-style: normal;
}
.empty-hint {
color: var(--color-text-muted);
text-align: center;
margin: 12px 0 0;
}
</style>
+411
View File
@@ -0,0 +1,411 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useCrmStore } from '@/stores/crm'
import type { ProductCategory, SeasonCategory } from '@/types/crm'
import IconBell from '@/components/icons/IconBell.vue'
import IconSearch from '@/components/icons/IconSearch.vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconShoppingBag from '@/components/icons/IconShoppingBag.vue'
import IconLayoutGrid from '@/components/icons/IconLayoutGrid.vue'
import IconIdCard from '@/components/icons/IconIdCard.vue'
import IconHeadset from '@/components/icons/IconHeadset.vue'
import IconDiamond from '@/components/icons/IconDiamond.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
import IconBolt from '@/components/icons/IconBolt.vue'
import IconFeather from '@/components/icons/IconFeather.vue'
import { useGoBack } from '@/composables/useGoBack'
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
const router = useRouter()
const { goBack } = useGoBack('/')
const crm = useCrmStore()
const usageCategories = ref<ProductCategory[]>([])
const seasonCategories = ref<SeasonCategory[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const usageIcons: Record<string, unknown> = {
shirt: IconIdCard,
suit: IconShoppingBag,
trousers: IconLayoutGrid,
}
const seasonIcons: Record<string, unknown> = {
summer: IconBolt,
winter: IconFeather,
all_season: IconDiamond,
}
onMounted(async () => {
try {
const data = await crm.loadCategories()
usageCategories.value = data.usage
seasonCategories.value = data.seasons as SeasonCategory[]
} catch (e) {
error.value = e instanceof Error ? e.message : 'بارگذاری دسته‌ها ناموفق بود.'
} finally {
loading.value = false
}
})
function openCategory(id: string) {
router.push({ path: '/products', query: { categoryId: id } })
}
function openSeason(id: string) {
router.push({ path: '/products', query: { season: id } })
}
</script>
<template>
<div class="page categories-page">
<header class="cat-top">
<button type="button" class="icon-btn" aria-label="بازگشت" @click="goBack">
<IconChevronBack :size="20" />
</button>
<h1 class="cat-top__title">دسته‌بندی‌ها</h1>
<div class="cat-top__actions">
<AuthThemeToggle variant="inline" />
<button type="button" class="icon-btn" aria-label="جستجو" @click="router.push({ path: '/products', query: { q: '' } })">
<IconSearch :size="20" />
</button>
<button type="button" class="icon-btn" aria-label="اعلان‌ها" @click="router.push('/profile/notifications')">
<IconBell :size="20" />
</button>
</div>
</header>
<div class="page__scroll page__scroll--with-nav cat-scroll">
<p v-if="error">{{ error }}</p>
<PageSkeleton v-else-if="loading" variant="grid" />
<template v-else>
<section>
<h2 class="block-title">انتخاب بر اساس کاربرد</h2>
<p class="block-sub">دسته موردنظر خود را انتخاب کنید</p>
<div class="usage-grid">
<button
v-for="item in usageCategories"
:key="item.id"
type="button"
class="usage-card"
@click="openCategory(item.id)"
>
<div
class="usage-card__image"
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
>
<span class="usage-card__icon">
<component :is="usageIcons[item.id] || IconLayoutGrid" :size="18" />
</span>
</div>
<div class="usage-card__body">
<strong>{{ item.title }}</strong>
<span class="usage-card__link">
مشاهده محصولات
<IconChevronLeft :size="14" />
</span>
</div>
</button>
</div>
</section>
<section>
<h2 class="block-title">انتخاب بر اساس فصل</h2>
<p class="block-sub">بر اساس فصل مناسب، پارچه دلخواه را پیدا کنید</p>
<div class="season-stack">
<button
v-for="item in seasonCategories"
:key="item.id"
type="button"
class="season-card"
@click="openSeason(item.id)"
>
<div
class="season-card__image"
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
/>
<div class="season-card__body">
<div class="season-card__title-row">
<span class="season-card__icon">
<component :is="seasonIcons[item.id] || IconDiamond" :size="16" />
</span>
<strong>{{ item.title }}</strong>
</div>
<p>{{ item.description }}</p>
<span class="usage-card__link">
مشاهده محصولات
<IconChevronLeft :size="14" />
</span>
</div>
</button>
</div>
</section>
<section class="feature-row" aria-label="ویژگی‌ها">
<div class="feature-card">
<span class="feature-card__icon" aria-hidden="true">
<IconHeadset :size="22" />
</span>
<div class="feature-card__text">
<strong>پشتیبانی اختصاصی</strong>
<span>در کنار شما هستیم</span>
</div>
</div>
<div class="feature-card">
<span class="feature-card__icon" aria-hidden="true">
<IconDiamond :size="22" />
</span>
<div class="feature-card__text">
<strong>مزایای مشتریان ویژه</strong>
<span>بر اساس سطح شما</span>
</div>
</div>
<div
class="feature-card feature-card--link"
role="button"
tabindex="0"
@click="router.push('/catalog-request')"
@keydown.enter="router.push('/catalog-request')"
>
<span class="feature-card__icon" aria-hidden="true">
<IconCatalog :size="22" />
</span>
<div class="feature-card__text">
<strong>درخواست کالیته فیزیکی</strong>
<span>کالیته را دریافت کنید</span>
</div>
</div>
</section>
</template>
</div>
</div>
</template>
<style scoped>
.categories-page {
background: var(--color-bg);
}
.cat-top {
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: center;
gap: 4px;
padding: 10px var(--page-padding);
background: var(--color-surface);
color: var(--color-text);
}
.cat-top__title {
margin: 0;
text-align: center;
font-size: 1rem;
}
.cat-top__actions {
display: flex;
align-items: center;
gap: 0;
color: inherit;
}
.icon-btn {
width: 40px;
height: 40px;
border: none;
border-radius: 12px;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
color: var(--color-text);
}
.cat-scroll {
padding: 16px var(--page-padding) 24px;
display: flex;
flex-direction: column;
gap: 22px;
}
.block-title {
margin: 0;
font-size: 1.05rem;
}
.block-sub {
margin: 4px 0 12px;
color: var(--color-text-muted);
font-size: 0.8rem;
}
.usage-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.usage-card {
border: none;
background: var(--color-surface);
border-radius: var(--radius-md);
overflow: hidden;
text-align: start;
cursor: pointer;
padding: 0;
box-shadow: var(--shadow-sm);
}
.usage-card__image {
position: relative;
height: 72px;
background: linear-gradient(140deg, #6b7280, #1f2937) center / cover no-repeat;
}
.usage-card__icon {
position: absolute;
left: 50%;
bottom: -12px;
transform: translateX(-50%);
width: 28px;
height: 28px;
border-radius: 999px;
background: var(--color-surface);
color: var(--color-primary);
display: grid;
place-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.usage-card__body {
padding: 18px 8px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.usage-card__body strong {
font-size: 0.72rem;
line-height: 1.5;
}
.usage-card__link {
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--color-info);
font-size: 0.65rem;
}
.season-stack {
display: flex;
flex-direction: column;
gap: 10px;
}
.season-card {
display: grid;
grid-template-columns: 110px 1fr;
border: none;
background: var(--color-surface);
border-radius: var(--radius-lg);
overflow: hidden;
text-align: start;
cursor: pointer;
padding: 0;
box-shadow: var(--shadow-sm);
}
.season-card__image {
min-height: 100px;
background: linear-gradient(160deg, #d6c3a8, #8b7355) center / cover no-repeat;
}
.season-card__body {
padding: 12px;
}
.season-card__body strong {
display: block;
margin-bottom: 0;
}
.season-card__title-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.season-card__icon {
width: 28px;
height: 28px;
border-radius: 999px;
background: var(--color-primary-soft);
color: var(--color-primary);
display: grid;
place-items: center;
flex-shrink: 0;
}
.season-card__body p {
margin: 0 0 8px;
color: var(--color-text-muted);
font-size: 0.75rem;
line-height: 1.6;
}
.feature-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.feature-card {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
min-width: 0;
background: #f3f4f6;
border-radius: 12px;
padding: 10px 8px;
}
.feature-card--link {
cursor: pointer;
}
.feature-card__icon {
flex-shrink: 0;
width: 22px;
height: 22px;
color: var(--color-text);
display: grid;
place-items: center;
}
.feature-card__text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
text-align: start;
}
.feature-card__text strong {
font-size: 0.6875rem;
font-weight: 700;
line-height: 1.35;
color: var(--color-text);
}
.feature-card__text span {
font-size: 0.625rem;
line-height: 1.35;
color: var(--color-text-muted);
}
</style>
+320
View File
@@ -0,0 +1,320 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useOrderStore } from '@/stores/order'
import { useCatalogStore } from '@/stores/catalog'
import PageHeader from '@/components/layout/PageHeader.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import IconSuccessCircle from '@/components/icons/IconSuccessCircle.vue'
import { formatCurrency, formatDateTime } from '@/utils/format'
import { useRequirePricedOrder } from '@/composables/useRequirePricedOrder'
import type { BankAccount } from '@/types'
const router = useRouter()
const orderStore = useOrderStore()
const catalogStore = useCatalogStore()
const bankAccounts = ref<BankAccount[]>([])
const { orderId, order } = useRequirePricedOrder()
const companyName = computed(() => order.value?.companyName ?? '')
watch(
orderId,
async (id) => {
if (id && !orderStore.getOrderById(id)) await orderStore.fetchOrder(id)
},
{ immediate: true },
)
watch(
() => order.value?.id,
async (id) => {
if (!id || !order.value) {
bankAccounts.value = []
return
}
if (!catalogStore.companies.length) await catalogStore.loadCompanies()
bankAccounts.value = await orderStore.getBankAccountsForOrder(order.value)
},
{ immediate: true },
)
const invoiceLabel = computed(() =>
order.value?.finalAmount ? formatCurrency(order.value.finalAmount) : '—',
)
const expiryMessage = computed(() => {
const expiry = order.value?.cashInvoiceExpiresAt
if (!expiry) return 'مبلغ فاکتور را به یکی از حساب‌های زیر واریز و رسید را ثبت کنید.'
return `مهلت پرداخت این فاکتور تا ${formatDateTime(expiry)} است.`
})
const accountsSectionTitle = computed(() => {
const name = companyName.value.replace(/^شرکت\s+/u, '')
return name
? `شماره حساب‌های واریز — ${name}`
: 'شماره حساب‌های واریز'
})
function formatCardNumber(value: string) {
const digits = value.replace(/\D/g, '')
return digits.replace(/(\d{4})(?=\d)/g, '$1 ').trim()
}
function formatIban(value: string) {
const clean = value.replace(/\s/g, '').toUpperCase()
return clean.replace(/(.{4})/g, '$1 ').trim()
}
function viewOrder() {
router.replace(`/orders/${orderId.value}?track=1`)
}
function registerReceipt() {
router.replace(`/orders/${orderId.value}?track=1&receipt=1`)
}
</script>
<template>
<!-- Mobile -->
<div class="page cash-mobile-page mobile-only">
<PageHeader
title="تسویه نقدی"
show-back
centered
@back="router.push(`/orders/${orderId}`)"
/>
<div class="cash-page__scroll page__scroll">
<div class="cash-page__success cash-page__success--done">
<IconSuccessCircle :size="56" />
<p class="cash-page__success-title">مبلغ قابل پرداخت {{ invoiceLabel }}</p>
<p class="cash-page__success-subtitle">{{ expiryMessage }}</p>
</div>
<section class="accounts-panel">
<h2 class="accounts-panel__title">{{ accountsSectionTitle }}</h2>
<article v-for="account in bankAccounts" :key="account.id" class="bank-card">
<div class="bank-card__head">
<span class="bank-card__name">{{ account.bankName }}</span>
<span class="bank-card__icon" aria-hidden="true">
{{ account.bankName.charAt(0) }}
</span>
</div>
<div class="bank-card__field">
<span class="bank-card__label">شماره کارت</span>
<strong class="bank-card__value" dir="ltr">{{ formatCardNumber(account.cardNumber) }}</strong>
</div>
<div class="bank-card__field">
<span class="bank-card__label">شماره شبا</span>
<strong class="bank-card__value" dir="ltr">{{ formatIban(account.iban) }}</strong>
</div>
</article>
</section>
</div>
<div class="cash-page__footer">
<BaseButton variant="outline" block @click="viewOrder">مشاهده سفارش</BaseButton>
<BaseButton block class="cash-page__submit" @click="registerReceipt">
ثبت رسید پرداخت
</BaseButton>
</div>
</div>
<!-- Desktop -->
<div class="desktop-checkout-shell cash-desktop-page desktop-only">
<header class="desktop-page__head">
<h1 class="desktop-page__title">تسویه نقدی</h1>
<p class="desktop-page__subtitle">{{ expiryMessage }}</p>
</header>
<div class="cash-page__success cash-page__success--done">
<IconSuccessCircle :size="56" />
<p class="cash-page__success-title">مبلغ قابل پرداخت {{ invoiceLabel }}</p>
</div>
<section class="accounts-panel">
<h2 class="accounts-panel__title">{{ accountsSectionTitle }}</h2>
<div class="desktop-grid-2">
<article v-for="account in bankAccounts" :key="account.id" class="bank-card">
<div class="bank-card__head">
<span class="bank-card__name">{{ account.bankName }}</span>
<span class="bank-card__icon" aria-hidden="true">{{ account.bankName.charAt(0) }}</span>
</div>
<div class="bank-card__field">
<span class="bank-card__label">شماره کارت</span>
<strong class="bank-card__value" dir="ltr">{{ formatCardNumber(account.cardNumber) }}</strong>
</div>
<div class="bank-card__field">
<span class="bank-card__label">شماره شبا</span>
<strong class="bank-card__value" dir="ltr">{{ formatIban(account.iban) }}</strong>
</div>
</article>
</div>
</section>
<div class="cash-desktop-page__actions">
<BaseButton variant="outline" class="desktop-btn-compact" @click="viewOrder">
مشاهده سفارش
</BaseButton>
<BaseButton class="desktop-btn-compact" @click="registerReceipt">
ثبت رسید پرداخت
</BaseButton>
</div>
</div>
</template>
<style scoped>
.cash-mobile-page {
overflow: hidden;
background: var(--color-bg);
padding: 12px 0 0;
}
.cash-desktop-page__actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.cash-page__scroll {
flex: 1;
min-height: 0;
padding-bottom: 12px;
}
.cash-page__success {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
margin-bottom: 24px;
padding-top: 8px;
}
.cash-page__success-title {
margin: 0;
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
}
.cash-page__success--done .cash-page__success-title {
color: var(--color-success);
}
.cash-page__success-subtitle {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-muted);
line-height: 1.6;
}
.accounts-panel {
padding: 16px;
border-radius: 16px;
background: var(--color-surface);
border: 1px solid var(--color-border);
}
.accounts-panel__title {
margin: 0 0 14px;
font-size: 0.9375rem;
font-weight: 700;
color: var(--color-text);
text-align: right;
}
.bank-card {
padding: 14px 16px;
border-radius: 14px;
background: var(--color-elevated);
color: var(--color-text);
box-shadow: var(--shadow-sm);
}
.bank-card + .bank-card {
margin-top: 12px;
}
.cash-mobile-page .bank-card + .bank-card {
margin-top: 12px;
}
.cash-desktop-page .bank-card + .bank-card {
margin-top: 0;
}
.bank-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.bank-card__icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: var(--color-primary-light);
color: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
font-weight: 700;
flex-shrink: 0;
}
.bank-card__name {
flex: 1;
font-size: 0.875rem;
color: var(--color-text-muted);
text-align: right;
}
.bank-card__field {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.bank-card__field + .bank-card__field {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--color-border);
}
.bank-card__label {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.bank-card__value {
font-size: 0.9375rem;
font-weight: 700;
color: var(--color-text);
letter-spacing: 0.02em;
}
.cash-page__footer {
flex-shrink: 0;
padding: 12px 0 calc(12px + env(safe-area-inset-bottom, 0));
background: var(--color-bg);
border-top: 1px solid var(--color-border);
margin: 0 calc(-1 * var(--page-padding));
padding-left: var(--page-padding);
padding-right: var(--page-padding);
}
.cash-page__submit {
margin-top: 10px;
}
</style>
+470
View File
@@ -0,0 +1,470 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useOrderStore } from '@/stores/order'
import PageHeader from '@/components/layout/PageHeader.vue'
import BaseInput from '@/components/ui/BaseInput.vue'
import CurrencyInput from '@/components/ui/CurrencyInput.vue'
import PersianDateInput from '@/components/ui/PersianDateInput.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import IconCash from '@/components/icons/IconCash.vue'
import IconCheckBook from '@/components/icons/IconCheckBook.vue'
import { formatCurrency, formatNumber, parseFormattedNumber } from '@/utils/format'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
import {
validateNationalId,
validateSayadId,
validateImageFile,
fileToBase64,
} from '@/utils/validation'
import { useRequirePricedOrder } from '@/composables/useRequirePricedOrder'
const router = useRouter()
const orderStore = useOrderStore()
const { orderId, order } = useRequirePricedOrder()
const isFull = computed(() => orderStore.draftCheckMode === 'full')
const amount = ref('')
const dueDate = ref('')
const sayadId = ref('')
const checkNumber = ref('')
const ownerNationalId = ref('')
const ownerFirstName = ref('')
const ownerLastName = ref('')
const bankName = ref('')
const branchName = ref('')
const frontImage = ref('')
const backImage = ref('')
const notes = ref('')
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
const suggestedAmountApplied = ref(false)
const invoiceAmount = computed(() => order.value?.finalAmount ?? 0)
const previousChecksTotal = computed(() =>
orderStore.draftChecks.reduce((sum, check) => sum + check.amount, 0),
)
const currentCheckAmount = computed(() => parseFormattedNumber(amount.value) || 0)
const remainingBeforeCurrent = computed(() =>
Math.max(0, invoiceAmount.value - previousChecksTotal.value),
)
const remainingAfterCurrent = computed(() =>
Math.max(0, invoiceAmount.value - previousChecksTotal.value - currentCheckAmount.value),
)
const isCovered = computed(
() =>
invoiceAmount.value > 0 &&
previousChecksTotal.value + currentCheckAmount.value >= invoiceAmount.value,
)
const isOverInvoice = computed(
() =>
invoiceAmount.value > 0 &&
previousChecksTotal.value + currentCheckAmount.value > invoiceAmount.value,
)
watch(
[invoiceAmount, remainingBeforeCurrent],
() => {
if (suggestedAmountApplied.value || amount.value) return
if (remainingBeforeCurrent.value <= 0) return
amount.value = String(remainingBeforeCurrent.value)
suggestedAmountApplied.value = true
},
{ immediate: true },
)
watch(amount, () => {
fieldErrors.value = clearFieldError(fieldErrors.value, 'amount')
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
})
watch(dueDate, () => {
fieldErrors.value = clearFieldError(fieldErrors.value, 'dueDate')
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
})
watch(sayadId, () => {
fieldErrors.value = clearFieldError(fieldErrors.value, 'sayadId')
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
})
watch(ownerNationalId, () => {
fieldErrors.value = clearFieldError(fieldErrors.value, 'ownerNationalId')
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
})
watch(frontImage, () => {
fieldErrors.value = clearFieldError(fieldErrors.value, 'frontImage')
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
})
async function handleImageUpload(event: Event, target: 'front' | 'back') {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
const validation = validateImageFile(file)
if (!validation.valid) {
if (target === 'front') {
fieldErrors.value = { ...fieldErrors.value, frontImage: validation.message ?? 'خطا' }
}
return
}
const base64 = await fileToBase64(file)
if (target === 'front') frontImage.value = base64
else backImage.value = base64
}
function fillRemainingAmount() {
if (remainingBeforeCurrent.value <= 0) return
amount.value = String(remainingBeforeCurrent.value)
}
function validateForm(): boolean {
fieldErrors.value = {}
formError.value = ''
const errors: FieldErrors = {}
const amountNum = parseFormattedNumber(amount.value)
if (!amountNum || amountNum <= 0) {
errors.amount = 'مبلغ چک باید بزرگ‌تر از صفر باشد.'
}
if (!dueDate.value) {
errors.dueDate = 'تاریخ سررسید الزامی است.'
}
if (!frontImage.value) {
errors.frontImage = 'تصویر روی چک الزامی است.'
}
if (isFull.value) {
if (!validateSayadId(sayadId.value)) {
errors.sayadId = 'شناسه صیادی باید ۱۶ رقم باشد.'
}
if (!validateNationalId(ownerNationalId.value)) {
errors.ownerNationalId = 'کد ملی صاحب چک معتبر نیست.'
}
const existingSayad = orderStore.draftChecks.some(
(c) => c.sayadId === sayadId.value.replace(/\s/g, ''),
)
if (existingSayad) {
errors.sayadId = 'شناسه صیادی تکراری است.'
}
}
fieldErrors.value = errors
if (hasFieldErrors(errors)) {
formError.value = FORM_VALIDATION_SUMMARY
return false
}
return true
}
function saveCheck() {
if (!validateForm()) return
orderStore.addDraftCheck({
amount: parseFormattedNumber(amount.value),
dueDate: dueDate.value,
sayadId: isFull.value ? sayadId.value.replace(/\s/g, '') : undefined,
checkNumber: isFull.value ? checkNumber.value : undefined,
ownerNationalId: isFull.value ? ownerNationalId.value : undefined,
ownerFirstName: isFull.value ? ownerFirstName.value : undefined,
ownerLastName: isFull.value ? ownerLastName.value : undefined,
bankName: isFull.value ? bankName.value : undefined,
branchName: isFull.value ? branchName.value : undefined,
frontImage: frontImage.value,
backImage: backImage.value || undefined,
notes: notes.value || undefined,
mode: orderStore.draftCheckMode ?? 'brief',
})
resetForm()
router.push(`/orders/${orderId.value}/check-list`)
}
function resetForm() {
amount.value = ''
dueDate.value = ''
sayadId.value = ''
checkNumber.value = ''
ownerNationalId.value = ''
ownerFirstName.value = ''
ownerLastName.value = ''
bankName.value = ''
branchName.value = ''
frontImage.value = ''
backImage.value = ''
notes.value = ''
fieldErrors.value = {}
formError.value = ''
suggestedAmountApplied.value = false
}
</script>
<template>
<div class="page">
<PageHeader
:title="isFull ? 'ثبت چک - حالت کامل' : 'ثبت چک - حالت مختصر'"
show-back
@back="router.push(`/orders/${orderId}/check-mode`)"
/>
<div class="page__scroll checkout-page__scroll">
<FormErrorBanner :message="formError" />
<div
v-if="invoiceAmount > 0"
class="check-balance mb-4 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-4"
:class="{
'check-balance--ok': isCovered && !isOverInvoice,
'check-balance--warn': isOverInvoice,
}"
>
<div class="mb-3 flex items-start gap-3">
<span
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--color-primary-soft)] text-[var(--color-primary)]"
aria-hidden="true"
>
<IconCash :size="22" />
</span>
<div class="min-w-0 flex-1">
<p class="m-0 text-sm font-semibold text-[var(--color-text)]">مانده فاکتور</p>
<p class="m-0 mt-1 text-xs leading-relaxed text-[var(--color-text-muted)]">
با هر چک، مبلغ باقی‌مانده به‌روز می‌شود تا پوشش کامل فاکتور را از دست ندهید.
</p>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-3 text-sm">
<span class="text-[var(--color-text-muted)]">مبلغ فاکتور</span>
<strong class="font-tabular text-[var(--color-text)]">{{ formatCurrency(invoiceAmount) }}</strong>
</div>
<div class="flex items-center justify-between gap-3 text-sm">
<span class="inline-flex items-center gap-1.5 text-[var(--color-text-muted)]">
<IconCheckBook :size="14" />
چک‌های ثبت‌شده
<span v-if="orderStore.draftChecks.length" class="text-xs">
({{ formatNumber(orderStore.draftChecks.length) }})
</span>
</span>
<strong class="font-tabular text-[var(--color-text)]">
{{ formatCurrency(previousChecksTotal) }}
</strong>
</div>
<div
class="mt-1 flex items-center justify-between gap-3 border-t border-[var(--color-border)] pt-3"
>
<span class="text-sm font-semibold text-[var(--color-text)]">باقی‌مانده</span>
<strong
class="font-tabular text-base"
:class="
isCovered && !isOverInvoice
? 'text-[var(--color-success)]'
: isOverInvoice
? 'text-[var(--color-danger)]'
: 'text-[var(--color-primary)]'
"
>
{{
isOverInvoice
? `${formatCurrency(previousChecksTotal + currentCheckAmount - invoiceAmount)} بیش از فاکتور`
: isCovered
? 'پوشش کامل شد'
: formatCurrency(remainingAfterCurrent)
}}
</strong>
</div>
</div>
</div>
<div class="steps">
<span class="step active">۱. اطلاعات چک</span>
<span class="step">۲. چک بعدی</span>
<span class="step">۳. خلاصه</span>
</div>
<div class="card">
<CurrencyInput
v-model="amount"
label="مبلغ چک (تومان)"
placeholder="مبلغ"
:error="fieldErrors.amount"
/>
<button
v-if="remainingBeforeCurrent > 0 && currentCheckAmount !== remainingBeforeCurrent"
type="button"
class="check-fill-remaining mb-3 text-sm font-medium text-[var(--color-primary)]"
@click="fillRemainingAmount"
>
پر کردن با باقی‌مانده ({{ formatCurrency(remainingBeforeCurrent) }})
</button>
<PersianDateInput v-model="dueDate" label="تاریخ سررسید" :error="fieldErrors.dueDate" />
<template v-if="isFull">
<BaseInput
v-model="sayadId"
label="شناسه صیادی"
placeholder="۱۶ رقم"
maxlength="16"
inputmode="numeric"
dir="ltr"
:error="fieldErrors.sayadId"
/>
<BaseInput
v-model="checkNumber"
label="شماره چک"
inputmode="numeric"
dir="ltr"
/>
<BaseInput
v-model="ownerNationalId"
label="کد ملی صاحب چک"
maxlength="10"
inputmode="numeric"
dir="ltr"
:error="fieldErrors.ownerNationalId"
/>
<BaseInput v-model="ownerFirstName" label="نام صاحب چک" />
<BaseInput v-model="ownerLastName" label="نام خانوادگی صاحب چک" />
<BaseInput v-model="bankName" label="نام بانک" />
<BaseInput v-model="branchName" label="نام شعبه" />
</template>
<div class="form-group">
<label class="form-label">تصویر روی چک *</label>
<label class="upload-box" :class="{ 'upload-box--error': fieldErrors.frontImage }">
<input
type="file"
accept="image/*"
class="upload-box__input"
@change="handleImageUpload($event, 'front')"
/>
<img v-if="frontImage" :src="frontImage" class="upload-box__preview" alt="روی چک" />
<template v-else>
<span class="upload-box__placeholder">بارگذاری تصویر چک</span>
</template>
</label>
<p v-if="fieldErrors.frontImage" class="input-error-text">{{ fieldErrors.frontImage }}</p>
</div>
<div v-if="isFull" class="form-group">
<label class="form-label">تصویر پشت چک</label>
<label class="upload-box">
<input
type="file"
accept="image/*"
class="upload-box__input"
@change="handleImageUpload($event, 'back')"
/>
<img v-if="backImage" :src="backImage" class="upload-box__preview" alt="پشت چک" />
<template v-else>
<span class="upload-box__placeholder">بارگذاری تصویر</span>
</template>
</label>
</div>
<BaseInput v-model="notes" label="توضیحات" />
<BaseButton block class="mt-4" @click="saveCheck">ذخیره چک</BaseButton>
</div>
</div>
</div>
</template>
<style scoped>
.checkout-page__scroll {
padding-bottom: 24px;
}
.check-balance--ok {
border-color: color-mix(in srgb, var(--color-success) 35%, var(--color-border));
background: var(--color-success-soft);
}
.check-balance--warn {
border-color: color-mix(in srgb, var(--color-danger) 35%, var(--color-border));
background: var(--color-danger-light);
}
.check-fill-remaining {
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
text-align: start;
}
.check-fill-remaining:hover {
text-decoration: underline;
}
.font-tabular {
font-variant-numeric: tabular-nums;
}
.steps {
display: flex;
gap: 8px;
margin-bottom: 16px;
font-size: 0.6875rem;
}
.step {
flex: 1;
text-align: center;
padding: 10px 6px;
background: var(--color-tab-inactive-bg);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
font-weight: 500;
}
.step.active {
background: var(--color-primary-light);
color: var(--color-primary);
font-weight: 600;
}
.upload-box {
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
border: 1.5px dashed var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
cursor: pointer;
overflow: hidden;
position: relative;
}
.upload-box--error {
border-color: var(--color-danger);
}
.upload-box__input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.upload-box__placeholder {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.upload-box__preview {
max-width: 100%;
max-height: 140px;
object-fit: contain;
}
.input-error-text {
color: var(--color-danger);
font-size: 0.8125rem;
margin: 4px 0 0;
}
</style>
+714
View File
@@ -0,0 +1,714 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useOrderStore } from '@/stores/order'
import { useAuthStore } from '@/stores/auth'
import BaseButton from '@/components/ui/BaseButton.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import { formatCurrency, formatDate, formatNumber, toPersianDigits } from '@/utils/format'
import { calculateCheckRass } from '@/utils/checkRass'
import { confirm } from '@/composables/useConfirm'
import { useRequirePricedOrder } from '@/composables/useRequirePricedOrder'
import type { CheckData } from '@/types'
const route = useRoute()
const router = useRouter()
const orderStore = useOrderStore()
const authStore = useAuthStore()
const formError = ref('')
const submitting = ref(false)
const isCheckout = computed(() => route.name === 'check-list')
const { orderId } = useRequirePricedOrder()
const order = computed(() =>
orderId.value ? orderStore.getOrderById(orderId.value) : undefined,
)
watch(
orderId,
async (id) => {
if (!id || !isCheckout.value) return
const current = orderStore.getOrderById(id) ?? (await orderStore.fetchOrder(id))
if (!current) return
// در مسیر اصلاح، چک‌های ثبت‌شده به فرم برمی‌گردند تا مشتری ویرایش کند
if (orderStore.draftChecks.length === 0 && (current.checks?.length ?? 0) > 0) {
orderStore.loadChecksIntoDraft(current)
} else if (orderStore.draftOrderId !== id) {
orderStore.startCheckDraft(id, current.checkMode ?? undefined)
}
},
{ immediate: true },
)
const checks = computed<CheckData[]>(() => {
if (orderStore.draftChecks.length > 0) return orderStore.draftChecks
if (isCheckout.value) return order.value?.checks ?? []
if (!authStore.user) return []
return orderStore
.userOrders(authStore.user.id)
.filter((o) => o.settlementType === 'check' && o.checks?.length)
.flatMap((o) => o.checks ?? [])
})
const rass = computed(() => {
if (orderStore.draftChecks.length > 0) return orderStore.getDraftRass()
return calculateCheckRass(checks.value, new Date())
})
const invoiceAmount = computed(() => order.value?.finalAmount ?? 0)
const remainingAmount = computed(() =>
Math.max(0, invoiceAmount.value - rass.value.totalAmount),
)
const isAmountComplete = computed(
() => invoiceAmount.value > 0 && rass.value.totalAmount >= invoiceAmount.value,
)
const isOverInvoice = computed(
() => invoiceAmount.value > 0 && rass.value.totalAmount > invoiceAmount.value,
)
const openMenuId = computed({
get: () => (route.query.menu as string) ?? '',
set: (id: string) => {
router.replace({ query: id ? { menu: id } : {} })
},
})
function goBack() {
router.push(isCheckout.value ? `/orders/${orderId.value}` : '/profile')
}
function addAnother() {
router.push(`/orders/${orderId.value}/check-form`)
}
async function removeCheck(id: string) {
openMenuId.value = ''
const ok = await confirm({
title: 'حذف چک',
message: 'آیا از حذف این چک مطمئن هستید؟',
confirmLabel: 'حذف',
cancelLabel: 'انصراف',
tone: 'danger',
})
if (!ok) return
orderStore.removeDraftCheck(id)
}
function toggleMenu(id: string) {
openMenuId.value = openMenuId.value === id ? '' : id
}
async function submitChecks() {
if (!orderId.value || orderStore.draftChecks.length === 0 || submitting.value) return
formError.value = ''
submitting.value = true
try {
await orderStore.updateOrderChecks(
orderId.value,
[...orderStore.draftChecks],
orderStore.draftCheckMode ?? 'brief',
)
router.replace(`/orders/${orderId.value}?track=1`)
} catch (e) {
formError.value = e instanceof Error ? e.message : 'ثبت چک‌ها ناموفق بود.'
} finally {
submitting.value = false
}
}
</script>
<template>
<!-- Mobile -->
<div class="check-list-page mobile-only">
<header class="check-list-page__nav">
<button type="button" class="nav-btn" aria-label="بازگشت" @click="goBack">
<IconChevronBack :size="20" />
</button>
<h1 class="check-list-page__title">چک‌های ثبت شده</h1>
<button type="button" class="nav-btn nav-btn--ghost" aria-hidden="true" tabindex="-1">
<IconChevronBack :size="18" class="nav-btn__flip" />
</button>
</header>
<div v-if="checks.length === 0" class="check-list-page__empty page__scroll page__scroll--with-nav">
<EmptyState variant="checks" message="برای ادامه، حداقل یک چک ثبت کنید">
<BaseButton v-if="isCheckout" @click="addAnother">ثبت چک جدید</BaseButton>
<BaseButton v-else variant="outline" @click="router.push('/companies')">ثبت سفارش جدید</BaseButton>
</EmptyState>
</div>
<template v-else>
<div class="check-list-page__scroll page__scroll">
<FormErrorBanner :message="formError" />
<div class="summary-cards">
<div class="summary-card">
<span class="summary-card__label">مجموع مبلغ چک‌ها</span>
<strong class="summary-card__value">{{ formatNumber(rass.totalAmount) }}</strong>
<span class="summary-card__unit">تومان</span>
</div>
<div class="summary-card">
<span class="summary-card__label">مبلغ فاکتور</span>
<strong class="summary-card__value">{{ formatNumber(invoiceAmount) }}</strong>
<span class="summary-card__unit">تومان</span>
<p v-if="isAmountComplete && !isOverInvoice" class="summary-card__status">
<span class="summary-card__status-icon">✓</span>
مبلغ کامل است
</p>
<p v-else-if="isOverInvoice" class="summary-card__status summary-card__status--warn">
بیش از مبلغ فاکتور
</p>
</div>
<div
class="summary-card summary-card--remain"
:class="{
'summary-card--remain-ok': isAmountComplete && !isOverInvoice,
'summary-card--remain-warn': isOverInvoice,
}"
>
<span class="summary-card__label">باقی‌مانده</span>
<strong class="summary-card__value">
{{
isOverInvoice
? formatNumber(rass.totalAmount - invoiceAmount)
: formatNumber(remainingAmount)
}}
</strong>
<span class="summary-card__unit">تومان</span>
<p v-if="!isAmountComplete" class="summary-card__hint">
هنوز باید چک ثبت کنید
</p>
</div>
</div>
<div class="checks-list">
<article v-for="(check, index) in checks" :key="check.id" class="check-card">
<div class="check-card__menu-wrap">
<button
v-if="isCheckout && orderStore.draftChecks.length > 0"
type="button"
class="check-card__menu-btn"
aria-label="گزینه‌ها"
@click="toggleMenu(check.id)"
>
⋮
</button>
<div v-if="openMenuId === check.id" class="check-card__menu">
<button type="button" @click="removeCheck(check.id)">حذف</button>
</div>
</div>
<div class="check-card__body">
<h3 class="check-card__title">چک {{ toPersianDigits(String(index + 1)) }}</h3>
<p class="check-card__amount">{{ formatCurrency(check.amount) }}</p>
<p class="check-card__date">تاریخ: {{ formatDate(check.dueDate) }}</p>
</div>
<div class="check-card__thumb-wrap">
<img
v-if="check.frontImage"
:src="check.frontImage"
class="check-card__thumb"
alt="تصویر چک"
/>
<div v-else class="check-card__thumb check-card__thumb--empty">چک</div>
</div>
</article>
</div>
<div class="rass-panel">
<h3 class="rass-panel__title">رأس چک‌ها</h3>
<div class="rass-panel__days">{{ toPersianDigits(String(rass.rassDays)) }} روز</div>
<p v-if="rass.rassDate" class="rass-panel__date">
تاریخ رأس تقریبی: {{ formatDate(rass.rassDate) }}
</p>
</div>
</div>
<div v-if="isCheckout && orderStore.draftChecks.length > 0" class="check-list-page__footer">
<BaseButton block variant="outline" :disabled="submitting" @click="addAnother">
افزودن چک جدید
</BaseButton>
<BaseButton
block
:disabled="checks.length === 0"
:loading="submitting"
@click="submitChecks"
>
ارسال برای بررسی
</BaseButton>
</div>
</template>
</div>
<!-- Desktop -->
<div v-if="checks.length > 0" class="desktop-checkout-shell check-list-desktop desktop-only">
<header class="desktop-page__head">
<h1 class="desktop-page__title">چک‌های ثبت شده</h1>
<p class="desktop-page__subtitle">خلاصه و فهرست چک‌های ثبت‌شده</p>
</header>
<div class="desktop-two-col">
<div class="desktop-two-col__main">
<div class="desktop-table-wrap">
<div class="desktop-table-scroll">
<table class="desktop-table check-list-desktop__table">
<thead>
<tr>
<th scope="col">تصویر</th>
<th scope="col">مبلغ (تومان)</th>
<th scope="col">تاریخ سررسید</th>
<th scope="col">شناسه</th>
<th v-if="isCheckout" scope="col">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="(check, index) in checks" :key="check.id">
<td>
<img
v-if="check.frontImage"
:src="check.frontImage"
class="check-list-desktop__thumb"
alt="تصویر چک"
/>
<span v-else>—</span>
</td>
<td class="check-list-desktop__amount">{{ formatNumber(check.amount) }}</td>
<td>{{ formatDate(check.dueDate) }}</td>
<td>چک {{ toPersianDigits(String(index + 1)) }}</td>
<td v-if="isCheckout && orderStore.draftChecks.length > 0">
<button type="button" class="orders-table__action" @click="removeCheck(check.id)">
حذف
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<BaseButton
v-if="isCheckout"
variant="outline"
class="desktop-btn-compact check-list-desktop__add"
@click="addAnother"
>
ثبت چک دیگر
</BaseButton>
</div>
<aside class="desktop-summary-panel">
<h2 class="desktop-summary-panel__title">خلاصه مالی</h2>
<div class="desktop-summary-panel__row">
<span>مجموع چک‌ها</span>
<strong>{{ formatNumber(rass.totalAmount) }} تومان</strong>
</div>
<div class="desktop-summary-panel__row">
<span>مبلغ فاکتور</span>
<strong>{{ formatNumber(invoiceAmount) }} تومان</strong>
</div>
<div
class="desktop-summary-panel__row desktop-summary-panel__row--remain"
:class="{
'desktop-summary-panel__row--ok': isAmountComplete && !isOverInvoice,
'desktop-summary-panel__row--warn': isOverInvoice,
}"
>
<span>باقی‌مانده</span>
<strong>
{{
isOverInvoice
? `${formatNumber(rass.totalAmount - invoiceAmount)} بیش از فاکتور`
: isAmountComplete
? 'پوشش کامل'
: `${formatNumber(remainingAmount)} تومان`
}}
</strong>
</div>
<div class="desktop-summary-panel__row">
<span>رأس چک‌ها</span>
<strong>{{ toPersianDigits(String(rass.rassDays)) }} روز</strong>
</div>
<div v-if="isCheckout && orderStore.draftChecks.length > 0" class="desktop-summary-panel__action">
<BaseButton block variant="outline" :disabled="submitting" @click="addAnother">
افزودن چک جدید
</BaseButton>
<BaseButton
block
:disabled="checks.length === 0"
:loading="submitting"
@click="submitChecks"
>
ارسال برای بررسی
</BaseButton>
</div>
</aside>
</div>
</div>
<div v-else class="desktop-checkout-shell desktop-only">
<EmptyState variant="checks" message="برای ادامه، حداقل یک چک ثبت کنید">
<BaseButton v-if="isCheckout" @click="addAnother">ثبت چک جدید</BaseButton>
</EmptyState>
</div>
</template>
<style scoped>
.check-list-desktop__thumb {
width: 48px;
height: 32px;
object-fit: cover;
border-radius: 4px;
display: block;
}
.check-list-desktop__amount {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.check-list-desktop__table {
min-width: 560px;
}
.check-list-desktop__add {
margin-top: 16px;
}
@media (min-width: 768px) and (max-width: 1099px) {
.check-list-desktop :deep(.desktop-two-col) {
grid-template-columns: 1fr;
}
.check-list-desktop :deep(.desktop-summary-panel) {
position: static;
}
}
.desktop-summary-panel__action {
display: flex;
flex-direction: column;
gap: 10px;
}
.desktop-summary-panel__row--remain strong {
color: var(--color-primary);
}
.desktop-summary-panel__row--ok strong {
color: var(--color-success);
}
.desktop-summary-panel__row--warn strong {
color: var(--color-danger);
}
.check-list-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
padding: 12px var(--page-padding) 0;
background: var(--color-bg);
overflow: hidden;
}
.check-list-page__nav {
flex-shrink: 0;
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
margin-bottom: 16px;
}
.check-list-page__title {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
text-align: center;
}
.nav-btn {
width: 40px;
height: 40px;
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.nav-btn--ghost {
visibility: hidden;
pointer-events: none;
}
.nav-btn__flip {
transform: scaleX(-1);
}
.check-list-page__scroll {
flex: 1;
min-height: 0;
padding-bottom: 12px;
}
.check-list-page__empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
color: var(--color-text-muted);
}
.summary-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 16px;
}
.summary-card {
padding: 14px 12px;
border-radius: 14px;
background: var(--color-summary-bg);
border: 1px solid var(--color-border);
text-align: center;
color: var(--color-text);
}
.summary-card--remain {
grid-column: 1 / -1;
border-color: color-mix(in srgb, var(--color-primary) 30%, var(--color-border));
background: var(--color-primary-soft);
}
.summary-card--remain-ok {
border-color: color-mix(in srgb, var(--color-success) 35%, var(--color-border));
background: var(--color-success-soft);
}
.summary-card--remain-warn {
border-color: color-mix(in srgb, var(--color-danger) 35%, var(--color-border));
background: var(--color-danger-light);
}
.summary-card__label {
display: block;
font-size: 0.75rem;
color: var(--color-text-muted);
margin-bottom: 8px;
}
.summary-card__value {
display: block;
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
line-height: 1.2;
}
.summary-card__unit {
display: block;
margin-top: 2px;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.summary-card__status {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin: 8px 0 0;
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-success);
}
.summary-card__status--warn {
color: var(--color-danger);
}
.summary-card__hint {
margin: 8px 0 0;
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-primary);
}
.summary-card__status-icon {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-success-light);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.625rem;
}
.checks-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
.check-card {
display: grid;
grid-template-columns: 28px 1fr 72px;
gap: 10px;
align-items: center;
padding: 12px;
border-radius: 14px;
background: var(--color-elevated);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.check-card__menu-wrap {
position: relative;
}
.check-card__menu-btn {
width: 28px;
height: 28px;
border: none;
background: none;
color: var(--color-text-muted);
font-size: 1.125rem;
line-height: 1;
cursor: pointer;
padding: 0;
}
.check-card__menu {
position: absolute;
top: 100%;
right: 0;
z-index: 10;
min-width: 88px;
padding: 4px;
border-radius: var(--radius-sm);
background: var(--color-elevated);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-md);
}
.check-card__menu button {
width: 100%;
border: none;
background: none;
padding: 8px 10px;
text-align: right;
font-size: 0.8125rem;
color: var(--color-danger);
cursor: pointer;
border-radius: 6px;
}
.check-card__menu button:hover {
background: var(--color-danger-light);
}
.check-card__body {
min-width: 0;
}
.check-card__title {
margin: 0 0 4px;
font-size: 0.875rem;
font-weight: 700;
color: var(--color-text);
}
.check-card__amount {
margin: 0 0 4px;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text);
}
.check-card__date {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.check-card__thumb-wrap {
flex-shrink: 0;
}
.check-card__thumb {
width: 72px;
height: 48px;
border-radius: 8px;
object-fit: cover;
border: 1px solid var(--color-border);
background: var(--color-surface);
}
.check-card__thumb--empty {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6875rem;
color: var(--color-text-muted);
}
.rass-panel {
padding: 16px;
border-radius: 14px;
background: var(--color-success-light);
border: 1px solid var(--color-success);
margin-bottom: 12px;
text-align: center;
}
.rass-panel__title {
margin: 0 0 6px;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-success);
}
.rass-panel__days {
font-size: 1.75rem;
font-weight: 700;
color: var(--color-success);
line-height: 1.2;
}
.rass-panel__date {
margin: 6px 0 0;
font-size: 0.8125rem;
color: var(--color-success);
}
.check-list-page__footer {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 0 calc(12px + env(safe-area-inset-bottom, 0));
background: var(--color-bg);
border-top: 1px solid var(--color-border);
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
margin: 0 calc(-1 * var(--page-padding));
padding-left: var(--page-padding);
padding-right: var(--page-padding);
}
</style>
+163
View File
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useOrderStore } from '@/stores/order'
import PageHeader from '@/components/layout/PageHeader.vue'
import IconCheckBook from '@/components/icons/IconCheckBook.vue'
import IconEditPen from '@/components/icons/IconEditPen.vue'
import { useRequirePricedOrder } from '@/composables/useRequirePricedOrder'
import { formatCurrency } from '@/utils/format'
const router = useRouter()
const orderStore = useOrderStore()
const { orderId, order } = useRequirePricedOrder()
const invoiceLabel = computed(() =>
order.value?.finalAmount ? formatCurrency(order.value.finalAmount) : '—',
)
watch(
orderId,
async (id) => {
if (!id) return
if (!orderStore.getOrderById(id)) await orderStore.fetchOrder(id)
if (orderStore.draftOrderId !== id) orderStore.startCheckDraft(id)
},
{ immediate: true },
)
function selectMode(mode: 'full' | 'brief') {
orderStore.setCheckMode(mode)
router.push(`/orders/${orderId.value}/check-form`)
}
</script>
<template>
<div class="page check-mode-page">
<PageHeader
title="تسویه چکی"
subtitle="بر اساس مبلغ فاکتور، نحوه ثبت چک را انتخاب کنید"
show-back
centered
@back="router.push(`/orders/${orderId}`)"
/>
<div class="page__scroll check-mode-page__scroll">
<div class="check-mode-invoice">
<span class="check-mode-invoice__label">مبلغ فاکتور</span>
<strong class="check-mode-invoice__value">{{ invoiceLabel }}</strong>
</div>
<div class="mode-options">
<button type="button" class="mode-card" @click="selectMode('full')">
<span class="mode-card__icon">
<IconCheckBook :size="36" />
</span>
<h3 class="mode-card__title">ثبت کامل (پیشنهادی)</h3>
<p class="mode-card__desc">وارد کردن همه اطلاعات چک</p>
</button>
<button type="button" class="mode-card" @click="selectMode('brief')">
<span class="mode-card__icon">
<IconEditPen :size="36" />
</span>
<h3 class="mode-card__title">ثبت مختصر</h3>
<p class="mode-card__desc">وارد کردن حداقل اطلاعات</p>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.check-mode-page {
background: var(--color-bg);
}
.check-mode-page__scroll {
padding-bottom: 24px;
}
.check-mode-invoice {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 16px;
margin-bottom: 8px;
border-radius: 16px;
background: var(--color-primary-light);
border: 1px solid var(--color-primary);
}
.check-mode-invoice__label {
font-size: 0.8125rem;
color: var(--color-primary);
}
.check-mode-invoice__value {
font-size: 1.125rem;
font-weight: 700;
color: var(--color-primary);
}
.mode-options {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 8px;
}
.mode-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
width: 100%;
padding: 32px 24px;
border: none;
border-radius: 16px;
background: var(--color-elevated);
color: var(--color-text);
box-shadow: var(--shadow-sm);
cursor: pointer;
transition:
box-shadow 0.15s,
transform 0.15s;
}
.mode-card:hover {
box-shadow: var(--shadow-md);
}
.mode-card:active {
transform: scale(0.99);
}
.mode-card__icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--color-primary-light);
color: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.mode-card__title {
margin: 0 0 8px;
font-size: 1.0625rem;
font-weight: 700;
color: var(--color-primary);
}
.mode-card__desc {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
line-height: 1.5;
}
</style>
+305
View File
@@ -0,0 +1,305 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useOrderStore } from '@/stores/order'
import PageHeader from '@/components/layout/PageHeader.vue'
import CheckoutStepper from '@/components/layout/CheckoutStepper.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import IconCashSettlement from '@/components/icons/IconCashSettlement.vue'
import IconCheckBook from '@/components/icons/IconCheckBook.vue'
import { formatCurrency } from '@/utils/format'
import { isInvoicePriced } from '@/utils/orderInvoice'
import { useRequirePricedOrder } from '@/composables/useRequirePricedOrder'
const router = useRouter()
const orderStore = useOrderStore()
const { orderId, order, invoiceReady } = useRequirePricedOrder()
const submitting = ref(false)
const formError = ref('')
watch(
orderId,
async (id) => {
if (id && !orderStore.getOrderById(id)) await orderStore.fetchOrder(id)
},
{ immediate: true },
)
const invoiceLabel = computed(() =>
order.value?.finalAmount ? formatCurrency(order.value.finalAmount) : '—',
)
async function selectType(type: 'cash' | 'check') {
if (submitting.value || !isInvoicePriced(order.value)) {
formError.value = 'تا قبل از قیمت‌گذاری مدیریت، امکان انتخاب روش تسویه وجود ندارد.'
return
}
submitting.value = true
formError.value = ''
try {
await orderStore.chooseSettlement(orderId.value, type)
if (type === 'cash') {
router.replace(`/orders/${orderId.value}/cash`)
} else {
orderStore.startCheckDraft(orderId.value)
router.replace(`/orders/${orderId.value}/check-mode`)
}
} catch (e) {
formError.value = e instanceof Error ? e.message : 'ثبت روش تسویه ناموفق بود.'
} finally {
submitting.value = false
}
}
</script>
<template>
<!-- Mobile -->
<div class="page settlement-mobile-page mobile-only">
<PageHeader
title="انتخاب نوع تسویه"
subtitle="پس از صدور فاکتور، نوع تسویه را انتخاب کنید"
show-back
centered
@back="router.push(`/orders/${orderId}`)"
/>
<div class="page__scroll">
<FormErrorBanner :message="formError" />
<div v-if="!invoiceReady" class="settlement-waiting">
<h3 class="settlement-waiting__title">در انتظار قیمت‌گذاری</h3>
<p class="settlement-waiting__text">
پیش‌سفارش ثبت شده است. پس از تعیین قیمت توسط مدیریت و صدور فاکتور، از این صفحه روش
تسویه (نقد یا چک) را انتخاب کنید.
</p>
<BaseButton block variant="outline" @click="router.push(`/orders/${orderId}`)">
بازگشت به جزئیات سفارش
</BaseButton>
</div>
<template v-else>
<div class="settlement-invoice">
<span class="settlement-invoice__label">مبلغ فاکتور</span>
<strong class="settlement-invoice__value">{{ invoiceLabel }}</strong>
<span v-if="order" class="settlement-invoice__meta">سفارش {{ order.orderNumber }}</span>
</div>
<div class="settlement-mobile-options">
<button
type="button"
class="settlement-card"
:disabled="submitting"
@click="selectType('cash')"
>
<span class="settlement-card__icon settlement-card__icon--cash">
<IconCashSettlement :size="36" />
</span>
<h3>تسویه نقدی</h3>
<p>پرداخت از طریق واریز به حساب شرکت</p>
</button>
<button
type="button"
class="settlement-card"
:disabled="submitting"
@click="selectType('check')"
>
<span class="settlement-card__icon settlement-card__icon--check">
<IconCheckBook :size="36" />
</span>
<h3>تسویه چکی</h3>
<p>پرداخت بر اساس مبلغ فاکتور با ثبت چک</p>
</button>
</div>
</template>
</div>
</div>
<!-- Desktop -->
<div class="desktop-checkout-shell desktop-only">
<CheckoutStepper :current-step="4" />
<header class="desktop-page__head">
<h1 class="desktop-page__title">انتخاب نوع تسویه</h1>
<p class="desktop-page__subtitle">
<template v-if="invoiceReady">
مبلغ فاکتور {{ invoiceLabel }}{{ order ? ` · سفارش ${order.orderNumber}` : '' }}
</template>
<template v-else>پس از قیمت‌گذاری مدیریت فعال می‌شود</template>
</p>
</header>
<FormErrorBanner :message="formError" />
<div v-if="!invoiceReady" class="settlement-waiting settlement-waiting--desktop">
<h3 class="settlement-waiting__title">در انتظار قیمت‌گذاری</h3>
<p class="settlement-waiting__text">
پیش‌سفارش ثبت شده است. پس از صدور فاکتور می‌توانید روش تسویه را انتخاب کنید.
</p>
<BaseButton variant="outline" @click="router.push(`/orders/${orderId}`)">
بازگشت به جزئیات سفارش
</BaseButton>
</div>
<div v-else class="desktop-settlement-grid">
<button
type="button"
class="settlement-card settlement-card--desktop"
:disabled="submitting"
@click="selectType('cash')"
>
<span class="settlement-card__icon settlement-card__icon--cash">
<IconCashSettlement :size="40" />
</span>
<h3>تسویه نقدی</h3>
<p>پرداخت از طریق واریز به حساب شرکت</p>
</button>
<button
type="button"
class="settlement-card settlement-card--desktop"
:disabled="submitting"
@click="selectType('check')"
>
<span class="settlement-card__icon settlement-card__icon--check">
<IconCheckBook :size="40" />
</span>
<h3>تسویه چکی</h3>
<p>پرداخت بر اساس مبلغ فاکتور با ثبت چک</p>
</button>
</div>
</div>
</template>
<style scoped>
.settlement-mobile-page {
background: var(--color-bg);
padding: 12px 0 0;
}
.settlement-waiting {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 28px 20px;
text-align: center;
border-radius: 16px;
background: var(--color-elevated);
border: 1px solid var(--color-border);
}
.settlement-waiting--desktop {
max-width: 480px;
margin-inline: auto;
}
.settlement-waiting__title {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
color: var(--color-text);
}
.settlement-waiting__text {
margin: 0;
font-size: 0.875rem;
line-height: 1.7;
color: var(--color-text-muted);
}
.settlement-mobile-options {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 8px;
}
.settlement-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
width: 100%;
padding: 32px 24px;
border: none;
border-radius: 16px;
background: var(--color-elevated);
box-shadow: var(--shadow-sm);
cursor: pointer;
color: var(--color-text);
}
.settlement-card:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.settlement-invoice {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 18px;
margin-bottom: 8px;
border-radius: 16px;
background: var(--color-primary-light);
border: 1px solid var(--color-primary);
}
.settlement-invoice__label {
font-size: 0.8125rem;
color: var(--color-primary);
}
.settlement-invoice__value {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-primary);
}
.settlement-invoice__meta {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.settlement-card--desktop {
min-height: 220px;
border: 1px solid var(--color-border);
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.settlement-card--desktop:hover:not(:disabled) {
border-color: var(--color-primary);
box-shadow: var(--shadow-md);
}
.settlement-card__icon {
width: 64px;
height: 64px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.settlement-card__icon--cash {
background: var(--color-success-light);
}
.settlement-card__icon--check {
background: var(--color-primary-light);
}
.settlement-card h3 {
margin: 0 0 8px;
font-size: 1.0625rem;
}
.settlement-card p {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
}
</style>
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useCatalogStore } from '@/stores/catalog'
import { useCartStore } from '@/stores/cart'
import IconParLogo from '@/components/icons/IconParLogo.vue'
import IconFamBaftLogo from '@/components/icons/IconFamBaftLogo.vue'
import MobileCompanySelect from '@/views/companies/components/MobileCompanySelect.vue'
import DesktopHomeDashboard from '@/views/companies/components/DesktopHomeDashboard.vue'
import type { Company } from '@/types'
import { useGoBack } from '@/composables/useGoBack'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
const router = useRouter()
const { goBack } = useGoBack('/')
const catalogStore = useCatalogStore()
const cartStore = useCartStore()
const loading = ref(true)
const companies = computed(() => catalogStore.getActiveCompanies())
const displayMeta: Record<string, { description: string; logo: typeof IconParLogo }> = {
par: { description: 'تولید و عرضه انواع پارچه', logo: IconParLogo },
'fam-baft': { description: 'تولید و عرضه انواع پارچه', logo: IconFamBaftLogo },
}
onMounted(async () => {
await catalogStore.loadCompanies()
loading.value = false
})
function selectCompany(companyId: string) {
const company = catalogStore.getCompany(companyId)
if (!company) return
cartStore.selectCompany(company)
router.push(`/companies/${companyId}/catalogs`)
}
function cardVariant(slug: string) {
return slug === 'par' ? 'par' : 'fam-baft'
}
function companyDescription(company: Company) {
return displayMeta[company.slug]?.description ?? company.description
}
function companyLogo(slug: string) {
return displayMeta[slug]?.logo ?? IconParLogo
}
</script>
<template>
<div v-if="loading" class="page page__scroll">
<PageSkeleton variant="cards" />
</div>
<template v-else>
<MobileCompanySelect
class="mobile-only"
:companies="companies"
:card-variant="cardVariant"
:company-description="companyDescription"
:company-logo="companyLogo"
@select="selectCompany"
@back="goBack"
/>
<DesktopHomeDashboard
class="desktop-only"
:companies="companies"
:card-variant="cardVariant"
:company-description="companyDescription"
:company-logo="companyLogo"
@select="selectCompany"
/>
</template>
</template>
@@ -0,0 +1,102 @@
<script setup lang="ts">
import type { Company } from '@/types'
import type { Component } from 'vue'
defineProps<{
companies: Company[]
cardVariant: (slug: string) => string
companyDescription: (company: Company) => string
companyLogo: (slug: string) => Component
}>()
defineEmits<{
select: [companyId: string]
}>()
</script>
<template>
<div class="desktop-page company-desktop-page">
<header class="desktop-page__head">
<h1 class="desktop-page__title">انتخاب شرکت</h1>
<p class="desktop-page__subtitle">شرکت مورد نظر خود را انتخاب کنید</p>
</header>
<div class="desktop-company-grid">
<button
v-for="company in companies"
:key="company.id"
type="button"
class="company-brand-card"
:class="`company-brand-card--${cardVariant(company.slug)}`"
@click="$emit('select', company.id)"
>
<span class="company-brand-card__logo">
<component :is="companyLogo(company.slug)" :size="80" />
</span>
<h2 class="company-brand-card__name">{{ company.name }}</h2>
<p class="company-brand-card__desc">{{ companyDescription(company) }}</p>
</button>
</div>
</div>
</template>
<style scoped>
.company-desktop-page {
justify-content: flex-start;
}
.company-brand-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 240px;
padding: 36px 28px;
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
text-align: center;
transition: transform 0.15s, box-shadow 0.15s;
}
.company-brand-card:hover {
transform: translateY(-2px);
}
.company-brand-card--par {
background: var(--color-primary);
color: #fff;
box-shadow: var(--shadow-md);
}
.company-brand-card--fam-baft {
background: #f3ece3;
color: #1a202c;
box-shadow: var(--shadow-sm);
}
.company-brand-card__logo {
margin-bottom: 18px;
}
.company-brand-card__name {
margin: 0 0 8px;
font-size: 1.125rem;
font-weight: 700;
}
.company-brand-card__desc {
margin: 0;
font-size: 0.875rem;
max-width: 280px;
line-height: 1.6;
}
.company-brand-card--par .company-brand-card__desc {
color: rgba(255, 255, 255, 0.85);
}
.company-brand-card--fam-baft .company-brand-card__desc {
color: #718096;
}
</style>
@@ -0,0 +1,587 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconHome from '@/components/icons/IconHome.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
import IconCart from '@/components/icons/IconCart.vue'
import IconOrders from '@/components/icons/IconOrders.vue'
import IconShoppingBag from '@/components/icons/IconShoppingBag.vue'
import IconBuilding from '@/components/icons/IconBuilding.vue'
import IconLayoutGrid from '@/components/icons/IconLayoutGrid.vue'
import type { Company } from '@/types'
import type { Component } from 'vue'
const props = defineProps<{
companies: Company[]
cardVariant: (slug: string) => string
companyDescription: (company: Company) => string
companyLogo: (slug: string) => Component
}>()
const emit = defineEmits<{ select: [companyId: string] }>()
const router = useRouter()
const displayCompanies = computed(() =>
[...props.companies].sort((a, b) => {
if (a.slug === 'fam-baft') return -1
if (b.slug === 'fam-baft') return 1
return 0
}),
)
const quickLinks = [
{
label: 'انتخاب شرکت',
icon: IconHome,
path: '/',
tone: 'orange',
},
{
label: 'سفارش‌گذاری',
icon: IconCatalog,
path: '/',
tone: 'blue',
},
{
label: 'سبد خرید',
icon: IconCart,
path: '/cart',
tone: 'green',
},
{
label: 'سفارش‌های من',
icon: IconOrders,
path: '/orders',
tone: 'purple',
},
]
const orderSteps = [
{ id: 1, label: 'انتخاب شرکت', icon: IconBuilding, active: true },
{ id: 2, label: 'انتخاب کالیته', icon: IconLayoutGrid, active: false },
{ id: 3, label: 'ثبت سفارش', icon: IconOrders, active: false },
]
const currentStep = 1
</script>
<template>
<div class="desktop-page home-dashboard">
<header class="home-dashboard__head">
<h1 class="home-dashboard__title">انتخاب شرکت</h1>
<p class="home-dashboard__subtitle">شرکت موردنظر خود را برای شروع سفارش‌گذاری انتخاب کنید</p>
</header>
<section class="welcome-banner" aria-label="خوش‌آمدگویی">
<div class="welcome-banner__visual" aria-hidden="true">
<div class="welcome-banner__fabric">
<span class="welcome-banner__roll welcome-banner__roll--1" />
<span class="welcome-banner__roll welcome-banner__roll--2" />
</div>
</div>
<div class="welcome-banner__content">
<h2 class="welcome-banner__heading">به پارس بافت خوش آمدید</h2>
<p class="welcome-banner__text">
برای شروع سفارش، ابتدا شرکت موردنظر را انتخاب کنید و سپس کالیته و محصولات را اضافه
کنید.
</p>
</div>
<div class="welcome-banner__icon" aria-hidden="true">
<IconShoppingBag :size="26" />
</div>
</section>
<div class="home-dashboard__companies">
<article
v-for="company in displayCompanies"
:key="company.id"
class="company-showcase"
:class="`company-showcase--${cardVariant(company.slug)}`"
>
<div class="company-showcase__pattern" aria-hidden="true" />
<div class="company-showcase__body">
<span class="company-showcase__logo">
<component :is="companyLogo(company.slug)" :size="72" />
</span>
<h3 class="company-showcase__name">{{ company.name }}</h3>
<p class="company-showcase__desc">{{ companyDescription(company) }}</p>
<button
type="button"
class="company-showcase__cta"
@click="emit('select', company.id)"
>
<span class="company-showcase__cta-text">ورود به کاتالوگ‌ها</span>
<IconChevronLeft :size="16" class="company-showcase__cta-icon" aria-hidden="true" />
</button>
</div>
</article>
</div>
<div class="home-dashboard__bottom">
<section class="home-panel home-panel--quick">
<h3 class="home-panel__title">دسترسی سریع</h3>
<div class="quick-access">
<button
v-for="link in quickLinks"
:key="link.label"
type="button"
class="quick-access__item"
@click="router.push(link.path)"
>
<span class="quick-access__icon" :class="`quick-access__icon--${link.tone}`">
<component :is="link.icon" :size="22" />
</span>
<span class="quick-access__label">{{ link.label }}</span>
</button>
</div>
</section>
<section class="home-panel home-panel--steps">
<h3 class="home-panel__title">مراحل ثبت سفارش</h3>
<ol class="order-steps">
<template v-for="(step, index) in orderSteps" :key="step.id">
<li
class="order-step"
:class="{ 'order-step--active': step.id === currentStep }"
>
<span class="order-step__dot">{{ step.id }}</span>
<span class="order-step__icon">
<component :is="step.icon" :size="18" />
</span>
<span class="order-step__label">{{ step.label }}</span>
</li>
<li
v-if="index < orderSteps.length - 1"
class="order-step-bridge"
aria-hidden="true"
>
<span
class="order-step-bridge__line"
:class="{ 'order-step-bridge__line--done': index < currentStep }"
/>
</li>
</template>
</ol>
</section>
</div>
</div>
</template>
<style scoped>
.home-dashboard {
width: 100%;
padding-bottom: 32px;
}
.home-dashboard__head {
text-align: center;
margin-bottom: 24px;
}
.home-dashboard__title {
margin: 0 0 8px;
font-size: 1.5rem;
font-weight: 700;
color: var(--color-primary);
}
.home-dashboard__subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.welcome-banner {
direction: ltr;
display: grid;
grid-template-columns: 132px 1fr 56px;
align-items: center;
gap: 20px;
padding: 18px 22px;
margin-bottom: 24px;
background: linear-gradient(
90deg,
var(--color-surface) 0%,
var(--color-surface) 58%,
var(--color-primary-soft) 58%
);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.welcome-banner__visual {
justify-self: start;
}
.welcome-banner__fabric {
position: relative;
width: 112px;
height: 72px;
}
.welcome-banner__roll {
position: absolute;
bottom: 4px;
border-radius: 999px 999px 6px 6px;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--color-primary) 22%, var(--color-surface)) 0%,
color-mix(in srgb, var(--color-primary) 55%, var(--color-surface)) 55%,
var(--color-primary) 100%
);
box-shadow: inset 0 -4px 0 color-mix(in srgb, var(--color-text) 8%, transparent);
}
.welcome-banner__roll--1 {
width: 36px;
height: 52px;
left: 8px;
z-index: 2;
}
.welcome-banner__roll--2 {
width: 30px;
height: 44px;
left: 44px;
background: linear-gradient(
180deg,
var(--color-elevated) 0%,
var(--color-border) 55%,
var(--color-text-muted) 100%
);
z-index: 1;
}
.welcome-banner__content {
direction: rtl;
text-align: right;
}
.welcome-banner__heading {
margin: 0 0 6px;
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
}
.welcome-banner__text {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-muted);
line-height: 1.65;
max-width: 460px;
}
.welcome-banner__icon {
justify-self: end;
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--color-primary);
color: var(--color-surface);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.home-dashboard__companies {
direction: ltr;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.company-showcase {
position: relative;
border-radius: 20px;
overflow: hidden;
min-height: 292px;
box-shadow: var(--shadow-md);
}
.company-showcase__pattern {
position: absolute;
inset: 0;
pointer-events: none;
}
.company-showcase--par {
background: #17379f;
color: #fff;
}
.company-showcase--par .company-showcase__pattern {
opacity: 0.22;
background:
radial-gradient(circle at 20% 30%, rgba(255, 255, 255, 0.18) 0%, transparent 42%),
radial-gradient(circle at 80% 70%, rgba(255, 255, 255, 0.12) 0%, transparent 38%),
repeating-linear-gradient(
115deg,
transparent,
transparent 14px,
rgba(255, 255, 255, 0.07) 14px,
rgba(255, 255, 255, 0.07) 28px
);
}
.company-showcase--fam-baft {
background: #f3ece3;
color: #1a202c;
}
.company-showcase--fam-baft .company-showcase__pattern {
opacity: 0.35;
background: repeating-linear-gradient(
45deg,
transparent,
transparent 8px,
rgba(0, 0, 0, 0.025) 8px,
rgba(0, 0, 0, 0.025) 16px
);
}
.company-showcase__body {
direction: rtl;
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 34px 24px 28px;
height: 100%;
}
.company-showcase__logo {
margin-bottom: 16px;
}
.company-showcase__name {
margin: 0 0 8px;
font-size: 1.25rem;
font-weight: 700;
}
.company-showcase__desc {
margin: 0 0 22px;
font-size: 0.8125rem;
line-height: 1.65;
max-width: 260px;
flex: 1;
}
.company-showcase--par .company-showcase__desc {
color: rgba(255, 255, 255, 0.88);
}
.company-showcase--fam-baft .company-showcase__desc {
color: #718096;
}
.company-showcase__cta {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 11px 22px;
border: none;
border-radius: 999px;
font-family: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.company-showcase--par .company-showcase__cta {
background: #fff;
color: var(--color-primary);
}
.company-showcase--fam-baft .company-showcase__cta {
background: #c9b896;
color: #1a202c;
}
.company-showcase__cta:hover {
opacity: 0.92;
}
.company-showcase__cta-icon {
flex-shrink: 0;
}
.home-dashboard__bottom {
direction: ltr;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.home-panel {
direction: rtl;
padding: 20px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.home-panel__title {
margin: 0 0 16px;
font-size: 0.9375rem;
font-weight: 700;
color: var(--color-text);
}
.quick-access {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.quick-access__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 4px 6px 0;
border: none;
background: none;
cursor: pointer;
font-family: inherit;
}
.quick-access__icon {
width: 46px;
height: 46px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s;
}
.quick-access__item:hover .quick-access__icon {
transform: translateY(-2px);
}
.quick-access__icon--orange {
background: var(--color-warning-light);
color: var(--color-warning);
}
.quick-access__icon--blue {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.quick-access__icon--green {
background: var(--color-success-soft);
color: var(--color-success);
}
.quick-access__icon--purple {
background: var(--color-purple-light);
color: var(--color-purple);
}
.quick-access__label {
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-text);
text-align: center;
line-height: 1.35;
}
.order-steps {
direction: rtl;
display: flex;
align-items: flex-start;
margin: 0;
padding: 0;
list-style: none;
}
.order-step {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
text-align: center;
}
.order-step-bridge {
flex: 1 1 0;
min-width: 16px;
list-style: none;
height: 28px;
display: flex;
align-items: center;
padding: 0;
margin: 0;
}
.order-step-bridge__line {
display: block;
width: 100%;
height: 0;
border-top: 2px dashed var(--color-border);
}
.order-step-bridge__line--done {
border-top-color: #93a8e8;
}
.order-step__dot {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-muted);
font-size: 0.75rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.order-step--active .order-step__dot {
border-color: var(--color-primary);
background: var(--color-primary);
color: #fff;
}
.order-step__icon {
width: 22px;
height: 22px;
color: var(--color-text-muted);
display: flex;
align-items: center;
justify-content: center;
}
.order-step--active .order-step__icon {
color: var(--color-primary);
}
.order-step__label {
font-size: 0.6875rem;
color: var(--color-text-muted);
font-weight: 500;
line-height: 1.35;
}
.order-step--active .order-step__label {
color: var(--color-primary);
font-weight: 600;
}
</style>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import type { Company } from '@/types'
import type { Component } from 'vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
defineProps<{
companies: Company[]
cardVariant: (slug: string) => string
companyDescription: (company: Company) => string
companyLogo: (slug: string) => Component
}>()
defineEmits<{
select: [companyId: string]
back: []
}>()
</script>
<template>
<div class="company-mobile-page">
<header class="company-mobile-page__header">
<div class="company-mobile-page__nav">
<button type="button" class="nav-btn" aria-label="بازگشت" @click="$emit('back')">
<IconChevronBack :size="20" />
</button>
<h1 class="company-mobile-page__title">انتخاب شرکت</h1>
<span class="nav-btn nav-btn--spacer" aria-hidden="true" />
</div>
<p class="company-mobile-page__subtitle">شرکت مورد نظر خود را انتخاب کنید</p>
</header>
<div class="company-mobile-page__list">
<button
v-for="company in companies"
:key="company.id"
type="button"
class="company-brand-card"
:class="`company-brand-card--${cardVariant(company.slug)}`"
@click="$emit('select', company.id)"
>
<span class="company-brand-card__logo">
<component :is="companyLogo(company.slug)" :size="72" />
</span>
<h2 class="company-brand-card__name">{{ company.name }}</h2>
<p class="company-brand-card__desc">{{ companyDescription(company) }}</p>
</button>
</div>
</div>
</template>
<style scoped>
.company-mobile-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--color-bg);
color: var(--color-text);
}
.company-mobile-page__header {
padding: 8px var(--page-padding) 12px;
background: var(--color-surface);
}
.company-mobile-page__nav {
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
margin-bottom: 8px;
}
.nav-btn {
width: 40px;
height: 40px;
border: none;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
color: var(--color-text);
padding: 0;
}
.nav-btn--spacer {
visibility: hidden;
pointer-events: none;
}
.company-mobile-page__title {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
text-align: center;
}
.company-mobile-page__subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
text-align: center;
}
.company-mobile-page__list {
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
padding-inline: var(--page-padding);
padding-bottom: 24px;
}
.company-brand-card {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
min-height: 200px;
padding: 32px 24px;
border: none;
border-radius: 20px;
cursor: pointer;
text-align: center;
}
.company-brand-card--par {
background: var(--color-primary);
color: #fff;
box-shadow: var(--shadow-md);
}
.company-brand-card--fam-baft {
background: var(--color-brand-warm);
color: var(--color-brand-warm-text);
box-shadow: var(--shadow-card);
border: 1px solid var(--color-border);
}
.company-brand-card__logo {
margin-bottom: 16px;
}
.company-brand-card__name {
margin: 0 0 8px;
font-size: 1.125rem;
font-weight: 700;
}
.company-brand-card__desc {
margin: 0;
font-size: 0.875rem;
opacity: 0.9;
}
.company-brand-card--par .company-brand-card__desc {
color: rgba(255, 255, 255, 0.85);
}
.company-brand-card--fam-baft .company-brand-card__desc {
color: var(--color-text-muted);
}
</style>
+169
View File
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useCrmStore } from '@/stores/crm'
import { useGoBack } from '@/composables/useGoBack'
import PageHeader from '@/components/layout/PageHeader.vue'
import BaseInput from '@/components/ui/BaseInput.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import { normalizePhone } from '@/utils/format'
import { validatePhone } from '@/utils/validation'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
import { ApiError } from '@/services/http'
const router = useRouter()
const auth = useAuthStore()
const crm = useCrmStore()
const { goBack } = useGoBack('/customers')
const firstName = ref('')
const lastName = ref('')
const phone = ref('')
const businessName = ref('')
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
const saving = ref(false)
function clearField(key: string) {
fieldErrors.value = clearFieldError(fieldErrors.value, key)
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
async function submit() {
fieldErrors.value = {}
formError.value = ''
if (!auth.isAgent) {
formError.value = 'فقط واسطه می‌تواند مشتری به لیست خود اضافه کند.'
return
}
const normalized = normalizePhone(phone.value)
const errors: FieldErrors = {}
if (!firstName.value.trim()) errors.firstName = 'نام الزامی است.'
if (!lastName.value.trim()) errors.lastName = 'نام خانوادگی الزامی است.'
if (!validatePhone(normalized)) errors.phone = 'شماره موبایل معتبر نیست.'
if (hasFieldErrors(errors)) {
fieldErrors.value = errors
formError.value = FORM_VALIDATION_SUMMARY
return
}
saving.value = true
try {
const created = (await crm.registerCustomer({
firstName: firstName.value.trim(),
lastName: lastName.value.trim(),
phone: normalized,
businessName: businessName.value.trim() || undefined,
})) as { id?: string }
if (created?.id) {
await router.replace({ path: '/customer', query: { id: created.id } })
return
}
await router.replace('/customers')
} catch (e) {
formError.value =
e instanceof ApiError || e instanceof Error
? e.message
: 'ثبت مشتری ناموفق بود.'
} finally {
saving.value = false
}
}
</script>
<template>
<div class="page customer-create-page">
<PageHeader title="افزودن مشتری" show-back centered @back="goBack" />
<div class="page__scroll customer-create-scroll">
<p class="intro">
مشتری به لیست شما اضافه می‌شود و بعد از ورود با همین شماره، سفارش‌هایش به حساب شما وصل می‌ماند.
</p>
<FormErrorBanner :message="formError" />
<div class="form-card">
<BaseInput
v-model="firstName"
label="نام"
placeholder="نام مشتری"
:error="fieldErrors.firstName"
@update:model-value="clearField('firstName')"
/>
<BaseInput
v-model="lastName"
label="نام خانوادگی"
placeholder="نام خانوادگی"
:error="fieldErrors.lastName"
@update:model-value="clearField('lastName')"
/>
<BaseInput
v-model="phone"
label="شماره موبایل"
type="tel"
inputmode="tel"
dir="ltr"
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
:error="fieldErrors.phone"
@update:model-value="clearField('phone')"
/>
<BaseInput
v-model="businessName"
label="نام فروشگاه یا برند (اختیاری)"
placeholder="مثلاً بوتیک رز"
/>
</div>
</div>
<footer class="footer">
<BaseButton block :loading="saving" :disabled="saving" @click="submit">
ثبت مشتری
</BaseButton>
</footer>
</div>
</template>
<style scoped>
.customer-create-page {
background: var(--color-bg);
}
.customer-create-scroll {
padding: 8px var(--page-padding) 100px;
display: flex;
flex-direction: column;
gap: 12px;
}
.intro {
margin: 0;
color: var(--color-text-muted);
font-size: 0.875rem;
line-height: 1.7;
}
.form-card {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow-sm);
}
.footer {
position: sticky;
bottom: 0;
padding: 12px var(--page-padding) calc(12px + env(safe-area-inset-bottom, 0px));
background: color-mix(in srgb, var(--color-bg) 92%, transparent);
}
</style>
+683
View File
@@ -0,0 +1,683 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCrmStore } from '@/stores/crm'
import { useAuthStore } from '@/stores/auth'
import type {
CrmCustomer,
CrmMeeting,
CrmOrderSummary,
CustomerTab,
MeetingInteractionType,
} from '@/types/crm'
import { formatNumber } from '@/utils/format'
import { ORDER_STATUS_LABELS, STATUS_BADGE_VARIANT } from '@/utils/constants'
import type { OrderStatus } from '@/types'
import PageHeader from '@/components/layout/PageHeader.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconCart from '@/components/icons/IconCart.vue'
import IconClipboardEmpty from '@/components/icons/IconClipboardEmpty.vue'
import IconPhone from '@/components/icons/IconPhone.vue'
import IconMail from '@/components/icons/IconMail.vue'
import IconEditPen from '@/components/icons/IconEditPen.vue'
const route = useRoute()
const router = useRouter()
const crm = useCrmStore()
const auth = useAuthStore()
const id = computed(() => String(route.query.id ?? ''))
const tab = computed(() => (String(route.query.tab ?? 'info') as CustomerTab))
const customer = ref<CrmCustomer | null>(null)
const orders = ref<CrmOrderSummary[]>([])
const meetings = ref<CrmMeeting[]>([])
const meetingFilter = ref<'all' | MeetingInteractionType>('all')
const loading = ref(true)
const error = ref<string | null>(null)
const tabs: Array<{ id: CustomerTab; label: string }> = [
{ id: 'info', label: 'اطلاعات' },
{ id: 'orders', label: 'سفارش‌ها' },
{ id: 'reports', label: 'گزارش‌ها و پیگیری‌ها' },
]
async function load() {
if (!id.value) {
customer.value = null
loading.value = false
return
}
loading.value = true
error.value = null
try {
const data = await crm.loadCustomerDetail(id.value)
customer.value = data.customer
orders.value = data.orders
meetings.value = data.meetings
} catch (e) {
customer.value = null
error.value = e instanceof Error ? e.message : 'مشتری یافت نشد.'
} finally {
loading.value = false
}
}
onMounted(load)
watch(id, load)
function setTab(next: CustomerTab) {
router.replace({ path: '/customer', query: { id: id.value, tab: next } })
}
function typeLabel(type: MeetingInteractionType) {
if (type === 'in_person') return 'ملاقات حضوری'
if (type === 'call') return 'تماس'
if (type === 'message') return 'پیام'
return 'یادداشت'
}
const agentName = computed(() =>
customer.value?.agentName || auth.fullName || 'واسطه',
)
const agentCode = computed(() => customer.value?.agentCode || auth.user?.agentCode || '—')
const agentInitial = computed(() => agentName.value.charAt(0))
const meetingFilters: Array<{ id: 'all' | MeetingInteractionType; label: string }> = [
{ id: 'all', label: 'همه' },
{ id: 'in_person', label: 'ملاقات حضوری' },
{ id: 'call', label: 'تماس' },
{ id: 'message', label: 'پیام' },
{ id: 'note', label: 'یادداشت' },
]
const filteredMeetings = computed(() => {
if (meetingFilter.value === 'all') return meetings.value
return meetings.value.filter((m) => m.type === meetingFilter.value)
})
function meetingIconClass(type: MeetingInteractionType) {
if (type === 'in_person') return 'meeting-row__icon--green'
if (type === 'call') return 'meeting-row__icon--blue'
return 'meeting-row__icon--purple'
}
function openOrder(orderId: string) {
if (!orderId) return
router.push({ path: '/order', query: { id: orderId } })
}
function orderStatusLabel(order: CrmOrderSummary) {
const key = order.statusLabel as OrderStatus
return ORDER_STATUS_LABELS[key] || order.statusLabel
}
function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info' {
const variant = STATUS_BADGE_VARIANT[order.statusLabel]
if (variant === 'success') return 'success'
if (variant === 'warning' || variant === 'danger') return 'warning'
return order.statusTone || 'info'
}
</script>
<template>
<div v-if="customer" class="page customer-page">
<PageHeader
:title="tab === 'reports' ? 'پروفایل مشتری' : 'جزئیات مشتری'"
show-back
centered
@back="router.push('/customers')"
>
<template v-if="tab === 'info'" #trailing>
<button type="button" class="edit-btn" @click="router.back()">ویرایش</button>
</template>
</PageHeader>
<div class="page__scroll page__scroll--with-nav customer-scroll">
<section v-if="tab !== 'reports'" class="profile-row">
<div class="avatar" :style="{ background: customer.avatarColor }">
{{ customer.avatarLetter }}
</div>
<div>
<div class="name-row">
<h2>{{ customer.name }}</h2>
<span class="badge">فعال</span>
</div>
<p dir="ltr">{{ customer.phone }}</p>
</div>
</section>
<section v-else class="profile-banner">
<div class="profile-banner__top">
<div class="avatar avatar--lg" :style="{ background: customer.avatarColor }">
{{ customer.avatarLetter }}
</div>
<div>
<h2>{{ customer.name }}</h2>
<p>{{ customer.contactName }}</p>
<p dir="ltr">{{ customer.phone }}</p>
</div>
<span class="badge badge--light">مشتری فعال</span>
</div>
<div class="profile-banner__stats">
<div>
<span>سطح مشتری</span>
<strong>{{ customer.levelLabel }}</strong>
</div>
<div>
<span>تاریخ اولین خرید</span>
<strong>{{ customer.firstPurchaseDate }}</strong>
</div>
<div>
<span>تعداد سفارش</span>
<strong>{{ formatNumber(customer.orderCount) }} سفارش</strong>
</div>
</div>
</section>
<div class="tabs">
<button
v-for="item in tabs"
:key="item.id"
type="button"
class="tab"
:class="{ 'tab--active': tab === item.id }"
@click="setTab(item.id)"
>
{{ item.label }}
</button>
</div>
<template v-if="tab === 'info'">
<section class="stats">
<h3>اطلاعات خرید</h3>
<div class="stats__grid">
<div>
<span>تعداد سفارش</span>
<strong>{{ formatNumber(customer.orderCount) }}</strong>
</div>
<div>
<span>مجموع خرید</span>
<strong>{{ formatNumber(customer.totalPurchaseToman) }}</strong>
<em>تومان</em>
</div>
<div>
<span>میانگین هر سفارش</span>
<strong>{{ formatNumber(customer.averageOrderToman) }}</strong>
<em>تومان</em>
</div>
</div>
</section>
<button type="button" class="agent-card" @click="router.push('/profile')">
<div class="agent-card__avatar">{{ agentInitial }}</div>
<div>
<strong>{{ agentName }}</strong>
<p>کد واسطه: {{ agentCode }}</p>
</div>
<IconChevronLeft :size="18" />
</button>
<section>
<div class="section-head">
<h3>آخرین سفارش‌ها</h3>
<button type="button" class="link" @click="setTab('orders')">مشاهده همه</button>
</div>
<article
v-for="order in orders"
:key="order.id"
class="order-row"
role="button"
tabindex="0"
@click="openOrder(order.id)"
@keydown.enter="openOrder(order.id)"
>
<div>
<strong>{{ order.title }}</strong>
<p>{{ formatNumber(order.amountToman) }} تومان</p>
</div>
<span class="tone" :class="`tone--${orderStatusTone(order)}`">{{ orderStatusLabel(order) }}</span>
<IconChevronLeft :size="16" class="order-row__chevron" />
</article>
<p v-if="!orders.length" class="empty-inline">سفارشی ثبت نشده است.</p>
</section>
</template>
<template v-else-if="tab === 'orders'">
<article
v-for="order in orders"
:key="order.id"
class="order-row"
role="button"
tabindex="0"
@click="openOrder(order.id)"
@keydown.enter="openOrder(order.id)"
>
<div>
<strong>{{ order.title }}</strong>
<p>{{ order.date }} — {{ formatNumber(order.amountToman) }} تومان</p>
</div>
<span class="tone" :class="`tone--${orderStatusTone(order)}`">{{ orderStatusLabel(order) }}</span>
<IconChevronLeft :size="16" class="order-row__chevron" />
</article>
<p v-if="!orders.length" class="empty-inline">سفارشی ثبت نشده است.</p>
</template>
<template v-else>
<h3 class="block-title">تعاملات و پیگیری‌ها</h3>
<button
type="button"
class="dashed-btn"
@click="router.push({ path: '/meeting-report', query: { customerId: customer.id } })"
>
+ ثبت گزارش ملاقات جدید
</button>
<div class="meeting-filters">
<button
v-for="chip in meetingFilters"
:key="chip.id"
type="button"
class="meeting-filter"
:class="{ 'meeting-filter--active': meetingFilter === chip.id }"
@click="meetingFilter = chip.id"
>
{{ chip.label }}
</button>
</div>
<article v-for="item in filteredMeetings" :key="item.id" class="meeting-row">
<div class="meeting-row__icon" :class="meetingIconClass(item.type)">
<IconClipboardEmpty v-if="item.type === 'in_person'" :size="16" />
<IconPhone v-else-if="item.type === 'call'" :size="16" />
<IconMail v-else-if="item.type === 'message'" :size="16" />
<IconEditPen v-else :size="16" />
</div>
<div class="meeting-row__body">
<strong>{{ typeLabel(item.type) }}</strong>
<p>{{ item.description || item.title }}</p>
</div>
<span>{{ item.date }}</span>
</article>
<p v-if="!filteredMeetings.length" class="empty-inline">
گزارشی برای این فیلتر ثبت نشده است.
</p>
</template>
</div>
<footer v-if="tab === 'info'" class="cta-bar">
<button type="button" class="cta" @click="router.push('/categories')">
<IconCart :size="20" color="#fff" />
سفارش جدید برای این مشتری
</button>
</footer>
</div>
<div v-else-if="loading" class="page empty">
<PageSkeleton variant="detail" />
</div>
<div v-else class="page empty">
<p>{{ error || 'مشتری یافت نشد.' }}</p>
</div>
</template>
<style scoped>
.customer-page {
background: var(--color-bg);
}
.customer-scroll {
padding: 8px var(--page-padding) 100px;
display: flex;
flex-direction: column;
gap: 14px;
}
.profile-row {
display: flex;
gap: 12px;
align-items: center;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 1.4rem;
font-weight: 700;
}
.avatar--lg {
width: 56px;
height: 56px;
}
.name-row {
display: flex;
align-items: center;
gap: 8px;
}
.name-row h2,
.profile-banner h2 {
margin: 0;
font-size: 1.1rem;
}
.profile-row p,
.profile-banner p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.badge {
background: var(--color-primary-soft);
color: var(--color-primary);
border-radius: 999px;
padding: 2px 10px;
font-size: 0.75rem;
font-weight: 700;
}
.badge--light {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.profile-banner {
background: var(--color-primary);
color: #fff;
border-radius: var(--radius-lg);
padding: 16px;
}
.profile-banner__top {
display: grid;
grid-template-columns: 56px 1fr auto;
gap: 10px;
align-items: start;
}
.profile-banner__stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.profile-banner__stats span {
display: block;
font-size: 0.7rem;
opacity: 0.85;
}
.profile-banner__stats strong {
display: block;
margin-top: 4px;
font-size: 0.8rem;
}
.tabs {
display: flex;
gap: 12px;
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
}
.tab {
border: none;
background: transparent;
padding: 10px 2px;
color: var(--color-text-muted);
white-space: nowrap;
cursor: pointer;
}
.tab--active {
color: var(--color-text);
font-weight: 700;
box-shadow: inset 0 -3px 0 var(--color-text);
}
.stats,
.agent-card,
.order-row,
.meeting-row {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 14px;
box-shadow: var(--shadow-sm);
}
.stats h3,
.section-head h3,
.block-title {
margin: 0 0 10px;
font-size: 0.95rem;
}
.stats__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
text-align: center;
}
.stats__grid span,
.stats__grid em {
display: block;
font-size: 0.7rem;
color: var(--color-text-muted);
font-style: normal;
}
.stats__grid strong {
display: block;
margin: 4px 0;
font-size: 0.9rem;
}
.agent-card {
width: 100%;
border: none;
display: grid;
grid-template-columns: 44px 1fr 20px;
gap: 10px;
align-items: center;
text-align: start;
cursor: pointer;
}
.agent-card__avatar {
width: 44px;
height: 44px;
border-radius: 999px;
background: var(--color-primary-soft);
color: var(--color-primary);
display: grid;
place-items: center;
font-weight: 700;
}
.agent-card p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.78rem;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.link {
border: none;
background: transparent;
color: var(--color-info);
cursor: pointer;
font-size: 0.8rem;
}
.order-row,
.meeting-row {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
margin-bottom: 8px;
}
.order-row {
cursor: pointer;
}
.order-row__chevron {
color: var(--color-text-muted);
flex-shrink: 0;
}
.meeting-row__body {
flex: 1;
min-width: 0;
}
.order-row p,
.meeting-row p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.78rem;
}
.empty-inline {
margin: 8px 0 0;
text-align: center;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.tone {
border-radius: 999px;
padding: 4px 10px;
font-size: 0.72rem;
white-space: nowrap;
}
.tone--warning {
background: var(--color-warning-light);
color: #b45309;
}
.tone--success {
background: var(--color-success-light);
color: var(--color-success);
}
.tone--info {
background: var(--color-info-light);
color: var(--color-info);
}
.edit-btn {
border: none;
background: transparent;
color: var(--color-info, #2563eb);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
padding: 0 4px;
white-space: nowrap;
}
.meeting-filters {
display: flex;
gap: 8px;
overflow-x: auto;
margin-bottom: 8px;
padding-bottom: 4px;
}
.meeting-filter {
border: 1px solid var(--color-border);
background: var(--color-surface);
border-radius: 999px;
padding: 6px 12px;
font-size: 0.72rem;
color: var(--color-text-muted);
cursor: pointer;
white-space: nowrap;
}
.meeting-filter--active {
border-color: var(--color-primary);
background: var(--color-primary-soft);
color: var(--color-primary);
font-weight: 700;
}
.dashed-btn {
width: 100%;
border: 1.5px dashed var(--color-primary);
background: transparent;
color: var(--color-primary);
border-radius: var(--radius-md);
padding: 14px;
font-weight: 700;
cursor: pointer;
margin-bottom: 8px;
}
.meeting-row__icon {
width: 36px;
height: 36px;
border-radius: 999px;
flex-shrink: 0;
display: grid;
place-items: center;
}
.meeting-row__icon--green {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.meeting-row__icon--blue {
background: #e0f2fe;
color: #0369a1;
}
.meeting-row__icon--purple {
background: #ede9fe;
color: #6d28d9;
}
.cta-bar {
position: sticky;
bottom: 0;
padding: 12px var(--page-padding) calc(12px + env(safe-area-inset-bottom, 0));
background: linear-gradient(transparent, var(--color-bg) 30%);
}
.cta {
width: 100%;
min-height: 52px;
border: none;
border-radius: 14px;
background: var(--color-primary);
color: #fff;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.empty {
padding: 40px;
text-align: center;
}
</style>
+413
View File
@@ -0,0 +1,413 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCrmStore } from '@/stores/crm'
import type { CrmCustomer, CustomerStatus } from '@/types/crm'
import { formatNumber } from '@/utils/format'
import PageHeader from '@/components/layout/PageHeader.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import SearchField from '@/components/ui/SearchField.vue'
import IconFilter from '@/components/icons/IconFilter.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconPlus from '@/components/icons/IconPlus.vue'
import IconProfile from '@/components/icons/IconProfile.vue'
const route = useRoute()
const router = useRouter()
const crm = useCrmStore()
const q = ref(String(route.query.q ?? ''))
const status = computed(() => String(route.query.status ?? 'all') as CustomerStatus | 'all')
const customers = ref<CrmCustomer[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const filtered = computed(() => customers.value)
const activeCount = computed(() => customers.value.filter((c) => c.status === 'active').length)
const totalPurchase = computed(() =>
customers.value.reduce((sum, c) => sum + c.totalPurchaseToman, 0),
)
const tabs: Array<{ id: 'all' | CustomerStatus; label: string }> = [
{ id: 'all', label: 'همه' },
{ id: 'active', label: 'فعال' },
{ id: 'inactive', label: 'غیرفعال' },
{ id: 'new', label: 'جدید' },
]
async function load() {
loading.value = true
error.value = null
try {
customers.value = await crm.loadCustomers({
q: q.value || undefined,
status: status.value === 'all' ? undefined : status.value,
})
} catch (e) {
error.value = e instanceof Error ? e.message : 'بارگذاری مشتریان ناموفق بود.'
customers.value = []
} finally {
loading.value = false
}
}
onMounted(load)
watch([status, q], load)
function setStatus(id: string) {
router.replace({
path: '/customers',
query: {
...(id !== 'all' ? { status: id } : {}),
...(q.value ? { q: q.value } : {}),
},
})
}
function onSearch(value: string) {
q.value = value
router.replace({
path: '/customers',
query: {
...(status.value !== 'all' ? { status: status.value } : {}),
...(value ? { q: value } : {}),
},
})
}
function statusLabel(s: CustomerStatus) {
if (s === 'active') return 'فعال'
if (s === 'inactive') return 'غیرفعال'
return 'جدید'
}
function onRegisterCustomer() {
router.push('/customers/new')
}
function cycleStatusFilter() {
const order = ['all', 'active', 'inactive', 'new'] as const
const idx = order.indexOf(status.value as (typeof order)[number])
const next = order[(idx < 0 ? 0 : idx + 1) % order.length] ?? 'all'
setStatus(next)
}
</script>
<template>
<div class="page customers-page">
<PageHeader title="مشتریان من" show-back centered @back="router.back()">
<template #trailing>
<button
type="button"
class="nav-add-btn"
aria-label="ثبت مشتری"
@click="onRegisterCustomer"
>
<IconPlus :size="22" />
</button>
</template>
</PageHeader>
<div class="page__scroll page__scroll--with-nav customers-scroll">
<div class="toolbar">
<SearchField
:model-value="q"
placeholder="جستجو بر اساس نام یا موبایل..."
@update:model-value="onSearch"
/>
<button type="button" class="filter-btn" aria-label="فیلتر وضعیت" @click="cycleStatusFilter">
<IconFilter :size="18" />
</button>
</div>
<section class="summary-card">
<div class="summary-card__icon" aria-hidden="true">
<IconProfile :size="22" color="#fff" />
</div>
<div class="summary-card__cols">
<div>
<span>تعداد مشتری فعال</span>
<strong>{{ formatNumber(activeCount) }}</strong>
</div>
<span class="summary-card__divider" aria-hidden="true" />
<div>
<span>مجموع خرید (ریال)</span>
<strong>{{ formatNumber(totalPurchase) }}</strong>
<em>تومان</em>
</div>
</div>
</section>
<div class="tabs" role="tablist">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
role="tab"
class="tab"
:class="{ 'tab--active': status === tab.id }"
:aria-selected="status === tab.id"
@click="setStatus(tab.id)"
>
<span class="tab__text">{{ tab.label }}</span>
</button>
</div>
<PageSkeleton v-if="loading" variant="list" />
<p v-else-if="error">{{ error }}</p>
<button
v-for="item in filtered"
:key="item.id"
type="button"
class="customer-card"
@click="router.push({ path: '/customer', query: { id: item.id } })"
>
<div
class="customer-card__avatar"
:style="{ background: item.avatarColor }"
>
{{ item.avatarLetter }}
</div>
<div class="customer-card__body">
<div class="customer-card__top">
<strong>{{ item.name }}</strong>
<span
class="status"
:class="item.status === 'active' ? 'status--ok' : 'status--muted'"
>
{{ statusLabel(item.status) }}
</span>
</div>
<p dir="ltr">{{ item.phone }}</p>
<p>مجموع خرید: {{ formatNumber(item.totalPurchaseToman) }} تومان</p>
</div>
<IconChevronLeft :size="18" class="chevron" />
</button>
<section class="promo">
<div>
<strong>افزایش مشتریان = افزایش سطح شما</strong>
<p>با جذب مشتریان بیشتر به سطح VIP برسید</p>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.customers-page {
background: var(--color-bg);
}
.customers-scroll {
padding: 8px var(--page-padding) 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.toolbar {
display: grid;
grid-template-columns: 1fr 44px;
gap: 8px;
align-items: center;
}
.filter-btn {
width: 44px;
height: 44px;
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-surface);
display: grid;
place-items: center;
cursor: pointer;
}
.summary-card {
display: flex;
gap: 12px;
align-items: center;
background: var(--color-primary);
color: #fff;
border-radius: var(--radius-lg);
padding: 16px;
}
.nav-add-btn {
width: 40px;
height: 40px;
border: none;
background: transparent;
color: var(--color-text);
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
}
.summary-card__icon {
width: 44px;
height: 44px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
flex-shrink: 0;
display: grid;
place-items: center;
}
.summary-card__cols {
flex: 1;
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 12px;
align-items: stretch;
}
.summary-card__divider {
width: 1px;
align-self: stretch;
background: rgba(255, 255, 255, 0.35);
}
.summary-card__cols span,
.summary-card__cols em {
display: block;
font-size: 0.75rem;
font-style: normal;
opacity: 0.85;
}
.summary-card__cols > div {
text-align: center;
}
.summary-card__cols strong {
display: block;
margin-top: 4px;
font-size: 1.05rem;
}
.tabs {
display: flex;
width: 100%;
align-items: stretch;
border-bottom: 1px solid var(--color-border);
}
.tab {
flex: 1;
display: flex;
justify-content: center;
align-items: flex-end;
min-width: 0;
appearance: none;
-webkit-appearance: none;
border: 0;
background: none;
padding: 10px 0 0;
color: #9ca3af;
font-family: inherit;
font-size: 0.9375rem;
font-weight: 400;
line-height: 1.5;
cursor: pointer;
}
.tab__text {
position: relative;
display: inline-block;
padding-bottom: 11px;
}
.tab--active {
color: var(--color-text);
font-weight: 700;
}
.tab--active .tab__text::after {
content: '';
position: absolute;
right: 0;
left: 0;
bottom: -1px;
height: 3px;
background: var(--color-primary);
border-radius: 2px 2px 0 0;
}
.customer-card {
display: grid;
grid-template-columns: 48px 1fr 20px;
gap: 10px;
align-items: center;
border: none;
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 12px;
text-align: start;
cursor: pointer;
box-shadow: var(--shadow-sm);
}
.customer-card__avatar {
width: 48px;
height: 48px;
border-radius: 999px;
display: grid;
place-items: center;
font-weight: 700;
color: #111;
}
.customer-card__top {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
}
.customer-card__body p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.78rem;
}
.status {
font-size: 0.72rem;
padding: 2px 8px;
border-radius: 999px;
}
.status--ok {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.status--muted {
background: #eef0ef;
color: var(--color-text-muted);
}
.chevron {
color: var(--color-text-muted);
}
.promo {
background: var(--color-primary-soft);
border-radius: var(--radius-md);
padding: 14px;
}
.promo strong {
display: block;
margin-bottom: 4px;
}
.promo p {
margin: 0;
color: var(--color-text-muted);
font-size: 0.8rem;
}
</style>
+430
View File
@@ -0,0 +1,430 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCrmStore } from '@/stores/crm'
import type { CrmCustomer, MeetingInteractionType } from '@/types/crm'
import PageHeader from '@/components/layout/PageHeader.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import IconCheckCircleOutline from '@/components/icons/IconCheckCircleOutline.vue'
import IconClipboardEmpty from '@/components/icons/IconClipboardEmpty.vue'
import IconPhone from '@/components/icons/IconPhone.vue'
import IconMail from '@/components/icons/IconMail.vue'
import IconEditPen from '@/components/icons/IconEditPen.vue'
const route = useRoute()
const router = useRouter()
const crm = useCrmStore()
const customerId = computed(() => String(route.query.customerId ?? ''))
const customer = ref<CrmCustomer | null>(null)
const loading = ref(true)
const saving = ref(false)
const error = ref<string | null>(null)
const interactionType = ref<MeetingInteractionType>('in_person')
const date = ref('')
const time = ref('')
const subject = ref('')
const content = ref('')
const reminderOn = ref(true)
const reminderTitle = ref('')
const reminderDate = ref('')
const reminderTime = ref('')
const reminderNote = ref('')
const types: Array<{ id: MeetingInteractionType; label: string; icon: 'clipboard' | 'phone' | 'mail' | 'pen' }> = [
{ id: 'in_person', label: 'ملاقات حضوری', icon: 'clipboard' },
{ id: 'call', label: 'تماس تلفنی', icon: 'phone' },
{ id: 'message', label: 'پیام', icon: 'mail' },
{ id: 'note', label: 'یادداشت', icon: 'pen' },
]
onMounted(async () => {
if (!customerId.value) {
loading.value = false
error.value = 'مشتری مشخص نشده است.'
return
}
try {
const data = await crm.loadCustomerDetail(customerId.value)
customer.value = data.customer
} catch (e) {
error.value = e instanceof Error ? e.message : 'بارگذاری مشتری ناموفق بود.'
} finally {
loading.value = false
}
})
async function submit() {
if (!customerId.value || !subject.value.trim() || !date.value.trim()) {
error.value = 'موضوع و تاریخ الزامی است.'
return
}
saving.value = true
error.value = null
try {
await crm.submitMeeting({
customerId: customerId.value,
type: interactionType.value,
title: subject.value.trim(),
subject: subject.value.trim(),
content: content.value.trim() || undefined,
description: content.value.trim() || undefined,
date: date.value.trim(),
time: time.value.trim() || undefined,
createReminder: reminderOn.value,
reminderTitle: reminderOn.value
? reminderTitle.value.trim() || subject.value.trim()
: undefined,
reminderDate: reminderOn.value
? reminderDate.value.trim() || date.value.trim()
: undefined,
reminderTime: reminderOn.value
? reminderTime.value.trim() || time.value.trim() || '10:00'
: undefined,
reminderNote: reminderOn.value
? reminderNote.value.trim() || undefined
: undefined,
})
router.push({
path: '/customer',
query: { id: customerId.value, tab: 'reports' },
})
} catch (e) {
error.value = e instanceof Error ? e.message : 'ثبت گزارش ناموفق بود.'
} finally {
saving.value = false
}
}
</script>
<template>
<div class="page meeting-page">
<PageHeader title="ثبت گزارش ملاقات" show-back centered @back="router.back()">
<template #trailing>
<button
type="button"
class="submit-icon-btn"
aria-label="ثبت گزارش"
:disabled="saving"
@click="submit"
>
<IconCheckCircleOutline :size="22" />
</button>
</template>
</PageHeader>
<div class="page__scroll meeting-scroll">
<PageSkeleton v-if="loading" variant="form" />
<p v-else-if="error" class="form-error">{{ error }}</p>
<section v-if="customer" class="customer-card">
<div class="avatar" :style="{ background: customer.avatarColor }">
{{ customer.avatarLetter }}
</div>
<div>
<strong>{{ customer.name }}</strong>
<p>{{ customer.contactName }}</p>
<p dir="ltr">{{ customer.phone }}</p>
</div>
</section>
<section>
<h3>نوع تعامل</h3>
<div class="type-grid">
<button
v-for="item in types"
:key="item.id"
type="button"
class="type-btn"
:class="{ 'type-btn--active': interactionType === item.id }"
@click="interactionType = item.id"
>
<IconClipboardEmpty v-if="item.icon === 'clipboard'" :size="22" />
<IconPhone v-else-if="item.icon === 'phone'" :size="22" />
<IconMail v-else-if="item.icon === 'mail'" :size="22" />
<IconEditPen v-else :size="22" />
<span>{{ item.label }}</span>
</button>
</div>
</section>
<section>
<h3>تاریخ و زمان</h3>
<div class="two-cols">
<label class="field">
<span>تاریخ</span>
<input v-model="date" type="text" />
</label>
<label class="field">
<span>ساعت</span>
<input v-model="time" type="text" />
</label>
</div>
</section>
<label class="field">
<span>موضوع گزارش</span>
<input v-model="subject" type="text" />
</label>
<label class="field">
<span>محتوای گزارش</span>
<textarea v-model="content" rows="5" maxlength="1000" />
<em>{{ content.length.toLocaleString('fa-IR') }}/۱۰۰۰</em>
</label>
<section>
<h3>تصاویر و فایل‌ها</h3>
<div class="files">
<div class="file-thumb" />
<div class="file-thumb" />
<button type="button" class="file-add" aria-label="افزودن فایل">+</button>
</div>
</section>
<section class="reminder-box">
<div class="reminder-box__head">
<h3>یادآور</h3>
<button
type="button"
class="toggle"
:class="{ 'toggle--on': reminderOn }"
:aria-pressed="reminderOn"
@click="reminderOn = !reminderOn"
>
<span />
</button>
</div>
<template v-if="reminderOn">
<label class="field">
<span>عنوان یادآور</span>
<input v-model="reminderTitle" type="text" />
</label>
<div class="two-cols">
<label class="field">
<span>تاریخ یادآور</span>
<input v-model="reminderDate" type="text" />
</label>
<label class="field">
<span>ساعت یادآور</span>
<input v-model="reminderTime" type="text" />
</label>
</div>
<label class="field">
<span>یادداشت یادآور (اختیاری)</span>
<input v-model="reminderNote" type="text" />
</label>
</template>
</section>
</div>
<footer class="footer">
<button type="button" class="submit" :disabled="saving" @click="submit">
{{ saving ? 'در حال ثبت...' : 'ثبت گزارش و یادآور' }}
</button>
</footer>
</div>
</template>
<style scoped>
.meeting-page {
background: var(--color-bg);
}
.meeting-scroll {
padding: 8px var(--page-padding) 100px;
display: flex;
flex-direction: column;
gap: 16px;
}
.customer-card {
display: flex;
gap: 12px;
align-items: center;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 12px;
}
.avatar {
width: 52px;
height: 52px;
border-radius: 999px;
display: grid;
place-items: center;
font-weight: 700;
}
.customer-card p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.8rem;
}
h3 {
margin: 0 0 8px;
font-size: 0.9rem;
}
.type-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.submit-icon-btn {
width: 40px;
height: 40px;
border: none;
background: transparent;
color: var(--color-primary);
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
}
.submit-icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.type-btn {
border: 1.5px solid var(--color-border);
background: var(--color-surface);
border-radius: 12px;
min-height: 72px;
padding: 8px 6px;
font-size: 0.72rem;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
}
.type-btn--active {
border-color: var(--color-primary);
border-width: 2px;
color: var(--color-primary);
font-weight: 700;
background: var(--color-primary-soft);
}
.two-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.85rem;
}
.field input,
.field textarea {
border: 1px solid var(--color-border);
border-radius: 12px;
background: #f5f5f5;
padding: 12px;
font: inherit;
resize: vertical;
}
.field em {
font-style: normal;
color: var(--color-text-muted);
font-size: 0.75rem;
align-self: flex-end;
}
.files {
display: flex;
gap: 8px;
}
.file-thumb,
.file-add {
width: 64px;
height: 64px;
border-radius: 12px;
}
.file-thumb {
background: linear-gradient(145deg, #4b5563, #1f2937);
}
.file-add {
border: 1.5px dashed var(--color-border);
background: transparent;
font-size: 1.4rem;
color: var(--color-text-muted);
cursor: pointer;
}
.reminder-box {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.reminder-box__head {
display: flex;
justify-content: space-between;
align-items: center;
}
.toggle {
width: 46px;
height: 28px;
border: none;
border-radius: 999px;
background: #d1d5db;
padding: 3px;
cursor: pointer;
}
.toggle span {
display: block;
width: 22px;
height: 22px;
border-radius: 999px;
background: #fff;
transition: transform 0.15s;
}
.toggle--on {
background: var(--color-primary);
}
.toggle--on span {
transform: translateX(-18px);
}
.footer {
position: sticky;
bottom: 0;
padding: 12px var(--page-padding) calc(12px + env(safe-area-inset-bottom, 0));
background: var(--color-surface);
border-top: 1px solid var(--color-border);
}
.submit {
width: 100%;
min-height: 52px;
border: none;
border-radius: 14px;
background: var(--color-primary);
color: #fff;
font-weight: 700;
cursor: pointer;
}
</style>
+728
View File
@@ -0,0 +1,728 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useCrmStore } from '@/stores/crm'
import { useFavoritesStore } from '@/stores/favorites'
import { fetchAgentProfile, fetchBanners, type AppBannerDto } from '@/services/agentApi'
import IconBell from '@/components/icons/IconBell.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconMedal from '@/components/icons/IconMedal.vue'
import IconHeart from '@/components/icons/IconHeart.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
import IconDiamond from '@/components/icons/IconDiamond.vue'
import IconLayoutGrid from '@/components/icons/IconLayoutGrid.vue'
import IconProfile from '@/components/icons/IconProfile.vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import { formatNumber } from '@/utils/format'
import type { CatalogProductCard } from '@/types/crm'
import type { Component } from 'vue'
import { useGoBack } from '@/composables/useGoBack'
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import UserAvatar from '@/components/ui/UserAvatar.vue'
const router = useRouter()
const { goBack } = useGoBack('/')
const auth = useAuthStore()
const crm = useCrmStore()
const favorites = useFavoritesStore()
const loading = ref(true)
const error = ref<string | null>(null)
const newest = ref<CatalogProductCard[]>([])
const favError = ref<string | null>(null)
const agentLevel = ref<{
label: string
nextLabel: string
pointsRemaining: number
progressPercent: number
} | null>(null)
const customerActions: Array<{ id: string; label: string; to: string; icon: Component }> = [
{ id: 'qa-club', label: 'باشگاه مشتریان', to: '/profile', icon: IconProfile },
{ id: 'qa-cat', label: 'دسته بندی', to: '/categories', icon: IconLayoutGrid },
{ id: 'qa-new', label: 'محصولات جدید', to: '/products', icon: IconDiamond },
{ id: 'qa-swatch', label: 'درخواست کالیته', to: '/catalog-request', icon: IconCatalog },
]
const agentActions: Array<{ id: string; label: string; to: string; icon: Component }> = [
{ id: 'qa-customers', label: 'مشتریان من', to: '/customers', icon: IconProfile },
{ id: 'qa-reminders', label: 'یادآورها', to: '/reminders', icon: IconBell },
{ id: 'qa-cat', label: 'دسته بندی', to: '/categories', icon: IconLayoutGrid },
{ id: 'qa-swatch', label: 'درخواست کالیته', to: '/catalog-request', icon: IconCatalog },
]
const actions = computed(() => (auth.isAgent ? agentActions : customerActions))
const FALLBACK_BANNER: AppBannerDto = {
id: 'fallback',
slot: 'home_hero',
title: 'کالکشن پاییز و زمستان',
subtitle: 'جدیدترین پارچه‌ها',
ctaLabel: 'مشاهده کالکشن',
ctaLink: '/categories',
imageUrl: '',
}
const banners = ref<AppBannerDto[]>([FALLBACK_BANNER])
const bannerIndex = ref(0)
let bannerTimer: number | undefined
const banner = computed(() => banners.value[bannerIndex.value] ?? FALLBACK_BANNER)
function startBannerRotation() {
stopBannerRotation()
if (banners.value.length < 2) return
bannerTimer = window.setInterval(() => {
bannerIndex.value = (bannerIndex.value + 1) % banners.value.length
}, 5000)
}
function stopBannerRotation() {
if (bannerTimer) window.clearInterval(bannerTimer)
bannerTimer = undefined
}
function setBanner(index: number) {
bannerIndex.value = index
startBannerRotation()
}
const displayFirst = computed(() => auth.user?.firstName || 'کاربر')
const displayLast = computed(() => auth.user?.lastName || '')
const homeLevel = computed(() => {
if (agentLevel.value) return agentLevel.value
const u = auth.user
return {
label: u?.levelLabel || 'برنزی',
nextLabel: u?.nextLevelLabel || 'نقره‌ای',
pointsRemaining: u?.pointsToNext ?? 0,
progressPercent: u?.levelProgress ?? 0,
}
})
onUnmounted(stopBannerRotation)
onMounted(async () => {
loading.value = true
error.value = null
try {
const [products, homeBanners] = await Promise.all([
crm.loadBrowseProducts(),
fetchBanners('home_hero').catch(() => [] as AppBannerDto[]),
favorites.hydrate(),
])
if (homeBanners.length) banners.value = homeBanners
startBannerRotation()
newest.value = products.filter((p) => p.isNew).slice(0, 6)
if (!newest.value.length) newest.value = products.slice(0, 6)
if (auth.isAgent) {
try {
const profile = await fetchAgentProfile()
agentLevel.value = {
label: profile.levelLabel,
nextLabel: profile.nextLevelLabel,
pointsRemaining: profile.pointsToNext,
progressPercent: profile.levelProgress,
}
} catch {
// سطح از /auth/me؛ عدم دیپلوی agent API خانه را خراب نکند
}
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'بارگذاری خانه ناموفق بود.'
} finally {
loading.value = false
}
})
function go(to: string) {
if (/^https?:\/\//i.test(to)) {
window.location.assign(to)
return
}
router.push(to)
}
function goBanner() {
go(banner.value.ctaLink.trim() || '/categories')
}
async function toggleFavorite(productId: string) {
favError.value = null
try {
await favorites.toggle(productId)
} catch (e) {
favError.value = e instanceof Error ? e.message : 'ذخیره علاقه‌مندی ناموفق بود.'
}
}
</script>
<template>
<div class="page home-page">
<header class="home-header">
<button type="button" class="icon-btn" aria-label="بازگشت" @click="goBack">
<IconChevronBack :size="20" />
</button>
<div class="home-header__user">
<UserAvatar
class="home-header__avatar"
:src="auth.user?.avatarUrl"
:initials="displayFirst.charAt(0)"
:size="44"
/>
<div>
<p class="home-header__hello">
سلام، {{ displayFirst }} {{ displayLast }}
</p>
<p class="home-header__welcome">خوش آمدید</p>
</div>
</div>
<div class="home-header__actions">
<AuthThemeToggle variant="inline" />
<button type="button" class="icon-btn" aria-label="اعلان‌ها" @click="go('/profile/notifications')">
<IconBell :size="20" />
</button>
</div>
</header>
<div class="page__scroll page__scroll--with-nav home-scroll">
<p v-if="error" class="home-error">{{ error }}</p>
<PageSkeleton v-else-if="loading" variant="home" />
<template v-else>
<section
class="hero-banner"
:aria-label="banner.title"
:style="banner.imageUrl ? { '--hero-image': `url(\"${banner.imageUrl}\")` } : undefined"
>
<div class="hero-banner__content">
<h2 class="hero-banner__title">
{{ banner.title }}
<span v-if="banner.subtitle">{{ banner.subtitle }}</span>
</h2>
<button
v-if="banner.ctaLabel"
type="button"
class="hero-banner__cta"
@click="goBanner"
>
{{ banner.ctaLabel }}
</button>
</div>
<div v-if="banners.length > 1" class="hero-banner__dots" role="tablist" aria-label="بنرها">
<button
v-for="(item, index) in banners"
:key="item.id"
type="button"
class="dot"
:class="{ 'dot--active': index === bannerIndex }"
:aria-label="item.title"
:aria-selected="index === bannerIndex"
@click="setBanner(index)"
/>
</div>
</section>
<section class="quick-grid" aria-label="دسترسی سریع">
<button
v-for="action in actions"
:key="action.id"
type="button"
class="quick-card"
@click="go(action.to)"
>
<span class="quick-card__icon" aria-hidden="true">
<component :is="action.icon" :size="20" />
</span>
<span class="quick-card__label">{{ action.label }}</span>
</button>
</section>
<section class="level-card" aria-label="سطح عضویت">
<div class="level-card__top">
<div class="level-card__identity">
<IconMedal :size="48" />
<div class="level-card__text">
<span class="level-card__eyebrow">سطح شما</span>
<strong class="level-card__name">{{ homeLevel.label }}</strong>
<span class="level-card__next">تا سطح {{ homeLevel.nextLabel }}</span>
</div>
</div>
<button type="button" class="level-card__details" @click="go('/profile')">
جزئیات سطح
</button>
</div>
<div class="level-card__progress">
<div
class="level-card__bar"
role="progressbar"
:aria-valuenow="homeLevel.progressPercent"
aria-valuemin="0"
aria-valuemax="100"
>
<span
class="level-card__bar-fill"
:style="{ width: `${homeLevel.progressPercent}%` }"
/>
</div>
<span class="level-card__points">
{{ formatNumber(homeLevel.pointsRemaining) }} امتیاز دیگر
</span>
</div>
</section>
<section class="section">
<div class="section__head">
<h3 class="section__title">جدیدترین محصولات</h3>
<button type="button" class="section__link" @click="go('/products')">
مشاهده همه
<IconChevronLeft :size="14" />
</button>
</div>
<div class="product-row">
<article
v-for="(item, index) in newest"
:key="item.id"
class="product-card"
@click="go(`/product?id=${item.id}`)"
>
<div
class="product-card__image"
:class="`product-card__image--${index % 3}`"
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
>
<span v-if="item.isNew" class="product-card__badge">جدید</span>
</div>
<div class="product-card__body">
<strong class="product-card__name">{{ item.name }}</strong>
<span class="product-card__sub">{{ item.subtitle }}</span>
<button
type="button"
class="product-card__fav"
:class="{ 'product-card__fav--on': favorites.isFavorite(item.id) }"
:aria-label="
favorites.isFavorite(item.id)
? 'حذف از علاقه‌مندی‌ها'
: 'افزودن به علاقه‌مندی‌ها'
"
:aria-pressed="favorites.isFavorite(item.id)"
@click.stop="toggleFavorite(item.id)"
>
<IconHeart
:size="16"
:filled="favorites.isFavorite(item.id)"
:color="favorites.isFavorite(item.id) ? '#0a353c' : '#b0b7c0'"
/>
</button>
</div>
</article>
</div>
</section>
</template>
</div>
</div>
</template>
<style scoped>
.home-page {
background: var(--color-bg);
}
.home-error {
margin: 0;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.home-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px var(--page-padding) 8px;
background: var(--color-surface);
color: var(--color-text);
}
.home-header__user {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1;
}
.home-header__avatar {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.home-header__hello {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
}
.home-header__welcome {
margin: 2px 0 0;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.home-header__actions {
display: flex;
align-items: center;
gap: 0;
color: inherit;
}
.icon-btn {
position: relative;
width: 40px;
height: 40px;
border: none;
border-radius: 12px;
background: transparent;
color: var(--color-text);
display: grid;
place-items: center;
cursor: pointer;
}
.home-scroll {
display: flex;
flex-direction: column;
gap: 16px;
padding: 12px var(--page-padding) 8px;
}
.home-scroll.page__scroll--with-nav {
padding-bottom: 8px;
}
.home-scroll > .section:last-child {
margin-top: auto;
}
.hero-banner {
--hero-image: #8a7a65;
position: relative;
min-height: 160px;
border-radius: var(--radius-lg);
background:
linear-gradient(120deg, rgba(var(--color-accent-navy-rgb), 0.72), rgba(var(--color-primary-rgb), 0.45)),
var(--hero-image);
background-size: cover;
background-position: center;
color: #fff;
padding: 20px;
overflow: hidden;
}
.hero-banner__title {
margin: 0 0 14px;
font-size: 1.05rem;
line-height: 1.7;
max-width: 16rem;
font-weight: 700;
}
.hero-banner__title span {
display: block;
font-weight: 500;
opacity: 0.95;
}
.hero-banner__cta {
border: none;
border-radius: 999px;
background: var(--color-accent-navy);
color: #fff;
padding: 10px 16px;
font-weight: 600;
cursor: pointer;
}
.hero-banner__dots {
position: absolute;
bottom: 12px;
inset-inline-start: 50%;
transform: translateX(-50%);
display: flex;
gap: 6px;
}
.dot {
width: 6px;
height: 6px;
border: none;
padding: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.45);
cursor: pointer;
}
.dot--active {
width: 16px;
background: #fff;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.quick-card {
border: none;
background: transparent;
border-radius: var(--radius-md);
padding: 12px 6px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: pointer;
box-shadow: none;
}
.quick-card__icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--color-primary-soft);
display: grid;
place-items: center;
color: var(--color-primary);
}
.quick-card__label {
font-size: 0.7rem;
font-weight: 600;
text-align: center;
line-height: 1.4;
}
.level-card {
background: var(--color-surface);
border-radius: 18px;
padding: 16px;
box-shadow: 0 2px 12px rgba(16, 24, 40, 0.06);
border: 1px solid rgba(230, 232, 230, 0.9);
}
.level-card__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.level-card__identity {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.level-card__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.level-card__eyebrow,
.level-card__next {
font-size: 0.75rem;
line-height: 1.4;
color: var(--color-text-muted);
font-weight: 500;
}
.level-card__name {
font-size: 1.15rem;
line-height: 1.35;
color: var(--color-accent-navy);
font-weight: 800;
}
.level-card__details {
flex-shrink: 0;
border: none;
border-radius: 999px;
background: #eef1f4;
color: var(--color-text-muted);
font-size: 0.75rem;
font-weight: 600;
padding: 8px 12px;
cursor: pointer;
white-space: nowrap;
}
.level-card__progress {
margin-top: 14px;
display: flex;
align-items: center;
gap: 10px;
}
.level-card__bar {
flex: 1;
min-width: 0;
height: 8px;
border-radius: 999px;
background: #e8ecf0;
overflow: hidden;
}
.level-card__bar-fill {
display: block;
height: 100%;
border-radius: 999px;
background: var(--color-accent-navy);
}
.level-card__points {
flex-shrink: 0;
font-size: 0.72rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.section__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.section__title {
margin: 0;
font-size: 1rem;
}
.section__link {
border: none;
background: transparent;
color: var(--color-info);
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 0.8rem;
cursor: pointer;
}
.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;
}
.product-card {
flex: 0 0 148px;
display: flex;
flex-direction: column;
border: none;
background: var(--color-surface);
border-radius: 16px;
padding: 0;
text-align: start;
cursor: pointer;
overflow: hidden;
box-shadow: 0 2px 10px rgba(16, 24, 40, 0.05);
scroll-snap-align: start;
}
.product-card__image {
position: relative;
height: 128px;
background:
linear-gradient(160deg, rgba(255, 255, 255, 0.12), rgba(0, 0, 0, 0.18)),
linear-gradient(145deg, #c9b8a0 0%, #8f7a62 55%, #6d5a48 100%);
background-size: cover;
background-position: center;
}
.product-card__image--1 {
background:
linear-gradient(160deg, rgba(255, 255, 255, 0.1), rgba(0, 0, 0, 0.22)),
linear-gradient(145deg, #9aa4b2 0%, #5d6b7c 55%, #3f4a57 100%);
}
.product-card__image--2 {
background:
linear-gradient(160deg, rgba(255, 255, 255, 0.12), rgba(0, 0, 0, 0.2)),
linear-gradient(145deg, #d7c4a8 0%, #b08968 50%, #7a5a3c 100%);
}
.product-card__badge {
position: absolute;
top: 10px;
inset-inline-end: 10px;
background: var(--color-accent-navy);
color: #fff;
font-size: 0.68rem;
font-weight: 700;
line-height: 1;
padding: 5px 9px;
border-radius: 999px;
}
.product-card__body {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
padding: 10px 12px 12px;
padding-inline-end: 34px;
min-height: 56px;
}
.product-card__name {
display: block;
width: 100%;
margin: 0;
font-size: 13px;
font-weight: 700;
line-height: 1.4;
color: #1a1a1a;
text-align: start;
}
.product-card__sub {
display: block;
width: 100%;
margin: 0;
font-size: 11px;
font-weight: 400;
line-height: 1.4;
color: #8b939e;
text-align: start;
}
.product-card__fav {
position: absolute;
bottom: 10px;
inset-inline-end: 8px;
width: 22px;
height: 22px;
border: none;
background: transparent;
padding: 0;
display: grid;
place-items: center;
cursor: pointer;
color: #b0b7c0;
}
.product-card__fav--on {
color: var(--color-primary);
}
</style>
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useOrderStore } from '@/stores/order'
import BaseButton from '@/components/ui/BaseButton.vue'
import PageHeader from '@/components/layout/PageHeader.vue'
import { formatMeterage } from '@/utils/format'
import { useGoBack } from '@/composables/useGoBack'
const route = useRoute()
const router = useRouter()
const { goBack } = useGoBack('/orders')
const orderStore = useOrderStore()
const order = computed(() => orderStore.getOrderById(route.params.id as string))
</script>
<template>
<div class="page success-page">
<PageHeader title="ثبت موفق" show-back centered @back="goBack" />
<div class="page__scroll success-page__scroll">
<div class="success-icon">✓</div>
<h1 class="page-title text-center">سفارش ثبت شد</h1>
<p class="text-center text-muted mb-4">
سفارش شما با موفقیت ثبت شد. کارشناسان ما سفارش را بررسی و قیمت‌گذاری می‌کنند و به‌محض صدور
فاکتور، از طریق پیامک و پنل کاربری به شما اطلاع می‌دهیم تا روش تسویه را انتخاب کنید.
</p>
<div v-if="order" class="card mb-4">
<div class="info-row">
<span>شماره سفارش:</span>
<strong>{{ order.orderNumber }}</strong>
</div>
<div class="info-row">
<span>شرکت:</span>
<span>{{ order.companyName }}</span>
</div>
<div class="info-row">
<span>متراژ:</span>
<span>{{ formatMeterage(order.totalMeterage) }}</span>
</div>
<div class="info-row">
<span>وضعیت:</span>
<span>در انتظار قیمت‌گذاری</span>
</div>
</div>
<BaseButton block @click="router.replace(`/orders/${order?.id}`)">
مشاهده جزئیات سفارش
</BaseButton>
<BaseButton block variant="outline" class="mt-2" @click="router.replace('/')">
بازگشت به خانه
</BaseButton>
</div>
</div>
</template>
<style scoped>
.success-page {
overflow: hidden;
text-align: center;
}
.success-page__scroll {
padding-top: 48px;
padding-bottom: 24px;
}
.success-icon {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--color-success-light);
color: var(--color-success);
font-size: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 0.9375rem;
}
</style>
@@ -0,0 +1,206 @@
<script setup lang="ts">
import EmptyState from '@/components/ui/EmptyState.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import IconOrders from '@/components/icons/IconOrders.vue'
import IconHourglass from '@/components/icons/IconHourglass.vue'
import IconProgressClock from '@/components/icons/IconProgressClock.vue'
import IconCheckCircleOutline from '@/components/icons/IconCheckCircleOutline.vue'
import IconFilter from '@/components/icons/IconFilter.vue'
import IconMoreVertical from '@/components/icons/IconMoreVertical.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import { formatDateTime, formatNumber } from '@/utils/format'
import type { Order } from '@/types'
import type { DesktopOrderFilterId } from '@/utils/orderStatusGroups'
defineProps<{
stats: { total: number; review: number; inProgress: number; done: number }
desktopFilter: DesktopOrderFilterId
desktopFilterTabs: { id: DesktopOrderFilterId; label: string }[]
filteredOrders: Order[]
paginatedOrders: Order[]
paginationRange: { start: number; end: number }
visiblePages: (number | 'ellipsis')[]
currentPage: number
totalPages: number
}>()
const emit = defineEmits<{
viewStatFilter: [filter: DesktopOrderFilterId]
setDesktopFilter: [filter: DesktopOrderFilterId]
select: [id: string]
setPage: [page: number]
newOrder: []
}>()
</script>
<template>
<div class="desktop-page orders-desktop-page">
<header class="desktop-page__head">
<h1 class="desktop-page__title">سفارش‌ها</h1>
<p class="desktop-page__subtitle">مدیریت و پیگیری سفارش‌های ثبت شده</p>
</header>
<div class="orders-stats desktop-grid-4">
<article class="orders-stat-card">
<div class="orders-stat-card__head">
<span class="orders-stat-card__label orders-stat-card__label--total">همه سفارش‌ها</span>
<span class="orders-stat-card__icon orders-stat-card__icon--total" aria-hidden="true">
<IconOrders :size="20" />
</span>
</div>
<strong class="orders-stat-card__value">{{ formatNumber(stats.total) }}</strong>
<button type="button" class="orders-stat-card__link" @click="emit('viewStatFilter', 'all')">
مشاهده همه
</button>
</article>
<article class="orders-stat-card">
<div class="orders-stat-card__head">
<span class="orders-stat-card__label orders-stat-card__label--review">در انتظار بررسی</span>
<span class="orders-stat-card__icon orders-stat-card__icon--review" aria-hidden="true">
<IconHourglass :size="20" />
</span>
</div>
<strong class="orders-stat-card__value">{{ formatNumber(stats.review) }}</strong>
<button type="button" class="orders-stat-card__link" @click="emit('viewStatFilter', 'review')">
مشاهده همه
</button>
</article>
<article class="orders-stat-card">
<div class="orders-stat-card__head">
<span class="orders-stat-card__label orders-stat-card__label--active">در حال انجام</span>
<span class="orders-stat-card__icon orders-stat-card__icon--active" aria-hidden="true">
<IconProgressClock :size="20" />
</span>
</div>
<strong class="orders-stat-card__value">{{ formatNumber(stats.inProgress) }}</strong>
<button
type="button"
class="orders-stat-card__link"
@click="emit('viewStatFilter', 'in_progress')"
>
مشاهده همه
</button>
</article>
<article class="orders-stat-card">
<div class="orders-stat-card__head">
<span class="orders-stat-card__label orders-stat-card__label--done">تکمیل شده</span>
<span class="orders-stat-card__icon orders-stat-card__icon--done" aria-hidden="true">
<IconCheckCircleOutline :size="20" />
</span>
</div>
<strong class="orders-stat-card__value">{{ formatNumber(stats.done) }}</strong>
<button type="button" class="orders-stat-card__link" @click="emit('viewStatFilter', 'delivered')">
مشاهده همه
</button>
</article>
</div>
<div class="desktop-toolbar">
<div class="desktop-toolbar__actions">
<button type="button" class="orders-toolbar__filter-btn">
<IconFilter :size="18" />
فیلترها
</button>
</div>
<div class="orders-toolbar__pills" role="tablist" aria-label="فیلتر وضعیت">
<button
v-for="pill in desktopFilterTabs"
:key="pill.id"
type="button"
role="tab"
class="orders-filter-pill"
:class="{ 'orders-filter-pill--active': desktopFilter === pill.id }"
:aria-selected="desktopFilter === pill.id"
@click="emit('setDesktopFilter', pill.id)"
>
{{ pill.label }}
</button>
</div>
</div>
<div v-if="filteredOrders.length > 0" class="desktop-table-wrap">
<div class="desktop-table-scroll">
<table class="desktop-table orders-table">
<thead>
<tr>
<th scope="col">شماره سفارش</th>
<th scope="col">شرکت</th>
<th scope="col">مبلغ فاکتور (تومان)</th>
<th scope="col">وضعیت</th>
<th scope="col">تاریخ ثبت</th>
<th scope="col">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="order in paginatedOrders" :key="order.id">
<td class="orders-table__number">{{ order.orderNumber }}</td>
<td>{{ order.companyName }}</td>
<td class="orders-table__amount">
{{ order.finalAmount ? formatNumber(order.finalAmount) : '—' }}
</td>
<td><StatusBadge :status="order.status" /></td>
<td class="orders-table__date">{{ formatDateTime(order.createdAt) }}</td>
<td>
<div class="orders-table__actions">
<button type="button" class="orders-table__action" @click="emit('select', order.id)">
مشاهده
</button>
<button type="button" class="orders-table__menu" aria-label="گزینه‌های بیشتر">
<IconMoreVertical :size="18" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="orders-pagination">
<span class="orders-pagination__summary">
نمایش {{ formatNumber(paginationRange.start) }} تا {{ formatNumber(paginationRange.end) }} از
{{ formatNumber(filteredOrders.length) }} سفارش
</span>
<div class="orders-pagination__controls">
<button
type="button"
class="orders-pagination__arrow"
:disabled="currentPage <= 1"
aria-label="صفحه قبل"
@click="emit('setPage', currentPage - 1)"
>
<IconChevronLeft :size="16" class="orders-pagination__arrow-icon--prev" />
</button>
<template v-for="(page, index) in visiblePages" :key="`${page}-${index}`">
<span v-if="page === 'ellipsis'" class="orders-pagination__ellipsis">…</span>
<button
v-else
type="button"
class="orders-pagination__page"
:class="{ 'orders-pagination__page--active': page === currentPage }"
@click="emit('setPage', page)"
>
{{ formatNumber(page) }}
</button>
</template>
<button
type="button"
class="orders-pagination__arrow"
:disabled="currentPage >= totalPages"
aria-label="صفحه بعد"
@click="emit('setPage', currentPage + 1)"
>
<IconChevronLeft :size="16" class="orders-pagination__arrow-icon--next" />
</button>
</div>
</div>
</div>
<EmptyState v-else variant="orders" message="هنوز سفارشی ثبت نکرده‌اید">
<BaseButton @click="emit('newOrder')">ثبت سفارش جدید</BaseButton>
</EmptyState>
</div>
</template>
@@ -0,0 +1,326 @@
<script setup lang="ts">
import EmptyState from '@/components/ui/EmptyState.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import FilterTabs from '@/components/ui/FilterTabs.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconParLogo from '@/components/icons/IconParLogo.vue'
import IconFamBaftLogo from '@/components/icons/IconFamBaftLogo.vue'
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
import { formatCurrency, formatNumber, toPersianDigits } from '@/utils/format'
import type { Order } from '@/types'
defineProps<{
tab: string
tabs: { id: string; label: string }[]
orders: Order[]
totalCount: number
currentPage: number
totalPages: number
paginationRange: { start: number; end: number }
visiblePages: (number | 'ellipsis')[]
companySlug: (order: Order) => string
iconVariant: (order: Order) => string
}>()
const emit = defineEmits<{
'update:tab': [value: string]
select: [id: string]
back: []
'set-page': [page: number]
}>()
</script>
<template>
<div class="page orders-mobile-page">
<header class="orders-mobile-page__nav">
<button type="button" class="nav-btn" aria-label="بازگشت" @click="emit('back')">
<IconChevronBack :size="20" />
</button>
<h1 class="orders-mobile-page__title">سفارش‌ها</h1>
<AuthThemeToggle variant="inline" />
</header>
<FilterTabs
:model-value="tab"
:tabs="tabs"
equal
class="orders-mobile-page__tabs"
@update:model-value="emit('update:tab', $event)"
/>
<div class="page__scroll page__scroll--with-nav orders-mobile-page__scroll">
<div v-if="orders.length > 0" class="orders-mobile-page__list">
<button
v-for="order in orders"
:key="order.id"
type="button"
class="order-card"
@click="emit('select', order.id)"
>
<div class="order-card__content">
<span class="order-card__label">سفارش شماره</span>
<strong class="order-card__number">{{ order.orderNumber }}</strong>
<p class="order-card__company">{{ order.companyName }}</p>
<p v-if="order.finalAmount" class="order-card__amount">
{{ formatCurrency(order.finalAmount) }}
</p>
<StatusBadge :status="order.status" class="order-card__badge" />
</div>
<span class="order-card__icon" :class="`order-card__icon--${iconVariant(order)}`">
<IconFamBaftLogo v-if="companySlug(order) === 'fam-baft'" :size="36" />
<IconParLogo v-else :size="36" />
</span>
</button>
</div>
<EmptyState v-else variant="orders" message="هنوز سفارشی ثبت نکرده‌اید">
<BaseButton @click="emit('back')">ثبت سفارش جدید</BaseButton>
</EmptyState>
<div v-if="totalCount > 0" class="orders-mobile-pagination">
<p class="orders-mobile-pagination__summary">
{{ toPersianDigits(String(paginationRange.start)) }}–
{{ toPersianDigits(String(paginationRange.end)) }}
از {{ formatNumber(totalCount) }}
</p>
<div class="orders-mobile-pagination__controls">
<button
type="button"
class="orders-mobile-pagination__arrow"
:disabled="currentPage <= 1"
aria-label="صفحه قبل"
@click="emit('set-page', currentPage - 1)"
>
<IconChevronLeft :size="16" class="orders-mobile-pagination__arrow--prev" />
</button>
<template v-for="(page, index) in visiblePages" :key="`${page}-${index}`">
<span v-if="page === 'ellipsis'" class="orders-mobile-pagination__ellipsis">…</span>
<button
v-else
type="button"
class="orders-mobile-pagination__page"
:class="{ 'orders-mobile-pagination__page--active': page === currentPage }"
@click="emit('set-page', page)"
>
{{ toPersianDigits(String(page)) }}
</button>
</template>
<button
type="button"
class="orders-mobile-pagination__arrow"
:disabled="currentPage >= totalPages"
aria-label="صفحه بعد"
@click="emit('set-page', currentPage + 1)"
>
<IconChevronLeft :size="16" class="orders-mobile-pagination__arrow--next" />
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.orders-mobile-page {
background: var(--color-bg);
color: var(--color-text);
padding: 12px 0 0;
height: 100%;
max-height: 100%;
}
.orders-mobile-page__nav {
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: center;
margin-bottom: 16px;
padding-inline: var(--page-padding);
flex-shrink: 0;
color: var(--color-text);
}
.orders-mobile-page__title {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
text-align: center;
color: var(--color-text);
}
.nav-btn {
width: 40px;
height: 40px;
border: none;
background: none;
color: inherit;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.orders-mobile-page__tabs {
margin-bottom: 12px;
padding-inline: var(--page-padding);
flex-shrink: 0;
}
.orders-mobile-page__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-inline: var(--page-padding);
padding-bottom: 24px;
}
.orders-mobile-page__list {
display: flex;
flex-direction: column;
gap: 12px;
}
.order-card {
display: flex;
width: 100%;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: 16px;
background: var(--color-elevated);
box-shadow: var(--shadow-card);
color: var(--color-text);
text-align: right;
cursor: pointer;
}
.order-card__content {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.order-card__label {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.order-card__number {
font-size: 0.9375rem;
font-weight: 700;
margin-bottom: 6px;
color: var(--color-text);
}
.order-card__company {
margin: 0 0 6px;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.order-card__amount {
margin: 0 0 10px;
font-size: 0.875rem;
font-weight: 700;
color: var(--color-text);
}
.order-card__badge {
margin-top: auto;
}
.order-card__icon {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 14px;
align-self: center;
}
.order-card__icon--par {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.order-card__icon--fam-baft {
background: var(--color-brand-warm);
color: var(--color-brand-warm-text);
}
.order-card__icon--success {
background: var(--color-success-soft);
color: var(--color-success);
}
.orders-mobile-pagination {
margin: 16px 0 0;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.orders-mobile-pagination__summary {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.orders-mobile-pagination__controls {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
justify-content: center;
}
.orders-mobile-pagination__arrow,
.orders-mobile-pagination__page {
width: 36px;
height: 36px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-elevated);
color: var(--color-text);
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
font-family: inherit;
font-size: 0.8125rem;
cursor: pointer;
}
.orders-mobile-pagination__arrow:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.orders-mobile-pagination__arrow--prev {
transform: scaleX(-1);
}
.orders-mobile-pagination__arrow--next {
transform: none;
}
.orders-mobile-pagination__page--active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
font-weight: 600;
}
.orders-mobile-pagination__ellipsis {
min-width: 20px;
text-align: center;
color: var(--color-text-muted);
}
</style>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import PageHeader from '@/components/layout/PageHeader.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import { useFavoritesStore } from '@/stores/favorites'
import { useCrmStore } from '@/stores/crm'
import type { CatalogProductCard } from '@/types/crm'
import IconHeart from '@/components/icons/IconHeart.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
const router = useRouter()
const favorites = useFavoritesStore()
const crm = useCrmStore()
const products = ref<CatalogProductCard[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
onMounted(async () => {
loading.value = true
try {
await favorites.hydrate()
const all = await crm.loadBrowseProducts()
const set = new Set(favorites.ids)
products.value = all.filter((p) => set.has(p.id))
} catch (e) {
error.value = e instanceof Error ? e.message : 'بارگذاری علاقه‌مندی‌ها ناموفق بود.'
} finally {
loading.value = false
}
})
async function remove(id: string) {
await favorites.toggle(id)
products.value = products.value.filter((p) => p.id !== id)
}
</script>
<template>
<div class="page">
<PageHeader title="علاقه‌مندی‌ها" show-back centered @back="router.back()" />
<div class="page__scroll page__scroll--with-nav body">
<PageSkeleton v-if="loading" variant="grid" />
<p v-else-if="error">{{ error }}</p>
<p v-else-if="!products.length" class="empty">هنوز محصولی ذخیره نکرده‌اید.</p>
<article
v-for="item in products"
:key="item.id"
class="row"
@click="router.push({ path: '/product', query: { id: item.id } })"
>
<div class="row__body">
<strong>{{ item.name }}</strong>
<span>کد {{ item.code }}</span>
</div>
<button
type="button"
class="bookmark"
aria-label="حذف از نشان‌شده‌ها"
@click.stop="remove(item.id)"
>
<IconHeart :size="18" filled color="#0a353c" />
</button>
<IconChevronLeft :size="16" color="#9aa3a0" />
</article>
</div>
</div>
</template>
<style scoped>
.body {
padding: 12px var(--page-padding) 24px;
display: flex;
flex-direction: column;
gap: 10px;
}
.empty {
text-align: center;
color: var(--color-text-muted);
}
.row {
display: flex;
align-items: center;
gap: 8px;
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 12px;
box-shadow: var(--shadow-sm);
cursor: pointer;
}
.row__body {
flex: 1;
min-width: 0;
}
.row__body strong {
display: block;
}
.row__body span {
color: var(--color-text-muted);
font-size: 0.8rem;
}
.bookmark {
border: none;
background: transparent;
cursor: pointer;
width: 36px;
height: 36px;
display: grid;
place-items: center;
}
</style>
+604
View File
@@ -0,0 +1,604 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCrmStore } from '@/stores/crm'
import { useFavoritesStore } from '@/stores/favorites'
import { useCartStore } from '@/stores/cart'
import { useCatalogStore } from '@/stores/catalog'
import type { CatalogProductCard } from '@/types/crm'
import type { Product } from '@/types'
import { formatNumber } from '@/utils/format'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import IconCart from '@/components/icons/IconCart.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
import IconHeart from '@/components/icons/IconHeart.vue'
import IconMail from '@/components/icons/IconMail.vue'
import { useGoBack } from '@/composables/useGoBack'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
const route = useRoute()
const router = useRouter()
const { goBack } = useGoBack('/products')
const crm = useCrmStore()
const favorites = useFavoritesStore()
const cartStore = useCartStore()
const catalogStore = useCatalogStore()
const productId = computed(() => String(route.query.id ?? ''))
const product = ref<CatalogProductCard | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const selectedColor = ref(0)
const rolls = ref(1)
const adding = ref(false)
const actionMessage = ref<string | null>(null)
const showAllSpecs = ref(false)
const rollMeters = computed(() => product.value?.rollMeters ?? 50)
const orderMeters = computed(() => rolls.value * rollMeters.value)
const minRolls = computed(() => {
const min = product.value?.minMeterage ?? rollMeters.value
return Math.max(1, Math.ceil(min / rollMeters.value))
})
async function load() {
if (!productId.value) {
product.value = null
loading.value = false
return
}
loading.value = true
error.value = null
actionMessage.value = null
try {
await Promise.all([favorites.hydrate(), catalogStore.loadCompanies()])
product.value = await crm.loadProductById(productId.value)
selectedColor.value = 0
rolls.value = minRolls.value
} catch (e) {
product.value = null
error.value = e instanceof Error ? e.message : 'محصول یافت نشد.'
} finally {
loading.value = false
}
}
onMounted(load)
watch(productId, load)
function toCartProduct(p: CatalogProductCard): Product | null {
if (!p.companyId || !p.catalogId) return null
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
return {
id: p.id,
companyId: p.companyId,
catalogId: p.catalogId,
name: p.name,
code: p.code,
color: p.color || p.colors[selectedColor.value] || '—',
minMeterage: min,
orderMultiplier: step,
basePrice: p.basePrice,
isActive: true,
}
}
function snapMeterage(meters: number, step: number, min: number) {
const safeStep = step > 0 ? step : 1
const snapped = Math.ceil(meters / safeStep) * safeStep
const minSnapped = Math.ceil(min / safeStep) * safeStep
return Math.max(snapped, minSnapped)
}
async function addToOrder() {
if (!product.value) return
const cartProduct = toCartProduct(product.value)
if (!cartProduct) {
actionMessage.value = 'اطلاعات شرکت/کاتالوگ محصول ناقص است.'
return
}
if (!catalogStore.getCompany(cartProduct.companyId)) {
await catalogStore.loadCompanies()
}
adding.value = true
actionMessage.value = null
try {
const meterage = snapMeterage(
orderMeters.value,
cartProduct.orderMultiplier,
cartProduct.minMeterage,
)
const result = await cartStore.addItem(
cartProduct,
product.value.catalogCode || product.value.code,
meterage,
)
if (result.success) {
actionMessage.value = 'به سفارش اضافه شد.'
await router.push('/cart')
} else {
actionMessage.value = result.message || 'افزودن به سفارش ناموفق بود.'
}
} finally {
adding.value = false
}
}
function toggleFavorite() {
if (!product.value) return
void favorites.toggle(product.value.id)
}
function adjustRolls(delta: number) {
rolls.value = Math.max(minRolls.value, rolls.value + delta)
}
function shareProduct() {
const url = window.location.href
if (navigator.share) {
void navigator.share({ title: product.value?.name, url }).catch(() => undefined)
} else {
void navigator.clipboard?.writeText(url)
actionMessage.value = 'لینک محصول کپی شد.'
}
}
</script>
<template>
<div v-if="product" class="page product-detail">
<header class="pd-header">
<button type="button" class="icon-btn" aria-label="بازگشت" @click="goBack">
<IconChevronBack :size="20" />
</button>
<div class="pd-header__actions">
<button
type="button"
class="icon-btn"
:aria-label="
favorites.isFavorite(product.id)
? 'حذف از علاقه‌مندی‌ها'
: 'افزودن به علاقه‌مندی‌ها'
"
:aria-pressed="favorites.isFavorite(product.id)"
@click="toggleFavorite"
>
<IconHeart
:size="20"
:filled="favorites.isFavorite(product.id)"
:color="favorites.isFavorite(product.id) ? '#0a353c' : undefined"
/>
</button>
<button type="button" class="icon-btn" aria-label="اشتراک‌گذاری" @click="shareProduct">
<IconMail :size="20" />
</button>
</div>
</header>
<div class="page__scroll pd-scroll">
<div class="gallery">
<div
class="gallery__main"
:style="
product.imageUrl
? {
backgroundImage: `url(${product.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
: { background: product.colors[selectedColor] || '#6b7280' }
"
>
<span class="gallery__count">
{{ formatNumber(selectedColor + 1) }}/{{ formatNumber(Math.max(product.colors.length, 1)) }}
</span>
</div>
<div class="gallery__thumbs">
<button
v-for="(color, idx) in product.colors"
:key="idx"
type="button"
class="thumb"
:class="{ 'thumb--active': selectedColor === idx }"
:style="{ background: color }"
@click="selectedColor = idx"
/>
</div>
</div>
<div class="pd-body">
<div class="title-row">
<h1>{{ product.name }}</h1>
<span v-if="product.isNew" class="new-badge">جدید</span>
</div>
<p class="code">کد محصول: {{ product.code }}</p>
<div class="specs">
<div class="spec-cell">
<span class="spec-cell__label">عرض</span>
<strong>{{ formatNumber(product.widthCm) }} سانتی متر</strong>
</div>
<div class="spec-cell">
<span class="spec-cell__label">جنس</span>
<strong>{{ product.composition || '—' }}</strong>
</div>
<div class="spec-cell">
<span class="spec-cell__label">وزن</span>
<strong>{{ product.weightGrams ? `${formatNumber(product.weightGrams)} گرم` : '—' }}</strong>
</div>
<div class="spec-cell">
<span class="spec-cell__label">نوع بافت</span>
<strong>{{ product.weaveType || '—' }}</strong>
</div>
</div>
<button type="button" class="specs-more" @click="showAllSpecs = !showAllSpecs">
{{ showAllSpecs ? 'بستن مشخصات' : 'مشاهده همه مشخصات' }}
</button>
<div v-if="showAllSpecs" class="specs-extra">
<p>فصل: {{ product.season }}</p>
<p>دسته: {{ product.categoryId }}</p>
<p v-if="product.basePrice">قیمت پایه: {{ formatNumber(product.basePrice) }} تومان</p>
</div>
<section>
<h2 class="section-label">
رنگ بندی
<span>({{ formatNumber(product.colors.length) }} رنگ موجود)</span>
</h2>
<div class="colors">
<button
v-for="(color, idx) in product.colors"
:key="idx"
type="button"
class="color-dot"
:class="{ 'color-dot--active': selectedColor === idx }"
:style="{ background: color }"
@click="selectedColor = idx"
/>
</div>
</section>
<section>
<h2 class="section-label">دسته بندی</h2>
<div class="tags">
<span v-for="tag in product.tags" :key="tag" class="tag">{{ tag }}</span>
<span v-if="!product.tags.length" class="tag">{{ product.categoryId }}</span>
</div>
</section>
<section>
<h2 class="section-label">متراژ هر ساقه (طاقه)</h2>
<div class="roll-field">{{ formatNumber(product.rollMeters) }} متر</div>
</section>
<section>
<h2 class="section-label">تعداد طاقه</h2>
<div class="roll-stepper">
<button type="button" class="step-btn" @click="adjustRolls(-1)">−</button>
<strong>{{ formatNumber(rolls) }}</strong>
<button type="button" class="step-btn" @click="adjustRolls(1)">+</button>
</div>
<p class="order-meters">جمع متراژ سفارش: {{ formatNumber(orderMeters) }} متر</p>
</section>
<p v-if="actionMessage" class="action-msg">{{ actionMessage }}</p>
</div>
</div>
<footer class="pd-footer">
<button type="button" class="btn-secondary" @click="router.push({ path: '/catalog-request', query: { productId: product.id } })">
<IconCatalog :size="18" />
درخواست کالیته
</button>
<div class="pd-footer__primary">
<button type="button" class="btn-primary" :disabled="adding" @click="addToOrder">
<IconCart :size="18" color="#ffffff" />
{{ adding ? 'در حال افزودن...' : 'افزودن به سفارش' }}
</button>
<p class="min-note">حداقل سفارش: {{ formatNumber(minRolls) }} طاقه</p>
</div>
</footer>
</div>
<div v-else-if="loading" class="page empty">
<PageSkeleton variant="detail" />
</div>
<div v-else class="page empty">
<p>{{ error || 'محصول یافت نشد.' }}</p>
<button type="button" @click="router.push('/products')">بازگشت به لیست</button>
</div>
</template>
<style scoped>
.product-detail {
background: var(--color-bg);
display: flex;
flex-direction: column;
min-height: 100%;
}
.pd-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px var(--page-padding);
background: var(--color-surface);
}
.pd-header__actions {
display: flex;
gap: 4px;
}
.icon-btn {
width: 40px;
height: 40px;
border: none;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
}
.pd-scroll {
flex: 1;
padding-bottom: 120px;
}
.gallery__main {
position: relative;
height: 260px;
background: linear-gradient(160deg, #a8b5a0, #6f8570);
}
.gallery__count {
position: absolute;
bottom: 12px;
inset-inline-start: 12px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border-radius: 999px;
padding: 4px 10px;
font-size: 0.75rem;
}
.gallery__thumbs {
display: flex;
gap: 8px;
padding: 12px var(--page-padding);
overflow-x: auto;
background: var(--color-surface);
}
.thumb {
width: 48px;
height: 48px;
border-radius: 10px;
border: 2px solid transparent;
cursor: pointer;
flex-shrink: 0;
}
.thumb--active {
border-color: #111;
}
.pd-body {
padding: 16px var(--page-padding) 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.title-row {
display: flex;
align-items: center;
gap: 8px;
}
.title-row h1 {
margin: 0;
font-size: 1.25rem;
}
.new-badge {
background: var(--color-primary-soft);
color: var(--color-primary);
border-radius: 999px;
padding: 2px 10px;
font-size: 0.75rem;
font-weight: 700;
}
.code {
margin: -8px 0 0;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.specs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 12px;
font-size: 0.8rem;
}
.spec-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.spec-cell__label {
color: var(--color-text-muted);
font-size: 0.75rem;
}
.specs-more {
border: none;
background: transparent;
color: var(--color-primary);
font-weight: 700;
cursor: pointer;
align-self: flex-start;
padding: 0;
}
.specs-extra {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 12px;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.specs-extra p {
margin: 0 0 6px;
}
.section-label {
margin: 0 0 8px;
font-size: 0.95rem;
}
.section-label span {
color: var(--color-text-muted);
font-weight: 500;
font-size: 0.8rem;
}
.colors {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.color-dot {
width: 28px;
height: 28px;
border-radius: 999px;
border: 2px solid transparent;
cursor: pointer;
}
.color-dot--active {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag {
background: var(--color-bg);
border-radius: 999px;
padding: 8px 12px;
font-size: 0.8rem;
}
.roll-field {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
padding: 12px 14px;
}
.roll-stepper {
display: inline-flex;
align-items: center;
gap: 16px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 8px 12px;
}
.step-btn {
width: 36px;
height: 36px;
border-radius: 10px;
border: 1px solid var(--color-border);
background: #fff;
font-size: 1.2rem;
cursor: pointer;
}
.order-meters,
.action-msg {
margin: 8px 0 0;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.action-msg {
color: var(--color-primary);
font-weight: 600;
}
.pd-footer {
position: sticky;
bottom: 0;
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 8px;
padding: 12px var(--page-padding) calc(12px + env(safe-area-inset-bottom, 0));
background: var(--color-surface);
border-top: 1px solid var(--color-border);
}
.pd-footer__primary {
display: flex;
flex-direction: column;
}
.btn-secondary,
.btn-primary {
width: 100%;
min-height: 48px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
font-weight: 700;
cursor: pointer;
}
.btn-secondary {
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-primary);
}
.btn-primary {
border: none;
background: var(--color-accent-navy);
color: #fff;
}
.btn-primary:disabled {
opacity: 0.7;
cursor: wait;
}
.min-note {
margin: 6px 0 0;
text-align: center;
font-size: 0.7rem;
color: var(--color-text-muted);
}
.empty {
padding: 40px var(--page-padding);
text-align: center;
}
</style>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useProductOrdering } from '@/composables/useProductOrdering'
import MobileProductPage from '@/views/products/components/MobileProductPage.vue'
import DesktopProductPage from '@/views/products/components/DesktopProductPage.vue'
const router = useRouter()
const {
companyId,
catalog,
company,
colorFilter,
colors,
colorLabels,
products,
meterages,
errors,
toast,
cartStore,
adjustMeterage,
setMeterage,
addProduct,
goToCart,
goBack,
} = useProductOrdering()
</script>
<template>
<MobileProductPage
class="mobile-only"
:catalog-code="catalog?.code ?? ''"
:company-name="company?.name ?? ''"
:colors="colors"
:color-labels="colorLabels"
:color-filter="colorFilter"
:products="products"
:meterages="meterages"
:errors="errors"
:total-meterage="cartStore.totalMeterage"
:item-count="cartStore.itemCount"
:toast="toast"
@back="goBack"
@update:color-filter="colorFilter = $event"
@adjust="adjustMeterage"
@set-meterage="setMeterage"
@add="addProduct"
@cart="goToCart"
/>
<DesktopProductPage
class="desktop-only"
:catalog-code="catalog?.code ?? ''"
:company-name="company?.name ?? ''"
:company-id="companyId"
:colors="colors"
:color-labels="colorLabels"
:color-filter="colorFilter"
:products="products"
:meterages="meterages"
:errors="errors"
:item-count="cartStore.itemCount"
:total-meterage="cartStore.totalMeterage"
:remaining-capacity="cartStore.remainingCapacity"
:toast="toast"
@update:color-filter="colorFilter = $event"
@adjust="adjustMeterage"
@set-meterage="setMeterage"
@add="addProduct"
@cart="goToCart"
@home="router.push('/')"
@catalogs="goBack"
/>
</template>
+449
View File
@@ -0,0 +1,449 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCrmStore } from '@/stores/crm'
import type { CatalogProductCard, SeasonCategory, SeasonKind } from '@/types/crm'
import PageHeader from '@/components/layout/PageHeader.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import SearchField from '@/components/ui/SearchField.vue'
import IconFilter from '@/components/icons/IconFilter.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconHeart from '@/components/icons/IconHeart.vue'
import { useFavoritesStore } from '@/stores/favorites'
const route = useRoute()
const router = useRouter()
const crm = useCrmStore()
const favorites = useFavoritesStore()
const categoryId = computed(() => String(route.query.categoryId ?? ''))
const season = computed(() => String(route.query.season ?? '') as SeasonKind | '')
const q = computed(() => String(route.query.q ?? ''))
const searchDraft = ref(String(route.query.q ?? ''))
const products = ref<CatalogProductCard[]>([])
const seasons = ref<SeasonCategory[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const sortMode = ref<'newest' | 'name'>('newest')
const title = computed(() => {
if (categoryId.value) {
const cat = crm.usageCategories.find((c) => c.id === categoryId.value)
return cat?.title ?? 'محصولات'
}
if (season.value) {
return seasons.value.find((s) => s.id === season.value)?.title ?? 'محصولات'
}
if (q.value) return `جستجو: ${q.value}`
return 'همه محصولات'
})
const sortedProducts = computed(() => {
const needle = q.value.trim().toLowerCase()
let list = [...products.value]
if (needle) {
list = list.filter(
(p) =>
p.name.toLowerCase().includes(needle) ||
p.code.toLowerCase().includes(needle) ||
(p.composition || '').toLowerCase().includes(needle),
)
}
if (sortMode.value === 'name') {
return list.sort((a, b) => a.name.localeCompare(b.name, 'fa'))
}
return list.sort((a, b) => Number(b.isNew) - Number(a.isNew) || a.name.localeCompare(b.name, 'fa'))
})
function cycleSort() {
sortMode.value = sortMode.value === 'newest' ? 'name' : 'newest'
}
function applySearch(value: string) {
searchDraft.value = value
router.replace({
path: '/products',
query: {
...(categoryId.value ? { categoryId: categoryId.value } : {}),
...(season.value ? { season: season.value } : {}),
...(value.trim() ? { q: value.trim() } : {}),
},
})
}
async function load() {
loading.value = true
error.value = null
try {
if (!crm.usageCategories.length || !seasons.value.length) {
const data = await crm.loadCategories()
seasons.value = data.seasons as SeasonCategory[]
}
products.value = await crm.loadBrowseProducts({
categoryId: categoryId.value || undefined,
season: season.value || undefined,
})
} catch (e) {
error.value = e instanceof Error ? e.message : 'بارگذاری محصولات ناموفق بود.'
products.value = []
} finally {
loading.value = false
}
}
onMounted(async () => {
await favorites.hydrate()
await load()
})
watch([categoryId, season], load)
watch(q, (value) => {
searchDraft.value = value
})
function setSeason(value: string) {
router.replace({
path: '/products',
query: {
...(categoryId.value ? { categoryId: categoryId.value } : {}),
...(value ? { season: value } : {}),
...(q.value ? { q: q.value } : {}),
},
})
}
function openProduct(id: string) {
router.push({ path: '/product', query: { id } })
}
function seasonTitle(id: string) {
return seasons.value.find((s) => s.id === id)?.title ?? id
}
function toggleFavorite(productId: string) {
void favorites.toggle(productId)
}
</script>
<template>
<div class="page products-page">
<PageHeader :title="title" show-back centered @back="router.back()">
<template #trailing>
<button type="button" class="nav-filter" aria-label="فیلتر" @click="router.push('/categories')">
<IconFilter :size="18" />
</button>
</template>
</PageHeader>
<div class="page__scroll page__scroll--with-nav products-scroll">
<SearchField
:model-value="searchDraft"
placeholder="جستجو نام یا کد محصول..."
@update:model-value="applySearch"
/>
<p v-if="error">{{ error }}</p>
<PageSkeleton v-else-if="loading" variant="grid" />
<template v-else>
<section class="cat-banner">
<div class="cat-banner__icon" aria-hidden="true" />
<div>
<h2>{{ title }}</h2>
<p>شیک، بادوام و رسمی</p>
</div>
</section>
<div class="chips" role="tablist" aria-label="فصل">
<button
type="button"
class="chip"
:class="{ 'chip--active': !season }"
@click="setSeason('')"
>
همه
</button>
<button
v-for="item in seasons"
:key="item.id"
type="button"
class="chip"
:class="{ 'chip--active': season === item.id }"
@click="setSeason(item.id)"
>
{{ item.title }}
</button>
</div>
<div class="list-meta">
<span>تعداد محصولات: {{ sortedProducts.length.toLocaleString('fa-IR') }}</span>
<button type="button" class="list-meta__sort" @click="cycleSort">
مرتب‌سازی: {{ sortMode === 'newest' ? 'جدیدترین' : 'نام' }}
</button>
</div>
<article
v-for="item in sortedProducts"
:key="item.id"
class="product-item"
@click="openProduct(item.id)"
>
<div
class="product-item__image"
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
/>
<div class="product-item__body">
<div class="product-item__head">
<div class="product-item__title">
<strong>{{ item.name }}</strong>
<span v-if="item.isNew" class="new-tag">جدید</span>
</div>
<button
type="button"
class="bookmark"
:aria-label="
favorites.isFavorite(item.id)
? 'حذف از علاقه‌مندی‌ها'
: 'افزودن به علاقه‌مندی‌ها'
"
:aria-pressed="favorites.isFavorite(item.id)"
@click.stop="toggleFavorite(item.id)"
>
<IconHeart
:size="18"
:filled="favorites.isFavorite(item.id)"
:color="favorites.isFavorite(item.id) ? '#0a353c' : '#9ca3af'"
/>
</button>
</div>
<ul class="product-item__specs">
<li>عرض: {{ item.widthCm.toLocaleString('fa-IR') }} سانتی‌متر</li>
<li>ترکیب: {{ item.composition || '—' }}</li>
<li>مناسب: {{ seasonTitle(item.season) }}</li>
</ul>
<div class="swatches">
<span
v-for="(color, idx) in item.colors.slice(0, 6)"
:key="idx"
class="swatch"
:style="{ background: color }"
/>
</div>
<button type="button" class="details-link" @click.stop="openProduct(item.id)">
مشاهده جزئیات
<IconChevronLeft :size="14" />
</button>
</div>
</article>
</template>
</div>
</div>
</template>
<style scoped>
.products-page {
position: relative;
background: var(--color-bg);
}
.nav-filter {
width: 40px;
height: 40px;
border: none;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
color: var(--color-text);
}
.products-scroll {
padding: 8px var(--page-padding) 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.cat-banner {
display: flex;
align-items: center;
gap: 12px;
min-height: 96px;
padding: 16px;
border-radius: var(--radius-lg);
background:
linear-gradient(100deg, rgba(var(--color-primary-rgb), 0.75), rgba(var(--color-primary-rgb), 0.35)),
#2f3430;
color: #fff;
}
.cat-banner__icon {
width: 48px;
height: 48px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
flex-shrink: 0;
}
.cat-banner h2 {
margin: 0 0 4px;
font-size: 1rem;
}
.cat-banner p {
margin: 0;
font-size: 0.8rem;
opacity: 0.9;
}
.chips {
display: flex;
gap: 8px;
overflow-x: auto;
}
.chip {
border: none;
border-radius: 999px;
padding: 8px 14px;
background: #ecefee;
color: var(--color-text);
white-space: nowrap;
cursor: pointer;
font-size: 0.8rem;
}
.chip--active {
background: var(--color-primary);
color: #fff;
}
.list-meta {
display: flex;
justify-content: space-between;
color: var(--color-text-muted);
font-size: 0.8rem;
align-items: center;
}
.list-meta__sort {
border: none;
background: transparent;
color: var(--color-primary);
font: inherit;
font-weight: 700;
cursor: pointer;
padding: 0;
}
.product-item {
display: grid;
grid-template-columns: 112px 1fr;
align-items: stretch;
gap: 12px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 12px;
cursor: pointer;
}
.product-item__image {
border-radius: 12px;
background:
linear-gradient(145deg, #2a3544, #111827);
background-size: cover;
background-position: center;
min-height: 112px;
}
.product-item__body {
display: flex;
flex-direction: column;
min-width: 0;
gap: 6px;
}
.product-item__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.product-item__title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.product-item__title strong {
font-size: 0.9375rem;
font-weight: 700;
line-height: 1.4;
color: var(--color-text);
}
.new-tag {
background: var(--color-primary);
color: #fff;
font-size: 0.65rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
white-space: nowrap;
}
.bookmark {
width: 28px;
height: 28px;
margin-inline-start: auto;
border: none;
background: transparent;
padding: 0;
display: grid;
place-items: center;
cursor: pointer;
flex-shrink: 0;
}
.product-item__specs {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
color: var(--color-text-muted);
font-size: 0.75rem;
line-height: 1.55;
text-align: start;
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 2px;
}
.swatch {
width: 14px;
height: 14px;
border-radius: 999px;
border: 1px solid rgba(0, 0, 0, 0.08);
}
.details-link {
margin-top: auto;
align-self: flex-end;
border: none;
background: transparent;
color: var(--color-info);
display: inline-flex;
align-items: center;
gap: 2px;
padding: 0;
font-size: 0.75rem;
cursor: pointer;
}
</style>
@@ -0,0 +1,322 @@
<script setup lang="ts">
import BaseButton from '@/components/ui/BaseButton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import IconCart from '@/components/icons/IconCart.vue'
import { formatMeterage, formatNumber } from '@/utils/format'
import type { Product } from '@/types'
defineProps<{
catalogCode: string
companyName: string
companyId: string
colors: string[]
colorLabels: Record<string, string>
colorFilter: string
products: Product[]
meterages: Record<string, number>
errors: Record<string, string>
itemCount: number
totalMeterage: number
remainingCapacity: number
toast: string
}>()
const emit = defineEmits<{
'update:colorFilter': [value: string]
adjust: [productId: string, delta: number, step: number]
setMeterage: [productId: string, value: number]
add: [productId: string]
cart: []
home: []
catalogs: []
}>()
</script>
<template>
<div class="desktop-page product-desktop-page">
<header class="desktop-page__head">
<h1 class="desktop-page__title">کالیته {{ catalogCode }}</h1>
<p class="desktop-page__subtitle">{{ companyName }}</p>
</header>
<nav class="product-breadcrumbs" aria-label="مسیر">
<button type="button" @click="emit('home')">سفارش‌گذاری</button>
<span aria-hidden="true">›</span>
<button type="button" @click="emit('catalogs')">{{ companyName }}</button>
<span aria-hidden="true">›</span>
<span>کالیته {{ catalogCode }}</span>
</nav>
<div class="desktop-two-col">
<div class="desktop-two-col__main">
<div class="color-tabs">
<button
v-for="color in colors"
:key="color"
type="button"
class="color-tab"
:class="{ active: colorFilter === color }"
@click="emit('update:colorFilter', color)"
>
{{ colorLabels[color] ?? color }}
</button>
</div>
<div v-if="products.length > 0" class="product-desktop-grid">
<article v-for="product in products" :key="product.id" class="product-desktop-card">
<div class="product-desktop-card__preview" aria-hidden="true" />
<div class="product-desktop-card__body">
<div class="product-desktop-card__head">
<h3>{{ product.name }}</h3>
<span>کد: {{ product.code }}</span>
</div>
<p class="product-desktop-card__meta">
حداقل سفارش: {{ formatMeterage(product.minMeterage) }}
</p>
<div class="meterage-input-row">
<button
type="button"
class="qty-btn"
aria-label="کاهش"
@click="emit('adjust', product.id, -1, product.orderMultiplier)"
>
−
</button>
<input
:value="meterages[product.id] ?? ''"
type="number"
class="meterage-input"
:step="product.orderMultiplier"
:min="0"
placeholder="۰"
@input="
emit(
'setMeterage',
product.id,
Number(($event.target as HTMLInputElement).value) || 0,
)
"
/>
<button
type="button"
class="qty-btn"
aria-label="افزایش"
@click="emit('adjust', product.id, 1, product.orderMultiplier)"
>
+
</button>
</div>
<BaseButton class="desktop-btn-compact product-desktop-card__add" @click="emit('add', product.id)">
<IconCart :size="16" />
افزودن
</BaseButton>
<p v-if="errors[product.id]" class="error-text">{{ errors[product.id] }}</p>
</div>
</article>
</div>
<EmptyState
v-else
variant="products"
message="برای این کالیته یا رنگ، محصولی ثبت نشده است"
/>
</div>
<aside v-if="itemCount > 0" class="desktop-summary-panel">
<h2 class="desktop-summary-panel__title">خلاصه سبد</h2>
<div class="desktop-summary-panel__row">
<span>تعداد اقلام</span>
<strong>{{ formatNumber(itemCount) }}</strong>
</div>
<div class="desktop-summary-panel__row">
<span>کل متراژ</span>
<strong>{{ formatMeterage(totalMeterage) }}</strong>
</div>
<div class="desktop-summary-panel__row">
<span>باقی‌مانده</span>
<strong>{{ formatMeterage(remainingCapacity) }}</strong>
</div>
<div class="desktop-summary-panel__action">
<BaseButton block @click="emit('cart')">
<IconCart :size="18" />
مشاهده سبد خرید
</BaseButton>
</div>
</aside>
</div>
<div v-if="toast" class="product-desktop-toast">{{ toast }}</div>
</div>
</template>
<style scoped>
.product-breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.product-breadcrumbs button {
border: none;
background: none;
padding: 0;
font-family: inherit;
color: var(--color-primary);
cursor: pointer;
}
.color-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.color-tab {
height: 34px;
padding: 0 16px;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
font-size: 0.8125rem;
cursor: pointer;
}
.color-tab.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.product-desktop-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr));
gap: 16px;
}
.product-desktop-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
box-shadow: var(--shadow-sm);
min-width: 0;
}
@media (min-width: 1200px) {
.product-desktop-card {
flex-direction: row;
align-items: flex-start;
gap: 14px;
max-width: 420px;
}
}
.product-desktop-card__preview {
width: 72px;
height: 72px;
border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--color-border-light), var(--color-border));
flex-shrink: 0;
}
.product-desktop-card__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.product-desktop-card__head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: baseline;
gap: 4px 8px;
margin-bottom: 4px;
}
.product-desktop-card__head h3 {
margin: 0;
font-size: 0.9375rem;
line-height: 1.35;
}
.product-desktop-card__head span {
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.product-desktop-card__meta {
margin: 0 0 10px;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.meterage-input-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
}
.qty-btn {
width: 32px;
height: 32px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
cursor: pointer;
}
.meterage-input {
width: 72px;
height: 32px;
text-align: center;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
}
.product-desktop-card__add :deep(.btn) {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 36px;
padding-inline: 16px;
}
.error-text {
color: var(--color-danger);
font-size: 0.75rem;
margin: 6px 0 0;
}
.product-desktop-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--color-text);
color: var(--color-bg);
padding: 10px 20px;
border-radius: var(--radius-sm);
z-index: 150;
box-shadow: var(--shadow-md);
font-size: 0.875rem;
}
.desktop-summary-panel__action :deep(.btn) {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
</style>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import PageHeader from '@/components/layout/PageHeader.vue'
import CartMeterageFooter from '@/components/ui/CartMeterageFooter.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import { formatMeterage } from '@/utils/format'
import type { Product } from '@/types'
defineProps<{
catalogCode: string
companyName: string
colors: string[]
colorLabels: Record<string, string>
colorFilter: string
products: Product[]
meterages: Record<string, number>
errors: Record<string, string>
totalMeterage: number
itemCount: number
toast: string
}>()
const emit = defineEmits<{
back: []
'update:colorFilter': [value: string]
adjust: [productId: string, delta: number, step: number]
setMeterage: [productId: string, value: number]
add: [productId: string]
cart: []
}>()
</script>
<template>
<div class="page product-mobile-page">
<PageHeader
:title="`کالیته ${catalogCode}`"
:subtitle="companyName"
show-back
centered
@back="emit('back')"
/>
<div class="color-tabs">
<button
v-for="color in colors"
:key="color"
type="button"
class="color-tab"
:class="{ active: colorFilter === color }"
@click="emit('update:colorFilter', color)"
>
{{ colorLabels[color] ?? color }}
</button>
</div>
<div class="page__scroll product-mobile-page__scroll">
<div v-if="products.length > 0" class="product-mobile-list">
<article v-for="product in products" :key="product.id" class="product-mobile-card">
<div class="product-mobile-card__head">
<h3>{{ product.name }}</h3>
<span>کد: {{ product.code }}</span>
</div>
<p class="product-mobile-card__meta">حداقل سفارش: {{ formatMeterage(product.minMeterage) }}</p>
<div class="meterage-input-row">
<button
type="button"
class="qty-btn"
aria-label="کاهش"
@click="emit('adjust', product.id, -1, product.orderMultiplier)"
>
−
</button>
<input
:value="meterages[product.id] ?? ''"
type="number"
class="meterage-input"
:step="product.orderMultiplier"
:min="0"
placeholder="۰"
@input="
emit(
'setMeterage',
product.id,
Number(($event.target as HTMLInputElement).value) || 0,
)
"
/>
<button
type="button"
class="qty-btn"
aria-label="افزایش"
@click="emit('adjust', product.id, 1, product.orderMultiplier)"
>
+
</button>
</div>
<BaseButton block @click="emit('add', product.id)">افزودن</BaseButton>
<p v-if="errors[product.id]" class="error-text">{{ errors[product.id] }}</p>
</article>
</div>
<EmptyState
v-else
variant="products"
message="برای این کالیته یا رنگ، محصولی ثبت نشده است"
/>
</div>
<div v-if="toast" class="toast">{{ toast }}</div>
<div v-if="itemCount > 0" class="sticky-footer">
<CartMeterageFooter
:total-meterage="totalMeterage"
:item-count="itemCount"
@click="emit('cart')"
/>
</div>
</div>
</template>
<style scoped>
.product-mobile-page {
background: var(--color-bg);
color: var(--color-text);
padding: 12px 0 0;
}
.product-mobile-page__scroll {
padding-bottom: 96px;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.color-tabs {
display: flex;
gap: 8px;
overflow-x: auto;
margin-bottom: 16px;
}
.color-tab {
flex-shrink: 0;
height: 34px;
padding: 0 16px;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
font-size: 0.8125rem;
cursor: pointer;
}
.color-tab.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.product-mobile-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.product-mobile-card {
padding: 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.product-mobile-card__head {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.product-mobile-card__head h3 {
margin: 0;
font-size: 0.9375rem;
}
.product-mobile-card__head span {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.product-mobile-card__meta {
margin: 0 0 12px;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.meterage-input-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 12px;
}
.qty-btn {
width: 40px;
height: 40px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
font-size: 1.25rem;
cursor: pointer;
}
.meterage-input {
width: 88px;
height: 40px;
text-align: center;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.error-text {
color: var(--color-danger);
font-size: 0.8125rem;
margin: 8px 0 0;
text-align: center;
}
.toast {
position: fixed;
bottom: calc(var(--nav-height) + 72px);
left: 50%;
transform: translateX(-50%);
background: var(--color-text);
color: var(--color-bg);
padding: 10px 20px;
border-radius: var(--radius-sm);
z-index: 150;
box-shadow: var(--shadow-md);
font-size: 0.875rem;
white-space: nowrap;
max-width: calc(100% - 32px);
}
</style>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import CheckListView from '@/views/checkout/CheckListView.vue'
</script>
<template>
<CheckListView />
</template>
+297
View File
@@ -0,0 +1,297 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useTimeoutFn } from '@vueuse/core'
import BaseButton from '@/components/ui/BaseButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import ProfileSubpageLayout from '@/views/profile/components/ProfileSubpageLayout.vue'
import { useAuthStore } from '@/stores/auth'
import { useOrderStore } from '@/stores/order'
import { useThemeStore, type ThemePreference } from '@/stores/theme'
import { ref } from 'vue'
const router = useRouter()
const auth = useAuthStore()
const orderStore = useOrderStore()
const theme = useThemeStore()
const saved = ref(false)
const { start: hideSaved } = useTimeoutFn(() => {
saved.value = false
}, 2000)
const themeOptions: {
value: ThemePreference
label: string
description: string
icon: string
}[] = [
{
value: 'light',
label: 'روشن',
description: 'پس‌زمینه روشن و کنتراست بالا',
icon: 'vuesax-linear:sun-1',
},
{
value: 'dark',
label: 'تاریک',
description: 'مناسب استفاده در شب',
icon: 'vuesax-linear:moon',
},
{
value: 'system',
label: 'سیستم',
description: 'هم‌تراز با تنظیمات دستگاه',
icon: 'vuesax-linear:mobile',
},
]
const checksCount = computed(() => orderStore.draftChecks.length)
function save() {
saved.value = true
hideSaved()
}
function logout() {
auth.logout()
router.replace('/login')
}
</script>
<template>
<ProfileSubpageLayout title="تنظیمات" subtitle="نمایش، حساب کاربری و خروج">
<div class="settings-page">
<nav class="settings-links" aria-label="حساب کاربری">
<button type="button" class="settings-link" @click="router.push('/profile/edit')">
<span class="settings-link__icon" aria-hidden="true">
<BasicIcon icon="vuesax-linear:profile-circle" :size="20" :stroke-width="1.5" />
</span>
<span class="settings-link__label">اطلاعات کاربری</span>
<BasicIcon
class="settings-link__chevron"
icon="vuesax-linear:arrow-left-1"
:size="16"
:stroke-width="1.5"
/>
</button>
<button type="button" class="settings-link" @click="router.push('/profile/checks')">
<span class="settings-link__icon" aria-hidden="true">
<BasicIcon icon="vuesax-linear:card" :size="20" :stroke-width="1.5" />
</span>
<span class="settings-link__label">
لیست چک‌ها
<em v-if="checksCount">({{ checksCount }})</em>
</span>
<BasicIcon
class="settings-link__chevron"
icon="vuesax-linear:arrow-left-1"
:size="16"
:stroke-width="1.5"
/>
</button>
</nav>
<fieldset class="display-fieldset">
<legend class="display-fieldset__legend">حالت نمایش</legend>
<div class="display-grid" role="radiogroup" aria-label="حالت نمایش">
<button
v-for="option in themeOptions"
:key="option.value"
type="button"
class="theme-card"
:class="{ 'theme-card--active': theme.preference === option.value }"
role="radio"
:aria-checked="theme.preference === option.value"
@click="theme.preference = option.value"
>
<span class="theme-card__icon" aria-hidden="true">
<BasicIcon :icon="option.icon" :size="26" :stroke-width="1.5" />
</span>
<span class="theme-card__body">
<strong class="theme-card__label">{{ option.label }}</strong>
<span class="theme-card__desc">{{ option.description }}</span>
</span>
<span class="theme-card__check" aria-hidden="true">
<BasicIcon
v-if="theme.preference === option.value"
icon="vuesax-linear:tick-circle"
:size="20"
:stroke-width="1.75"
/>
</span>
</button>
</div>
</fieldset>
<BaseButton block @click="save">ذخیره تنظیمات</BaseButton>
<p v-if="saved" class="display-saved">تنظیمات نمایش ذخیره شد ✓</p>
<button type="button" class="settings-logout" @click="logout">خروج</button>
</div>
</ProfileSubpageLayout>
</template>
<style scoped>
.settings-page {
display: flex;
flex-direction: column;
gap: 20px;
}
.settings-links {
display: flex;
flex-direction: column;
gap: 8px;
}
.settings-link {
display: grid;
grid-template-columns: 40px 1fr 16px;
align-items: center;
gap: 10px;
width: 100%;
min-height: 52px;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: 14px;
background: var(--color-surface);
color: var(--color-text);
font: inherit;
text-align: right;
cursor: pointer;
}
.settings-link__icon {
width: 40px;
height: 40px;
border-radius: 12px;
display: grid;
place-items: center;
background: var(--color-primary-soft);
color: var(--color-primary);
}
.settings-link__label {
font-size: 0.9375rem;
font-weight: 600;
}
.settings-link__label em {
font-style: normal;
color: var(--color-text-muted);
font-weight: 500;
}
.settings-link__chevron {
color: var(--color-text-muted);
}
.display-fieldset {
margin: 0;
padding: 0;
border: none;
}
.display-fieldset__legend {
margin: 0 0 12px;
padding: 0;
font-size: 0.875rem;
font-weight: 700;
color: var(--color-text);
}
.display-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.theme-card {
display: grid;
grid-template-columns: 48px 1fr 24px;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
border: 1px solid var(--color-border);
border-radius: 16px;
background: var(--color-elevated);
color: var(--color-text);
text-align: right;
cursor: pointer;
}
.theme-card--active {
border-color: var(--color-primary);
background: var(--color-primary-soft);
box-shadow: 0 0 0 1px var(--color-primary);
}
.theme-card__icon {
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.theme-card--active .theme-card__icon {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.theme-card__body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.theme-card__label {
font-size: 0.9375rem;
font-weight: 700;
}
.theme-card__desc {
font-size: 0.75rem;
color: var(--color-text-muted);
line-height: 1.4;
}
.theme-card--active .theme-card__label {
color: var(--color-primary);
}
.theme-card__check {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary);
min-height: 20px;
}
.display-saved {
margin: 0;
font-size: 0.875rem;
text-align: center;
color: var(--color-success);
}
.settings-logout {
width: 100%;
min-height: 48px;
border: 1px solid var(--color-danger-light);
border-radius: 14px;
background: var(--color-surface);
color: var(--color-danger);
font: inherit;
font-weight: 600;
cursor: pointer;
}
</style>
+354
View File
@@ -0,0 +1,354 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import BaseInput from '@/components/ui/BaseInput.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import ProfileSubpageLayout from '@/views/profile/components/ProfileSubpageLayout.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import UserAvatar from '@/components/ui/UserAvatar.vue'
import { fileToProfileAvatar } from '@/utils/profileImage'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
const auth = useAuthStore()
const firstName = ref(auth.user?.firstName ?? '')
const lastName = ref(auth.user?.lastName ?? '')
const nationalId = ref(auth.user?.nationalId ?? '')
const avatarUrl = ref<string | null>(auth.user?.avatarUrl ?? null)
const fileInput = ref<HTMLInputElement | null>(null)
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
const saved = ref(false)
const photoBusy = ref(false)
const initials = computed(() => {
const first = firstName.value.trim().charAt(0)
const last = lastName.value.trim().charAt(0)
return `${first}${last}` || '؟'
})
function clearField(key: string) {
fieldErrors.value = clearFieldError(fieldErrors.value, key)
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
function openPicker() {
fileInput.value?.click()
}
async function persistAvatar(next: string | null) {
photoBusy.value = true
formError.value = ''
try {
const updated = await auth.updateProfile({ avatarUrl: next ?? '' })
if (updated) avatarUrl.value = updated.avatarUrl ?? null
else avatarUrl.value = next
} catch (e) {
formError.value = e instanceof Error ? e.message : 'ذخیره تصویر ناموفق بود.'
} finally {
photoBusy.value = false
}
}
async function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
photoBusy.value = true
formError.value = ''
try {
const dataUrl = await fileToProfileAvatar(file)
avatarUrl.value = dataUrl
await persistAvatar(dataUrl)
} catch (e) {
formError.value = e instanceof Error ? e.message : 'بارگذاری تصویر ناموفق بود.'
photoBusy.value = false
}
}
async function removeAvatar() {
avatarUrl.value = null
await persistAvatar(null)
}
async function save() {
fieldErrors.value = {}
formError.value = ''
saved.value = false
const errors: FieldErrors = {}
if (!firstName.value.trim()) errors.firstName = 'نام الزامی است.'
if (!lastName.value.trim()) errors.lastName = 'نام خانوادگی الزامی است.'
if (nationalId.value && nationalId.value.length !== 10) {
errors.nationalId = 'کد ملی باید ۱۰ رقم باشد.'
}
if (hasFieldErrors(errors)) {
fieldErrors.value = errors
formError.value = FORM_VALIDATION_SUMMARY
return
}
const updated = await auth.updateProfile({
firstName: firstName.value.trim(),
lastName: lastName.value.trim(),
nationalId: nationalId.value || undefined,
avatarUrl: avatarUrl.value ?? '',
})
if (!updated) {
formError.value = 'ذخیره پروفایل ناموفق بود.'
return
}
saved.value = true
setTimeout(() => {
saved.value = false
}, 2000)
}
</script>
<template>
<ProfileSubpageLayout
title="اطلاعات کاربری"
subtitle="ویرایش تصویر، نام و کد ملی حساب کاربری"
>
<FormErrorBanner :message="formError" />
<div class="profile-edit">
<div class="avatar-editor">
<UserAvatar :src="avatarUrl" :initials="initials" :size="96" class="avatar-editor__photo" />
<div class="avatar-editor__actions">
<p class="avatar-editor__title">تصویر پروفایل</p>
<p class="avatar-editor__hint">افزودن، تغییر یا حذف عکس نمایش‌داده‌شده در پروفایل</p>
<div class="avatar-editor__btns">
<button type="button" class="avatar-btn" :disabled="photoBusy" @click="openPicker">
{{ avatarUrl ? 'تغییر تصویر' : 'افزودن تصویر' }}
</button>
<button
v-if="avatarUrl"
type="button"
class="avatar-btn avatar-btn--danger"
:disabled="photoBusy"
@click="removeAvatar"
>
حذف تصویر
</button>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="image/*"
class="avatar-editor__file"
@change="onFileChange"
/>
</div>
<div class="profile-edit__intro">
<span class="profile-edit__intro-icon" aria-hidden="true">
<BasicIcon icon="vuesax-linear:profile-circle" :size="22" :stroke-width="1.5" />
</span>
<div class="profile-edit__intro-text">
<strong>مشخصات فردی</strong>
<p>این اطلاعات روی سفارش‌ها و چک‌های ثبت‌شده نمایش داده می‌شود.</p>
</div>
</div>
<div class="profile-edit__grid">
<BaseInput
v-model="firstName"
label="نام"
:error="fieldErrors.firstName"
@update:model-value="clearField('firstName')"
/>
<BaseInput
v-model="lastName"
label="نام خانوادگی"
:error="fieldErrors.lastName"
@update:model-value="clearField('lastName')"
/>
<BaseInput
v-model="nationalId"
class="profile-edit__full"
label="کد ملی"
maxlength="10"
inputmode="numeric"
dir="ltr"
hint="برای ثبت چک الزامی است"
:error="fieldErrors.nationalId"
@update:model-value="clearField('nationalId')"
/>
</div>
<div class="profile-edit__actions">
<BaseButton class="profile-edit__save" @click="save">ذخیره تغییرات</BaseButton>
<p v-if="saved" class="profile-edit__saved">ذخیره شد ✓</p>
</div>
</div>
</ProfileSubpageLayout>
</template>
<style scoped>
.profile-edit {
display: flex;
flex-direction: column;
gap: 20px;
}
.avatar-editor {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-elevated);
}
.avatar-editor__photo {
background: var(--color-primary);
color: #fff;
}
.avatar-editor__actions {
min-width: 0;
flex: 1;
}
.avatar-editor__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 700;
}
.avatar-editor__hint {
margin: 4px 0 10px;
font-size: 0.75rem;
color: var(--color-text-muted);
line-height: 1.5;
}
.avatar-editor__btns {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.avatar-btn {
min-height: 36px;
padding: 0 12px;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-surface);
color: var(--color-text);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
.avatar-btn:disabled {
opacity: 0.6;
cursor: wait;
}
.avatar-btn--danger {
color: var(--color-danger);
border-color: var(--color-danger-light);
}
.avatar-editor__file {
display: none;
}
.profile-edit__intro {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border-radius: var(--radius-md);
background: var(--color-bg);
border: 1px solid var(--color-border);
}
.profile-edit__intro-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary-soft);
color: var(--color-primary);
}
.profile-edit__intro-text {
min-width: 0;
}
.profile-edit__intro-text strong {
display: block;
font-size: 0.9375rem;
font-weight: 700;
color: var(--color-text);
}
.profile-edit__intro-text p {
margin: 4px 0 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--color-text-muted);
}
.profile-edit__grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
@media (min-width: 768px) {
.profile-edit__grid {
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.profile-edit__full {
grid-column: 1 / -1;
}
}
.profile-edit__actions {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
padding-top: 4px;
}
@media (min-width: 768px) {
.profile-edit__actions {
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 14px;
}
.profile-edit__save {
width: auto;
min-width: 180px;
}
}
.profile-edit__saved {
margin: 0;
font-size: 0.875rem;
color: var(--color-success);
}
</style>
@@ -0,0 +1,227 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import BaseButton from '@/components/ui/BaseButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import ProfileSubpageLayout from '@/views/profile/components/ProfileSubpageLayout.vue'
import { useNotificationStore } from '@/stores/notification'
import { formatDateTime } from '@/utils/format'
import type { AppNotification, NotificationTone } from '@/types'
const router = useRouter()
const notificationStore = useNotificationStore()
const items = computed(() => notificationStore.items)
const hasUnread = computed(() => notificationStore.hasUnread)
const toneIcons: Record<NotificationTone, string> = {
info: 'vuesax-linear:information',
success: 'vuesax-linear:tick-circle',
warning: 'vuesax-linear:timer',
danger: 'vuesax-linear:close-circle',
}
onMounted(() => {
void notificationStore.hydrate()
})
async function open(item: AppNotification) {
await notificationStore.markRead(item.id)
if (item.link) router.push(item.link)
}
</script>
<template>
<ProfileSubpageLayout title="اعلان‌ها" subtitle="آخرین خبرهای سفارش‌های شما">
<div v-if="items.length" class="notif-box">
<div class="notif-box__head">
<span class="notif-box__count">
{{ hasUnread ? `${notificationStore.unreadCount} اعلان خوانده‌نشده` : 'همه خوانده شده' }}
</span>
<BaseButton
v-if="hasUnread"
variant="outline"
class="notif-box__mark"
@click="notificationStore.markAllRead()"
>
خواندن همه
</BaseButton>
</div>
<button
v-for="item in items"
:key="item.id"
type="button"
class="notif-row"
:class="[`notif-row--${item.tone}`, { 'notif-row--unread': !item.isRead }]"
@click="open(item)"
>
<span class="notif-row__icon" aria-hidden="true">
<BasicIcon :icon="toneIcons[item.tone]" :size="22" :stroke-width="1.5" />
</span>
<span class="notif-row__body">
<span class="notif-row__title-row">
<strong class="notif-row__title">{{ item.title }}</strong>
<span v-if="!item.isRead" class="notif-row__dot" aria-label="خوانده‌نشده" />
</span>
<span class="notif-row__text">{{ item.body }}</span>
<span class="notif-row__time">{{ formatDateTime(item.sentAt) }}</span>
</span>
</button>
</div>
<EmptyState
v-else
icon="vuesax-linear:notification-bing"
title="اعلانی ندارید"
message="وقتی فاکتور سفارشتان صادر شود یا وضعیت چک و پرداخت تغییر کند، همین‌جا خبردار می‌شوید."
>
<BaseButton variant="outline" @click="router.push('/orders')">سفارش‌های من</BaseButton>
</EmptyState>
</ProfileSubpageLayout>
</template>
<style scoped>
.notif-box {
display: flex;
flex-direction: column;
gap: 10px;
}
.notif-box__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 4px;
}
.notif-box__count {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.notif-box__mark {
width: auto;
padding-inline: 14px;
}
.notif-row {
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
align-items: flex-start;
gap: 12px;
width: 100%;
padding: 14px 16px;
text-align: right;
border: 1px solid var(--color-border);
border-radius: 14px;
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
font-family: inherit;
transition: border-color 0.15s;
}
.notif-row:hover {
border-color: var(--color-primary);
}
.notif-row--unread {
background: var(--color-elevated);
}
.notif-row__icon {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-info-light);
color: var(--color-primary);
flex-shrink: 0;
}
.notif-row--success .notif-row__icon {
background: var(--color-success-light);
color: var(--color-success);
}
.notif-row--warning .notif-row__icon {
background: var(--color-warning-light);
color: var(--color-warning);
}
.notif-row--danger .notif-row__icon {
background: var(--color-danger-light);
color: var(--color-danger);
}
.notif-row__body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.notif-row__title-row {
display: flex;
align-items: center;
gap: 8px;
}
.notif-row__title {
font-size: 0.875rem;
font-weight: 700;
}
.notif-row__text {
font-size: 0.8125rem;
line-height: 1.6;
color: var(--color-text-muted);
}
.notif-row__time {
font-size: 0.6875rem;
color: var(--color-text-muted);
}
.notif-row__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-danger);
flex-shrink: 0;
}
@media (min-width: 768px) {
.notif-box {
gap: 12px;
}
.notif-row {
grid-template-columns: 44px minmax(0, 1fr);
gap: 16px;
padding: 16px 18px;
}
.notif-row__icon {
width: 44px;
height: 44px;
}
.notif-row__title {
font-size: 0.9375rem;
}
.notif-row__text {
font-size: 0.875rem;
}
.notif-row__time {
margin-top: 2px;
}
}
</style>
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { ref } from 'vue'
import BaseInput from '@/components/ui/BaseInput.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import ProfileSubpageLayout from '@/views/profile/components/ProfileSubpageLayout.vue'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
const saved = ref(false)
function clearField(key: string) {
fieldErrors.value = clearFieldError(fieldErrors.value, key)
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
function save() {
fieldErrors.value = {}
formError.value = ''
saved.value = false
const errors: FieldErrors = {}
if (!currentPassword.value) errors.currentPassword = 'رمز فعلی الزامی است.'
if (newPassword.value.length < 6) errors.newPassword = 'رمز جدید باید حداقل ۶ کاراکتر باشد.'
if (confirmPassword.value !== newPassword.value) {
errors.confirmPassword = 'تکرار رمز با رمز جدید یکسان نیست.'
}
if (hasFieldErrors(errors)) {
fieldErrors.value = errors
formError.value = FORM_VALIDATION_SUMMARY
return
}
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
saved.value = true
setTimeout(() => {
saved.value = false
}, 2000)
}
</script>
<template>
<ProfileSubpageLayout title="امنیت حساب" subtitle="تغییر رمز عبور و تنظیمات ورود">
<FormErrorBanner :message="formError" />
<div class="settings-form">
<BaseInput
v-model="currentPassword"
type="password"
label="رمز عبور فعلی"
:error="fieldErrors.currentPassword"
@update:model-value="clearField('currentPassword')"
/>
<BaseInput
v-model="newPassword"
type="password"
label="رمز عبور جدید"
:error="fieldErrors.newPassword"
@update:model-value="clearField('newPassword')"
/>
<BaseInput
v-model="confirmPassword"
type="password"
label="تکرار رمز عبور جدید"
:error="fieldErrors.confirmPassword"
@update:model-value="clearField('confirmPassword')"
/>
<BaseButton block @click="save">ذخیره رمز جدید</BaseButton>
<p v-if="saved" class="settings-form__saved">رمز عبور با موفقیت تغییر کرد ✓</p>
</div>
</ProfileSubpageLayout>
</template>
<style scoped>
.settings-form {
display: flex;
flex-direction: column;
gap: 4px;
}
.settings-form__saved {
margin: 8px 0 0;
font-size: 0.875rem;
text-align: center;
color: var(--color-success);
}
</style>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import MobileProfilePage from '@/views/profile/components/MobileProfilePage.vue'
import DesktopProfilePage from '@/views/profile/components/DesktopProfilePage.vue'
const router = useRouter()
const auth = useAuthStore()
function logout() {
auth.logout()
router.replace('/login')
}
</script>
<template>
<MobileProfilePage class="mobile-only" />
<DesktopProfilePage class="desktop-only" @logout="logout" />
</template>
@@ -0,0 +1,736 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useOrderStore } from '@/stores/order'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconEditPen from '@/components/icons/IconEditPen.vue'
import IconOrders from '@/components/icons/IconOrders.vue'
import IconCheckBook from '@/components/icons/IconCheckBook.vue'
import IconCash from '@/components/icons/IconCash.vue'
import IconBuilding from '@/components/icons/IconBuilding.vue'
import IconBolt from '@/components/icons/IconBolt.vue'
import IconSettings from '@/components/icons/IconSettings.vue'
import IconProfile from '@/components/icons/IconProfile.vue'
import IconBell from '@/components/icons/IconBell.vue'
import IconMapPin from '@/components/icons/IconMapPin.vue'
import IconHeadset from '@/components/icons/IconHeadset.vue'
import IconCashSettlement from '@/components/icons/IconCashSettlement.vue'
import IconPhone from '@/components/icons/IconPhone.vue'
import IconMail from '@/components/icons/IconMail.vue'
import IconVerified from '@/components/icons/IconVerified.vue'
import IconLayoutGrid from '@/components/icons/IconLayoutGrid.vue'
import IconLock from '@/components/icons/IconLock.vue'
import IconIdCard from '@/components/icons/IconIdCard.vue'
import UserAvatar from '@/components/ui/UserAvatar.vue'
import { formatCurrency, formatDate, formatNumber, toPersianDigits } from '@/utils/format'
import type { Component } from 'vue'
const router = useRouter()
const auth = useAuthStore()
const orderStore = useOrderStore()
const emit = defineEmits<{ logout: [] }>()
const initials = computed(() => {
const first = auth.user?.firstName?.[0] ?? ''
const last = auth.user?.lastName?.[0] ?? ''
return `${first}${last}` || '؟'
})
const ordersCount = computed(() => {
if (!auth.user?.id) return 0
return orderStore.userOrders(auth.user.id).length
})
const checksCount = computed(() => {
if (!auth.user?.id) return 0
const submitted = orderStore
.userOrders(auth.user.id)
.reduce((sum, order) => sum + (order.checks?.length ?? 0), 0)
return submitted + orderStore.draftChecks.length
})
const customerCode = computed(() => {
const u = auth.user
if (!u) return toPersianDigits('—')
if (auth.isAgent && u.agentCode) return toPersianDigits(u.agentCode)
if (u.nationalId) return toPersianDigits(u.nationalId.slice(-6).padStart(6, '0'))
return toPersianDigits(u.id?.slice(-6) ?? '—')
})
const codeLabel = computed(() => (auth.isAgent ? 'کد واسطه:' : 'کد مشتری:'))
const displayEmail = computed(() => {
const phone = auth.user?.phone ?? ''
if (!phone) return '—'
return `user${phone.slice(-4)}@parsbaft.ir`
})
const joinDate = computed(() =>
auth.user?.createdAt ? formatDate(auth.user.createdAt) : '—',
)
const creditBalance = computed(() => 0)
const companyRows = computed(() => [
{ label: 'نام شرکت', value: auth.fullName || '—' },
{ label: 'شناسه ملی', value: auth.user?.nationalId ? toPersianDigits(auth.user.nationalId) : '—' },
{
label: 'کد اقتصادی',
value: auth.user?.nationalId ? toPersianDigits(`4${auth.user.nationalId.slice(0, 10)}`) : '—',
},
{ label: 'تاریخ عضویت', value: joinDate.value },
{
label: 'نوع مشتری',
value: auth.isAgent ? 'واسطه' : 'مشتری عمده',
badge: true,
},
{ label: 'وضعیت حساب', value: 'فعال', badge: true },
])
const quickAccess = [
{ label: 'حساب‌های بانکی', icon: IconCashSettlement },
{ label: 'آدرس‌ها', icon: IconMapPin },
{ label: 'حمایت و پشتیبانی', icon: IconHeadset, href: 'tel:02112345678' },
{ label: 'فاکتورهای من', icon: IconOrders, path: '/orders' },
]
const settingsItems: Array<{
id: string
label: string
description: string
icon: Component
path?: string
action?: 'logout'
}> = [
{
id: 'profile',
label: 'اطلاعات کاربری',
description: 'ویرایش نام، موبایل و ایمیل',
icon: IconProfile,
path: '/profile/edit',
},
{
id: 'security',
label: 'امنیت حساب',
description: 'تغییر رمز عبور و تنظیمات ورود',
icon: IconLock,
path: '/profile/security',
},
{
id: 'notifications',
label: 'اعلان‌ها',
description: 'خبرهای فاکتور، چک و ارسال سفارش',
icon: IconBell,
path: '/profile/notifications',
},
{
id: 'display',
label: 'تنظیمات',
description: 'نمایش، اطلاعات کاربری و خروج',
icon: IconLayoutGrid,
path: '/profile/display',
},
{
id: 'logout',
label: 'خروج از حساب',
description: 'پایان نشست و ورود مجدد',
icon: IconLock,
action: 'logout',
},
]
function handleQuick(item: (typeof quickAccess)[number]) {
if (item.path) router.push(item.path)
else if (item.href) window.location.href = item.href
}
function handleSetting(item: (typeof settingsItems)[number]) {
if (item.action === 'logout') {
emit('logout')
return
}
if (item.path) router.push(item.path)
}
</script>
<template>
<div class="desktop-page desktop-profile">
<header class="desktop-profile__head">
<h1 class="desktop-profile__title">پروفایل</h1>
<p class="desktop-profile__subtitle">مدیریت اطلاعات حساب کاربری و تنظیمات</p>
</header>
<section class="profile-overview">
<div class="profile-overview__identity">
<div class="profile-overview__aside">
<UserAvatar
class="profile-overview__avatar"
:src="auth.user?.avatarUrl"
:initials="initials"
:size="92"
/>
<button
type="button"
class="profile-overview__edit"
@click="router.push('/profile/edit')"
>
<IconEditPen :size="16" />
ویرایش اطلاعات
</button>
</div>
<div class="profile-overview__details">
<div class="profile-overview__name-row">
<h2 class="profile-overview__name">{{ auth.fullName }}</h2>
<span class="profile-overview__verified" title="حساب تأیید شده">
<IconVerified :size="18" />
</span>
</div>
<p class="profile-overview__meta">
<IconIdCard :size="15" class="profile-overview__meta-icon" />
<span class="profile-overview__meta-label">{{ codeLabel }}</span>
<span>{{ customerCode }}</span>
</p>
<p class="profile-overview__meta">
<IconPhone :size="15" class="profile-overview__meta-icon" />
<span dir="ltr">{{ auth.user?.phone }}</span>
</p>
<p class="profile-overview__meta">
<IconMail :size="15" class="profile-overview__meta-icon" />
<span dir="ltr">{{ displayEmail }}</span>
</p>
</div>
</div>
<div class="profile-overview__stats">
<article class="profile-stat">
<span class="profile-stat__icon profile-stat__icon--amber">
<IconCash :size="20" />
</span>
<strong class="profile-stat__value profile-stat__value--sm">
{{ formatCurrency(creditBalance) }}
</strong>
<span class="profile-stat__label">موجودی اعتبار</span>
<button type="button" class="profile-stat__link">مشاهده جزئیات</button>
</article>
<article class="profile-stat">
<span class="profile-stat__icon profile-stat__icon--green">
<IconCheckBook :size="20" />
</span>
<strong class="profile-stat__value">{{ formatNumber(checksCount) }}</strong>
<span class="profile-stat__label">چک فعال</span>
<button type="button" class="profile-stat__link" @click="router.push('/profile/checks')">
مشاهده همه
</button>
</article>
<article class="profile-stat">
<span class="profile-stat__icon profile-stat__icon--blue">
<IconOrders :size="20" />
</span>
<strong class="profile-stat__value">{{ formatNumber(ordersCount) }}</strong>
<span class="profile-stat__label">سفارش ثبت‌شده</span>
<button type="button" class="profile-stat__link" @click="router.push('/orders')">
مشاهده همه
</button>
</article>
</div>
</section>
<div class="desktop-profile__grid">
<section class="profile-panel profile-panel--settings">
<h3 class="profile-panel__title">
<IconSettings :size="18" />
تنظیمات حساب
</h3>
<nav class="profile-settings" aria-label="تنظیمات حساب">
<button
v-for="item in settingsItems"
:key="item.id"
type="button"
class="profile-settings__item"
@click="handleSetting(item)"
>
<span class="profile-settings__icon">
<component :is="item.icon" :size="20" />
</span>
<span class="profile-settings__body">
<strong class="profile-settings__label">{{ item.label }}</strong>
<span class="profile-settings__desc">{{ item.description }}</span>
</span>
<IconChevronLeft :size="16" class="profile-settings__chevron" aria-hidden="true" />
</button>
</nav>
</section>
<section class="profile-panel profile-panel--quick">
<h3 class="profile-panel__title">
<IconBolt :size="18" />
دسترسی سریع
</h3>
<div class="profile-quick-grid">
<button
v-for="item in quickAccess"
:key="item.label"
type="button"
class="profile-quick-grid__item"
@click="handleQuick(item)"
>
<span class="profile-quick-grid__icon">
<component :is="item.icon" :size="22" />
</span>
<span class="profile-quick-grid__label">{{ item.label }}</span>
</button>
</div>
</section>
<section class="profile-panel profile-panel--company">
<h3 class="profile-panel__title">
<IconBuilding :size="18" />
اطلاعات شرکت
</h3>
<dl class="profile-info-list">
<div
v-for="row in companyRows"
:key="row.label"
class="profile-info-list__row"
>
<dt>{{ row.label }}</dt>
<dd>
<span v-if="row.badge" class="profile-badge">{{ row.value }}</span>
<template v-else>{{ row.value }}</template>
</dd>
</div>
</dl>
</section>
</div>
</div>
</template>
<style scoped>
.desktop-profile {
width: 100%;
padding-bottom: 32px;
}
.desktop-profile__head {
margin-bottom: 20px;
}
.desktop-profile__title {
margin: 0 0 6px;
font-size: 1.375rem;
font-weight: 700;
color: var(--color-text);
}
.desktop-profile__subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.profile-overview {
direction: rtl;
display: flex;
align-items: stretch;
gap: 0;
padding: 22px 24px;
margin-bottom: 16px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.profile-overview__identity {
flex: 1.1;
display: flex;
align-items: flex-start;
gap: 18px;
padding-inline-end: 24px;
border-inline-end: 1px solid var(--color-border);
}
.profile-overview__aside {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.profile-overview__avatar {
background: var(--color-primary);
color: var(--color-surface);
}
.profile-overview__edit {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 132px;
padding: 9px 14px;
border: 1px solid var(--color-primary);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-primary);
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.profile-overview__edit:hover {
background: var(--color-primary-soft);
}
.profile-overview__details {
min-width: 0;
flex: 1;
padding-top: 6px;
}
.profile-overview__name-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.profile-overview__name {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
color: var(--color-text);
}
.profile-overview__verified {
color: var(--color-success);
display: flex;
flex-shrink: 0;
}
.profile-overview__meta {
margin: 0 0 8px;
font-size: 0.8125rem;
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: 8px;
}
.profile-overview__meta-icon {
color: var(--color-text-muted);
flex-shrink: 0;
}
.profile-overview__meta-label {
color: var(--color-text);
font-weight: 600;
}
.profile-overview__stats {
direction: ltr;
flex: 1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: stretch;
}
.profile-stat {
direction: rtl;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 12px 14px;
border-inline-start: 1px solid var(--color-border);
}
.profile-stat:first-child {
border-inline-start: none;
}
.profile-stat__icon {
width: 42px;
height: 42px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
}
.profile-stat__icon--blue {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.profile-stat__icon--green {
background: var(--color-success-soft);
color: var(--color-success);
}
.profile-stat__icon--amber {
background: var(--color-warning-light);
color: var(--color-warning);
}
.profile-stat__value {
font-size: 1.375rem;
font-weight: 700;
color: var(--color-text);
margin-bottom: 4px;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.profile-stat__value--sm {
font-size: 0.875rem;
line-height: 1.45;
}
.profile-stat__label {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-bottom: 8px;
}
.profile-stat__link {
border: none;
background: none;
padding: 0;
color: var(--color-primary);
font-family: inherit;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
}
.profile-stat__link:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
.desktop-profile__grid {
direction: ltr;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
align-items: stretch;
}
.profile-panel {
direction: rtl;
padding: 18px 18px 16px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.profile-panel__title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-border);
font-size: 0.9375rem;
font-weight: 700;
color: var(--color-text);
}
.profile-panel__title svg {
color: var(--color-primary);
flex-shrink: 0;
}
.profile-info-list {
margin: 0;
}
.profile-info-list__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 0;
border-bottom: 1px solid var(--color-border);
font-size: 0.8125rem;
}
.profile-info-list__row:last-child {
border-bottom: none;
}
.profile-info-list__row dt {
color: var(--color-text-muted);
font-weight: 500;
}
.profile-info-list__row dd {
margin: 0;
color: var(--color-text);
font-weight: 600;
text-align: left;
}
.profile-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 999px;
background: var(--color-success-soft);
color: var(--color-success);
font-size: 0.75rem;
font-weight: 600;
}
.profile-quick-grid {
direction: ltr;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.profile-quick-grid__item {
direction: rtl;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 108px;
padding: 16px 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-elevated);
cursor: pointer;
font-family: inherit;
transition: border-color 0.15s, background 0.15s;
}
.profile-quick-grid__item:hover {
border-color: var(--color-primary);
background: var(--color-primary-soft);
}
.profile-quick-grid__icon {
width: 42px;
height: 42px;
border-radius: 12px;
background: var(--color-surface);
color: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-sm);
}
.profile-quick-grid__label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text);
text-align: center;
line-height: 1.35;
}
.profile-settings {
display: flex;
flex-direction: column;
gap: 0;
}
.profile-settings__item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 4px;
border: none;
border-bottom: 1px solid var(--color-border);
border-radius: 0;
background: transparent;
cursor: pointer;
text-align: right;
font-family: inherit;
transition: background 0.15s;
}
.profile-settings__item:last-child {
border-bottom: none;
}
.profile-settings__item:hover {
background: var(--color-elevated);
}
.profile-settings__icon {
width: 38px;
height: 38px;
border-radius: 10px;
background: var(--color-primary-soft);
color: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.profile-settings__body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.profile-settings__label {
font-size: 0.8125rem;
font-weight: 700;
color: var(--color-text);
}
.profile-settings__desc {
font-size: 0.6875rem;
color: var(--color-text-muted);
line-height: 1.45;
}
.profile-settings__chevron {
color: #cbd5e1;
flex-shrink: 0;
}
@media (max-width: 900px) {
.profile-overview {
flex-direction: column;
}
.profile-overview__identity {
border-inline-end: none;
padding-inline-end: 0;
padding-bottom: 16px;
border-bottom: 1px solid var(--color-border);
}
.profile-overview__stats {
padding-top: 8px;
}
.profile-stat:first-child {
border-inline-start: none;
}
.desktop-profile__grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,774 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useOrderStore } from '@/stores/order'
import { useCrmStore } from '@/stores/crm'
import { useNotificationStore } from '@/stores/notification'
import { fetchAgentProfile, type AgentProfileDto } from '@/services/agentApi'
import type { CatalogProductCard } from '@/types/crm'
import { formatDate, formatNumber } from '@/utils/format'
import { ORDER_STATUS_LABELS, STATUS_BADGE_VARIANT } from '@/utils/constants'
import IconBell from '@/components/icons/IconBell.vue'
import IconSettings from '@/components/icons/IconSettings.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconCart from '@/components/icons/IconCart.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import UserAvatar from '@/components/ui/UserAvatar.vue'
const SAMPLE_PRODUCT: CatalogProductCard = {
id: 'sample-base-price',
name: 'پارچه فاستونی ایتالیایی',
code: 'DF-1204',
categoryId: '',
season: 'all_season',
widthCm: 160,
composition: '',
colors: ['1', '2', '3', '4', '5', '6'],
tags: [],
isNew: false,
rollMeters: 0,
basePrice: 380_000,
}
const router = useRouter()
const auth = useAuthStore()
const orderStore = useOrderStore()
const crm = useCrmStore()
const notificationStore = useNotificationStore()
const profile = ref<AgentProfileDto | null>(null)
const sampleProduct = ref<CatalogProductCard>(SAMPLE_PRODUCT)
const loading = ref(false)
/** مطابق موکاپ ۱۱ — بدون gate نقش */
const quickActions = [
{ 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:money-4' },
] as const
const recentOrders = computed(() =>
[...orderStore.orders]
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
.slice(0, 3)
.map((o) => {
const variant = STATUS_BADGE_VARIANT[o.status] ?? 'info'
const tone =
variant === 'danger' || variant === 'neutral'
? 'danger'
: variant === 'success'
? 'success'
: variant === 'warning'
? 'warning'
: 'info'
return {
id: o.id,
title: `سفارش #${o.orderNumber}`,
date: o.createdAt ? formatDate(o.createdAt) : '',
customer: o.companyName || '',
statusLabel: ORDER_STATUS_LABELS[o.status] || o.status,
statusTone: tone as 'warning' | 'success' | 'info' | 'danger',
}
}),
)
const displayName = computed(() => auth.fullName || 'کاربر')
const code = computed(() => profile.value?.code || auth.user?.agentCode || '—')
const levelLabel = computed(
() => profile.value?.levelLabel || auth.user?.levelLabel || 'برنزی',
)
const nextLevelLabel = computed(
() => profile.value?.nextLevelLabel || auth.user?.nextLevelLabel || 'نقره‌ای',
)
const pointsToNext = computed(
() => profile.value?.pointsToNext ?? auth.user?.pointsToNext ?? 0,
)
const periodLabel = computed(() => profile.value?.periodLabel || '۳۰ روز اخیر')
const customersCount = computed(() => profile.value?.customersCount ?? 0)
const ordersCount = computed(() => profile.value?.ordersCount ?? orderStore.orders.length)
const salesVolume = computed(() => profile.value?.salesVolumeToman ?? 0)
const profit = computed(() => profile.value?.profitToman ?? 0)
const levelProgress = computed(
() => profile.value?.levelProgress ?? auth.user?.levelProgress ?? 72,
)
const unreadLabel = computed(() =>
notificationStore.unreadCount > 9 ? '9+' : String(notificationStore.unreadCount),
)
const samplePrice = computed(() => sampleProduct.value.basePrice ?? SAMPLE_PRODUCT.basePrice ?? 0)
onMounted(async () => {
loading.value = true
try {
void notificationStore.hydrate()
try {
profile.value = await fetchAgentProfile()
} catch {
profile.value = null
}
try {
const products = await crm.loadBrowseProducts()
const first = products[0]
sampleProduct.value = first
? { ...SAMPLE_PRODUCT, ...first, basePrice: first.basePrice ?? SAMPLE_PRODUCT.basePrice }
: SAMPLE_PRODUCT
} catch {
sampleProduct.value = SAMPLE_PRODUCT
}
if (!orderStore.orders.length) await orderStore.hydrate()
} finally {
loading.value = false
}
})
</script>
<template>
<div class="page profile-page">
<header class="hero">
<div class="hero__top">
<button
type="button"
class="icon-btn"
aria-label="تنظیمات"
@click="router.push('/profile/display')"
>
<IconSettings :size="20" color="#ffffff" />
</button>
<h1>پروفایل من</h1>
<button
type="button"
class="icon-btn"
aria-label="اعلان‌ها"
@click="router.push('/profile/notifications')"
>
<IconBell :size="20" color="#ffffff" />
<span
v-if="notificationStore.hasUnread"
class="icon-btn__badge"
:aria-label="`${notificationStore.unreadCount} اعلان خوانده‌نشده`"
>
{{ unreadLabel }}
</span>
</button>
</div>
<UserAvatar
class="hero__avatar"
:src="auth.user?.avatarUrl"
:initials="displayName.charAt(0)"
:size="84"
/>
<h2>{{ displayName }}</h2>
<p>کد واسطه: {{ code }}</p>
<span class="level-pill">
<svg class="level-pill__crown" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M3.5 17.5h17l-1.2-8.2a1 1 0 0 0-1.5-.7L14 11l-2.5-5.2a1 1 0 0 0-1.8 0L7.2 11 4.2 8.6a1 1 0 0 0-1.5.7L3.5 17.5Zm1.7 1.5h13.6v1.2H5.2V19Z"
/>
</svg>
سطح: {{ levelLabel }}
</span>
</header>
<div class="page__scroll page__scroll--with-nav profile-scroll">
<PageSkeleton v-if="loading" variant="profile" />
<template v-else>
<section class="panel">
<div class="panel__head">
<h3>خلاصه عملکرد</h3>
<span class="panel__period">
{{ periodLabel }}
<BasicIcon icon="vuesax-linear:arrow-down" :size="14" :stroke-width="1.5" />
</span>
</div>
<div class="stats">
<div class="stat-card">
<div class="stat-card__text">
<span class="stat-card__label">سفارش‌ها</span>
<strong class="stat-card__value">{{ formatNumber(ordersCount) }}</strong>
<em class="stat-card__hint">سفارش</em>
</div>
<span class="stat-card__icon stat-card__icon--orders">
<IconCart :size="18" />
</span>
</div>
<div class="stat-card">
<div class="stat-card__text">
<span class="stat-card__label">مشتریان من</span>
<strong class="stat-card__value">{{ formatNumber(customersCount) }}</strong>
<em class="stat-card__hint">مشتری فعال</em>
</div>
<span class="stat-card__icon stat-card__icon--customers">
<BasicIcon icon="vuesax-linear:people" :size="18" :stroke-width="1.5" />
</span>
</div>
<div class="stat-card">
<div class="stat-card__text">
<span class="stat-card__label">سود شما (ریال)</span>
<strong class="stat-card__value">{{ formatNumber(profit) }}</strong>
<em class="stat-card__hint">تومان</em>
</div>
<span class="stat-card__icon stat-card__icon--profit">
<BasicIcon icon="vuesax-linear:chart-2" :size="18" :stroke-width="1.5" />
</span>
</div>
<div class="stat-card">
<div class="stat-card__text">
<span class="stat-card__label">حجم فروش (ریال)</span>
<strong class="stat-card__value">{{ formatNumber(salesVolume) }}</strong>
<em class="stat-card__hint">تومان</em>
</div>
<span class="stat-card__icon stat-card__icon--sales">
<BasicIcon icon="vuesax-linear:wallet" :size="18" :stroke-width="1.5" />
</span>
</div>
</div>
</section>
<section class="loyalty">
<div class="loyalty__row">
<div class="loyalty__crown" aria-hidden="true">
<svg viewBox="0 0 48 48" width="40" height="40">
<path
fill="#E0C35A"
d="M8 34h32l-2.4-16.2a2 2 0 0 0-3-.1L28 24l-4.8-10a2 2 0 0 0-3.6 0L14.8 24 9.4 17.7a2 2 0 0 0-3.1.1L8 34Z"
/>
<path fill="#C9A227" d="M10 36h28v3H10z" />
<circle cx="24" cy="28" r="3" fill="#F7E7A1" />
</svg>
</div>
<p class="loyalty__text">
شما در سطح {{ levelLabel }} قرار دارید. تا سطح
{{ nextLevelLabel }} فقط
{{ formatNumber(pointsToNext) }} تومان فروش دیگر نیاز دارید.
</p>
</div>
<div class="loyalty__bar">
<span :style="{ width: `${Math.min(100, Math.max(0, levelProgress))}%` }" />
</div>
</section>
<section>
<h3 class="section-title">دسترسی سریع</h3>
<div class="quick-grid">
<button
v-for="item in quickActions"
:key="item.label"
type="button"
class="quick-item"
@click="router.push(item.to)"
>
<span class="quick-item__icon">
<BasicIcon :icon="item.icon" :size="22" :stroke-width="1.5" />
</span>
{{ item.label }}
</button>
</div>
</section>
<section class="orders-box">
<div class="section-head">
<h3>آخرین سفارش‌ها</h3>
<button type="button" class="link" @click="router.push('/orders')">مشاهده همه</button>
</div>
<article
v-for="order in recentOrders"
:key="order.id"
class="order-row"
role="button"
tabindex="0"
@click="router.push({ path: '/order', query: { id: order.id } })"
@keydown.enter="router.push({ path: '/order', query: { id: order.id } })"
>
<div class="order-row__meta">
<span class="order-row__date">{{ order.date }}</span>
<strong class="order-row__title">{{ order.title }}</strong>
<p v-if="order.customer" class="order-row__customer">مشتری: {{ order.customer }}</p>
</div>
<span class="tone" :class="`tone--${order.statusTone}`">{{ order.statusLabel }}</span>
<IconChevronLeft :size="16" class="order-row__chevron" />
</article>
<button type="button" class="orders-all" @click="router.push('/orders')">
مشاهده همه سفارش‌ها
</button>
</section>
<section class="price-section">
<h3 class="section-title">قیمت‌های پایه (نمونه)</h3>
<div class="price-card">
<div class="price-card__body">
<div class="price-card__info">
<strong>{{ sampleProduct.name }}</strong>
<p>کد: {{ sampleProduct.code }}</p>
<p>عرض: {{ formatNumber(sampleProduct.widthCm) }} سانتی‌متر</p>
<p>رنگ‌ها: {{ formatNumber(sampleProduct.colors.length) }} رنگ</p>
</div>
<div
class="price-card__image"
:style="
sampleProduct.imageUrl
? { backgroundImage: `url(${sampleProduct.imageUrl})` }
: undefined
"
/>
</div>
<footer>قیمت پایه به شما: {{ formatNumber(samplePrice) }} تومان / متر</footer>
</div>
</section>
</template>
</div>
</div>
</template>
<style scoped>
.profile-page {
background: var(--color-primary);
}
.hero {
background: linear-gradient(
145deg,
var(--color-primary) 0%,
color-mix(in srgb, var(--color-primary) 78%, #0a1f18) 100%
);
color: #fff;
padding: 12px var(--page-padding) 44px;
text-align: center;
}
.hero__top {
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
margin-bottom: 16px;
}
.hero__top h1 {
margin: 0;
font-size: 1rem;
font-weight: 700;
}
.icon-btn {
position: relative;
width: 40px;
height: 40px;
border: none;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
color: #fff;
}
.icon-btn__badge {
position: absolute;
top: 4px;
inset-inline-start: 4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 0.625rem;
font-weight: 700;
line-height: 16px;
text-align: center;
}
.hero__avatar {
margin: 0 auto 12px;
border: 3px solid rgba(255, 255, 255, 0.35);
color: #fff;
}
.hero h2 {
margin: 0;
font-size: 1.2rem;
font-weight: 700;
}
.hero p {
margin: 6px 0 12px;
opacity: 0.88;
font-size: 0.85rem;
}
.level-pill {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--color-level-gold);
border-radius: 999px;
padding: 5px 14px;
font-size: 0.8125rem;
background: color-mix(in srgb, var(--color-level-gold) 16%, transparent);
color: #fff;
}
.level-pill__crown {
width: 14px;
height: 14px;
color: var(--color-level-gold);
}
.profile-scroll {
position: relative;
z-index: 1;
margin-top: -24px;
padding: 20px var(--page-padding) 24px;
display: flex;
flex-direction: column;
gap: 18px;
background: var(--color-bg);
border-radius: 24px 24px 0 0;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
}
.profile-scroll > * {
flex-shrink: 0;
}
.panel__head,
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.panel__head h3,
.section-head h3,
.section-title {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
color: var(--color-text);
}
.section-title {
margin-bottom: 12px;
}
.panel__period {
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--color-text-muted);
font-size: 0.8rem;
}
.link {
border: none;
background: transparent;
color: var(--color-primary);
font-weight: 600;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
}
.stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.stat-card {
background: var(--color-surface);
border-radius: 14px;
padding: 12px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.stat-card__text {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
text-align: start;
}
.stat-card__icon {
width: 32px;
height: 32px;
border-radius: 10px;
display: grid;
place-items: center;
flex-shrink: 0;
}
.stat-card__icon--orders,
.stat-card__icon--customers {
background: color-mix(in srgb, var(--color-success) 14%, transparent);
color: var(--color-success);
}
.stat-card__icon--profit {
background: color-mix(in srgb, var(--color-info) 14%, transparent);
color: var(--color-info);
}
.stat-card__icon--sales {
background: color-mix(in srgb, var(--color-level-gold) 22%, transparent);
color: #b8860b;
}
.stat-card__label {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.stat-card__value {
font-size: 1.05rem;
font-weight: 700;
color: var(--color-text);
line-height: 1.3;
}
.stat-card__hint {
font-size: 0.7rem;
font-style: normal;
color: var(--color-text-muted);
}
.loyalty {
background: var(--color-primary-soft);
border-radius: 14px;
padding: 14px;
}
.loyalty__row {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.loyalty__crown {
flex-shrink: 0;
width: 40px;
height: 40px;
display: grid;
place-items: center;
}
.loyalty__text {
margin: 0;
font-size: 0.8125rem;
line-height: 1.75;
color: var(--color-text);
text-align: start;
}
.loyalty__bar {
height: 8px;
border-radius: 999px;
background: rgba(var(--color-primary-rgb), 0.15);
overflow: hidden;
}
.loyalty__bar span {
display: block;
height: 100%;
background: var(--color-primary);
border-radius: inherit;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.quick-item {
border: 1px solid var(--color-border);
background: var(--color-surface);
border-radius: 14px;
padding: 14px 8px 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
font-size: 0.72rem;
color: var(--color-text);
cursor: pointer;
font-family: inherit;
text-align: center;
line-height: 1.4;
min-height: 88px;
}
.quick-item__icon {
width: 40px;
height: 40px;
color: var(--color-primary);
display: grid;
place-items: center;
}
.orders-box {
flex-shrink: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 14px;
overflow: hidden;
padding: 4px 0 0;
}
.orders-box .section-head {
margin: 0;
padding: 12px 14px 10px;
}
.order-row {
display: grid;
grid-template-columns: 1fr auto 16px;
gap: 8px;
align-items: center;
background: transparent;
border: none;
border-top: 1px solid var(--color-border);
border-radius: 0;
padding: 12px 14px;
margin: 0;
cursor: pointer;
}
.orders-all {
width: 100%;
margin: 0;
padding: 12px 14px;
border: none;
border-top: 1px solid var(--color-border);
border-radius: 0;
background: transparent;
color: var(--color-primary);
font: inherit;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
}
.order-row__meta {
min-width: 0;
text-align: start;
}
.order-row__date {
display: block;
font-size: 0.75rem;
color: var(--color-text-muted);
margin-bottom: 2px;
}
.order-row__title {
display: block;
font-size: 0.875rem;
}
.order-row__customer {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.75rem;
}
.order-row__chevron {
color: var(--color-text-muted);
}
.tone {
border-radius: 999px;
padding: 4px 10px;
font-size: 0.7rem;
white-space: nowrap;
}
.tone--warning {
background: var(--color-warning-light);
color: #b45309;
}
.tone--success {
background: var(--color-success-light);
color: var(--color-success);
}
.tone--info {
background: var(--color-info-light);
color: var(--color-info);
}
.tone--danger {
background: var(--color-danger-light);
color: var(--color-danger);
}
.price-section {
flex-shrink: 0;
}
.price-section .section-title {
margin-bottom: 10px;
}
.price-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 14px;
overflow: hidden;
}
.price-card__body {
display: grid;
grid-template-columns: 1fr 88px;
gap: 12px;
padding: 12px;
align-items: start;
}
.price-card__image {
border-radius: 12px;
background: linear-gradient(145deg, #1e3a5f, #111827);
background-size: cover;
background-position: center;
min-height: 88px;
}
.price-card__info {
text-align: start;
}
.price-card__info strong {
display: block;
font-size: 0.9rem;
margin-bottom: 4px;
}
.price-card__info p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.75rem;
}
.price-card footer {
border-top: 1px solid var(--color-border);
padding: 10px 12px;
font-size: 0.8rem;
font-weight: 600;
background: var(--color-elevated);
text-align: start;
}
</style>
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
defineProps<{
title: string
subtitle?: string
}>()
const router = useRouter()
function goBack() {
router.push('/profile')
}
</script>
<template>
<!-- Mobile -->
<div class="page profile-subpage mobile-only">
<div class="page__scroll page__scroll--with-nav">
<div class="profile-subpage__card card">
<header class="profile-subpage__nav">
<button type="button" class="nav-btn" aria-label="بازگشت" @click="goBack">
<IconChevronBack :size="20" />
</button>
<h1 class="profile-subpage__title">{{ title }}</h1>
<button type="button" class="nav-btn nav-btn--ghost" aria-hidden="true" tabindex="-1">
<IconChevronBack :size="18" class="nav-btn__flip" />
</button>
</header>
<slot />
</div>
</div>
</div>
<!-- Desktop -->
<div class="desktop-page profile-subpage-desktop desktop-only">
<header class="desktop-page__head profile-subpage-desktop__head">
<button type="button" class="profile-subpage-desktop__back" @click="goBack">
<IconChevronBack :size="18" />
بازگشت به پروفایل
</button>
<h1 class="desktop-page__title">{{ title }}</h1>
<p v-if="subtitle" class="desktop-page__subtitle">{{ subtitle }}</p>
</header>
<section class="profile-subpage-desktop__panel card">
<slot />
</section>
</div>
</template>
<style scoped>
.profile-subpage {
overflow: hidden;
}
.profile-subpage__card {
padding: 12px 16px 20px;
}
.profile-subpage__nav {
position: sticky;
top: 0;
z-index: 2;
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
min-height: 44px;
margin: -12px -16px 16px;
padding: 8px 12px;
background: var(--color-elevated);
border-bottom: 1px solid var(--color-border);
border-radius: var(--radius-md) var(--radius-md) 0 0;
box-sizing: border-box;
}
.profile-subpage__title {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
line-height: 1.3;
text-align: center;
color: var(--color-text);
}
.nav-btn {
width: 40px;
height: 40px;
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
place-self: center;
}
.nav-btn--ghost {
visibility: hidden;
pointer-events: none;
}
.nav-btn__flip {
transform: scaleX(-1);
}
.profile-subpage-desktop {
width: 100%;
}
.profile-subpage-desktop__head {
max-width: none;
}
.profile-subpage-desktop__back {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
padding: 0;
border: none;
background: none;
color: var(--color-primary);
font-family: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
.profile-subpage-desktop__back:hover {
color: var(--color-primary-hover);
}
.profile-subpage-desktop__panel {
width: 100%;
max-width: none;
padding: 28px;
box-shadow: var(--shadow-card);
}
</style>
+402
View File
@@ -0,0 +1,402 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCrmStore } from '@/stores/crm'
import { useAuthStore } from '@/stores/auth'
import type { CrmCustomer, CrmMeeting, CrmReminder, MeetingInteractionType } from '@/types/crm'
import PageHeader from '@/components/layout/PageHeader.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
const route = useRoute()
const router = useRouter()
const crm = useCrmStore()
const auth = useAuthStore()
const id = computed(() => String(route.query.id ?? ''))
const reminder = ref<CrmReminder | null>(null)
const customer = ref<CrmCustomer | null>(null)
const history = ref<CrmMeeting[]>([])
const loading = ref(true)
const saving = ref(false)
const error = ref<string | null>(null)
async function load() {
if (!id.value) {
reminder.value = null
loading.value = false
return
}
loading.value = true
error.value = null
try {
const data = (await crm.loadReminderDetail(id.value)) as {
reminder: CrmReminder
customer: CrmCustomer
history: CrmMeeting[]
}
reminder.value = data.reminder
customer.value = data.customer
history.value = data.history
} catch (e) {
reminder.value = null
error.value = e instanceof Error ? e.message : 'یادآور یافت نشد.'
} finally {
loading.value = false
}
}
onMounted(load)
watch(id, load)
function typeLabel(type: MeetingInteractionType) {
if (type === 'in_person') return 'ملاقات حضوری'
if (type === 'call') return 'تماس تلفنی'
if (type === 'message') return 'پیام'
return 'یادداشت'
}
async function complete() {
if (!reminder.value || reminder.value.completed) return
saving.value = true
error.value = null
try {
await crm.markReminderComplete(reminder.value.id)
reminder.value = { ...reminder.value, completed: true }
router.push('/reminders?filter=today')
} catch (e) {
error.value = e instanceof Error ? e.message : 'تکمیل یادآور ناموفق بود.'
} finally {
saving.value = false
}
}
const agentName = computed(() => auth.fullName || 'واسطه')
const interactionCounts = computed(() => {
const list = history.value
return {
total: list.length,
inPerson: list.filter((m) => m.type === 'in_person').length,
call: list.filter((m) => m.type === 'call').length,
other: list.filter((m) => m.type === 'message' || m.type === 'note').length,
}
})
const chartBars = computed(() => {
const bars = Array.from({ length: 6 }, () => ({ a: 0, b: 0, c: 0 }))
history.value.forEach((meeting, index) => {
const bar = bars[index % 6]
if (!bar) return
if (meeting.type === 'in_person') bar.a += 1
else if (meeting.type === 'call') bar.b += 1
else bar.c += 1
})
const max = Math.max(1, ...bars.map((b) => b.a + b.b + b.c))
return bars.map((b) => ({
a: `${(b.a / max) * 100}%`,
b: `${(b.b / max) * 100}%`,
c: `${(b.c / max) * 100}%`,
}))
})
</script>
<template>
<div v-if="reminder && customer" class="page reminder-detail">
<PageHeader title="جزئیات یادآور" show-back centered @back="router.back()" />
<div class="page__scroll detail-scroll">
<p v-if="error" class="form-error">{{ error }}</p>
<div class="layout">
<section class="panel">
<h2>جزئیات یادآور</h2>
<div class="field">
<span>عنوان</span>
<strong>{{ reminder.title }} — {{ reminder.description }}</strong>
</div>
<div class="field">
<span>مشتری</span>
<div class="customer-line">
<div class="avatar" :style="{ background: customer.avatarColor }">
{{ customer.avatarLetter }}
</div>
<div>
<strong>{{ customer.name }}</strong>
<p>{{ customer.contactName }}</p>
</div>
</div>
</div>
<div class="field">
<span>فروشنده حامل حضوری</span>
<strong>{{ agentName }}</strong>
</div>
<div class="field">
<span>تاریخ و زمان</span>
<strong>{{ reminder.date }} — {{ reminder.time }}</strong>
</div>
<div class="field">
<span>یادداشت</span>
<strong>{{ reminder.note || '—' }}</strong>
</div>
<div class="actions">
<button type="button" class="btn-outline" @click="router.back()">ویرایش</button>
<button
type="button"
class="btn-primary"
:disabled="saving || reminder.completed"
@click="complete"
>
{{ reminder.completed ? 'تکمیل‌شده' : saving ? '...' : 'تکمیل شد' }}
</button>
</div>
</section>
<section class="panel">
<h2>تاریخچه تعاملات</h2>
<article v-for="item in history" :key="item.id" class="history-row">
<div>
<strong>{{ typeLabel(item.type) }}</strong>
<p>{{ item.description }}</p>
</div>
<span>{{ item.date }}</span>
</article>
<button
type="button"
class="see-all"
@click="router.push({ path: '/customer', query: { id: customer.id, tab: 'reports' } })"
>
مشاهده همه
</button>
</section>
<section class="panel chart-panel">
<div class="chart-panel__head">
<h2>نمودار فعالیت</h2>
<span>تعاملات ثبت‌شده</span>
</div>
<div class="bars">
<div v-for="(bar, index) in chartBars" :key="index" class="bar">
<span v-if="bar.c !== '0%'" class="seg seg--c" :style="{ height: bar.c }" />
<span v-if="bar.b !== '0%'" class="seg seg--b" :style="{ height: bar.b }" />
<span v-if="bar.a !== '0%'" class="seg seg--a" :style="{ height: bar.a }" />
</div>
</div>
<div class="stats-grid">
<div><span>کل تعاملات</span><strong>{{ interactionCounts.total.toLocaleString('fa-IR') }} مورد</strong></div>
<div><span>ملاقات حضوری</span><strong>{{ interactionCounts.inPerson.toLocaleString('fa-IR') }} مورد</strong></div>
<div><span>تماس تلفنی</span><strong>{{ interactionCounts.call.toLocaleString('fa-IR') }} مورد</strong></div>
<div><span>یادداشت و پیام</span><strong>{{ interactionCounts.other.toLocaleString('fa-IR') }} مورد</strong></div>
</div>
</section>
</div>
</div>
</div>
<div v-else-if="loading" class="page empty">
<PageSkeleton variant="detail" />
</div>
<div v-else class="page empty">
<p>{{ error || 'یادآور یافت نشد.' }}</p>
</div>
</template>
<style scoped>
.reminder-detail {
background: var(--color-bg);
}
.detail-scroll {
padding: 12px var(--page-padding) 24px;
}
.form-error {
margin: 0 0 12px;
color: var(--color-danger, #b91c1c);
font-size: 0.85rem;
}
.layout {
display: flex;
flex-direction: column;
gap: 12px;
}
@media (min-width: 1024px) {
.layout {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr;
align-items: start;
}
}
.panel {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 16px;
}
.panel h2 {
margin: 0 0 14px;
font-size: 1rem;
}
.field {
margin-bottom: 14px;
}
.field > span {
display: block;
color: var(--color-text-muted);
font-size: 0.78rem;
margin-bottom: 4px;
}
.customer-line {
display: flex;
gap: 10px;
align-items: center;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 999px;
display: grid;
place-items: center;
font-weight: 700;
}
.customer-line p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.8rem;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.btn-outline,
.btn-primary {
min-height: 44px;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
}
.btn-outline {
border: 1px solid var(--color-border);
background: #fff;
}
.btn-primary {
border: none;
background: var(--color-primary);
color: #fff;
}
.btn-primary:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.history-row {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid var(--color-border);
}
.history-row p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.78rem;
}
.see-all {
width: 100%;
margin-top: 12px;
border: none;
border-radius: 12px;
background: #eef1ef;
min-height: 44px;
cursor: pointer;
font-weight: 600;
}
.chart-panel__head {
display: flex;
justify-content: space-between;
align-items: center;
}
.chart-panel__head span {
color: var(--color-text-muted);
font-size: 0.8rem;
}
.bars {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
height: 140px;
align-items: end;
margin: 16px 0;
}
.bar {
display: flex;
flex-direction: column-reverse;
gap: 2px;
height: 100%;
}
.seg {
display: block;
width: 100%;
border-radius: 4px 4px 0 0;
}
.seg--a {
background: var(--color-primary);
}
.seg--b {
background: #3b82f6;
}
.seg--c {
background: #a78bfa;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.stats-grid div {
background: var(--color-bg);
border-radius: 12px;
padding: 10px;
}
.stats-grid span {
display: block;
color: var(--color-text-muted);
font-size: 0.72rem;
}
.stats-grid strong {
display: block;
margin-top: 4px;
}
.empty {
padding: 40px;
text-align: center;
}
</style>
+586
View File
@@ -0,0 +1,586 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCrmStore } from '@/stores/crm'
import type { CrmCustomer, CrmReminder, MeetingInteractionType, ReminderFilter } from '@/types/crm'
import { formatNumber } from '@/utils/format'
import IconBell from '@/components/icons/IconBell.vue'
import IconMoreVertical from '@/components/icons/IconMoreVertical.vue'
import IconPlus from '@/components/icons/IconPlus.vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import { useGoBack } from '@/composables/useGoBack'
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
const route = useRoute()
const router = useRouter()
const { goBack } = useGoBack('/')
const crm = useCrmStore()
const filter = computed(() => String(route.query.filter ?? 'today') as ReminderFilter)
const reminders = ref<CrmReminder[]>([])
const customers = ref<CrmCustomer[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const showCreate = ref(false)
const saving = ref(false)
const form = ref({
customerId: '',
title: '',
date: '',
time: '10:00',
note: '',
})
const filters: Array<{ id: ReminderFilter; label: string }> = [
{ id: 'all', label: 'همه' },
{ id: 'today', label: 'امروز' },
{ id: 'week', label: 'هفته آینده' },
{ id: 'past', label: 'گذشته' },
]
const openReminders = computed(() => reminders.value.filter((r) => !r.completed))
const completedToday = computed(() => reminders.value.filter((r) => r.completed))
const openCount = computed(() => openReminders.value.length)
const pastCount = computed(() => completedToday.value.length)
async function load() {
loading.value = true
error.value = null
try {
reminders.value = await crm.loadReminders(filter.value)
} catch (e) {
error.value = e instanceof Error ? e.message : 'بارگذاری یادآورها ناموفق بود.'
reminders.value = []
} finally {
loading.value = false
}
}
onMounted(load)
watch(filter, load)
function setFilter(id: ReminderFilter) {
router.replace({ path: '/reminders', query: { filter: id } })
}
async function markDone(id: string) {
try {
await crm.markReminderComplete(id)
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : 'تکمیل یادآور ناموفق بود.'
}
}
function typeLabel(type: MeetingInteractionType) {
if (type === 'in_person') return 'ملاقات حضوری'
if (type === 'call') return 'تماس تلفنی'
if (type === 'message') return 'پیام'
return 'یادداشت'
}
function typeClass(type: MeetingInteractionType) {
if (type === 'in_person') return 'tag--green'
if (type === 'call') return 'tag--blue'
if (type === 'message') return 'tag--purple'
return 'tag--purple'
}
const summaryTitle = computed(() => {
if (filter.value === 'today') return 'پیگیری‌های امروز'
if (filter.value === 'week') return 'پیگیری‌های هفته آینده'
if (filter.value === 'past') return 'پیگیری‌های گذشته'
return 'پیگیری‌های باز'
})
async function openCreate() {
showCreate.value = true
error.value = null
try {
customers.value = await crm.loadCustomers()
if (!form.value.customerId && customers.value[0]) {
form.value.customerId = customers.value[0].id
}
if (!form.value.date) {
const d = new Date()
form.value.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'بارگذاری مشتریان ناموفق بود.'
}
}
async function submitCreate() {
if (!form.value.customerId || !form.value.title.trim() || !form.value.date || !form.value.time) {
error.value = 'مشتری، عنوان، تاریخ و ساعت الزامی است.'
return
}
saving.value = true
error.value = null
try {
await crm.createReminder({
customerId: form.value.customerId,
title: form.value.title.trim(),
date: form.value.date,
time: form.value.time,
note: form.value.note.trim() || undefined,
description: form.value.note.trim() || undefined,
})
showCreate.value = false
form.value.title = ''
form.value.note = ''
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : 'ثبت یادآور ناموفق بود.'
} finally {
saving.value = false
}
}
</script>
<template>
<div class="page reminders-page">
<header class="top">
<button type="button" class="icon-btn" aria-label="بازگشت" @click="goBack">
<IconChevronBack :size="20" />
</button>
<h1>یادآورها</h1>
<div class="top__actions">
<AuthThemeToggle variant="inline" />
<button type="button" class="icon-btn" aria-label="اعلان‌ها" @click="router.push('/profile/notifications')">
<IconBell :size="18" />
</button>
</div>
</header>
<div class="page__scroll page__scroll--with-nav reminders-scroll">
<div class="filters">
<button
v-for="item in filters"
:key="item.id"
type="button"
class="filter"
:class="{ 'filter--active': filter === item.id }"
@click="setFilter(item.id)"
>
{{ item.label }}
<span v-if="item.id === 'past' && pastCount" class="count-badge">{{
pastCount.toLocaleString('fa-IR')
}}</span>
</button>
</div>
<PageSkeleton v-if="loading" variant="list" />
<p v-else-if="error && !showCreate">{{ error }}</p>
<section v-if="showCreate" class="create-panel">
<h3>یادآور جدید</h3>
<p v-if="error" class="create-error">{{ error }}</p>
<label>
مشتری
<select v-model="form.customerId">
<option disabled value="">انتخاب مشتری</option>
<option v-for="c in customers" :key="c.id" :value="c.id">
{{ c.name || c.contactName }}
</option>
</select>
</label>
<label>
عنوان
<input v-model="form.title" type="text" placeholder="مثلاً پیگیری نمونه" />
</label>
<div class="create-row">
<label>
تاریخ
<input v-model="form.date" type="date" />
</label>
<label>
ساعت
<input v-model="form.time" type="time" />
</label>
</div>
<label>
یادداشت
<textarea v-model="form.note" rows="2" placeholder="اختیاری" />
</label>
<div class="create-actions">
<button type="button" class="btn-cancel" @click="showCreate = false">انصراف</button>
<button type="button" class="btn-save" :disabled="saving" @click="submitCreate">
{{ saving ? 'در حال ثبت...' : 'ثبت یادآور' }}
</button>
</div>
</section>
<section v-else-if="!loading && !error" class="summary">
<div class="summary__head">
<span class="summary__label">{{ summaryTitle }}</span>
</div>
<div class="summary__count">
<strong>{{ formatNumber(openCount) }}</strong>
<span>مورد</span>
</div>
<p class="summary__sub">یادآورهای شما برای پیگیری مشتریان</p>
</section>
<article
v-for="item in openReminders"
:key="item.id"
class="reminder-card"
@click="router.push({ path: '/reminder', query: { id: item.id } })"
>
<span class="time">{{ item.time }}</span>
<button type="button" class="check" aria-label="تکمیل" @click.stop="markDone(item.id)" />
<div class="reminder-card__body">
<div class="reminder-card__top">
<strong>{{ item.title }}</strong>
<IconMoreVertical :size="16" />
</div>
<span class="tag" :class="typeClass(item.interactionType)">
{{ typeLabel(item.interactionType) }}
</span>
<p class="customer">{{ item.customerName }}</p>
<p class="contact">{{ item.contactName }}</p>
<p class="desc">{{ item.description }}</p>
</div>
<div
class="avatar"
:style="{ background: item.avatarColor }"
>
{{ item.avatarLetter }}
</div>
</article>
<button type="button" class="completed-bar" @click="setFilter('past')">
یادآورهای تکمیل‌شده
<span>{{ formatNumber(completedToday.length) }}</span>
</button>
<button type="button" class="new-btn" @click="openCreate">
<IconPlus :size="20" color="#fff" />
یادآور جدید
</button>
</div>
</div>
</template>
<style scoped>
.reminders-page {
background: var(--color-bg);
}
.top {
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: center;
padding: 10px var(--page-padding);
background: var(--color-surface);
color: var(--color-text);
}
.top h1 {
margin: 0;
text-align: center;
font-size: 1rem;
}
.top__actions {
display: flex;
align-items: center;
}
.icon-btn {
width: 40px;
height: 40px;
border: none;
background: transparent;
cursor: pointer;
font-size: 1.4rem;
color: var(--color-text);
display: grid;
place-items: center;
}
.reminders-scroll {
padding: 12px var(--page-padding) 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.filters {
display: flex;
gap: 12px;
overflow-x: auto;
}
.filter {
border: none;
background: transparent;
color: var(--color-text-muted);
padding: 8px 2px;
cursor: pointer;
position: relative;
white-space: nowrap;
}
.filter--active {
color: var(--color-primary);
font-weight: 700;
}
.filter--active::after {
content: '';
position: absolute;
inset-inline: 0;
bottom: 0;
height: 3px;
background: var(--color-primary);
border-radius: 999px;
}
.count-badge {
display: inline-grid;
place-items: center;
min-width: 18px;
height: 18px;
margin-inline-start: 4px;
border-radius: 999px;
background: var(--color-danger);
color: #fff;
font-size: 0.65rem;
}
.summary {
display: flex;
flex-direction: column;
gap: 6px;
background: var(--color-primary);
color: #fff;
border-radius: var(--radius-lg);
padding: 16px;
}
.summary__label {
font-size: 0.85rem;
opacity: 0.92;
}
.summary__count {
display: flex;
align-items: baseline;
gap: 6px;
}
.summary__count strong {
font-size: 2rem;
line-height: 1;
}
.summary__count span {
font-size: 0.95rem;
font-weight: 600;
}
.summary__sub {
margin: 4px 0 0;
opacity: 0.88;
font-size: 0.78rem;
}
.reminder-card {
display: grid;
grid-template-columns: 24px 1fr 40px;
grid-template-rows: auto 1fr;
gap: 4px 10px;
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 12px;
box-shadow: var(--shadow-sm);
cursor: pointer;
}
.time {
grid-column: 1 / 3;
grid-row: 1;
font-size: 0.8rem;
font-weight: 700;
color: var(--color-text-muted);
}
.check {
grid-column: 1;
grid-row: 2;
width: 22px;
height: 22px;
margin-top: 4px;
border-radius: 999px;
border: 1.5px solid #cbd5e1;
background: transparent;
cursor: pointer;
align-self: start;
}
.reminder-card__body {
grid-column: 2;
grid-row: 2;
}
.avatar {
grid-column: 3;
grid-row: 1 / 3;
width: 40px;
height: 40px;
border-radius: 999px;
display: grid;
place-items: center;
font-weight: 700;
font-size: 0.85rem;
align-self: start;
justify-self: end;
}
.reminder-card__top {
display: flex;
justify-content: space-between;
gap: 8px;
}
.customer {
margin: 6px 0 0;
font-size: 0.85rem;
font-weight: 600;
}
.contact,
.desc {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.75rem;
}
.tag {
display: inline-block;
margin-top: 6px;
border-radius: 999px;
padding: 2px 8px;
font-size: 0.65rem;
}
.tag--green {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.tag--blue {
background: #e0f2fe;
color: #0369a1;
}
.tag--purple {
background: #ede9fe;
color: #6d28d9;
}
.completed-bar {
border: none;
border-radius: var(--radius-md);
background: #ecefee;
padding: 14px;
display: flex;
justify-content: space-between;
cursor: pointer;
font-weight: 600;
}
.new-btn {
border: none;
border-radius: 14px;
background: var(--color-primary);
color: #fff;
min-height: 52px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
}
.create-panel {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
box-shadow: var(--shadow-sm);
}
.create-panel h3 {
margin: 0;
font-size: 0.95rem;
}
.create-panel label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.create-panel input,
.create-panel select,
.create-panel textarea {
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 10px 12px;
font: inherit;
color: var(--color-text);
background: #fff;
}
.create-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.create-actions {
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 8px;
}
.btn-cancel,
.btn-save {
min-height: 44px;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
}
.btn-cancel {
border: 1px solid var(--color-border);
background: #fff;
}
.btn-save {
border: none;
background: var(--color-primary);
color: #fff;
}
.btn-save:disabled {
opacity: 0.7;
}
.create-error {
margin: 0;
color: #b42318;
font-size: 0.8rem;
}
</style>