fix some bugs
This commit is contained in:
@@ -14,14 +14,20 @@ const {
|
||||
filteredOrdersMobile,
|
||||
filteredOrdersDesktop,
|
||||
paginatedOrders,
|
||||
paginatedOrdersMobile,
|
||||
totalPages,
|
||||
mobileTotalPages,
|
||||
paginationRange,
|
||||
mobilePaginationRange,
|
||||
visiblePages,
|
||||
mobileVisiblePages,
|
||||
currentPage,
|
||||
mobilePage,
|
||||
companySlug,
|
||||
iconVariant,
|
||||
goToOrder,
|
||||
setPage,
|
||||
setMobilePage,
|
||||
setDesktopFilter,
|
||||
viewStatFilter,
|
||||
} = useOrderList()
|
||||
@@ -47,12 +53,18 @@ const desktopFilterTabs: { id: DesktopOrderFilterId; label: string }[] = [
|
||||
class="mobile-only"
|
||||
:tab="tab"
|
||||
:tabs="mobileTabs"
|
||||
:orders="filteredOrdersMobile"
|
||||
: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
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
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 { normalizePhone } from '@/utils/format'
|
||||
import { normalizePhone, toPersianDigits } from '@/utils/format'
|
||||
import { validatePhone } from '@/utils/validation'
|
||||
import {
|
||||
FORM_VALIDATION_SUMMARY,
|
||||
clearFieldError,
|
||||
@@ -20,24 +21,42 @@ 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)
|
||||
const errors: FieldErrors = {}
|
||||
phone.value = normalized
|
||||
|
||||
if (!/^09\d{9}$/.test(normalized)) {
|
||||
errors.phone = 'شماره موبایل معتبر نیست.'
|
||||
}
|
||||
|
||||
if (hasFieldErrors(errors)) {
|
||||
fieldErrors.value = errors
|
||||
const message = phoneErrorMessage(normalized)
|
||||
if (message) {
|
||||
fieldErrors.value = { phone: message }
|
||||
formError.value = FORM_VALIDATION_SUMMARY
|
||||
return
|
||||
}
|
||||
@@ -52,59 +71,41 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page auth-page">
|
||||
<div class="page__scroll auth-page__scroll">
|
||||
<div class="page page-shell auth-page">
|
||||
<div class="page__scroll flex flex-col justify-center py-6">
|
||||
<div class="auth-header">
|
||||
<div class="logo">🧵</div>
|
||||
<div class="text-5xl mb-3">🧵</div>
|
||||
<h1 class="page-title">ورود به سامانه</h1>
|
||||
<p class="page-subtitle">سامانه سفارش عمده پارچه</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form class="card" @submit.prevent="submit">
|
||||
<FormErrorBanner :message="formError" />
|
||||
|
||||
<BaseInput
|
||||
v-model="phone"
|
||||
:model-value="displayPhone"
|
||||
label="شماره موبایل"
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
autocomplete="tel"
|
||||
dir="ltr"
|
||||
align="center"
|
||||
maxlength="11"
|
||||
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
|
||||
:error="fieldErrors.phone"
|
||||
hint="کد تأیید به این شماره ارسال میشود"
|
||||
@update:model-value="clearPhoneError"
|
||||
@update:model-value="onPhoneInput"
|
||||
@blur="onPhoneBlur"
|
||||
/>
|
||||
<BaseButton block :loading="auth.loading" @click="submit">دریافت کد تأیید</BaseButton>
|
||||
</div>
|
||||
<BaseButton block type="submit" :loading="auth.loading">دریافت کد تأیید</BaseButton>
|
||||
</form>
|
||||
|
||||
<p class="auth-footer text-center text-sm">
|
||||
حساب کاربری ندارید؟
|
||||
<router-link to="/register">ثبتنام</router-link>
|
||||
</p>
|
||||
<div class="mt-6 w-full max-w-[var(--form-max-width)]">
|
||||
<p class="m-0 mb-2.5 text-center text-sm text-muted">حساب کاربری ندارید؟</p>
|
||||
<RouterLink v-slot="{ navigate }" to="/register" custom>
|
||||
<BaseButton block variant="outline" type="button" @click="navigate">ثبتنام</BaseButton>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-page__scroll {
|
||||
padding-top: 36px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 24px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
+237
-34
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
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 { toPersianDigits, extractNumericString } from '@/utils/format'
|
||||
import {
|
||||
FORM_VALIDATION_SUMMARY,
|
||||
clearFieldError,
|
||||
@@ -12,27 +13,155 @@ import {
|
||||
type FieldErrors,
|
||||
} from '@/utils/formErrors'
|
||||
|
||||
const OTP_LENGTH = 6
|
||||
const RESEND_SECONDS = 60
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const phone = computed(() => (route.query.phone as string) ?? '')
|
||||
const code = ref('')
|
||||
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 < 4) {
|
||||
errors.code = 'کد تأیید را وارد کنید.'
|
||||
if (code.value.length < OTP_LENGTH) {
|
||||
errors.code = 'کد تأیید را کامل وارد کنید.'
|
||||
}
|
||||
|
||||
if (hasFieldErrors(errors)) {
|
||||
@@ -46,55 +175,129 @@ async function submit() {
|
||||
router.replace('/')
|
||||
} else {
|
||||
formError.value = result.message ?? 'کد تأیید نامعتبر است.'
|
||||
digits.value = Array.from({ length: OTP_LENGTH }, () => '')
|
||||
focusInput(0)
|
||||
}
|
||||
}
|
||||
|
||||
async function resend() {
|
||||
if (!canResend.value) return
|
||||
|
||||
const isRegister = route.query.mode === 'register'
|
||||
await auth.requestOtp(phone.value, { isRegister })
|
||||
const result = await auth.requestOtp(phone.value, { isRegister })
|
||||
if (result.success) {
|
||||
digits.value = Array.from({ length: OTP_LENGTH }, () => '')
|
||||
formError.value = ''
|
||||
fieldErrors.value = {}
|
||||
startTimer()
|
||||
focusInput(0)
|
||||
} else {
|
||||
formError.value = result.message ?? 'خطا در ارسال مجدد کد.'
|
||||
}
|
||||
}
|
||||
|
||||
function changePhone() {
|
||||
const isRegister = route.query.mode === 'register'
|
||||
router.replace({ name: isRegister ? 'register' : 'login' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startTimer()
|
||||
focusInput(0)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page auth-page">
|
||||
<div class="page__scroll auth-page__scroll">
|
||||
<div class="page page-shell auth-page">
|
||||
<div class="page__scroll flex flex-col justify-center py-6">
|
||||
<div class="auth-header">
|
||||
<h1 class="page-title">تأیید شماره موبایل</h1>
|
||||
<p class="page-subtitle">کد ارسالشده به {{ phone }} را وارد کنید</p>
|
||||
<p class="page-subtitle">کد ارسالشده به {{ toPersianDigits(phone) }} را وارد کنید</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form class="card" @submit.prevent="submit">
|
||||
<FormErrorBanner :message="formError" />
|
||||
|
||||
<BaseInput
|
||||
v-model="code"
|
||||
label="کد تأیید"
|
||||
type="text"
|
||||
placeholder="۱۲۳۴۵۶"
|
||||
maxlength="6"
|
||||
:error="fieldErrors.code"
|
||||
hint="در نسخه آزمایشی: 123456"
|
||||
@update:model-value="clearCodeError"
|
||||
/>
|
||||
<BaseButton block :loading="auth.loading" @click="submit">تأیید و ورود</BaseButton>
|
||||
<BaseButton block variant="ghost" class="mt-2" @click="resend">ارسال مجدد کد</BaseButton>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="otp-0">کد تأیید</label>
|
||||
<div class="flex justify-center gap-2.5 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>
|
||||
<BaseButton block variant="ghost" type="button" class="mt-2" @click="changePhone">
|
||||
تغییر شماره موبایل
|
||||
</BaseButton>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-page__scroll {
|
||||
padding-top: 36px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
.otp-box {
|
||||
width: 48px;
|
||||
height: 52px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
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;
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.otp-box {
|
||||
width: 42px;
|
||||
height: 48px;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -40,7 +41,7 @@ async function submit() {
|
||||
if (!lastName.value.trim()) {
|
||||
errors.lastName = 'نام خانوادگی الزامی است.'
|
||||
}
|
||||
if (!/^09\d{9}$/.test(normalized)) {
|
||||
if (!validatePhone(normalized)) {
|
||||
errors.phone = 'شماره موبایل معتبر نیست.'
|
||||
}
|
||||
|
||||
@@ -64,10 +65,10 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page auth-page">
|
||||
<div class="page__scroll auth-page__scroll">
|
||||
<div class="page page-shell auth-page">
|
||||
<div class="page__scroll flex flex-col justify-center py-6">
|
||||
<div class="auth-header">
|
||||
<div class="logo">🧵</div>
|
||||
<div class="text-5xl mb-3">🧵</div>
|
||||
<h1 class="page-title">ثبتنام</h1>
|
||||
<p class="page-subtitle">ایجاد حساب کاربری جدید</p>
|
||||
</div>
|
||||
@@ -100,36 +101,12 @@ async function submit() {
|
||||
<BaseButton block :loading="auth.loading" @click="submit">دریافت کد تأیید</BaseButton>
|
||||
</div>
|
||||
|
||||
<p class="auth-footer text-center text-sm">
|
||||
قبلاً ثبتنام کردهاید؟
|
||||
<router-link to="/login">ورود</router-link>
|
||||
</p>
|
||||
<div class="mt-6 w-full max-w-[var(--form-max-width)]">
|
||||
<p class="m-0 mb-2.5 text-center text-sm text-muted">قبلاً ثبتنام کردهاید؟</p>
|
||||
<RouterLink v-slot="{ navigate }" to="/login" custom>
|
||||
<BaseButton block variant="outline" type="button" @click="navigate">ورود</BaseButton>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-page__scroll {
|
||||
padding-top: 36px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 24px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
+45
-35
@@ -30,49 +30,59 @@ function goToOrdering() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MobileCartPage
|
||||
class="mobile-only"
|
||||
:items="items"
|
||||
:company-name="companyName"
|
||||
:total-meterage="cartStore.totalMeterage"
|
||||
:max-meterage="cartStore.maxMeterage"
|
||||
:remaining-capacity="cartStore.remainingCapacity"
|
||||
:is-empty="isEmpty"
|
||||
@back="router.push('/')"
|
||||
@remove="removeItem"
|
||||
@checkout="proceedToCheckout"
|
||||
@select-company="router.push('/')"
|
||||
/>
|
||||
<div class="cart-view">
|
||||
<MobileCartPage
|
||||
class="mobile-only"
|
||||
:items="items"
|
||||
:company-name="companyName"
|
||||
:total-meterage="cartStore.totalMeterage"
|
||||
:max-meterage="cartStore.maxMeterage"
|
||||
:remaining-capacity="cartStore.remainingCapacity"
|
||||
:is-empty="isEmpty"
|
||||
@back="router.push('/')"
|
||||
@remove="removeItem"
|
||||
@checkout="proceedToCheckout"
|
||||
@select-company="router.push('/')"
|
||||
/>
|
||||
|
||||
<DesktopCartPage
|
||||
class="desktop-only"
|
||||
:items="items"
|
||||
:company-name="companyName"
|
||||
:total-meterage="cartStore.totalMeterage"
|
||||
:max-meterage="cartStore.maxMeterage"
|
||||
:remaining-capacity="cartStore.remainingCapacity"
|
||||
:is-empty="isEmpty"
|
||||
@remove="removeItem"
|
||||
@checkout="proceedToCheckout"
|
||||
@select-company="router.push('/')"
|
||||
@back="goToOrdering"
|
||||
/>
|
||||
<DesktopCartPage
|
||||
class="desktop-only"
|
||||
:items="items"
|
||||
:company-name="companyName"
|
||||
:total-meterage="cartStore.totalMeterage"
|
||||
:max-meterage="cartStore.maxMeterage"
|
||||
:remaining-capacity="cartStore.remainingCapacity"
|
||||
:is-empty="isEmpty"
|
||||
@remove="removeItem"
|
||||
@checkout="proceedToCheckout"
|
||||
@select-company="router.push('/')"
|
||||
@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" @click="proceedAnyway">تسویه همین فاکتور</BaseButton>
|
||||
<BaseButton @click="createNewInvoice">فاکتور جدید</BaseButton>
|
||||
<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" @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;
|
||||
}
|
||||
|
||||
.dialog h3 {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
import IconCart from '@/components/icons/IconCart.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'
|
||||
@@ -82,11 +82,9 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="cart-mobile-page__empty">
|
||||
<IconCart :size="56" />
|
||||
<p>سبد خرید خالی است</p>
|
||||
<EmptyState v-else variant="cart" message="برای شروع سفارش، یک شرکت انتخاب کنید">
|
||||
<BaseButton @click="emit('selectCompany')">انتخاب شرکت</BaseButton>
|
||||
</div>
|
||||
</EmptyState>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -96,8 +94,11 @@ const emit = defineEmits<{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
padding: 12px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cart-mobile-page__nav {
|
||||
@@ -112,6 +113,7 @@ const emit = defineEmits<{
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
@@ -119,6 +121,7 @@ const emit = defineEmits<{
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -156,6 +159,7 @@ const emit = defineEmits<{
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--color-elevated);
|
||||
}
|
||||
|
||||
.cart-mobile-table__head,
|
||||
@@ -168,7 +172,7 @@ const emit = defineEmits<{
|
||||
}
|
||||
|
||||
.cart-mobile-table__head {
|
||||
background: #f1f5f9;
|
||||
background: var(--color-summary-bg);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
@@ -176,6 +180,7 @@ const emit = defineEmits<{
|
||||
|
||||
.cart-mobile-table__row {
|
||||
border-top: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.col-product strong {
|
||||
@@ -209,23 +214,25 @@ const emit = defineEmits<{
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: #64748b;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.cart-mobile-page__footer {
|
||||
flex-shrink: 0;
|
||||
margin: 0 calc(-1 * var(--page-padding));
|
||||
padding: 12px var(--page-padding);
|
||||
margin: 0;
|
||||
padding: 12px 0 calc(12px + env(safe-area-inset-bottom, 0));
|
||||
border-top: 1px solid var(--color-border);
|
||||
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
|
||||
background: #fff;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.cart-mobile-summary {
|
||||
margin-bottom: 12px;
|
||||
padding: 14px 16px;
|
||||
background: #eef2f7;
|
||||
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 {
|
||||
@@ -236,16 +243,6 @@ const emit = defineEmits<{
|
||||
}
|
||||
|
||||
.cart-mobile-summary__row:not(:last-child) {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.cart-mobile-page__empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
color: var(--color-text-muted);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
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 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))
|
||||
@@ -16,11 +18,25 @@ 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>
|
||||
@@ -42,8 +58,8 @@ function getProductCount(catalogId: string) {
|
||||
class="catalog-page__search"
|
||||
/>
|
||||
|
||||
<div class="page__scroll page__scroll--with-nav">
|
||||
<div class="catalog-mobile-list">
|
||||
<div class="page__scroll page__scroll--with-nav catalog-mobile-scroll">
|
||||
<div v-if="catalogs.length > 0" class="catalog-mobile-list">
|
||||
<button
|
||||
v-for="catalog in catalogs"
|
||||
:key="catalog.id"
|
||||
@@ -58,8 +74,12 @@ function getProductCount(catalogId: string) {
|
||||
</div>
|
||||
<span class="catalog-card__count">{{ getProductCount(catalog.id) }} محصول</span>
|
||||
</button>
|
||||
<div v-if="catalogs.length === 0" class="catalog-empty">کالیتهای یافت نشد.</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else
|
||||
:variant="search ? 'search' : 'catalogs'"
|
||||
:message="search ? 'با این جستجو کالیتهای پیدا نشد' : 'برای این شرکت هنوز کالیتهای ثبت نشده است'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +96,7 @@ function getProductCount(catalogId: string) {
|
||||
class="catalog-page__search"
|
||||
/>
|
||||
|
||||
<div class="desktop-grid-3">
|
||||
<div v-if="catalogs.length > 0" class="desktop-grid-3">
|
||||
<button
|
||||
v-for="catalog in catalogs"
|
||||
:key="catalog.id"
|
||||
@@ -93,13 +113,18 @@ function getProductCount(catalogId: string) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="catalogs.length === 0" class="catalog-empty">کالیتهای یافت نشد.</div>
|
||||
<EmptyState
|
||||
v-else
|
||||
:variant="search ? 'search' : 'catalogs'"
|
||||
:message="search ? 'با این جستجو کالیتهای پیدا نشد' : 'برای این شرکت هنوز کالیتهای ثبت نشده است'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.catalog-mobile-page {
|
||||
background: #fff;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
padding: 12px 0 0;
|
||||
}
|
||||
|
||||
@@ -107,6 +132,13 @@ function getProductCount(catalogId: string) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.catalog-mobile-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.catalog-mobile-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -121,8 +153,9 @@ function getProductCount(catalogId: string) {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
background: var(--color-elevated);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--color-text);
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -135,7 +168,7 @@ function getProductCount(catalogId: string) {
|
||||
}
|
||||
|
||||
.catalog-card:hover {
|
||||
border-color: #cbd5e1;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
@@ -157,6 +190,7 @@ function getProductCount(catalogId: string) {
|
||||
.catalog-card__info h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.catalog-card__info p {
|
||||
@@ -170,10 +204,4 @@ function getProductCount(catalogId: string) {
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.catalog-empty {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
import { useOrderStore } from '@/stores/order'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
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 { toPersianDigits } from '@/utils/format'
|
||||
import { getCompanyById } from '@/data/mockData'
|
||||
import type { Order } from '@/types'
|
||||
import type { BankAccount, Order } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const cartStore = useCartStore()
|
||||
const orderStore = useOrderStore()
|
||||
const authStore = useAuthStore()
|
||||
const catalogStore = useCatalogStore()
|
||||
const bankAccounts = ref<BankAccount[]>([])
|
||||
const submitting = ref(false)
|
||||
|
||||
const companyId = computed(() => {
|
||||
const firstItem = cartStore.activeCart?.items[0]
|
||||
@@ -23,16 +26,24 @@ const companyId = computed(() => {
|
||||
const companyName = computed(
|
||||
() => cartStore.companyNamesLabel || cartStore.activeCart?.companyName || '',
|
||||
)
|
||||
const bankAccounts = computed(() => orderStore.getBankAccountsForCompany(companyId.value))
|
||||
|
||||
onMounted(async () => {
|
||||
if (!catalogStore.getCompany(companyId.value)) {
|
||||
await catalogStore.loadCompanies()
|
||||
}
|
||||
if (companyId.value) {
|
||||
bankAccounts.value = await catalogStore.loadBankAccounts(companyId.value)
|
||||
}
|
||||
})
|
||||
|
||||
const expiryMessage = computed(() => {
|
||||
const hour = getCompanyById(companyId.value)?.cashInvoiceValidUntilHour ?? 17
|
||||
const hour = catalogStore.getCompany(companyId.value)?.cashInvoiceValidUntilHour ?? 17
|
||||
const time = toPersianDigits(String(hour).padStart(2, '0'))
|
||||
return `این فاکتور تا ساعت ${time}:۰۰ امروز معتبر است.`
|
||||
})
|
||||
|
||||
const accountsSectionTitle = computed(() => {
|
||||
const company = getCompanyById(companyId.value)
|
||||
const company = catalogStore.getCompany(companyId.value)
|
||||
const name = (company?.name || companyName.value).replace(/^شرکت\s+/u, '')
|
||||
return `شماره حسابهای شرکت ${name}`
|
||||
})
|
||||
@@ -47,21 +58,29 @@ function formatIban(value: string) {
|
||||
return clean.replace(/(.{4})/g, '$1 ').trim()
|
||||
}
|
||||
|
||||
function createOrder(): Order | null {
|
||||
if (!authStore.user || !cartStore.activeCart) return null
|
||||
const order = orderStore.submitOrder(authStore.user.id, cartStore.activeCart, 'cash')
|
||||
cartStore.clearActiveCart()
|
||||
return order
|
||||
async function createOrder(): Promise<Order | null> {
|
||||
if (!authStore.user || !cartStore.activeCart || submitting.value) return null
|
||||
submitting.value = true
|
||||
try {
|
||||
const order = await orderStore.submitOrder(authStore.user.id, cartStore.activeCart, 'cash')
|
||||
await cartStore.hydrate()
|
||||
return order
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'ثبت سفارش ناموفق بود.')
|
||||
return null
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function viewOrder() {
|
||||
const order = createOrder()
|
||||
async function viewOrder() {
|
||||
const order = await createOrder()
|
||||
if (!order) return
|
||||
router.replace(`/orders/${order.id}?track=1`)
|
||||
}
|
||||
|
||||
function registerReceipt() {
|
||||
const order = createOrder()
|
||||
async function registerReceipt() {
|
||||
const order = await createOrder()
|
||||
if (!order) return
|
||||
router.replace(`/orders/${order.id}?track=1&receipt=1`)
|
||||
}
|
||||
@@ -200,7 +219,8 @@ function registerReceipt() {
|
||||
.accounts-panel {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background: #eef2f7;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.accounts-panel__title {
|
||||
@@ -214,8 +234,9 @@ function registerReceipt() {
|
||||
.bank-card {
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04);
|
||||
background: var(--color-elevated);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.bank-card + .bank-card {
|
||||
@@ -269,7 +290,7 @@ function registerReceipt() {
|
||||
.bank-card__field + .bank-card__field {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.bank-card__label {
|
||||
@@ -289,7 +310,6 @@ function registerReceipt() {
|
||||
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);
|
||||
|
||||
@@ -281,7 +281,7 @@ function resetForm() {
|
||||
min-height: 120px;
|
||||
border: 1.5px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: #fafafa;
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
@@ -86,18 +86,22 @@ function toggleMenu(id: string) {
|
||||
openMenuId.value = openMenuId.value === id ? '' : id
|
||||
}
|
||||
|
||||
function submitOrder() {
|
||||
async function submitOrder() {
|
||||
if (!authStore.user || !cartStore.activeCart || checks.value.length === 0) return
|
||||
|
||||
const order = orderStore.submitOrder(
|
||||
authStore.user.id,
|
||||
cartStore.activeCart,
|
||||
'check',
|
||||
[...orderStore.draftChecks],
|
||||
orderStore.draftCheckMode ?? 'brief',
|
||||
)
|
||||
cartStore.clearActiveCart()
|
||||
router.replace(`/orders/${order.id}/success`)
|
||||
try {
|
||||
const order = await orderStore.submitOrder(
|
||||
authStore.user.id,
|
||||
cartStore.activeCart,
|
||||
'check',
|
||||
[...orderStore.draftChecks],
|
||||
orderStore.draftCheckMode ?? 'brief',
|
||||
)
|
||||
await cartStore.hydrate()
|
||||
router.replace(`/orders/${order.id}/success`)
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'ثبت سفارش ناموفق بود.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -115,9 +119,10 @@ function submitOrder() {
|
||||
</header>
|
||||
|
||||
<div v-if="checks.length === 0" class="check-list-page__empty page__scroll page__scroll--with-nav">
|
||||
<p>چکی ثبت نشده است.</p>
|
||||
<BaseButton v-if="isCheckout" @click="addAnother">ثبت چک جدید</BaseButton>
|
||||
<BaseButton v-else variant="outline" @click="router.push('/')">ثبت سفارش جدید</BaseButton>
|
||||
<EmptyState variant="checks" message="برای ادامه، حداقل یک چک ثبت کنید">
|
||||
<BaseButton v-if="isCheckout" @click="addAnother">ثبت چک جدید</BaseButton>
|
||||
<BaseButton v-else variant="outline" @click="router.push('/')">ثبت سفارش جدید</BaseButton>
|
||||
</EmptyState>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
@@ -268,7 +273,7 @@ function submitOrder() {
|
||||
</div>
|
||||
|
||||
<div v-else class="desktop-checkout-shell desktop-only">
|
||||
<EmptyState message="چکی ثبت نشده است">
|
||||
<EmptyState variant="checks" message="برای ادامه، حداقل یک چک ثبت کنید">
|
||||
<BaseButton v-if="isCheckout" @click="addAnother">ثبت چک جدید</BaseButton>
|
||||
</EmptyState>
|
||||
</div>
|
||||
@@ -293,7 +298,7 @@ function submitOrder() {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 12px var(--page-padding) 0;
|
||||
background: #ffffff;
|
||||
background: var(--color-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -360,8 +365,10 @@ function submitOrder() {
|
||||
.summary-card {
|
||||
padding: 14px 12px;
|
||||
border-radius: 14px;
|
||||
background: #f1f5f9;
|
||||
background: var(--color-summary-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.summary-card__label {
|
||||
@@ -422,8 +429,9 @@ function submitOrder() {
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #eef2f7;
|
||||
background: var(--color-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.check-card__menu-wrap {
|
||||
@@ -450,7 +458,7 @@ function submitOrder() {
|
||||
min-width: 88px;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
background: var(--color-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
@@ -505,7 +513,7 @@ function submitOrder() {
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--color-border);
|
||||
background: #fff;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.check-card__thumb--empty {
|
||||
@@ -519,8 +527,8 @@ function submitOrder() {
|
||||
.rass-panel {
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
background: #ecfdf3;
|
||||
border: 1px solid #bbf7d0;
|
||||
background: var(--color-success-light);
|
||||
border: 1px solid var(--color-success);
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -529,26 +537,26 @@ function submitOrder() {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.rass-panel__days {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #15803d;
|
||||
color: var(--color-success);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.rass-panel__date {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: #166534;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.check-list-page__footer {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 0 calc(12px + env(safe-area-inset-bottom, 0));
|
||||
background: #fff;
|
||||
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));
|
||||
|
||||
@@ -71,8 +71,9 @@ function selectMode(mode: 'full' | 'brief') {
|
||||
padding: 32px 24px;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
|
||||
background: var(--color-elevated);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-sm);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
box-shadow 0.15s,
|
||||
|
||||
@@ -99,9 +99,10 @@ function selectType(type: 'cash' | 'check') {
|
||||
padding: 32px 24px;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
background: var(--color-elevated);
|
||||
box-shadow: var(--shadow-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.settlement-card--desktop {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<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'
|
||||
@@ -11,14 +12,20 @@ import type { Company } from '@/types'
|
||||
const router = useRouter()
|
||||
const catalogStore = useCatalogStore()
|
||||
const cartStore = useCartStore()
|
||||
const loading = ref(true)
|
||||
|
||||
const companies = catalogStore.getActiveCompanies()
|
||||
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
|
||||
@@ -41,21 +48,26 @@ function companyLogo(slug: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MobileCompanySelect
|
||||
class="mobile-only"
|
||||
:companies="companies"
|
||||
:card-variant="cardVariant"
|
||||
:company-description="companyDescription"
|
||||
:company-logo="companyLogo"
|
||||
@select="selectCompany"
|
||||
/>
|
||||
<div v-if="loading" class="page page__scroll" style="padding: 2rem; text-align: center">
|
||||
در حال بارگذاری…
|
||||
</div>
|
||||
<template v-else>
|
||||
<MobileCompanySelect
|
||||
class="mobile-only"
|
||||
:companies="companies"
|
||||
:card-variant="cardVariant"
|
||||
:company-description="companyDescription"
|
||||
:company-logo="companyLogo"
|
||||
@select="selectCompany"
|
||||
/>
|
||||
|
||||
<DesktopHomeDashboard
|
||||
class="desktop-only"
|
||||
:companies="companies"
|
||||
:card-variant="cardVariant"
|
||||
:company-description="companyDescription"
|
||||
:company-logo="companyLogo"
|
||||
@select="selectCompany"
|
||||
/>
|
||||
<DesktopHomeDashboard
|
||||
class="desktop-only"
|
||||
:companies="companies"
|
||||
:card-variant="cardVariant"
|
||||
:company-description="companyDescription"
|
||||
:company-logo="companyLogo"
|
||||
@select="selectCompany"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -46,7 +46,8 @@ defineEmits<{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: #fff;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
@@ -91,13 +92,14 @@ defineEmits<{
|
||||
.company-brand-card--par {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 24px rgba(29, 53, 87, 0.25);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.company-brand-card--fam-baft {
|
||||
background: #f3ece3;
|
||||
color: #1a202c;
|
||||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06);
|
||||
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 {
|
||||
@@ -121,6 +123,6 @@ defineEmits<{
|
||||
}
|
||||
|
||||
.company-brand-card--fam-baft .company-brand-card__desc {
|
||||
color: #718096;
|
||||
color: var(--color-brand-warm-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -64,8 +64,31 @@ watch(destinationAccountId, () => {
|
||||
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
|
||||
})
|
||||
|
||||
const bankAccounts = computed(() =>
|
||||
order.value ? orderStore.getBankAccountsForCompany(order.value.companyId) : [],
|
||||
const bankAccounts = ref(
|
||||
[] as Awaited<ReturnType<typeof orderStore.getBankAccountsForCompany>>,
|
||||
)
|
||||
|
||||
watch(
|
||||
() => order.value?.companyId,
|
||||
async (companyId) => {
|
||||
if (!companyId) {
|
||||
bankAccounts.value = []
|
||||
return
|
||||
}
|
||||
bankAccounts.value = await orderStore.getBankAccountsForCompany(companyId)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => orderId.value,
|
||||
async (id) => {
|
||||
if (!id) return
|
||||
if (!orderStore.getOrderById(id)) {
|
||||
await orderStore.fetchOrder(id)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const statusHero = computed(() => (order.value ? getStatusHero(order.value.status) : null))
|
||||
@@ -114,7 +137,7 @@ async function handleReceiptImage(event: Event) {
|
||||
receiptImage.value = await fileToBase64(file)
|
||||
}
|
||||
|
||||
function submitReceipt() {
|
||||
async function submitReceipt() {
|
||||
if (!order.value) return
|
||||
|
||||
fieldErrors.value = {}
|
||||
@@ -142,17 +165,21 @@ function submitReceipt() {
|
||||
return
|
||||
}
|
||||
|
||||
orderStore.submitCashReceipt(order.value.id, {
|
||||
amount,
|
||||
transferDate: receiptDate.value,
|
||||
transferTime: receiptTime.value || '12:00',
|
||||
trackingNumber: trackingNumber.value,
|
||||
destinationAccountId: destinationAccountId.value,
|
||||
receiptImage: receiptImage.value || undefined,
|
||||
})
|
||||
showReceiptForm.value = false
|
||||
fieldErrors.value = {}
|
||||
formError.value = ''
|
||||
try {
|
||||
await orderStore.submitCashReceipt(order.value.id, {
|
||||
amount,
|
||||
transferDate: receiptDate.value,
|
||||
transferTime: receiptTime.value || '12:00',
|
||||
trackingNumber: trackingNumber.value,
|
||||
destinationAccountId: destinationAccountId.value,
|
||||
receiptImage: receiptImage.value || undefined,
|
||||
})
|
||||
showReceiptForm.value = false
|
||||
fieldErrors.value = {}
|
||||
formError.value = ''
|
||||
} catch (e) {
|
||||
formError.value = e instanceof Error ? e.message : 'ثبت رسید ناموفق بود.'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -388,7 +415,8 @@ function submitReceipt() {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 12px var(--page-padding) 0;
|
||||
background: #ffffff;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -457,15 +485,15 @@ function submitReceipt() {
|
||||
}
|
||||
|
||||
.order-info-card {
|
||||
background: #ffffff;
|
||||
background: var(--color-elevated);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.08);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 20px 18px 8px;
|
||||
}
|
||||
|
||||
.order-info-card__head {
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #eeeeee;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
@@ -490,7 +518,7 @@ function submitReceipt() {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid #eeeeee;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.order-info-card__row--last {
|
||||
@@ -511,7 +539,7 @@ function submitReceipt() {
|
||||
}
|
||||
|
||||
.order-info-card__value--accent {
|
||||
color: #2b66e3;
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -525,7 +553,7 @@ function submitReceipt() {
|
||||
.order-detail-page__footer {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 0;
|
||||
background: #ffffff;
|
||||
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));
|
||||
@@ -550,7 +578,7 @@ function submitReceipt() {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
background: var(--color-elevated);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmptyState v-else message="سفارشی یافت نشد">
|
||||
<EmptyState v-else variant="orders" message="هنوز سفارشی ثبت نکردهاید">
|
||||
<BaseButton @click="emit('newOrder')">ثبت سفارش جدید</BaseButton>
|
||||
</EmptyState>
|
||||
</div>
|
||||
|
||||
@@ -4,15 +4,21 @@ 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 { formatCurrency } from '@/utils/format'
|
||||
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
|
||||
}>()
|
||||
@@ -21,6 +27,7 @@ const emit = defineEmits<{
|
||||
'update:tab': [value: string]
|
||||
select: [id: string]
|
||||
back: []
|
||||
'set-page': [page: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -44,7 +51,7 @@ const emit = defineEmits<{
|
||||
@update:model-value="emit('update:tab', $event)"
|
||||
/>
|
||||
|
||||
<div class="page__scroll page__scroll--with-nav">
|
||||
<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"
|
||||
@@ -69,16 +76,57 @@ const emit = defineEmits<{
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EmptyState v-else message="سفارشی یافت نشد">
|
||||
<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--next" />
|
||||
</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--prev" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.orders-mobile-page {
|
||||
background: #ffffff;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
padding: 12px 0 0;
|
||||
}
|
||||
|
||||
@@ -87,6 +135,7 @@ const emit = defineEmits<{
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-inline: var(--page-padding);
|
||||
}
|
||||
|
||||
.orders-mobile-page__title {
|
||||
@@ -94,6 +143,7 @@ const emit = defineEmits<{
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
@@ -101,6 +151,7 @@ const emit = defineEmits<{
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -118,6 +169,12 @@ const emit = defineEmits<{
|
||||
|
||||
.orders-mobile-page__tabs {
|
||||
margin-bottom: 16px;
|
||||
padding-inline: var(--page-padding);
|
||||
}
|
||||
|
||||
.orders-mobile-page__scroll {
|
||||
padding-inline: var(--page-padding);
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.orders-mobile-page__list {
|
||||
@@ -130,10 +187,11 @@ const emit = defineEmits<{
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
|
||||
background: var(--color-elevated);
|
||||
box-shadow: var(--shadow-card);
|
||||
color: var(--color-text);
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -154,18 +212,21 @@ const emit = defineEmits<{
|
||||
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 {
|
||||
@@ -189,11 +250,73 @@ const emit = defineEmits<{
|
||||
}
|
||||
|
||||
.order-card__icon--fam-baft {
|
||||
background: #f3ece3;
|
||||
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-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.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__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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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'
|
||||
@@ -61,7 +62,7 @@ const emit = defineEmits<{
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="product-desktop-grid">
|
||||
<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">
|
||||
@@ -113,6 +114,12 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-else
|
||||
variant="products"
|
||||
message="برای این کالیته یا رنگ، محصولی ثبت نشده است"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside v-if="itemCount > 0" class="desktop-summary-panel">
|
||||
@@ -288,10 +295,12 @@ const emit = defineEmits<{
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-text);
|
||||
color: #fff;
|
||||
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) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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'
|
||||
|
||||
@@ -53,7 +54,7 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
|
||||
<div class="page__scroll product-mobile-page__scroll">
|
||||
<div class="product-mobile-list">
|
||||
<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>
|
||||
@@ -97,6 +98,12 @@ const emit = defineEmits<{
|
||||
<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>
|
||||
@@ -113,12 +120,17 @@ const emit = defineEmits<{
|
||||
|
||||
<style scoped>
|
||||
.product-mobile-page {
|
||||
background: #fff;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
padding: 12px 0 0;
|
||||
}
|
||||
|
||||
.product-mobile-page__scroll {
|
||||
padding-bottom: 76px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.color-tabs {
|
||||
@@ -219,9 +231,13 @@ const emit = defineEmits<{
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-text);
|
||||
color: #fff;
|
||||
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>
|
||||
|
||||
@@ -1,51 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
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 { useThemeStore, type ThemePreference } from '@/stores/theme'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const language = ref('fa')
|
||||
const currency = ref('irr')
|
||||
const density = ref('comfortable')
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
function save() {
|
||||
saved.value = true
|
||||
setTimeout(() => {
|
||||
saved.value = false
|
||||
}, 2000)
|
||||
hideSaved()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProfileSubpageLayout title="تنظیمات نمایش" subtitle="زبان، واحد پول و حالت نمایش">
|
||||
<div class="display-form">
|
||||
<label class="display-field">
|
||||
<span class="display-field__label">زبان</span>
|
||||
<select v-model="language" class="display-field__control">
|
||||
<option value="fa">فارسی</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</label>
|
||||
<ProfileSubpageLayout title="تنظیمات نمایش" subtitle="حالت نمایش روشن، تاریک یا سیستم">
|
||||
<div class="display-page">
|
||||
<fieldset class="display-fieldset">
|
||||
<legend class="display-fieldset__legend">حالت نمایش</legend>
|
||||
|
||||
<label class="display-field">
|
||||
<span class="display-field__label">واحد پول</span>
|
||||
<select v-model="currency" class="display-field__control">
|
||||
<option value="irr">تومان</option>
|
||||
<option value="rial">ریال</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<fieldset class="display-field">
|
||||
<legend class="display-field__label">حالت نمایش</legend>
|
||||
<div class="display-options">
|
||||
<label class="display-option">
|
||||
<input v-model="density" type="radio" value="comfortable" />
|
||||
<span>راحت</span>
|
||||
</label>
|
||||
<label class="display-option">
|
||||
<input v-model="density" type="radio" value="compact" />
|
||||
<span>فشرده</span>
|
||||
</label>
|
||||
<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>
|
||||
|
||||
@@ -56,71 +88,113 @@ function save() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.display-form {
|
||||
.display-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.display-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
.display-fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.display-field__label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.display-field__control {
|
||||
width: 100%;
|
||||
min-height: var(--btn-height);
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.display-field__control:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.display-options {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.display-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
.display-fieldset__legend {
|
||||
margin: 0 0 12px;
|
||||
padding: 0;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.display-option:has(input:checked) {
|
||||
.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;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s,
|
||||
box-shadow 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.theme-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.theme-card:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.theme-card--active {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
|
||||
.display-option input {
|
||||
accent-color: 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 {
|
||||
|
||||
@@ -28,7 +28,7 @@ function clearField(key: string) {
|
||||
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
|
||||
}
|
||||
|
||||
function save() {
|
||||
async function save() {
|
||||
fieldErrors.value = {}
|
||||
formError.value = ''
|
||||
saved.value = false
|
||||
@@ -46,11 +46,15 @@ function save() {
|
||||
return
|
||||
}
|
||||
|
||||
auth.updateProfile({
|
||||
const updated = await auth.updateProfile({
|
||||
firstName: firstName.value.trim(),
|
||||
lastName: lastName.value.trim(),
|
||||
nationalId: nationalId.value || undefined,
|
||||
})
|
||||
if (!updated) {
|
||||
formError.value = 'ذخیره پروفایل ناموفق بود.'
|
||||
return
|
||||
}
|
||||
saved.value = true
|
||||
setTimeout(() => {
|
||||
saved.value = false
|
||||
|
||||
@@ -118,7 +118,7 @@ const settingsItems: Array<{
|
||||
{
|
||||
id: 'display',
|
||||
label: 'تنظیمات نمایش',
|
||||
description: 'زبان، واحد پول و حالت نمایش',
|
||||
description: 'تم روشن، تاریک یا سیستم',
|
||||
icon: IconLayoutGrid,
|
||||
path: '/profile/display',
|
||||
},
|
||||
|
||||
@@ -7,17 +7,12 @@ import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
|
||||
import IconProfile from '@/components/icons/IconProfile.vue'
|
||||
import IconCheckBook from '@/components/icons/IconCheckBook.vue'
|
||||
import IconOrders from '@/components/icons/IconOrders.vue'
|
||||
import IconSettings from '@/components/icons/IconSettings.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const orderStore = useOrderStore()
|
||||
|
||||
const initials = computed(() => {
|
||||
const first = auth.user?.firstName?.[0] ?? ''
|
||||
const last = auth.user?.lastName?.[0] ?? ''
|
||||
return `${first}${last}` || '؟'
|
||||
})
|
||||
|
||||
const draftCheckCount = computed(() => orderStore.draftChecks.length)
|
||||
|
||||
const menuItems = computed(() => [
|
||||
@@ -28,6 +23,13 @@ const menuItems = computed(() => [
|
||||
path: '/profile/edit',
|
||||
icon: IconProfile,
|
||||
},
|
||||
{
|
||||
id: 'display',
|
||||
label: 'تنظیمات نمایش',
|
||||
description: 'تم روشن، تاریک یا سیستم',
|
||||
path: '/profile/display',
|
||||
icon: IconSettings,
|
||||
},
|
||||
{
|
||||
id: 'checks',
|
||||
label: 'لیست چکها',
|
||||
@@ -51,7 +53,9 @@ const emit = defineEmits<{ logout: [] }>()
|
||||
<template>
|
||||
<div class="page profile-page">
|
||||
<div class="profile-hero">
|
||||
<div class="profile-hero__avatar">{{ initials }}</div>
|
||||
<div class="profile-hero__avatar" aria-hidden="true">
|
||||
<IconProfile :size="36" :stroke-width="1.5" />
|
||||
</div>
|
||||
<h1 class="profile-hero__name">{{ auth.fullName }}</h1>
|
||||
<p class="profile-hero__phone" dir="ltr">{{ auth.user?.phone }}</p>
|
||||
</div>
|
||||
@@ -108,8 +112,7 @@ const emit = defineEmits<{ logout: [] }>()
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.profile-hero__name {
|
||||
@@ -143,12 +146,13 @@ const emit = defineEmits<{ logout: [] }>()
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 16px;
|
||||
background: var(--color-surface);
|
||||
background: var(--color-elevated);
|
||||
box-shadow: var(--shadow-sm);
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
color: var(--color-text);
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
|
||||
@@ -201,15 +205,14 @@ const emit = defineEmits<{ logout: [] }>()
|
||||
|
||||
.profile-menu__chevron {
|
||||
color: var(--color-text-muted);
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.profile-logout {
|
||||
width: 100%;
|
||||
height: var(--btn-height);
|
||||
border: 1px solid #fecaca;
|
||||
border: 1px solid var(--color-danger-light);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
background: var(--color-elevated);
|
||||
color: var(--color-danger);
|
||||
font-family: inherit;
|
||||
font-size: 0.9375rem;
|
||||
|
||||
Reference in New Issue
Block a user