From 5066fcbd91c855ef50cbd7e79b568c014a6abb99 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 10 Jun 2026 14:22:26 +0330 Subject: [PATCH] feat: add insurance and location management - Introduced InsuranceType enum for insurance categorization. - Created InsuranceRepository for managing insurance entities. - Developed LocationController for handling provinces and cities, including CRUD operations. - Implemented City and Province entities with necessary fields and relationships. - Added CityRepository and ProvinceRepository for database interactions. - Established Specialty management with SpecialtyController, including CRUD operations. - Created Specialty and Tag entities with appropriate fields and relationships. - Implemented TagController for managing tags, including CRUD operations. - Added TagRepository for database interactions with tags. --- assets/admin/pages/CategoriesPage.tsx | 1343 ++++++++++------- .../admin/pages/RepresentationDetailPage.tsx | 12 +- assets/admin/pages/RepresentationsPage.tsx | 10 +- assets/admin/types/index.ts | 70 +- config/services.yaml | 2 +- migrations/Version20260610103401.php | 274 ++++ migrations/Version20260610140853.php | 83 + src/Admin/Controller/AdminApiController.php | 6 +- .../Controller/CategoryController.php | 176 +-- src/Category/Entity/Category.php | 165 -- .../Repository/CategoryRepository.php | 48 - src/Category/Service/CategoryService.php | 99 -- src/Clinic/Controller/ClinicController.php | 68 +- src/Clinic/Entity/Clinic.php | 66 +- src/Doctor/Controller/DoctorController.php | 67 +- src/Doctor/Entity/Doctor.php | 76 +- src/Doctor/Repository/DoctorRepository.php | 4 +- .../Controller/DoctorServiceController.php | 132 ++ src/DoctorService/Entity/DoctorService.php | 75 + .../Repository/DoctorServiceRepository.php | 42 + .../Controller/InsuranceController.php | 247 ++- src/Insurance/Entity/DoctorInsurance.php | 40 +- src/Insurance/Entity/Insurance.php | 66 + src/Insurance/Enum/InsuranceType.php | 17 + .../Repository/InsuranceRepository.php | 42 + .../Controller/LocationController.php | 226 +++ src/Location/Entity/City.php | 128 ++ src/Location/Entity/Province.php | 57 + src/Location/Repository/CityRepository.php | 42 + .../Repository/ProvinceRepository.php | 38 + .../Controller/SpecialtyController.php | 131 ++ src/Specialty/Entity/Specialty.php | 75 + .../Repository/SpecialtyRepository.php | 47 + src/Tag/Controller/TagController.php | 108 ++ src/Tag/Entity/Tag.php | 59 + src/Tag/Repository/TagRepository.php | 37 + 36 files changed, 2937 insertions(+), 1241 deletions(-) create mode 100644 migrations/Version20260610103401.php create mode 100644 migrations/Version20260610140853.php delete mode 100644 src/Category/Entity/Category.php delete mode 100644 src/Category/Repository/CategoryRepository.php delete mode 100644 src/Category/Service/CategoryService.php create mode 100644 src/DoctorService/Controller/DoctorServiceController.php create mode 100644 src/DoctorService/Entity/DoctorService.php create mode 100644 src/DoctorService/Repository/DoctorServiceRepository.php create mode 100644 src/Insurance/Entity/Insurance.php create mode 100644 src/Insurance/Enum/InsuranceType.php create mode 100644 src/Insurance/Repository/InsuranceRepository.php create mode 100644 src/Location/Controller/LocationController.php create mode 100644 src/Location/Entity/City.php create mode 100644 src/Location/Entity/Province.php create mode 100644 src/Location/Repository/CityRepository.php create mode 100644 src/Location/Repository/ProvinceRepository.php create mode 100644 src/Specialty/Controller/SpecialtyController.php create mode 100644 src/Specialty/Entity/Specialty.php create mode 100644 src/Specialty/Repository/SpecialtyRepository.php create mode 100644 src/Tag/Controller/TagController.php create mode 100644 src/Tag/Entity/Tag.php create mode 100644 src/Tag/Repository/TagRepository.php diff --git a/assets/admin/pages/CategoriesPage.tsx b/assets/admin/pages/CategoriesPage.tsx index 5cacdadd..0c2b3abb 100644 --- a/assets/admin/pages/CategoriesPage.tsx +++ b/assets/admin/pages/CategoriesPage.tsx @@ -10,57 +10,49 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { toast } from 'sonner'; import { api } from '../lib/api'; -import type { ApiResponse, PaginatedResponse } from '../lib/api'; -import type { Category, CategoryBundle, Representation } from '../types'; +import type { PaginatedResponse } from '../lib/api'; +import type { Province, City, SpecialtyFull, DoctorService, Insurance, Tag, 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 Pagination from '../components/ui/Pagination'; import SearchableSelect from '../components/ui/SearchableSelect'; -// ── Tab config ────────────────────────────────────────────────────────────── +// ── Tab types ──────────────────────────────────────────────────────────────── + +type TabKey = 'provinces' | 'cities' | 'specialties' | 'doctor_services' | 'insurances' | 'tags'; interface TabConfig { - key: CategoryBundle; + key: TabKey; 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', + key: 'provinces', 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', + key: 'cities', 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: 'تخصص‌های پزشکی', + key: 'specialties', + 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, + description: 'تخصص‌های پزشکی', }, { key: 'doctor_services', @@ -68,62 +60,33 @@ const TABS: TabConfig[] = [ 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: 'بیمه پایه', + key: 'insurances', + 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, + description: 'بیمه‌های پایه و تکمیلی', }, { - 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: 'تگ‌های بلاگ', + key: 'tags', + 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, }, ]; -// ── 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; - -// ── Logo uploader ──────────────────────────────────────────────────────────── +// ── Logo uploader ───────────────────────────────────────────────────────────── function LogoUploadField({ value, onChange, + uploadUrl, }: { value: string | null; onChange: (url: string | null) => void; + uploadUrl: string; }) { const [uploading, setUploading] = useState(false); const token = useAuthStore((s) => s.token); @@ -133,7 +96,7 @@ function LogoUploadField({ if (!file) return; setUploading(true); try { - const res = await fetch('/api/v1/admin/category/upload-logo', { + const res = await fetch(uploadUrl, { method: 'POST', headers: { 'Content-Disposition': `attachment; filename="${file.name}"`, @@ -161,11 +124,7 @@ function LogoUploadField({
{value ? (
- logo + logo
)} - -

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

+

JPG، PNG یا WebP

); } -// ── Helper to load parent list ────────────────────────────────────────────── +// ── StatusBadge helper ──────────────────────────────────────────────────────── -function useBundleList(bundle: CategoryBundle | undefined) { - return useQuery({ - queryKey: ['categories', bundle], - queryFn: () => api.get>(`/api/v1/categorys/${bundle}`), - enabled: !!bundle, - staleTime: 60_000, +function StatusBadge({ status }: { status: number }) { + return status === 1 ? ( + + فعال + + ) : ( + + غیرفعال + + ); +} + +// ── Provinces Tab ───────────────────────────────────────────────────────────── + +const provinceSchema = z.object({ + name: z.string().min(1, 'نام الزامی است'), + weight: z.string().optional(), + status: z.string().optional(), +}); +type ProvinceForm = z.infer; + +function ProvincesTab() { + const qc = useQueryClient(); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [addOpen, setAddOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: ['admin-provinces', page, search], + queryFn: () => api.get>(`/api/v1/admin/provinces?page=${page}&limit=20&search=${encodeURIComponent(search)}`), }); -} -// ── Dynamic form fields ───────────────────────────────────────────────────── + const items = data?.data ?? []; + const total = data?.meta?.totalRecords ?? 0; -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; -} + const { register, handleSubmit, reset, control, formState: { errors } } = useForm({ + resolver: zodResolver(provinceSchema), + defaultValues: { status: '1', weight: '0' }, + }); -function CategoryFormFields({ - register, control, errors, tab, - parentOptions, representationOptions, - parentLoading, representationLoading, - logoUrl, onLogoChange, -}: FormFieldsProps) { - return ( + const closeModal = () => { setAddOpen(false); setEditTarget(null); reset({ status: '1', weight: '0' }); }; + + const createMutation = useMutation({ + mutationFn: (d: ProvinceForm) => api.post('/api/v1/admin/province', { name: d.name.trim(), weight: parseInt(d.weight ?? '0'), status: parseInt(d.status ?? '1') }), + onSuccess: () => { toast.success('استان اضافه شد'); closeModal(); qc.invalidateQueries({ queryKey: ['admin-provinces'] }); }, + onError: (err: Error) => toast.error(err.message), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, d }: { id: number; d: ProvinceForm }) => api.patch(`/api/v1/admin/province/${id}`, { name: d.name.trim(), weight: parseInt(d.weight ?? '0'), status: parseInt(d.status ?? '1') }), + onSuccess: () => { toast.success('استان بروزرسانی شد'); closeModal(); qc.invalidateQueries({ queryKey: ['admin-provinces'] }); }, + onError: (err: Error) => toast.error(err.message), + }); + + const deleteMutation = useMutation({ + mutationFn: (p: Province) => api.delete(`/api/v1/admin/province/${p.id}`), + onSuccess: () => { toast.success('استان حذف شد'); setDeleteTarget(null); qc.invalidateQueries({ queryKey: ['admin-provinces'] }); }, + onError: (err: Error) => toast.error(err.message), + }); + + const openEdit = (p: Province) => { + setEditTarget(p); + reset({ name: p.name, weight: String(p.weight), status: String(p.status) }); + }; + + const columns: Column[] = [ + { key: 'id', header: 'شناسه', className: 'w-16', render: (p) => {p.id} }, + { key: 'name', header: 'نام', render: (p) => {p.name} }, + { key: 'weight', header: 'ترتیب', className: 'w-20', render: (p) => {p.weight} }, + { key: 'status', header: 'وضعیت', className: 'w-24', render: (p) => }, + ]; + + const formBody = (
- {/* Logo upload (insurance bundles) */} - {tab.hasLogo && ( -
- - -
- )} - - {/* Label */}
- - {errors.label &&

{errors.label.message}

} + + {errors.name &&

{errors.name.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 && ( - <> -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
- -