diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index a478d488..ec3fd504 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -4,6 +4,25 @@ import { useAuthStore } from './stores/authStore'; import AdminLayout from './components/layout/AdminLayout'; import LoginPage from './pages/LoginPage'; import DashboardPage from './pages/DashboardPage'; +import UsersPage from './pages/UsersPage'; +import UserDetailPage from './pages/UserDetailPage'; +import DoctorsPage from './pages/DoctorsPage'; +import DoctorDetailPage from './pages/DoctorDetailPage'; +import ClinicsPage from './pages/ClinicsPage'; +import ClinicDetailPage from './pages/ClinicDetailPage'; +import AppointmentsPage from './pages/AppointmentsPage'; +import AppointmentDetailPage from './pages/AppointmentDetailPage'; +import PaymentsPage from './pages/PaymentsPage'; +import PaymentDetailPage from './pages/PaymentDetailPage'; +import SettlementsPage from './pages/SettlementsPage'; +import RepresentationsPage from './pages/RepresentationsPage'; +import CommentsPage from './pages/CommentsPage'; +import RatingsPage from './pages/RatingsPage'; +import SmsPage from './pages/SmsPage'; +import CategoriesPage from './pages/CategoriesPage'; +import BlogsPage from './pages/BlogsPage'; +import BlogFormPage from './pages/BlogFormPage'; +import SecretariesPage from './pages/SecretariesPage'; function PrivateRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); @@ -36,6 +55,50 @@ export default function App() { > } /> } /> + + {/* Users */} + } /> + } /> + + {/* Doctors */} + } /> + } /> + + {/* Clinics */} + } /> + } /> + + {/* Appointments */} + } /> + } /> + + {/* Payments */} + } /> + } /> + + {/* Settlements */} + } /> + + {/* Representations */} + } /> + + {/* Comments & Ratings */} + } /> + } /> + + {/* SMS */} + } /> + + {/* Categories */} + } /> + + {/* Blogs */} + } /> + } /> + } /> + + {/* Secretaries */} + } /> } /> diff --git a/assets/admin/components/ui/ConfirmDialog.tsx b/assets/admin/components/ui/ConfirmDialog.tsx new file mode 100644 index 00000000..f00a6115 --- /dev/null +++ b/assets/admin/components/ui/ConfirmDialog.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'; + +interface Props { + open: boolean; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; + loading?: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +export default function ConfirmDialog({ + open, + title, + message, + confirmLabel = 'تأیید', + cancelLabel = 'لغو', + danger = false, + loading = false, + onConfirm, + onCancel, +}: Props) { + if (!open) return null; + + return ( +
+
+
+
+
+ +
+
+

{title}

+

{message}

+
+
+
+ + +
+
+
+ ); +} diff --git a/assets/admin/components/ui/DataTable.tsx b/assets/admin/components/ui/DataTable.tsx new file mode 100644 index 00000000..8bacc567 --- /dev/null +++ b/assets/admin/components/ui/DataTable.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; + +export interface Column { + key: string; + header: string; + render?: (row: T) => React.ReactNode; + sortable?: boolean; +} + +interface Props { + columns: Column[]; + data: T[]; + loading?: boolean; + searchValue?: string; + onSearchChange?: (v: string) => void; + searchPlaceholder?: string; + actions?: (row: T) => React.ReactNode; + emptyMessage?: string; + emptyAction?: React.ReactNode; +} + +function SkeletonRow({ cols }: { cols: number }) { + return ( + + {Array.from({ length: cols }).map((_, i) => ( + +
+ + ))} + + ); +} + +export default function DataTable({ + columns, + data, + loading, + searchValue, + onSearchChange, + searchPlaceholder = 'جستجو...', + actions, + emptyMessage = 'هیچ موردی یافت نشد', + emptyAction, +}: Props) { + const allColumns = actions + ? [...columns, { key: '__actions', header: 'اقدامات' }] + : columns; + + return ( +
+ {onSearchChange !== undefined && ( +
+
+ + onSearchChange(e.target.value)} + placeholder={searchPlaceholder} + className="w-full h-10 pr-9 pl-3 border border-gray-300 rounded-[10px] text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+ )} + +
+ + + + {allColumns.map((col) => ( + + ))} + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + )) + ) : data.length === 0 ? ( + + + + ) : ( + data.map((row, rowIdx) => ( + + {columns.map((col) => ( + + ))} + {actions && ( + + )} + + )) + )} + +
+ {col.header} +
+
+ + + +

