diff --git a/assets/admin/components/ui/SearchableSelect.tsx b/assets/admin/components/ui/SearchableSelect.tsx new file mode 100644 index 00000000..6da9ef4c --- /dev/null +++ b/assets/admin/components/ui/SearchableSelect.tsx @@ -0,0 +1,121 @@ +import React, { useMemo } from 'react'; +import Select, { StylesConfig, GroupBase } from 'react-select'; +import { useUiStore } from '../../stores/uiStore'; + +export interface SelectOption { + value: string | number; + label: string; +} + +interface Props { + options: SelectOption[]; + value?: string | number | null; + onChange?: (value: string | number | null) => void; + placeholder?: string; + isLoading?: boolean; + isDisabled?: boolean; + isClearable?: boolean; + noOptionsMessage?: string; + inputId?: string; +} + +export default function SearchableSelect({ + options, + value, + onChange, + placeholder = 'انتخاب کنید...', + isLoading, + isDisabled, + isClearable, + noOptionsMessage = 'موردی یافت نشد', + inputId, +}: Props) { + const darkMode = useUiStore((s) => s.darkMode); + + const selected = useMemo( + () => options.find((o) => o.value === value) ?? null, + [options, value], + ); + + const styles: StylesConfig> = { + control: (base, state) => ({ + ...base, + background: darkMode ? '#111827' : '#ffffff', + borderColor: state.isFocused ? '#7c3aed' : darkMode ? '#4b5563' : '#cbd5e1', + boxShadow: state.isFocused ? '0 0 0 2px rgba(124,58,237,0.25)' : 'none', + borderRadius: '0.75rem', + minHeight: '2.75rem', + fontSize: '0.875rem', + '&:hover': { + borderColor: state.isFocused ? '#7c3aed' : darkMode ? '#6b7280' : '#94a3b8', + }, + }), + menu: (base) => ({ + ...base, + background: darkMode ? '#1f2937' : '#ffffff', + border: `1px solid ${darkMode ? '#374151' : '#e2e8f0'}`, + boxShadow: '0 8px 24px rgba(0,0,0,0.15)', + borderRadius: '0.75rem', + overflow: 'hidden', + }), + menuPortal: (base) => ({ ...base, zIndex: 9999 }), + option: (base, state) => ({ + ...base, + background: state.isSelected + ? '#7c3aed' + : state.isFocused + ? darkMode ? '#374151' : '#f1f5f9' + : 'transparent', + color: state.isSelected ? '#ffffff' : darkMode ? '#e5e7eb' : '#1e293b', + fontSize: '0.875rem', + cursor: 'pointer', + }), + singleValue: (base) => ({ ...base, color: darkMode ? '#f3f4f6' : '#1e293b' }), + input: (base) => ({ ...base, color: darkMode ? '#f3f4f6' : '#1e293b' }), + placeholder: (base) => ({ ...base, color: darkMode ? '#6b7280' : '#94a3b8' }), + indicatorSeparator: (base) => ({ + ...base, + background: darkMode ? '#374151' : '#e2e8f0', + }), + dropdownIndicator: (base) => ({ + ...base, + color: darkMode ? '#6b7280' : '#94a3b8', + '&:hover': { color: darkMode ? '#9ca3af' : '#64748b' }, + }), + clearIndicator: (base) => ({ + ...base, + color: darkMode ? '#6b7280' : '#94a3b8', + '&:hover': { color: '#ef4444' }, + }), + loadingIndicator: (base) => ({ ...base, color: '#7c3aed' }), + noOptionsMessage: (base) => ({ + ...base, + color: darkMode ? '#6b7280' : '#94a3b8', + fontSize: '0.875rem', + }), + loadingMessage: (base) => ({ + ...base, + color: darkMode ? '#6b7280' : '#94a3b8', + fontSize: '0.875rem', + }), + }; + + return ( + + options={options} + value={selected} + onChange={(opt) => onChange?.(opt ? opt.value : null)} + placeholder={placeholder} + isLoading={isLoading} + isDisabled={isDisabled} + isClearable={isClearable} + styles={styles} + isRtl + menuPortalTarget={typeof document !== 'undefined' ? document.body : undefined} + menuPosition="fixed" + noOptionsMessage={() => noOptionsMessage} + loadingMessage={() => 'در حال بارگذاری...'} + inputId={inputId} + /> + ); +} diff --git a/assets/admin/pages/CategoriesPage.tsx b/assets/admin/pages/CategoriesPage.tsx index 313ac61e..5cacdadd 100644 --- a/assets/admin/pages/CategoriesPage.tsx +++ b/assets/admin/pages/CategoriesPage.tsx @@ -1,63 +1,460 @@ import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { TrashIcon, PlusIcon, PencilIcon } from '@heroicons/react/24/outline'; -import { useForm } from 'react-hook-form'; +import { + TrashIcon, PlusIcon, PencilIcon, TagIcon, MapPinIcon, + BuildingOffice2Icon, HeartIcon, ShieldCheckIcon, WrenchScrewdriverIcon, + CheckCircleIcon, XCircleIcon, PhotoIcon, XMarkIcon, +} from '@heroicons/react/24/outline'; +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 { Category, CategoryBundle } from '../types'; +import type { ApiResponse, PaginatedResponse } from '../lib/api'; +import type { Category, CategoryBundle, Representation } from '../types'; +import { useAuthStore } from '../stores/authStore'; import PageHeader from '../components/ui/PageHeader'; import DataTable, { Column } from '../components/ui/DataTable'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import Modal from '../components/ui/Modal'; +import SearchableSelect from '../components/ui/SearchableSelect'; -const TABS: { key: CategoryBundle; label: string }[] = [ - { key: 'state', label: 'استان‌ها' }, - { key: 'city', label: 'شهرها' }, - { key: 'specially_doctor', label: 'تخصص‌ها' }, - { key: 'doctor_services', label: 'خدمات پزشک' }, - { key: 'insurance_type', label: 'نوع بیمه' }, - { key: 'supplementary_insurance', label: 'بیمه تکمیلی' }, - { key: 'tag', label: 'تگ‌های بلاگ' }, +// ── Tab config ────────────────────────────────────────────────────────────── + +interface TabConfig { + key: CategoryBundle; + label: string; + icon: React.ElementType; + color: string; + description: string; + hasParent: boolean; + parentBundle?: CategoryBundle; + parentLabel?: string; + hasLogo: boolean; + hasCityFields: boolean; + hasWeight: boolean; +} + +const TABS: TabConfig[] = [ + { + key: 'state', + label: 'استان‌ها', + icon: MapPinIcon, + color: 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-400/10', + description: 'مدیریت استان‌ها', + hasParent: false, hasLogo: false, hasCityFields: false, hasWeight: true, + }, + { + key: 'city', + label: 'شهرها', + icon: BuildingOffice2Icon, + color: 'text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-400/10', + description: 'مدیریت شهرها و اطلاعات تفصیلی هر شهر', + hasParent: true, parentBundle: 'state', parentLabel: 'استان', + hasLogo: false, hasCityFields: true, hasWeight: true, + }, + { + key: 'specially_doctor', + label: 'تخصص‌های پزشکی', + icon: HeartIcon, + color: 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-400/10', + description: 'تخصص‌ها و زیرتخصص‌های پزشکی', + hasParent: true, parentBundle: 'specially_doctor', parentLabel: 'تخصص والد', + hasLogo: false, hasCityFields: false, hasWeight: true, + }, + { + key: 'doctor_services', + label: 'خدمات پزشک', + icon: WrenchScrewdriverIcon, + color: 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-400/10', + description: 'خدمات قابل ارائه توسط پزشکان', + hasParent: false, hasLogo: false, hasCityFields: false, hasWeight: true, + }, + { + key: 'insurance_type', + label: 'بیمه پایه', + icon: ShieldCheckIcon, + color: 'text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-400/10', + description: 'انواع بیمه‌های پایه درمانی', + hasParent: false, hasLogo: true, hasCityFields: false, hasWeight: false, + }, + { + key: 'supplementary_insurance', + label: 'بیمه تکمیلی', + icon: ShieldCheckIcon, + color: 'text-cyan-600 dark:text-cyan-400 bg-cyan-50 dark:bg-cyan-400/10', + description: 'انواع بیمه‌های تکمیلی درمانی', + hasParent: false, hasLogo: true, hasCityFields: false, hasWeight: false, + }, + { + key: 'tag', + label: 'تگ‌های بلاگ', + icon: TagIcon, + color: 'text-violet-600 dark:text-violet-400 bg-violet-50 dark:bg-violet-400/10', + description: 'تگ‌های مورد استفاده در مطالب بلاگ', + hasParent: false, hasLogo: false, hasCityFields: false, hasWeight: true, + }, ]; -const schema = z.object({ - label: z.string().min(1, 'نام الزامی است'), - parent_id: z.string().optional(), +// ── Zod schema ────────────────────────────────────────────────────────────── + +const baseSchema = z.object({ + label: z.string().min(1, 'نام الزامی است'), + status: z.string().optional(), + weight: z.string().optional(), + parent_id: z.number().nullable().optional(), + representation_id: z.number().nullable().optional(), + title: z.string().optional(), + contact_phone: z.string().optional(), + email: z.string().optional(), + description: z.string().optional(), + slogan: z.string().optional(), + domain: z.string().optional(), + keywords: z.string().optional(), + footer_description: z.string().optional(), + footer_disclaimer: z.string().optional(), }); -type FormData = z.infer; +type FormData = z.infer; + +// ── Logo uploader ──────────────────────────────────────────────────────────── + +function LogoUploadField({ + value, + onChange, +}: { + value: string | null; + onChange: (url: string | null) => void; +}) { + const [uploading, setUploading] = useState(false); + const token = useAuthStore((s) => s.token); + + const handleFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setUploading(true); + try { + const res = await fetch('/api/v1/admin/category/upload-logo', { + method: 'POST', + headers: { + 'Content-Disposition': `attachment; filename="${file.name}"`, + 'Content-Type': 'application/octet-stream', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: file, + }); + const json = await res.json(); + if (json.success) { + onChange(json.data.url); + toast.success('لگو آپلود شد'); + } else { + toast.error(json.errors?.[0]?.message ?? 'خطا در آپلود'); + } + } catch { + toast.error('خطا در آپلود تصویر'); + } finally { + setUploading(false); + e.target.value = ''; + } + }; + + return ( +
+ {value ? ( +
+ logo + +
+ ) : ( +
+ +
+ )} + + +

JPG، PNG یا WebP — حداکثر ۵ مگابایت

+
+ ); +} + +// ── Helper to load parent list ────────────────────────────────────────────── + +function useBundleList(bundle: CategoryBundle | undefined) { + return useQuery({ + queryKey: ['categories', bundle], + queryFn: () => api.get>(`/api/v1/categorys/${bundle}`), + enabled: !!bundle, + staleTime: 60_000, + }); +} + +// ── Dynamic form fields ───────────────────────────────────────────────────── + +interface FormFieldsProps { + register: ReturnType>['register']; + control: ReturnType>['control']; + errors: ReturnType>['formState']['errors']; + tab: TabConfig; + parentOptions: { value: number; label: string }[]; + representationOptions: { value: number; label: string }[]; + parentLoading: boolean; + representationLoading: boolean; + logoUrl: string | null; + onLogoChange: (url: string | null) => void; +} + +function CategoryFormFields({ + register, control, errors, tab, + parentOptions, representationOptions, + parentLoading, representationLoading, + logoUrl, onLogoChange, +}: FormFieldsProps) { + return ( +
+ {/* Logo upload (insurance bundles) */} + {tab.hasLogo && ( +
+ + +
+ )} + + {/* Label */} +
+ + + {errors.label &&

{errors.label.message}

} +
+ + {/* Parent selector */} + {tab.hasParent && ( +
+ + ( + field.onChange(val as number | null)} + placeholder="-- بدون والد --" + isClearable + isLoading={parentLoading} + noOptionsMessage="موردی یافت نشد" + /> + )} + /> +
+ )} + + {/* Representation selector (city only) */} + {tab.key === 'city' && ( +
+ + ( + field.onChange(val as number | null)} + placeholder="انتخاب نماینده..." + isClearable + isLoading={representationLoading} + noOptionsMessage="هیچ نماینده‌ای یافت نشد" + /> + )} + /> +
+ )} + + {/* Title (city) */} + {tab.key === 'city' && ( +
+ + +
+ )} + + {/* City-specific fields */} + {tab.hasCityFields && ( + <> +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +