feat: migrate CRM screens to Ant Design Vue and expand home dashboard
Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
This commit is contained in:
@@ -16,16 +16,6 @@ import {
|
||||
RESERVATION_STATUS_LABELS,
|
||||
RESERVATION_STATUSES,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
UiButton,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiModal,
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import {
|
||||
formatClockFa,
|
||||
@@ -35,6 +25,35 @@ import {
|
||||
} from '@/lib/jalali';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
InputNumber as AInputNumber,
|
||||
List as AList,
|
||||
Modal,
|
||||
Modal as AModal,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
TabPane as ATabPane,
|
||||
Tabs as ATabs,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
|
||||
type TabKey = 'appointments' | 'services';
|
||||
|
||||
@@ -73,6 +92,46 @@ const serviceEnd = ref(17);
|
||||
|
||||
const dayNames = ['یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه'];
|
||||
|
||||
const statusFilterOptions = computed(() => [
|
||||
{ value: '', label: 'همه' },
|
||||
...RESERVATION_STATUSES.map((s) => ({
|
||||
value: s,
|
||||
label: RESERVATION_STATUS_LABELS[s],
|
||||
})),
|
||||
]);
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
RESERVATION_STATUSES.map((s) => ({
|
||||
value: s,
|
||||
label: RESERVATION_STATUS_LABELS[s],
|
||||
})),
|
||||
);
|
||||
|
||||
const activeServiceOptions = computed(() =>
|
||||
services.value
|
||||
.filter((x) => x.isActive)
|
||||
.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name} (${toFaDigits(s.durationMinutes)} دقیقه)`,
|
||||
})),
|
||||
);
|
||||
|
||||
const personOptions = computed(() =>
|
||||
people.value.map((p) => ({ value: p.id, label: p.fullName })),
|
||||
);
|
||||
|
||||
const userOptions = computed(() => [
|
||||
{ value: '', label: '—' },
|
||||
...users.value.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
]);
|
||||
|
||||
const slotOptions = computed(() =>
|
||||
slots.value.map((sl) => ({
|
||||
value: sl.startsAt,
|
||||
label: slotLabel(sl.startsAt),
|
||||
})),
|
||||
);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -176,14 +235,22 @@ async function setStatus(id: string, status: ReservationStatus) {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAppointment(id: string) {
|
||||
if (!confirm('این نوبت حذف شود؟')) return;
|
||||
try {
|
||||
await api.delete(`/reservations/${id}`);
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'حذف نوبت ناموفق بود.';
|
||||
}
|
||||
function removeAppointment(id: string) {
|
||||
Modal.confirm({
|
||||
title: 'حذف نوبت',
|
||||
content: 'این نوبت حذف شود؟',
|
||||
okText: 'حذف',
|
||||
okType: 'danger',
|
||||
cancelText: 'انصراف',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.delete(`/reservations/${id}`);
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'حذف نوبت ناموفق بود.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openServiceCreate() {
|
||||
@@ -239,308 +306,376 @@ async function saveService() {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeService(id: string) {
|
||||
if (!confirm('این خدمت حذف شود؟')) return;
|
||||
try {
|
||||
await api.delete(`/reservations/services/${id}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'حذف ناموفق بود.')
|
||||
: 'حذف ناموفق بود.';
|
||||
}
|
||||
function removeService(id: string) {
|
||||
Modal.confirm({
|
||||
title: 'حذف خدمت',
|
||||
content: 'این خدمت حذف شود؟',
|
||||
okText: 'حذف',
|
||||
okType: 'danger',
|
||||
cancelText: 'انصراف',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.delete(`/reservations/services/${id}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'حذف ناموفق بود.')
|
||||
: 'حذف ناموفق بود.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function slotLabel(iso: string) {
|
||||
return formatClockFa(new Date(iso));
|
||||
}
|
||||
|
||||
function appointmentDescription(a: ReservationAppointmentDto) {
|
||||
const parts = [
|
||||
formatJalaliNumeric(new Date(a.startsAt)),
|
||||
`${formatClockFa(new Date(a.startsAt))} تا ${formatClockFa(new Date(a.endsAt))}`,
|
||||
RESERVATION_STATUS_LABELS[a.status],
|
||||
];
|
||||
if (a.assigneeName) parts.push(a.assigneeName);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function serviceDescription(s: ReservationServiceDto) {
|
||||
return [
|
||||
`مدت ${toFaDigits(s.durationMinutes)} دقیقه`,
|
||||
`ساعت ${toFaDigits(s.dayStartHour)} تا ${toFaDigits(s.dayEndHour)}`,
|
||||
s.isActive ? 'فعال' : 'غیرفعال',
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
function onTabChange(key: string | number) {
|
||||
tab.value = String(key) as TabKey;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<UiPageHeader
|
||||
title="رزرو و نوبتدهی"
|
||||
description="تعریف خدمت، انتخاب بازه آزاد و ثبت نوبت برای مشتری"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" :disabled="loading" @click="load">
|
||||
<UiIcon name="refresh" />
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">رزرو و نوبتدهی</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
تعریف خدمت، انتخاب بازه آزاد و ثبت نوبت برای مشتری
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-space wrap :size="8">
|
||||
<a-button :disabled="loading" @click="load">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
<UiButton v-if="tab === 'appointments'" @click="openCreate">
|
||||
</a-button>
|
||||
<a-button v-if="tab === 'appointments'" type="primary" @click="openCreate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
نوبت جدید
|
||||
</UiButton>
|
||||
<UiButton
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else-if="canEditServices"
|
||||
type="primary"
|
||||
@click="openServiceCreate"
|
||||
>
|
||||
<template #icon><PlusOutlined /></template>
|
||||
خدمت جدید
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<nav class="tabs">
|
||||
<button type="button" :class="{ on: tab === 'appointments' }" @click="tab = 'appointments'">
|
||||
نوبتها
|
||||
</button>
|
||||
<button type="button" :class="{ on: tab === 'services' }" @click="tab = 'services'">
|
||||
خدمات
|
||||
</button>
|
||||
</nav>
|
||||
<a-card :bordered="false">
|
||||
<a-tabs :active-key="tab" @change="onTabChange">
|
||||
<a-tab-pane key="appointments" tab="نوبتها" />
|
||||
<a-tab-pane key="services" tab="خدمات" />
|
||||
</a-tabs>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
|
||||
<template v-if="tab === 'appointments'">
|
||||
<div class="filters">
|
||||
<label>
|
||||
وضعیت
|
||||
<select v-model="statusFilter" @change="load">
|
||||
<option value="">همه</option>
|
||||
<option v-for="s in RESERVATION_STATUSES" :key="s" :value="s">
|
||||
{{ RESERVATION_STATUS_LABELS[s] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-if="!loading && !appointments.length"
|
||||
title="نوبتی نیست"
|
||||
description="اول خدمت تعریف کنید، بعد نوبت بسازید."
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="error"
|
||||
closable
|
||||
:style="{ marginBottom: '12px' }"
|
||||
@close="error = ''"
|
||||
/>
|
||||
<div v-else class="list">
|
||||
<article v-for="a in appointments" :key="a.id" class="item">
|
||||
<div>
|
||||
<strong>{{ a.serviceName }} — {{ a.personName }}</strong>
|
||||
<p class="muted">
|
||||
{{ formatJalaliNumeric(new Date(a.startsAt)) }}
|
||||
· {{ formatClockFa(new Date(a.startsAt)) }} تا {{ formatClockFa(new Date(a.endsAt)) }}
|
||||
· {{ RESERVATION_STATUS_LABELS[a.status] }}
|
||||
<template v-if="a.assigneeName"> · {{ a.assigneeName }}</template>
|
||||
</p>
|
||||
<p v-if="a.notes" class="muted">{{ a.notes }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<UiButton
|
||||
v-if="a.status === 'pending'"
|
||||
variant="secondary"
|
||||
@click="setStatus(a.id, 'confirmed')"
|
||||
>
|
||||
تأیید
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="a.status === 'confirmed' || a.status === 'pending'"
|
||||
variant="secondary"
|
||||
@click="setStatus(a.id, 'done')"
|
||||
>
|
||||
انجام شد
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="a.status !== 'canceled' && a.status !== 'done'"
|
||||
variant="secondary"
|
||||
@click="setStatus(a.id, 'canceled')"
|
||||
>
|
||||
لغو
|
||||
</UiButton>
|
||||
<UiButton variant="secondary" @click="removeAppointment(a.id)">حذف</UiButton>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<UiEmptyState
|
||||
v-if="!loading && !services.length"
|
||||
title="خدمتی نیست"
|
||||
description="مثلاً مشاوره ۳۰ دقیقهای تعریف کنید."
|
||||
<a-alert
|
||||
v-if="ok"
|
||||
type="success"
|
||||
show-icon
|
||||
:message="ok"
|
||||
closable
|
||||
:style="{ marginBottom: '12px' }"
|
||||
@close="ok = ''"
|
||||
/>
|
||||
<div v-else class="list">
|
||||
<article v-for="s in services" :key="s.id" class="item">
|
||||
<div>
|
||||
<strong>{{ s.name }}</strong>
|
||||
<p class="muted">
|
||||
مدت {{ toFaDigits(s.durationMinutes) }} دقیقه
|
||||
· ساعت {{ toFaDigits(s.dayStartHour) }} تا {{ toFaDigits(s.dayEndHour) }}
|
||||
· {{ s.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</p>
|
||||
<p v-if="s.description" class="muted">{{ s.description }}</p>
|
||||
</div>
|
||||
<div v-if="canEditServices" class="actions">
|
||||
<UiButton variant="secondary" @click="openServiceEdit(s)">ویرایش</UiButton>
|
||||
<UiButton variant="secondary" @click="removeService(s.id)">حذف</UiButton>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UiModal :open="formOpen" title="نوبت جدید" @close="formOpen = false">
|
||||
<div class="form-grid">
|
||||
<UiField label="خدمت">
|
||||
<select v-model="formServiceId">
|
||||
<option disabled value="">انتخاب کنید</option>
|
||||
<option
|
||||
v-for="s in services.filter((x) => x.isActive)"
|
||||
:key="s.id"
|
||||
:value="s.id"
|
||||
>
|
||||
{{ s.name }} ({{ toFaDigits(s.durationMinutes) }} دقیقه)
|
||||
</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="مشتری">
|
||||
<select v-model="formPersonId">
|
||||
<option disabled value="">انتخاب کنید</option>
|
||||
<option v-for="p in people" :key="p.id" :value="p.id">
|
||||
{{ p.fullName }}
|
||||
</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="کارشناس">
|
||||
<select v-model="formAssigneeId">
|
||||
<option value="">—</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="تاریخ">
|
||||
<JalaliDateInput v-model="formDate" />
|
||||
</UiField>
|
||||
<UiField label="ساعت آزاد">
|
||||
<select v-model="formStartsAt">
|
||||
<option disabled value="">انتخاب کنید</option>
|
||||
<option v-for="sl in slots" :key="sl.startsAt" :value="sl.startsAt">
|
||||
{{ slotLabel(sl.startsAt) }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="formServiceId && formDate && !slots.length" class="muted">
|
||||
برای این تاریخ اسلات آزادی نیست.
|
||||
</p>
|
||||
</UiField>
|
||||
<UiField label="وضعیت">
|
||||
<select v-model="formStatus">
|
||||
<option v-for="s in RESERVATION_STATUSES" :key="s" :value="s">
|
||||
{{ RESERVATION_STATUS_LABELS[s] }}
|
||||
</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="یادداشت">
|
||||
<UiTextarea v-model="formNotes" :rows="2" />
|
||||
</UiField>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="secondary" @click="formOpen = false">انصراف</UiButton>
|
||||
<UiButton :loading="saving" @click="saveAppointment">ثبت نوبت</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
<a-spin :spinning="loading">
|
||||
<template v-if="tab === 'appointments'">
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||
<a-form-item label="وضعیت">
|
||||
<a-select
|
||||
v-model:value="statusFilter"
|
||||
:options="statusFilterOptions"
|
||||
style="width: 100%"
|
||||
@change="load"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<UiModal
|
||||
:open="serviceFormOpen"
|
||||
<a-empty
|
||||
v-if="!loading && !appointments.length"
|
||||
description="نوبتی نیست — اول خدمت تعریف کنید، بعد نوبت بسازید."
|
||||
/>
|
||||
<a-list v-else :data-source="appointments" item-layout="horizontal">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-space wrap :size="8">
|
||||
<a-button
|
||||
v-if="(item as ReservationAppointmentDto).status === 'pending'"
|
||||
@click="setStatus((item as ReservationAppointmentDto).id, 'confirmed')"
|
||||
>
|
||||
تأیید
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="
|
||||
(item as ReservationAppointmentDto).status === 'confirmed' ||
|
||||
(item as ReservationAppointmentDto).status === 'pending'
|
||||
"
|
||||
@click="setStatus((item as ReservationAppointmentDto).id, 'done')"
|
||||
>
|
||||
انجام شد
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="
|
||||
(item as ReservationAppointmentDto).status !== 'canceled' &&
|
||||
(item as ReservationAppointmentDto).status !== 'done'
|
||||
"
|
||||
@click="setStatus((item as ReservationAppointmentDto).id, 'canceled')"
|
||||
>
|
||||
لغو
|
||||
</a-button>
|
||||
<a-button
|
||||
danger
|
||||
@click="removeAppointment((item as ReservationAppointmentDto).id)"
|
||||
>
|
||||
حذف
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
{{ (item as ReservationAppointmentDto).serviceName }} —
|
||||
{{ (item as ReservationAppointmentDto).personName }}
|
||||
</template>
|
||||
<template #description>
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-text type="secondary">
|
||||
{{ appointmentDescription(item as ReservationAppointmentDto) }}
|
||||
</a-typography-text>
|
||||
<a-typography-text
|
||||
v-if="(item as ReservationAppointmentDto).notes"
|
||||
type="secondary"
|
||||
>
|
||||
{{ (item as ReservationAppointmentDto).notes }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<a-empty
|
||||
v-if="!loading && !services.length"
|
||||
description="خدمتی نیست — مثلاً مشاوره ۳۰ دقیقهای تعریف کنید."
|
||||
/>
|
||||
<a-list v-else :data-source="services" item-layout="horizontal">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template v-if="canEditServices" #actions>
|
||||
<a-space wrap :size="8">
|
||||
<a-button @click="openServiceEdit(item as ReservationServiceDto)">
|
||||
ویرایش
|
||||
</a-button>
|
||||
<a-button danger @click="removeService((item as ReservationServiceDto).id)">
|
||||
حذف
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
{{ (item as ReservationServiceDto).name }}
|
||||
</template>
|
||||
<template #description>
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-text type="secondary">
|
||||
{{ serviceDescription(item as ReservationServiceDto) }}
|
||||
</a-typography-text>
|
||||
<a-typography-text
|
||||
v-if="(item as ReservationServiceDto).description"
|
||||
type="secondary"
|
||||
>
|
||||
{{ (item as ReservationServiceDto).description }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</template>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
|
||||
<a-modal v-model:open="formOpen" title="نوبت جدید" destroy-on-close :footer="null" width="640px">
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="خدمت" required>
|
||||
<a-select
|
||||
v-model:value="formServiceId"
|
||||
:options="activeServiceOptions"
|
||||
placeholder="انتخاب کنید"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="مشتری" required>
|
||||
<a-select
|
||||
v-model:value="formPersonId"
|
||||
:options="personOptions"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
placeholder="انتخاب کنید"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="کارشناس">
|
||||
<a-select
|
||||
v-model:value="formAssigneeId"
|
||||
:options="userOptions"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="تاریخ" required>
|
||||
<JalaliDateInput v-model="formDate" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="ساعت آزاد" required>
|
||||
<a-select
|
||||
v-model:value="formStartsAt"
|
||||
:options="slotOptions"
|
||||
placeholder="انتخاب کنید"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<a-typography-text
|
||||
v-if="formServiceId && formDate && !slots.length"
|
||||
type="secondary"
|
||||
>
|
||||
برای این تاریخ اسلات آزادی نیست.
|
||||
</a-typography-text>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="وضعیت">
|
||||
<a-select
|
||||
v-model:value="formStatus"
|
||||
:options="statusOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="یادداشت">
|
||||
<a-textarea v-model:value="formNotes" :rows="2" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
<a-flex justify="flex-end" :gap="8">
|
||||
<a-button :disabled="saving" @click="formOpen = false">انصراف</a-button>
|
||||
<a-button type="primary" :loading="saving" @click="saveAppointment">ثبت نوبت</a-button>
|
||||
</a-flex>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="serviceFormOpen"
|
||||
:title="serviceEditId ? 'ویرایش خدمت' : 'خدمت جدید'"
|
||||
@close="serviceFormOpen = false"
|
||||
destroy-on-close
|
||||
:footer="null"
|
||||
width="560px"
|
||||
>
|
||||
<div class="form-grid">
|
||||
<UiField label="نام">
|
||||
<UiInput v-model="serviceName" placeholder="مشاوره حضوری" />
|
||||
</UiField>
|
||||
<UiField label="مدت (دقیقه)">
|
||||
<UiInput v-model.number="serviceDuration" type="number" />
|
||||
</UiField>
|
||||
<UiField label="شروع روز (ساعت)">
|
||||
<UiInput v-model.number="serviceStart" type="number" />
|
||||
</UiField>
|
||||
<UiField label="پایان روز (ساعت)">
|
||||
<UiInput v-model.number="serviceEnd" type="number" />
|
||||
</UiField>
|
||||
<UiField label="توضیح">
|
||||
<UiTextarea v-model="serviceDesc" :rows="2" />
|
||||
</UiField>
|
||||
</div>
|
||||
<p class="muted">روزهای کاری پیشفرض: همه روزهای هفته ({{ dayNames.join('، ') }})</p>
|
||||
<template #footer>
|
||||
<UiButton variant="secondary" @click="serviceFormOpen = false">انصراف</UiButton>
|
||||
<UiButton :loading="saving" @click="saveService">ذخیره</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</div>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :span="24">
|
||||
<a-form-item label="نام" required>
|
||||
<a-input
|
||||
v-model:value="serviceName"
|
||||
placeholder="مشاوره حضوری"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مدت (دقیقه)">
|
||||
<a-input-number
|
||||
v-model:value="serviceDuration"
|
||||
:min="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="شروع روز (ساعت)">
|
||||
<a-input-number
|
||||
v-model:value="serviceStart"
|
||||
:min="0"
|
||||
:max="23"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پایان روز (ساعت)">
|
||||
<a-input-number
|
||||
v-model:value="serviceEnd"
|
||||
:min="0"
|
||||
:max="24"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="توضیح">
|
||||
<a-textarea v-model:value="serviceDesc" :rows="2" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
<a-typography-paragraph type="secondary">
|
||||
روزهای کاری پیشفرض: همه روزهای هفته ({{ dayNames.join('، ') }})
|
||||
</a-typography-paragraph>
|
||||
<a-flex justify="flex-end" :gap="8">
|
||||
<a-button :disabled="saving" @click="serviceFormOpen = false">انصراف</a-button>
|
||||
<a-button type="primary" :loading="saving" @click="saveService">ذخیره</a-button>
|
||||
</a-flex>
|
||||
</a-modal>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.tabs button {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tabs button.on {
|
||||
background: #f0fdfa;
|
||||
border-color: #99f6e4;
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
}
|
||||
.filters select,
|
||||
.form-grid select {
|
||||
font: inherit;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: start;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.item {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user