{emptyMessage}

+ {emptyAction} +
+
+ {col.render + ? col.render(row) + : ((row as Record)[col.key] as React.ReactNode) ?? '—'} + +
{actions(row)}
+
+
+
+ ); +} diff --git a/assets/admin/components/ui/Modal.tsx b/assets/admin/components/ui/Modal.tsx new file mode 100644 index 00000000..fc80b137 --- /dev/null +++ b/assets/admin/components/ui/Modal.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { XMarkIcon } from '@heroicons/react/24/outline'; + +type ModalSize = 'sm' | 'md' | 'lg' | 'xl'; + +const sizeMap: Record = { + sm: 'max-w-sm', + md: 'max-w-lg', + lg: 'max-w-2xl', + xl: 'max-w-4xl', +}; + +interface Props { + open: boolean; + title: string; + size?: ModalSize; + onClose: () => void; + children: React.ReactNode; + footer?: React.ReactNode; +} + +export default function Modal({ open, title, size = 'md', onClose, children, footer }: Props) { + if (!open) return null; + + return ( +
+
+
+
+

{title}

+ +
+
{children}
+ {footer && ( +
+ {footer} +
+ )} +
+
+ ); +} diff --git a/assets/admin/components/ui/PageHeader.tsx b/assets/admin/components/ui/PageHeader.tsx new file mode 100644 index 00000000..93b31977 --- /dev/null +++ b/assets/admin/components/ui/PageHeader.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +interface Crumb { + label: string; + to?: string; +} + +interface Props { + title: string; + breadcrumbs?: Crumb[]; + action?: React.ReactNode; +} + +export default function PageHeader({ title, breadcrumbs, action }: Props) { + return ( +
+
+

{title}

+ {breadcrumbs && breadcrumbs.length > 0 && ( + + )} +
+ {action &&
{action}
} +
+ ); +} diff --git a/assets/admin/components/ui/Pagination.tsx b/assets/admin/components/ui/Pagination.tsx new file mode 100644 index 00000000..886f209f --- /dev/null +++ b/assets/admin/components/ui/Pagination.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { ChevronRightIcon, ChevronLeftIcon } from '@heroicons/react/24/outline'; +import { formatNumber } from '../../lib/utils'; + +interface Props { + page: number; + total: number; + limit: number; + onPageChange: (page: number) => void; +} + +export default function Pagination({ page, total, limit, onPageChange }: Props) { + const totalPages = Math.ceil(total / limit); + if (totalPages <= 1) return null; + + const from = (page - 1) * limit + 1; + const to = Math.min(page * limit, total); + + const pages: (number | '...')[] = []; + if (totalPages <= 7) { + for (let i = 1; i <= totalPages; i++) pages.push(i); + } else { + pages.push(1); + if (page > 3) pages.push('...'); + for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) { + pages.push(i); + } + if (page < totalPages - 2) pages.push('...'); + pages.push(totalPages); + } + + return ( +
+ + نمایش {formatNumber(from)}–{formatNumber(to)} از {formatNumber(total)} + +
+ + {pages.map((p, i) => + p === '...' ? ( + ... + ) : ( + + ) + )} + +
+
+ ); +} diff --git a/assets/admin/components/ui/StatusBadge.tsx b/assets/admin/components/ui/StatusBadge.tsx new file mode 100644 index 00000000..62ce5f94 --- /dev/null +++ b/assets/admin/components/ui/StatusBadge.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import type { AppointmentStatus, PaymentStatus, SmsTemplateStatus, SettlementStatus } from '../../types'; + +const variants: Record = { + yellow: 'bg-yellow-100 text-yellow-800', + blue: 'bg-blue-100 text-blue-800', + purple: 'bg-purple-100 text-purple-800', + orange: 'bg-orange-100 text-orange-800', + indigo: 'bg-indigo-100 text-indigo-800', + green: 'bg-green-100 text-green-800', + red: 'bg-red-100 text-red-800', + gray: 'bg-gray-100 text-gray-700', + rose: 'bg-rose-100 text-rose-800', +}; + +const dots: Record = { + yellow: 'bg-yellow-500', + blue: 'bg-blue-500', + purple: 'bg-purple-500', + orange: 'bg-orange-500', + indigo: 'bg-indigo-500', + green: 'bg-green-500', + red: 'bg-red-500', + gray: 'bg-gray-400', + rose: 'bg-rose-500', +}; + +const appointmentMap: Record = { + waiting_for_payment: { color: 'yellow', label: 'در انتظار پرداخت' }, + reserved: { color: 'blue', label: 'رزرو شده' }, + checked_in: { color: 'purple', label: 'ورود به مطب' }, + waiting: { color: 'orange', label: 'در صف انتظار' }, + in_progress: { color: 'indigo', label: 'در حال ویزیت' }, + visited: { color: 'green', label: 'ویزیت شده' }, + completed: { color: 'green', label: 'تکمیل شده' }, + cancelled_by_user: { color: 'red', label: 'لغو توسط بیمار' }, + cancelled_by_doctor: { color: 'red', label: 'لغو توسط پزشک' }, + cancelled_by_admin: { color: 'red', label: 'لغو توسط ادمین' }, + auto_cancel_unpaid: { color: 'gray', label: 'لغو خودکار' }, + no_show: { color: 'rose', label: 'غیبت' }, +}; + +const paymentMap: Record = { + pending: { color: 'yellow', label: 'در انتظار' }, + received: { color: 'green', label: 'موفق' }, + canceled: { color: 'red', label: 'لغو شده' }, + refund: { color: 'blue', label: 'استرداد' }, +}; + +const smsMap: Record = { + draft: { color: 'gray', label: 'پیش‌نویس' }, + pending_approval: { color: 'yellow', label: 'در انتظار تأیید' }, + approved: { color: 'green', label: 'تأیید شده' }, + rejected: { color: 'red', label: 'رد شده' }, +}; + +const settlementMap: Record = { + pending: { color: 'yellow', label: 'در انتظار' }, + approved: { color: 'green', label: 'تأیید شده' }, + rejected: { color: 'red', label: 'رد شده' }, +}; + +interface Props { + type: 'appointment' | 'payment' | 'sms' | 'settlement' | 'active'; + value: string; +} + +export default function StatusBadge({ type, value }: Props) { + let color = 'gray'; + let label = value; + + if (type === 'appointment') { + const m = appointmentMap[value as AppointmentStatus]; + if (m) { color = m.color; label = m.label; } + } else if (type === 'payment') { + const m = paymentMap[value as PaymentStatus]; + if (m) { color = m.color; label = m.label; } + } else if (type === 'sms') { + const m = smsMap[value as SmsTemplateStatus]; + if (m) { color = m.color; label = m.label; } + } else if (type === 'settlement') { + const m = settlementMap[value as SettlementStatus]; + if (m) { color = m.color; label = m.label; } + } else if (type === 'active') { + color = value === 'true' || value === 'active' ? 'green' : 'gray'; + label = value === 'true' || value === 'active' ? 'فعال' : 'غیرفعال'; + } + + return ( + + + {label} + + ); +} + +export function ActiveBadge({ active }: { active: boolean }) { + return ( + + ); +} diff --git a/assets/admin/lib/api.ts b/assets/admin/lib/api.ts new file mode 100644 index 00000000..cf0b9c07 --- /dev/null +++ b/assets/admin/lib/api.ts @@ -0,0 +1,76 @@ +const BASE_URL = ''; + +function getToken(): string | null { + try { + const raw = localStorage.getItem('clinicpro-auth'); + if (!raw) return null; + const parsed = JSON.parse(raw); + return parsed?.state?.token ?? null; + } catch { + return null; + } +} + +export class ApiError extends Error { + constructor( + public status: number, + public code: string, + message: string, + ) { + super(message); + } +} + +async function request( + path: string, + options: RequestInit = {}, +): Promise { + const token = getToken(); + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(`${BASE_URL}${path}`, { ...options, headers }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + const firstErr = body?.errors?.[0]; + throw new ApiError( + res.status, + firstErr?.code ?? 'ERR_UNKNOWN', + firstErr?.message ?? 'خطای ناشناخته', + ); + } + + return res.json() as Promise; +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body: unknown) => + request(path, { method: 'POST', body: JSON.stringify(body) }), + patch: (path: string, body: unknown) => + request(path, { method: 'PATCH', body: JSON.stringify(body) }), + put: (path: string, body: unknown) => + request(path, { method: 'PUT', body: JSON.stringify(body) }), + delete: (path: string) => request(path, { method: 'DELETE' }), +}; + +export interface ApiResponse { + success: boolean; + data: T; + errors: { code: string; message: string; field?: string }[]; +} + +export interface PaginatedResponse { + success: boolean; + data: { + items: T[]; + total: number; + page: number; + limit: number; + }; + errors: []; +} diff --git a/assets/admin/lib/utils.ts b/assets/admin/lib/utils.ts new file mode 100644 index 00000000..83792a6e --- /dev/null +++ b/assets/admin/lib/utils.ts @@ -0,0 +1,44 @@ +export function formatRial(amount: number): string { + return new Intl.NumberFormat('fa-IR').format(amount) + ' تومان'; +} + +export function formatNumber(n: number): string { + return new Intl.NumberFormat('fa-IR').format(n); +} + +export function formatDate(dateStr: string | null | undefined): string { + if (!dateStr) return '—'; + try { + return new Intl.DateTimeFormat('fa-IR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(new Date(dateStr)); + } catch { + return dateStr; + } +} + +export function formatDateTime(dateStr: string | null | undefined): string { + if (!dateStr) return '—'; + try { + return new Intl.DateTimeFormat('fa-IR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(dateStr)); + } catch { + return dateStr; + } +} + +export function maskMobile(mobile: string): string { + if (mobile.length < 7) return mobile; + return mobile.slice(0, 4) + '***' + mobile.slice(-3); +} + +export function cn(...classes: (string | undefined | null | false)[]): string { + return classes.filter(Boolean).join(' '); +} diff --git a/assets/admin/pages/AppointmentDetailPage.tsx b/assets/admin/pages/AppointmentDetailPage.tsx new file mode 100644 index 00000000..389640a0 --- /dev/null +++ b/assets/admin/pages/AppointmentDetailPage.tsx @@ -0,0 +1,166 @@ +import React, { useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { ArrowRightIcon } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import type { Appointment, AppointmentStatus } from '../types'; +import { formatDate, formatDateTime, formatRial } from '../lib/utils'; +import PageHeader from '../components/ui/PageHeader'; +import StatusBadge from '../components/ui/StatusBadge'; +import ConfirmDialog from '../components/ui/ConfirmDialog'; + +const ALL_STATUSES: { value: AppointmentStatus; label: string }[] = [ + { value: 'waiting_for_payment', label: 'در انتظار پرداخت' }, + { value: 'reserved', label: 'رزرو شده' }, + { value: 'checked_in', label: 'ورود به مطب' }, + { value: 'waiting', label: 'در صف انتظار' }, + { value: 'in_progress', label: 'در حال ویزیت' }, + { value: 'visited', label: 'ویزیت شده' }, + { value: 'completed', label: 'تکمیل شده' }, + { value: 'cancelled_by_admin', label: 'لغو توسط ادمین' }, + { value: 'no_show', label: 'غیبت' }, +]; + +function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value ?? '—'} +
+ ); +} + +export default function AppointmentDetailPage() { + const { uuid } = useParams<{ uuid: string }>(); + const navigate = useNavigate(); + const qc = useQueryClient(); + const [cancelOpen, setCancelOpen] = useState(false); + const [newStatus, setNewStatus] = useState(''); + + const { data, isLoading } = useQuery({ + queryKey: ['appointment', uuid], + queryFn: () => api.get>(`/api/v1/appointment/${uuid}`), + enabled: !!uuid, + }); + + const statusMutation = useMutation({ + mutationFn: (status: string) => + api.patch>(`/api/v1/appointment/${uuid}/status`, { status }), + onSuccess: () => { + toast.success('وضعیت نوبت بروزرسانی شد'); + qc.invalidateQueries({ queryKey: ['appointment', uuid] }); + }, + onError: (err: Error) => toast.error(err.message), + }); + + const cancelMutation = useMutation({ + mutationFn: () => api.patch>(`/api/v1/appointment/${uuid}/cancel`, {}), + onSuccess: () => { + toast.success('نوبت لغو شد'); + setCancelOpen(false); + qc.invalidateQueries({ queryKey: ['appointment', uuid] }); + }, + onError: (err: Error) => toast.error(err.message), + }); + + const appt = data?.data; + + return ( +
+ navigate('/admin/appointments')} + className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-800 transition-colors"> + + بازگشت + + } + /> + + {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : appt ? ( +
+
+

اطلاعات بیمار

+ + {appt.patient_mobile}} /> + + + + + + +
+ +
+

