Initialize the user app and load home banners from the API.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import CheckListView from '@/views/checkout/CheckListView.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckListView />
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user