Add Persian date input and refine admin category/check screens.
Introduce shared PersianDateInput with jalali utilities and align categories and check management views with updated form patterns.
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
JALALI_MONTH_NAMES,
|
||||
JALALI_WEEKDAY_NAMES,
|
||||
buildCalendarDays,
|
||||
formatJalaliLabel,
|
||||
isoToJalali,
|
||||
jalaliToISO,
|
||||
todayJalali,
|
||||
} from '@/utils/jalali'
|
||||
import { toPersianDigits } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const viewYear = ref(todayJalali().jy)
|
||||
const viewMonth = ref(todayJalali().jm)
|
||||
|
||||
const selectedJalali = computed(() => isoToJalali(props.modelValue ?? ''))
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (!selectedJalali.value) return ''
|
||||
const { jy, jm, jd } = selectedJalali.value
|
||||
return formatJalaliLabel(jy, jm, jd)
|
||||
})
|
||||
|
||||
const calendarDays = computed(() => buildCalendarDays(viewYear.value, viewMonth.value))
|
||||
|
||||
const monthLabel = computed(
|
||||
() => `${JALALI_MONTH_NAMES[viewMonth.value - 1]} ${toPersianDigits(String(viewYear.value))}`,
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
const jalali = isoToJalali(value ?? '')
|
||||
if (jalali) {
|
||||
viewYear.value = jalali.jy
|
||||
viewMonth.value = jalali.jm
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function toggleCalendar() {
|
||||
if (props.disabled) return
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
function closeCalendar() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function selectDay(day: number) {
|
||||
emit('update:modelValue', jalaliToISO(viewYear.value, viewMonth.value, day))
|
||||
closeCalendar()
|
||||
}
|
||||
|
||||
function changeMonth(delta: number) {
|
||||
let month = viewMonth.value + delta
|
||||
let year = viewYear.value
|
||||
|
||||
if (month > 12) {
|
||||
month = 1
|
||||
year += 1
|
||||
} else if (month < 1) {
|
||||
month = 12
|
||||
year -= 1
|
||||
}
|
||||
|
||||
viewMonth.value = month
|
||||
viewYear.value = year
|
||||
}
|
||||
|
||||
function selectToday() {
|
||||
const today = todayJalali()
|
||||
viewYear.value = today.jy
|
||||
viewMonth.value = today.jm
|
||||
emit('update:modelValue', jalaliToISO(today.jy, today.jm, today.jd))
|
||||
closeCalendar()
|
||||
}
|
||||
|
||||
function isSelected(day: number) {
|
||||
if (!selectedJalali.value) return false
|
||||
const { jy, jm, jd } = selectedJalali.value
|
||||
return jy === viewYear.value && jm === viewMonth.value && jd === day
|
||||
}
|
||||
|
||||
function isToday(day: number) {
|
||||
const today = todayJalali()
|
||||
return today.jy === viewYear.value && today.jm === viewMonth.value && today.jd === day
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (!rootRef.value?.contains(event.target as Node)) {
|
||||
closeCalendar()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="form-group persian-date-input">
|
||||
<label v-if="label" class="form-label">{{ label }}</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="input persian-date-input__trigger"
|
||||
:class="{ 'input-error': error, 'is-open': isOpen }"
|
||||
:disabled="disabled"
|
||||
@click.stop="toggleCalendar"
|
||||
>
|
||||
<span :class="{ 'persian-date-input__placeholder': !displayValue }">
|
||||
{{ displayValue || placeholder || 'انتخاب تاریخ' }}
|
||||
</span>
|
||||
<span class="persian-date-input__icon" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M8 3v4M16 3v4M3 10h18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div v-if="isOpen" class="persian-date-input__panel" @click.stop>
|
||||
<div class="persian-date-input__header">
|
||||
<button type="button" class="persian-date-input__nav" @click="changeMonth(-1)">‹</button>
|
||||
<strong class="persian-date-input__month">{{ monthLabel }}</strong>
|
||||
<button type="button" class="persian-date-input__nav" @click="changeMonth(1)">›</button>
|
||||
</div>
|
||||
|
||||
<div class="persian-date-input__weekdays">
|
||||
<span v-for="weekday in JALALI_WEEKDAY_NAMES" :key="weekday">{{ weekday }}</span>
|
||||
</div>
|
||||
|
||||
<div class="persian-date-input__grid">
|
||||
<template v-for="(day, index) in calendarDays" :key="index">
|
||||
<span v-if="day === null" class="persian-date-input__cell persian-date-input__cell--empty" />
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="persian-date-input__cell"
|
||||
:class="{
|
||||
'persian-date-input__cell--selected': isSelected(day),
|
||||
'persian-date-input__cell--today': isToday(day),
|
||||
}"
|
||||
@click="selectDay(day)"
|
||||
>
|
||||
{{ toPersianDigits(String(day)) }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button type="button" class="persian-date-input__today" @click="selectToday">امروز</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="input-error-text">{{ error }}</p>
|
||||
<p v-else-if="hint" class="form-hint">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.persian-date-input {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: var(--btn-height);
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9375rem;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.persian-date-input__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.persian-date-input__trigger:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.persian-date-input__trigger.is-open,
|
||||
.persian-date-input__trigger:focus-visible {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.persian-date-input__trigger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.persian-date-input__placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.persian-date-input__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.input-error-text {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8125rem;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.persian-date-input__panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 120;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.persian-date-input__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.persian-date-input__month {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.persian-date-input__nav {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-elevated);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.persian-date-input__weekdays,
|
||||
.persian-date-input__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.persian-date-input__weekdays {
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.persian-date-input__cell {
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.persian-date-input__cell--empty {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.persian-date-input__cell--today {
|
||||
border: 1px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.persian-date-input__cell--selected {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-on-primary, #fff);
|
||||
}
|
||||
|
||||
.persian-date-input__today {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-elevated);
|
||||
color: var(--color-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.persian-date-input__today:hover {
|
||||
background: var(--color-primary-soft);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
import { jalaaliMonthLength, toGregorian, toJalaali } from 'jalaali-js'
|
||||
|
||||
const persianDigits = '۰۱۲۳۴۵۶۷۸۹'
|
||||
|
||||
function toPersianDigits(input: string): string {
|
||||
return input.replace(/\d/g, (d) => persianDigits[Number(d)]!)
|
||||
}
|
||||
|
||||
export const JALALI_MONTH_NAMES = [
|
||||
'فروردین',
|
||||
'اردیبهشت',
|
||||
'خرداد',
|
||||
'تیر',
|
||||
'مرداد',
|
||||
'شهریور',
|
||||
'مهر',
|
||||
'آبان',
|
||||
'آذر',
|
||||
'دی',
|
||||
'بهمن',
|
||||
'اسفند',
|
||||
] as const
|
||||
|
||||
export const JALALI_WEEKDAY_NAMES = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] as const
|
||||
|
||||
export interface JalaliDate {
|
||||
jy: number
|
||||
jm: number
|
||||
jd: number
|
||||
}
|
||||
|
||||
export function parseISODate(iso: string): Date {
|
||||
const match = iso.trim().match(/^(\d{4})-(\d{2})-(\d{2})/)
|
||||
if (match) {
|
||||
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
|
||||
}
|
||||
|
||||
const parsed = new Date(iso)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return new Date(NaN)
|
||||
}
|
||||
|
||||
return new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate())
|
||||
}
|
||||
|
||||
export function toISODate(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
export function toJalali(date: Date): JalaliDate {
|
||||
const { jy, jm, jd } = toJalaali(date.getFullYear(), date.getMonth() + 1, date.getDate())
|
||||
return { jy, jm, jd }
|
||||
}
|
||||
|
||||
export function fromJalali(jy: number, jm: number, jd: number): Date {
|
||||
const { gy, gm, gd } = toGregorian(jy, jm, jd)
|
||||
return new Date(gy, gm - 1, gd)
|
||||
}
|
||||
|
||||
export function isoToJalali(iso: string): JalaliDate | null {
|
||||
if (!iso) return null
|
||||
return toJalali(parseISODate(iso))
|
||||
}
|
||||
|
||||
export function jalaliToISO(jy: number, jm: number, jd: number): string {
|
||||
return toISODate(fromJalali(jy, jm, jd))
|
||||
}
|
||||
|
||||
export function formatJalaliDate(jy: number, jm: number, jd: number): string {
|
||||
const month = String(jm).padStart(2, '0')
|
||||
const day = String(jd).padStart(2, '0')
|
||||
return toPersianDigits(`${jy}/${month}/${day}`)
|
||||
}
|
||||
|
||||
export function formatJalaliLabel(jy: number, jm: number, jd: number): string {
|
||||
return `${toPersianDigits(String(jd))} ${JALALI_MONTH_NAMES[jm - 1]} ${toPersianDigits(String(jy))}`
|
||||
}
|
||||
|
||||
export function getPersianWeekday(jy: number, jm: number, jd: number): number {
|
||||
const { gy, gm, gd } = toGregorian(jy, jm, jd)
|
||||
const jsDay = new Date(gy, gm - 1, gd).getDay()
|
||||
return (jsDay + 1) % 7
|
||||
}
|
||||
|
||||
export function getMonthLength(jy: number, jm: number): number {
|
||||
return jalaaliMonthLength(jy, jm)
|
||||
}
|
||||
|
||||
export function buildCalendarDays(jy: number, jm: number): Array<number | null> {
|
||||
const length = getMonthLength(jy, jm)
|
||||
const startWeekday = getPersianWeekday(jy, jm, 1)
|
||||
const cells: Array<number | null> = Array.from({ length: startWeekday }, () => null)
|
||||
|
||||
for (let day = 1; day <= length; day += 1) {
|
||||
cells.push(day)
|
||||
}
|
||||
|
||||
while (cells.length % 7 !== 0) {
|
||||
cells.push(null)
|
||||
}
|
||||
|
||||
return cells
|
||||
}
|
||||
|
||||
export function todayJalali(): JalaliDate {
|
||||
return toJalali(new Date())
|
||||
}
|
||||
@@ -39,6 +39,9 @@ const toast = useToast()
|
||||
const showAddUsage = ref(false)
|
||||
const showAddSeason = ref(false)
|
||||
const ICON_OPTIONS = [
|
||||
{ value: 'shirt', label: 'پیراهن' },
|
||||
{ value: 'suit', label: 'کتشلوار' },
|
||||
{ value: 'trousers', label: 'شلوار' },
|
||||
{ value: 'bolt', label: 'آذرخش' },
|
||||
{ value: 'feather', label: 'پر' },
|
||||
{ value: 'diamond', label: 'الماس' },
|
||||
@@ -51,8 +54,9 @@ const ICON_OPTIONS = [
|
||||
]
|
||||
|
||||
function defaultUsageIcon(id: string) {
|
||||
if (id === 'shirt') return 'id-card'
|
||||
if (id === 'suit') return 'bag'
|
||||
if (id === 'shirt') return 'shirt'
|
||||
if (id === 'suit') return 'suit'
|
||||
if (id === 'trousers') return 'trousers'
|
||||
return 'grid'
|
||||
}
|
||||
|
||||
@@ -128,7 +132,7 @@ async function load() {
|
||||
id: 'trousers',
|
||||
title: 'پارچههای شلواری',
|
||||
subtitle: 'مشاهده محصولات',
|
||||
icon: 'grid',
|
||||
icon: 'trousers',
|
||||
showTitle: true,
|
||||
showSubtitle: true,
|
||||
showIcon: true,
|
||||
@@ -137,7 +141,7 @@ async function load() {
|
||||
id: 'suit',
|
||||
title: 'پارچههای کتشلواری',
|
||||
subtitle: 'مشاهده محصولات',
|
||||
icon: 'bag',
|
||||
icon: 'suit',
|
||||
showTitle: true,
|
||||
showSubtitle: true,
|
||||
showIcon: true,
|
||||
@@ -146,7 +150,7 @@ async function load() {
|
||||
id: 'shirt',
|
||||
title: 'پارچههای پیراهنی',
|
||||
subtitle: 'مشاهده محصولات',
|
||||
icon: 'id-card',
|
||||
icon: 'shirt',
|
||||
showTitle: true,
|
||||
showSubtitle: true,
|
||||
showIcon: true,
|
||||
@@ -212,7 +216,7 @@ async function load() {
|
||||
id: 'trousers',
|
||||
title: 'پارچههای شلواری',
|
||||
subtitle: 'مشاهده محصولات',
|
||||
icon: 'grid',
|
||||
icon: 'trousers',
|
||||
showTitle: true,
|
||||
showSubtitle: true,
|
||||
showIcon: true,
|
||||
@@ -221,7 +225,7 @@ async function load() {
|
||||
id: 'suit',
|
||||
title: 'پارچههای کتشلواری',
|
||||
subtitle: 'مشاهده محصولات',
|
||||
icon: 'bag',
|
||||
icon: 'suit',
|
||||
showTitle: true,
|
||||
showSubtitle: true,
|
||||
showIcon: true,
|
||||
@@ -230,7 +234,7 @@ async function load() {
|
||||
id: 'shirt',
|
||||
title: 'پارچههای پیراهنی',
|
||||
subtitle: 'مشاهده محصولات',
|
||||
icon: 'id-card',
|
||||
icon: 'shirt',
|
||||
showTitle: true,
|
||||
showSubtitle: true,
|
||||
showIcon: true,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import { checksApi, mapAdminOrder, ordersApi, type ApiOrder } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { formatCurrency, formatDate } from '@/utils/format'
|
||||
import { formatCurrency, formatDate, formatDateTime as formatPersianDateTime } from '@/utils/format'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { confirm } from '@/composables/useConfirm'
|
||||
import { rawStatusLabel } from '@/utils/orderStatus'
|
||||
@@ -118,15 +118,7 @@ function actorLabel(changedBy?: string) {
|
||||
}
|
||||
|
||||
function formatDateTime(iso?: string) {
|
||||
if (!iso) return '—'
|
||||
try {
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(iso))
|
||||
} catch {
|
||||
return iso.slice(0, 16).replace('T', ' ')
|
||||
}
|
||||
return formatPersianDateTime(iso)
|
||||
}
|
||||
|
||||
function ownerName(check: Check) {
|
||||
|
||||
Reference in New Issue
Block a user