وضعیت و اقدامات

+
+

وضعیت فعلی:

+ +
+ +
+ +
+ + +
+
+ +
+ +
+
+
+ ) : ( +
+ نوبتی یافت نشد +
+ )} + + cancelMutation.mutate()} + onCancel={() => setCancelOpen(false)} + /> +
+ ); +} diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx new file mode 100644 index 00000000..1e9c52a9 --- /dev/null +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -0,0 +1,129 @@ +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { EyeIcon } from '@heroicons/react/24/outline'; +import { api } from '../lib/api'; +import type { PaginatedResponse } from '../lib/api'; +import type { Appointment, AppointmentStatus } from '../types'; +import { formatDate, formatRial, maskMobile } from '../lib/utils'; +import PageHeader from '../components/ui/PageHeader'; +import DataTable, { Column } from '../components/ui/DataTable'; +import StatusBadge from '../components/ui/StatusBadge'; +import Pagination from '../components/ui/Pagination'; + +const STATUS_FILTERS: { value: string; label: string }[] = [ + { value: '', label: 'همه' }, + { value: 'waiting_for_payment', label: 'در انتظار پرداخت' }, + { value: 'reserved', label: 'رزرو شده' }, + { value: 'checked_in', label: 'ورود به مطب' }, + { value: 'waiting', label: 'در صف انتظار' }, + { value: 'in_progress', label: 'در حال ویزیت' }, + { value: 'visited', label: 'ویزیت شده' }, + { value: 'completed', label: 'تکمیل شده' }, + { value: 'cancelled_by_user', label: 'لغو توسط بیمار' }, + { value: 'no_show', label: 'غیبت' }, +]; + +export default function AppointmentsPage() { + const navigate = useNavigate(); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const limit = 15; + + const { data, isLoading } = useQuery({ + queryKey: ['appointments', page, search, statusFilter], + queryFn: () => { + const params = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (search) params.set('search', search); + if (statusFilter) params.set('status', statusFilter); + return api.get>(`/api/v1/appointments?${params}`); + }, + }); + + const columns: Column[] = [ + { + key: 'patient', + header: 'بیمار', + render: (a) => ( +
+

{a.patient_name || '—'}

+

{maskMobile(a.patient_mobile)}

+
+ ), + }, + { key: 'doctor_name', header: 'پزشک', render: (a) => `دکتر ${a.doctor_name}` }, + { key: 'clinic_name', header: 'کلینیک', render: (a) => a.clinic_name ?? '—' }, + { + key: 'appointment_date', + header: 'تاریخ نوبت', + render: (a) => ( +
+

{formatDate(a.appointment_date)}

+

{a.appointment_time}

+
+ ), + }, + { + key: 'status', + header: 'وضعیت', + render: (a) => , + }, + { + key: 'amount', + header: 'مبلغ', + render: (a) => {formatRial(a.amount)}, + }, + { key: 'created_at', header: 'تاریخ ثبت', render: (a) => formatDate(a.created_at) }, + ]; + + const items = data?.data?.items ?? []; + const total = data?.data?.total ?? 0; + + return ( +
+ + +
+
+ {STATUS_FILTERS.map((f) => ( + + ))} +
+ + + columns={columns} + data={items} + loading={isLoading} + searchValue={search} + onSearchChange={(v) => { setSearch(v); setPage(1); }} + searchPlaceholder="جستجو بر اساس موبایل یا نام پزشک..." + emptyMessage="هیچ نوبتی یافت نشد" + actions={(appt) => ( + + )} + /> + +
+
+ ); +} diff --git a/assets/admin/pages/BlogFormPage.tsx b/assets/admin/pages/BlogFormPage.tsx new file mode 100644 index 00000000..d289a02e --- /dev/null +++ b/assets/admin/pages/BlogFormPage.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import type { Blog } from '../types'; +import PageHeader from '../components/ui/PageHeader'; + +const schema = z.object({ + title: z.string().min(3, 'عنوان الزامی است'), + summary: z.string().optional(), + content: z.string().min(10, 'محتوا الزامی است'), + tags: z.string().optional(), + status: z.enum(['draft', 'published']), +}); +type FormData = z.infer; + +export default function BlogFormPage() { + const { uuid } = useParams<{ uuid: string }>(); + const navigate = useNavigate(); + const qc = useQueryClient(); + const isEdit = !!uuid; + + const { data, isLoading } = useQuery({ + queryKey: ['blog', uuid], + queryFn: () => api.get>(`/api/v1/blog/${uuid}`), + enabled: isEdit, + }); + + const blog = data?.data; + + const { register, handleSubmit, control, reset, formState: { errors, isSubmitting } } = useForm({ + resolver: zodResolver(schema), + defaultValues: { status: 'draft' }, + values: blog + ? { + title: blog.title, + summary: blog.summary ?? '', + content: blog.content, + tags: blog.tags?.join(', ') ?? '', + status: blog.status, + } + : undefined, + }); + + const createMutation = useMutation({ + mutationFn: (d: FormData) => + api.post>('/api/v1/blog', { + ...d, + tags: d.tags ? d.tags.split(',').map((t) => t.trim()).filter(Boolean) : [], + }), + onSuccess: () => { + toast.success('مقاله ذخیره شد'); + qc.invalidateQueries({ queryKey: ['blogs'] }); + navigate('/admin/blogs'); + }, + onError: (err: Error) => toast.error(err.message), + }); + + const updateMutation = useMutation({ + mutationFn: (d: FormData) => + api.patch>(`/api/v1/blog/${uuid}`, { + ...d, + tags: d.tags ? d.tags.split(',').map((t) => t.trim()).filter(Boolean) : [], + }), + onSuccess: () => { + toast.success('مقاله بروزرسانی شد'); + qc.invalidateQueries({ queryKey: ['blogs'] }); + qc.invalidateQueries({ queryKey: ['blog', uuid] }); + navigate('/admin/blogs'); + }, + onError: (err: Error) => toast.error(err.message), + }); + + const onSubmit = (d: FormData) => { + if (isEdit) updateMutation.mutate(d); + else createMutation.mutate(d); + }; + + if (isEdit && isLoading) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ); + } + + return ( +
+ + +
+
+
+
+
+ + + {errors.title &&

{errors.title.message}

} +
+ +
+ +