feat: enhance RepresentationsPage with searchable city filter and form improvements
- Updated RepresentationsPage to use SearchableSelect for city filtering. - Modified form handling to use Controller from react-hook-form for city selection. - Improved city filter handling to support null values. - Added city_id to Representation interface in types. - Implemented uploadLogo endpoint in CategoryController for logo uploads. - Added logo_url field to Category entity and updated related services. - Created SearchableSelect component for better user experience in selecting options. - Added migration to include logo_url in categories table.
This commit is contained in:
@@ -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<SelectOption, false, GroupBase<SelectOption>> = {
|
||||
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 (
|
||||
<Select<SelectOption>
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof schema>;
|
||||
type FormData = z.infer<typeof baseSchema>;
|
||||
|
||||
// ── 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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="flex items-center gap-3">
|
||||
{value ? (
|
||||
<div className="relative group">
|
||||
<img
|
||||
src={value}
|
||||
alt="logo"
|
||||
className="w-16 h-16 object-contain rounded-xl border border-slate-200 dark:border-gray-600 bg-white dark:bg-gray-800 p-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(null)}
|
||||
className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-red-500 hover:bg-red-600 rounded-full text-white flex items-center justify-center shadow transition-colors"
|
||||
>
|
||||
<XMarkIcon className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-xl border-2 border-dashed border-slate-300 dark:border-gray-600 flex items-center justify-center bg-slate-50 dark:bg-gray-800">
|
||||
<PhotoIcon className="w-6 h-6 text-slate-300 dark:text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className={`cursor-pointer cp-btn-secondary text-sm ${uploading ? 'opacity-60 pointer-events-none' : ''}`}>
|
||||
{uploading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
|
||||
</svg>
|
||||
در حال آپلود...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<PhotoIcon className="w-4 h-4" />
|
||||
{value ? 'تغییر لگو' : 'انتخاب لگو'}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFile}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">JPG، PNG یا WebP — حداکثر ۵ مگابایت</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helper to load parent list ──────────────────────────────────────────────
|
||||
|
||||
function useBundleList(bundle: CategoryBundle | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['categories', bundle],
|
||||
queryFn: () => api.get<ApiResponse<{ data: Category[] }>>(`/api/v1/categorys/${bundle}`),
|
||||
enabled: !!bundle,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Dynamic form fields ─────────────────────────────────────────────────────
|
||||
|
||||
interface FormFieldsProps {
|
||||
register: ReturnType<typeof useForm<FormData>>['register'];
|
||||
control: ReturnType<typeof useForm<FormData>>['control'];
|
||||
errors: ReturnType<typeof useForm<FormData>>['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 (
|
||||
<div className="space-y-4">
|
||||
{/* Logo upload (insurance bundles) */}
|
||||
{tab.hasLogo && (
|
||||
<div>
|
||||
<label className="cp-label">لگو</label>
|
||||
<LogoUploadField value={logoUrl} onChange={onLogoChange} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<div>
|
||||
<label className="cp-label">نام <span className="text-red-500">*</span></label>
|
||||
<input {...register('label')} className="cp-input h-11" placeholder="نام دستهبندی" />
|
||||
{errors.label && <p className="text-red-500 text-xs mt-1">{errors.label.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Parent selector */}
|
||||
{tab.hasParent && (
|
||||
<div>
|
||||
<label className="cp-label">{tab.parentLabel}</label>
|
||||
<Controller
|
||||
name="parent_id"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SearchableSelect
|
||||
options={parentOptions}
|
||||
value={field.value ?? null}
|
||||
onChange={(val) => field.onChange(val as number | null)}
|
||||
placeholder="-- بدون والد --"
|
||||
isClearable
|
||||
isLoading={parentLoading}
|
||||
noOptionsMessage="موردی یافت نشد"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Representation selector (city only) */}
|
||||
{tab.key === 'city' && (
|
||||
<div>
|
||||
<label className="cp-label">نماینده</label>
|
||||
<Controller
|
||||
name="representation_id"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SearchableSelect
|
||||
options={representationOptions}
|
||||
value={field.value ?? null}
|
||||
onChange={(val) => field.onChange(val as number | null)}
|
||||
placeholder="انتخاب نماینده..."
|
||||
isClearable
|
||||
isLoading={representationLoading}
|
||||
noOptionsMessage="هیچ نمایندهای یافت نشد"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title (city) */}
|
||||
{tab.key === 'city' && (
|
||||
<div>
|
||||
<label className="cp-label">عنوان صفحه</label>
|
||||
<input {...register('title')} className="cp-input h-11" placeholder="عنوان نمایشی" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* City-specific fields */}
|
||||
{tab.hasCityFields && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="cp-label">تلفن تماس</label>
|
||||
<input {...register('contact_phone')} className="cp-input h-10" dir="ltr" placeholder="021xxxxxxxx" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">ایمیل</label>
|
||||
<input {...register('email')} type="email" className="cp-input h-10" dir="ltr" placeholder="city@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">دامنه</label>
|
||||
<input {...register('domain')} className="cp-input h-10" dir="ltr" placeholder="city.example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">شعار (slogan)</label>
|
||||
<input {...register('slogan')} className="cp-input h-10" placeholder="شعار شهر" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">کلیدواژهها</label>
|
||||
<input {...register('keywords')} className="cp-input h-10" placeholder="کلیدواژه، کلیدواژه، ..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">توضیحات</label>
|
||||
<textarea {...register('description')} rows={3} className="cp-textarea" placeholder="توضیحات شهر" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">توضیحات فوتر</label>
|
||||
<textarea {...register('footer_description')} rows={2} className="cp-textarea" placeholder="متن فوتر شهر" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">سلب مسئولیت فوتر</label>
|
||||
<textarea {...register('footer_disclaimer')} rows={2} className="cp-textarea" placeholder="متن سلب مسئولیت" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Weight */}
|
||||
{tab.hasWeight && (
|
||||
<div>
|
||||
<label className="cp-label">ترتیب نمایش (weight)</label>
|
||||
<input {...register('weight')} type="number" dir="ltr" className="cp-input h-11 w-32" placeholder="0" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="cp-label">وضعیت</label>
|
||||
<Controller
|
||||
name="status"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SearchableSelect
|
||||
options={[
|
||||
{ value: '1', label: 'فعال' },
|
||||
{ value: '0', label: 'غیرفعال' },
|
||||
]}
|
||||
value={field.value ?? '1'}
|
||||
onChange={(val) => field.onChange(val ?? '1')}
|
||||
isClearable={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const qc = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState<CategoryBundle>('state');
|
||||
const [search, setSearch] = useState('');
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<Category | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Category | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<CategoryBundle>('state');
|
||||
const [search, setSearch] = useState('');
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<Category | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Category | null>(null);
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
|
||||
const tab = TABS.find((t) => t.key === activeTab)!;
|
||||
|
||||
// Main list
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['categories', activeTab],
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<{ data: Category[] }>>(`/api/v1/categorys/${activeTab}`),
|
||||
queryFn: () => api.get<ApiResponse<{ data: Category[] }>>(`/api/v1/categorys/${activeTab}`),
|
||||
});
|
||||
|
||||
const { register, handleSubmit, reset, setValue, formState: { errors, isSubmitting } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
// Parent list (state for city, specially_doctor for sub-specialties)
|
||||
const parentBundle = tab.hasParent ? tab.parentBundle : undefined;
|
||||
const parentQuery = useBundleList(parentBundle);
|
||||
const parentRaw: Category[] = (parentQuery.data?.data as any)?.data
|
||||
?? parentQuery.data?.data?.data
|
||||
?? [];
|
||||
const parentOptions = parentRaw.map((p) => ({ value: p.id, label: p.label }));
|
||||
|
||||
// Build parent ID → label map for table display
|
||||
const parentMap = React.useMemo(
|
||||
() => Object.fromEntries(parentRaw.map((p) => [p.id, p.label])),
|
||||
[parentRaw],
|
||||
);
|
||||
|
||||
// Representations list (only needed for city tab)
|
||||
const representationsQuery = useQuery({
|
||||
queryKey: ['representations', 'for-select'],
|
||||
queryFn: () =>
|
||||
api.get<PaginatedResponse<Representation>>('/api/v1/admin/representations?limit=200'),
|
||||
enabled: activeTab === 'city',
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
const representationOptions = (representationsQuery.data?.data ?? []).map((r) => ({
|
||||
value: r.id,
|
||||
label: r.full_name + (r.mobile_number ? ` — ${r.mobile_number}` : ''),
|
||||
}));
|
||||
const representationMap = React.useMemo(
|
||||
() => Object.fromEntries(
|
||||
(representationsQuery.data?.data ?? []).map((r) => [r.id, r.full_name])
|
||||
),
|
||||
[representationsQuery.data],
|
||||
);
|
||||
|
||||
const { register, handleSubmit, reset, control, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(baseSchema),
|
||||
defaultValues: { status: '1', weight: '0' },
|
||||
});
|
||||
|
||||
const closeAdd = () => {
|
||||
setAddOpen(false);
|
||||
setLogoUrl(null);
|
||||
reset({ status: '1', weight: '0' });
|
||||
};
|
||||
|
||||
const closeEdit = () => {
|
||||
setEditTarget(null);
|
||||
setLogoUrl(null);
|
||||
reset({ status: '1', weight: '0' });
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (d: FormData) =>
|
||||
api.post<ApiResponse<Category>>('/api/v1/category', {
|
||||
label: d.label,
|
||||
bundle: activeTab,
|
||||
...(d.parent_id ? { parent_id: parseInt(d.parent_id) } : {}),
|
||||
}),
|
||||
api.post<ApiResponse<Category>>('/api/v1/category', buildPayload(d, activeTab, logoUrl)),
|
||||
onSuccess: () => {
|
||||
toast.success('دستهبندی اضافه شد');
|
||||
setAddOpen(false);
|
||||
reset();
|
||||
closeAdd();
|
||||
qc.invalidateQueries({ queryKey: ['categories', activeTab] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
@@ -65,11 +462,10 @@ export default function CategoriesPage() {
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, d }: { id: number; d: FormData }) =>
|
||||
api.patch<ApiResponse<Category>>(`/api/v1/category/${id}`, { label: d.label }),
|
||||
api.patch<ApiResponse<Category>>(`/api/v1/category/${id}`, buildPayload(d, activeTab, logoUrl)),
|
||||
onSuccess: () => {
|
||||
toast.success('دستهبندی بروزرسانی شد');
|
||||
setEditTarget(null);
|
||||
reset();
|
||||
closeEdit();
|
||||
qc.invalidateQueries({ queryKey: ['categories', activeTab] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
@@ -87,86 +483,215 @@ export default function CategoriesPage() {
|
||||
|
||||
const openEdit = (c: Category) => {
|
||||
setEditTarget(c);
|
||||
setValue('label', c.label);
|
||||
setLogoUrl(c.logo_url ?? null);
|
||||
reset({
|
||||
label: c.label ?? '',
|
||||
status: String(c.status ?? 1),
|
||||
weight: String(c.weight ?? 0),
|
||||
parent_id: c.parent_id ?? null,
|
||||
representation_id: c.representation_id ?? null,
|
||||
title: c.title ?? '',
|
||||
contact_phone: c.contact_phone ?? '',
|
||||
email: c.email ?? '',
|
||||
description: c.description ?? '',
|
||||
slogan: c.slogan ?? '',
|
||||
domain: c.domain ?? '',
|
||||
keywords: c.keywords ?? '',
|
||||
footer_description: c.footer_description ?? '',
|
||||
footer_disclaimer: c.footer_disclaimer ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
const allItems = data?.data?.data ?? [];
|
||||
const handleTabChange = (key: CategoryBundle) => {
|
||||
setActiveTab(key);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const allItems: Category[] = (data?.data as any)?.data ?? (data?.data as any) ?? [];
|
||||
const filtered = search
|
||||
? allItems.filter((c) => c.label.includes(search))
|
||||
? allItems.filter((c) => (c.label ?? '').toLowerCase().includes(search.toLowerCase()))
|
||||
: allItems;
|
||||
|
||||
const columns: Column<Category>[] = [
|
||||
{ key: 'label', header: 'نام', render: (c) => <span className="font-medium">{c.label}</span> },
|
||||
// ── Per-bundle columns ──────────────────────────────────────────────────
|
||||
const baseColumns: Column<Category>[] = [
|
||||
{
|
||||
key: 'bundle',
|
||||
header: 'نوع',
|
||||
render: (c) => (
|
||||
<span className="text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 px-2 py-0.5 rounded-full">{c.bundle}</span>
|
||||
),
|
||||
key: 'id',
|
||||
header: 'شناسه',
|
||||
className: 'w-16',
|
||||
render: (c) => <span className="text-xs text-slate-400 dark:text-slate-500 font-mono">{c.id}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
key: 'label',
|
||||
header: 'نام',
|
||||
render: (c) => (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
c.status === 1 ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{c.status === 1 ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{tab.hasLogo && c.logo_url && (
|
||||
<img src={c.logo_url} alt={c.label ?? ''} className="w-8 h-8 object-contain rounded-lg border border-slate-200 dark:border-gray-600 bg-white dark:bg-gray-800 p-0.5 shrink-0" />
|
||||
)}
|
||||
{tab.hasLogo && !c.logo_url && (
|
||||
<div className="w-8 h-8 rounded-lg border border-dashed border-slate-200 dark:border-gray-600 flex items-center justify-center shrink-0">
|
||||
<PhotoIcon className="w-4 h-4 text-slate-300 dark:text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium text-slate-800 dark:text-slate-100">{c.label}</span>
|
||||
{c.title && c.title !== c.label && (
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 truncate max-w-[160px]">{c.title}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleTabChange = (tab: CategoryBundle) => {
|
||||
setActiveTab(tab);
|
||||
setSearch('');
|
||||
const parentCol: Column<Category> = {
|
||||
key: 'parent_id',
|
||||
header: tab.parentLabel ?? 'والد',
|
||||
render: (c) => c.parent_id
|
||||
? <span className="text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 px-2 py-0.5 rounded-full">{parentMap[c.parent_id] ?? `#${c.parent_id}`}</span>
|
||||
: <span className="text-slate-300 dark:text-slate-600">—</span>,
|
||||
};
|
||||
|
||||
const representationCol: Column<Category> = {
|
||||
key: 'representation_id',
|
||||
header: 'نماینده',
|
||||
render: (c) => c.representation_id
|
||||
? <span className="text-xs text-slate-600 dark:text-slate-300">{representationMap[c.representation_id] ?? `#${c.representation_id}`}</span>
|
||||
: <span className="text-slate-300 dark:text-slate-600">—</span>,
|
||||
};
|
||||
|
||||
const cityExtraCols: Column<Category>[] = [
|
||||
{
|
||||
key: 'domain',
|
||||
header: 'دامنه',
|
||||
render: (c) => c.domain
|
||||
? <span className="text-xs font-mono text-slate-500 dark:text-slate-400">{c.domain}</span>
|
||||
: <span className="text-slate-300 dark:text-slate-600">—</span>,
|
||||
},
|
||||
{
|
||||
key: 'contact_phone',
|
||||
header: 'تلفن',
|
||||
render: (c) => c.contact_phone
|
||||
? <span className="text-xs font-mono" dir="ltr">{c.contact_phone}</span>
|
||||
: <span className="text-slate-300 dark:text-slate-600">—</span>,
|
||||
},
|
||||
];
|
||||
|
||||
const weightCol: Column<Category> = {
|
||||
key: 'weight',
|
||||
header: 'ترتیب',
|
||||
className: 'w-20',
|
||||
render: (c) => <span className="text-xs text-slate-400 dark:text-slate-500">{c.weight}</span>,
|
||||
};
|
||||
|
||||
const statusCol: Column<Category> = {
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
className: 'w-24',
|
||||
render: (c) => c.status === 1
|
||||
? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-400/10 px-2 py-0.5 rounded-full">
|
||||
<CheckCircleIcon className="w-3 h-3" /> فعال
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-400/10 px-2 py-0.5 rounded-full">
|
||||
<XCircleIcon className="w-3 h-3" /> غیرفعال
|
||||
</span>
|
||||
),
|
||||
};
|
||||
|
||||
let columns: Column<Category>[] = [...baseColumns];
|
||||
if (tab.hasParent) columns = [...columns, parentCol];
|
||||
if (tab.key === 'city') columns = [...columns, representationCol, ...cityExtraCols];
|
||||
if (tab.hasWeight) columns = [...columns, weightCol];
|
||||
columns = [...columns, statusCol];
|
||||
|
||||
const modalSize = tab.hasCityFields ? 'lg' : 'sm';
|
||||
const formSharedProps = {
|
||||
register, control, errors, tab,
|
||||
parentOptions,
|
||||
representationOptions,
|
||||
parentLoading: parentQuery.isLoading,
|
||||
representationLoading: representationsQuery.isLoading,
|
||||
logoUrl,
|
||||
onLogoChange: setLogoUrl,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="دستهبندیها"
|
||||
description={tab.description}
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'دستهبندیها' }]}
|
||||
action={
|
||||
<button onClick={() => setAddOpen(true)}
|
||||
className="cp-btn-primary">
|
||||
<button onClick={() => { reset({ status: '1', weight: '0' }); setLogoUrl(null); setAddOpen(true); }} className="cp-btn-primary">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
افزودن
|
||||
افزودن {tab.label.slice(0, -1) || tab.label}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="cp-card">
|
||||
<div className="flex flex-wrap border-b border-slate-200 dark:border-gray-700 px-6 pt-4 gap-1">
|
||||
{TABS.map((t) => (
|
||||
<button key={t.key} onClick={() => handleTabChange(t.key)}
|
||||
className={`pb-3 px-4 text-sm font-medium border-b-2 transition-colors -mb-px ${
|
||||
activeTab === t.key
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'
|
||||
}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="cp-card overflow-hidden">
|
||||
{/* ── Tab bar ── */}
|
||||
<div className="flex overflow-x-auto border-b border-slate-200 dark:border-gray-700 px-2 pt-2 gap-1 scrollbar-none">
|
||||
{TABS.map((t) => {
|
||||
const Icon = t.icon;
|
||||
const isActive = t.key === activeTab;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => handleTabChange(t.key)}
|
||||
className={`flex items-center gap-2 px-3.5 py-2.5 text-sm font-medium rounded-t-xl whitespace-nowrap border-b-2 -mb-px transition-all ${
|
||||
isActive
|
||||
? 'border-primary-600 text-primary-600 dark:text-primary-400 bg-primary-50/50 dark:bg-primary-400/5'
|
||||
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-50 dark:hover:bg-gray-800/50'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
{t.label}
|
||||
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-100 dark:bg-primary-400/20 text-primary-700 dark:text-primary-300'
|
||||
: 'bg-slate-100 dark:bg-gray-700 text-slate-500 dark:text-slate-400'
|
||||
}`}>
|
||||
{isLoading ? '…' : allItems.length}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* ── Bundle info bar ── */}
|
||||
<div className="flex items-center gap-3 px-6 py-3 border-b border-slate-100 dark:border-gray-700/50 bg-slate-50/50 dark:bg-gray-800/30">
|
||||
<div className={`w-7 h-7 rounded-lg flex items-center justify-center ${tab.color.split(' ').slice(0, 2).join(' ')} bg-opacity-10`}>
|
||||
<tab.icon className={`w-4 h-4 ${tab.color.split(' ')[0]}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">{tab.label}</p>
|
||||
<p className="text-[11px] text-slate-400 dark:text-slate-500">{tab.description}</p>
|
||||
</div>
|
||||
<div className="mr-auto flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span className="font-mono bg-slate-100 dark:bg-gray-700 px-2 py-0.5 rounded text-[11px]">{activeTab}</span>
|
||||
{!isLoading && <span>{allItems.length} مورد</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Table ── */}
|
||||
<div className="p-5">
|
||||
<DataTable<Category>
|
||||
columns={columns}
|
||||
data={filtered}
|
||||
loading={isLoading}
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="جستجو..."
|
||||
emptyMessage="هیچ موردی یافت نشد"
|
||||
searchPlaceholder={`جستجو در ${tab.label}...`}
|
||||
emptyMessage={`هیچ ${tab.label.slice(0, -1) || tab.label}ی یافت نشد`}
|
||||
actions={(cat) => (
|
||||
<>
|
||||
<button onClick={() => openEdit(cat)}
|
||||
className="cp-action-edit" title="ویرایش">
|
||||
<button onClick={() => openEdit(cat)} className="cp-action-edit" title="ویرایش">
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setDeleteTarget(cat)}
|
||||
className="cp-action-delete" title="حذف">
|
||||
<button onClick={() => setDeleteTarget(cat)} className="cp-action-delete" title="حذف">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
@@ -175,71 +700,53 @@ export default function CategoriesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Modal */}
|
||||
<Modal open={addOpen} title={`افزودن — ${TABS.find((t) => t.key === activeTab)?.label}`}
|
||||
onClose={() => { setAddOpen(false); reset(); }}
|
||||
{/* ── Add Modal ── */}
|
||||
<Modal
|
||||
open={addOpen}
|
||||
title={`افزودن — ${tab.label}`}
|
||||
size={modalSize}
|
||||
onClose={closeAdd}
|
||||
footer={
|
||||
<>
|
||||
<button onClick={() => { setAddOpen(false); reset(); }}
|
||||
className="cp-btn-secondary">
|
||||
لغو
|
||||
</button>
|
||||
<button form="cat-form" type="submit" disabled={isSubmitting}
|
||||
className="cp-btn-primary">
|
||||
ذخیره
|
||||
<button onClick={closeAdd} className="cp-btn-secondary">لغو</button>
|
||||
<button form="cat-add-form" type="submit" disabled={createMutation.isPending} className="cp-btn-primary">
|
||||
{createMutation.isPending ? 'در حال ذخیره...' : 'افزودن'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="cat-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))} className="space-y-4">
|
||||
<div>
|
||||
<label className="cp-label">نام</label>
|
||||
<input {...register('label')}
|
||||
className="cp-input h-11" />
|
||||
{errors.label && <p className="text-red-500 text-xs mt-1">{errors.label.message}</p>}
|
||||
</div>
|
||||
{activeTab === 'city' && (
|
||||
<div>
|
||||
<label className="cp-label">شناسه استان</label>
|
||||
<input {...register('parent_id')} dir="ltr" placeholder="ID عددی استان"
|
||||
className="cp-input h-11" />
|
||||
</div>
|
||||
)}
|
||||
<form id="cat-add-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))}>
|
||||
<CategoryFormFields {...formSharedProps} />
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal open={!!editTarget} title="ویرایش دستهبندی"
|
||||
onClose={() => { setEditTarget(null); reset(); }}
|
||||
{/* ── Edit Modal ── */}
|
||||
<Modal
|
||||
open={!!editTarget}
|
||||
title={`ویرایش — ${editTarget?.label ?? ''}`}
|
||||
size={modalSize}
|
||||
onClose={closeEdit}
|
||||
footer={
|
||||
<>
|
||||
<button onClick={() => { setEditTarget(null); reset(); }}
|
||||
className="cp-btn-secondary">
|
||||
لغو
|
||||
</button>
|
||||
<button form="edit-cat-form" type="submit" disabled={isSubmitting}
|
||||
className="cp-btn-primary">
|
||||
ذخیره
|
||||
<button onClick={closeEdit} className="cp-btn-secondary">لغو</button>
|
||||
<button form="cat-edit-form" type="submit" disabled={updateMutation.isPending} className="cp-btn-primary">
|
||||
{updateMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="edit-cat-form"
|
||||
<form
|
||||
id="cat-edit-form"
|
||||
onSubmit={handleSubmit((d) => editTarget && updateMutation.mutate({ id: editTarget.id, d }))}
|
||||
className="space-y-4">
|
||||
<div>
|
||||
<label className="cp-label">نام</label>
|
||||
<input {...register('label')}
|
||||
className="cp-input h-11" />
|
||||
{errors.label && <p className="text-red-500 text-xs mt-1">{errors.label.message}</p>}
|
||||
</div>
|
||||
>
|
||||
<CategoryFormFields {...formSharedProps} />
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف دستهبندی"
|
||||
message={`آیا از حذف "${deleteTarget?.label}" اطمینان دارید؟`}
|
||||
message={`آیا از حذف "${deleteTarget?.label}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={deleteMutation.isPending}
|
||||
@@ -249,3 +756,35 @@ export default function CategoriesPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Build API payload from form data ───────────────────────────────────────
|
||||
|
||||
function buildPayload(d: FormData, bundle: CategoryBundle, logoUrl: string | null): Record<string, unknown> {
|
||||
const base: Record<string, unknown> = {
|
||||
label: d.label.trim(),
|
||||
bundle,
|
||||
status: d.status !== undefined ? parseInt(d.status) : 1,
|
||||
};
|
||||
|
||||
if (d.weight !== undefined && d.weight !== '') base.weight = parseInt(d.weight);
|
||||
if (d.parent_id != null) base.parent_id = d.parent_id;
|
||||
if (d.title?.trim()) base.title = d.title.trim();
|
||||
|
||||
if (bundle === 'insurance_type' || bundle === 'supplementary_insurance') {
|
||||
base.logo_url = logoUrl;
|
||||
}
|
||||
|
||||
if (bundle === 'city') {
|
||||
base.representation_id = d.representation_id ?? null;
|
||||
if (d.contact_phone?.trim()) base.contact_phone = d.contact_phone.trim();
|
||||
if (d.email?.trim()) base.email = d.email.trim();
|
||||
if (d.description?.trim()) base.description = d.description.trim();
|
||||
if (d.slogan?.trim()) base.slogan = d.slogan.trim();
|
||||
if (d.domain?.trim()) base.domain = d.domain.trim();
|
||||
if (d.keywords?.trim()) base.keywords = d.keywords.trim();
|
||||
if (d.footer_description?.trim()) base.footer_description = d.footer_description.trim();
|
||||
if (d.footer_disclaimer?.trim()) base.footer_disclaimer = d.footer_disclaimer.trim();
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { EyeIcon, TrashIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
@@ -16,11 +16,12 @@ import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
|
||||
const schema = z.object({
|
||||
full_name: z.string().min(2, 'نام الزامی است'),
|
||||
mobile_number: z.string().min(10, 'شماره موبایل معتبر نیست'),
|
||||
city_id: z.coerce.number().nullable().optional(),
|
||||
full_name: z.string().min(2, 'نام الزامی است'),
|
||||
mobile_number: z.string().min(10, 'شماره موبایل معتبر نیست'),
|
||||
city_id: z.number().nullable().optional(),
|
||||
commission_percent: z.coerce.number().min(0).max(100),
|
||||
});
|
||||
type FormData = z.infer<typeof schema>;
|
||||
@@ -30,7 +31,7 @@ export default function RepresentationsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [cityFilter, setCityFilter] = useState('');
|
||||
const [cityFilter, setCityFilter] = useState<number | null>(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Representation | null>(null);
|
||||
const limit = 15;
|
||||
@@ -42,18 +43,19 @@ export default function RepresentationsPage() {
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const cities: Category[] = (citiesQuery.data?.data as any)?.data ?? [];
|
||||
const cityOptions = cities.map((c) => ({ value: c.id, label: c.label }));
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['representations', page, search, cityFilter],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (search) params.set('search', search);
|
||||
if (cityFilter) params.set('city_id', cityFilter);
|
||||
if (cityFilter !== null) params.set('city_id', String(cityFilter));
|
||||
return api.get<PaginatedResponse<Representation>>(`/api/v1/admin/representations?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm<FormData>({
|
||||
const { register, handleSubmit, reset, control, formState: { errors, isSubmitting } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { commission_percent: 10 },
|
||||
});
|
||||
@@ -63,7 +65,7 @@ export default function RepresentationsPage() {
|
||||
api.post<ApiResponse<Representation>>('/api/v1/representation', {
|
||||
full_name: d.full_name,
|
||||
mobile_number: d.mobile_number,
|
||||
city_id: d.city_id || null,
|
||||
city_id: d.city_id ?? null,
|
||||
commission_percent: d.commission_percent,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
@@ -112,8 +114,7 @@ export default function RepresentationsPage() {
|
||||
title="نمایندگان"
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'نمایندگان' }]}
|
||||
action={
|
||||
<button onClick={() => setAddOpen(true)}
|
||||
className="cp-btn-primary">
|
||||
<button onClick={() => setAddOpen(true)} className="cp-btn-primary">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
افزودن نماینده
|
||||
</button>
|
||||
@@ -123,19 +124,20 @@ export default function RepresentationsPage() {
|
||||
<div className="cp-card p-6">
|
||||
{/* City filter */}
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<select
|
||||
value={cityFilter}
|
||||
onChange={(e) => { setCityFilter(e.target.value); setPage(1); }}
|
||||
className="cp-input min-w-[160px]"
|
||||
>
|
||||
<option value="">همه شهرها</option>
|
||||
{cities.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{cityFilter && (
|
||||
<div className="min-w-[220px]">
|
||||
<SearchableSelect
|
||||
options={cityOptions}
|
||||
value={cityFilter}
|
||||
onChange={(val) => { setCityFilter(val as number | null); setPage(1); }}
|
||||
placeholder="فیلتر بر اساس شهر..."
|
||||
isClearable
|
||||
isLoading={citiesQuery.isLoading}
|
||||
noOptionsMessage="هیچ شهری یافت نشد"
|
||||
/>
|
||||
</div>
|
||||
{cityFilter !== null && (
|
||||
<button
|
||||
onClick={() => { setCityFilter(''); setPage(1); }}
|
||||
onClick={() => { setCityFilter(null); setPage(1); }}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
پاک کردن فیلتر
|
||||
@@ -170,12 +172,10 @@ export default function RepresentationsPage() {
|
||||
<Modal open={addOpen} title="افزودن نماینده" onClose={() => { setAddOpen(false); reset(); }}
|
||||
footer={
|
||||
<>
|
||||
<button onClick={() => { setAddOpen(false); reset(); }}
|
||||
className="cp-btn-secondary">
|
||||
<button onClick={() => { setAddOpen(false); reset(); }} className="cp-btn-secondary">
|
||||
لغو
|
||||
</button>
|
||||
<button form="add-rep-form" type="submit" disabled={isSubmitting}
|
||||
className="cp-btn-primary">
|
||||
<button form="add-rep-form" type="submit" disabled={isSubmitting} className="cp-btn-primary">
|
||||
{isSubmitting ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
</>
|
||||
@@ -184,25 +184,31 @@ export default function RepresentationsPage() {
|
||||
<form id="add-rep-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))} className="space-y-4">
|
||||
<div>
|
||||
<label className="cp-label">نام کامل</label>
|
||||
<input {...register('full_name')} placeholder="علی محمدی"
|
||||
className="cp-input h-11" />
|
||||
<input {...register('full_name')} placeholder="علی محمدی" className="cp-input h-11" />
|
||||
{errors.full_name && <p className="text-red-500 text-xs mt-1">{errors.full_name.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">شماره موبایل</label>
|
||||
<input {...register('mobile_number')} dir="ltr" placeholder="09xxxxxxxxx"
|
||||
className="cp-input h-11" />
|
||||
<input {...register('mobile_number')} dir="ltr" placeholder="09xxxxxxxxx" className="cp-input h-11" />
|
||||
{errors.mobile_number && <p className="text-red-500 text-xs mt-1">{errors.mobile_number.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">شهر</label>
|
||||
<select {...register('city_id')}
|
||||
className="cp-input h-11">
|
||||
<option value="">انتخاب شهر</option>
|
||||
{cities.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<Controller
|
||||
name="city_id"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SearchableSelect
|
||||
options={cityOptions}
|
||||
value={field.value ?? null}
|
||||
onChange={(val) => field.onChange(val as number | null)}
|
||||
placeholder="انتخاب شهر..."
|
||||
isClearable
|
||||
isLoading={citiesQuery.isLoading}
|
||||
noOptionsMessage="هیچ شهری یافت نشد"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">درصد کمیسیون</label>
|
||||
|
||||
@@ -105,6 +105,7 @@ export interface Settlement {
|
||||
}
|
||||
|
||||
export interface Representation {
|
||||
id: number;
|
||||
uuid: string;
|
||||
full_name: string;
|
||||
domain?: string;
|
||||
@@ -180,6 +181,22 @@ export interface Category {
|
||||
bundle: CategoryBundle;
|
||||
status: number;
|
||||
weight: number;
|
||||
parent_id?: number | null;
|
||||
title?: string | null;
|
||||
logo_id?: number | null;
|
||||
// insurance-specific
|
||||
logo_url?: string | null;
|
||||
// city-specific
|
||||
representation_id?: number | null;
|
||||
contact_phone?: string | null;
|
||||
email?: string | null;
|
||||
description?: string | null;
|
||||
slogan?: string | null;
|
||||
domain?: string | null;
|
||||
keywords?: string | null;
|
||||
footer_description?: string | null;
|
||||
footer_disclaimer?: string | null;
|
||||
social_media?: Record<string, string> | null;
|
||||
}
|
||||
|
||||
export interface Blog {
|
||||
|
||||
@@ -73,3 +73,7 @@ services:
|
||||
App\Blog\Controller\BlogController:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Category\Controller\CategoryController:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260610092558 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE categories ADD logo_url VARCHAR(500) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE categories DROP logo_url');
|
||||
}
|
||||
}
|
||||
@@ -340,7 +340,7 @@ class AdminApiController extends BaseController
|
||||
$cityId = $request->query->get('city_id');
|
||||
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select('r.uuid, r.fullName, r.mobileNumber, r.cityId, r.commissionPercent, r.active, r.createdAt, c.label as city_name')
|
||||
->select('r.id, r.uuid, r.fullName, r.mobileNumber, r.cityId, r.commissionPercent, r.active, r.createdAt, c.label as city_name')
|
||||
->from(Representation::class, 'r')
|
||||
->leftJoin(Category::class, 'c', 'WITH', 'c.id = r.cityId')
|
||||
->orderBy('r.createdAt', 'DESC');
|
||||
@@ -361,6 +361,7 @@ class AdminApiController extends BaseController
|
||||
->getQuery()->getArrayResult();
|
||||
|
||||
$items = array_map(fn(array $r) => [
|
||||
'id' => (int) $r['id'],
|
||||
'uuid' => $r['uuid'],
|
||||
'domain' => $r['fullName'],
|
||||
'full_name' => $r['fullName'],
|
||||
|
||||
@@ -7,16 +7,20 @@ use App\Category\Repository\CategoryRepository;
|
||||
use App\Category\Service\CategoryService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Service\FileValidatorService;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
class CategoryController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CategoryRepository $repository,
|
||||
private readonly CategoryService $service,
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/categorys/tag', methods: ['GET'])]
|
||||
@@ -120,4 +124,42 @@ class CategoryController extends BaseController
|
||||
|
||||
return $this->success(['message' => 'دستهبندی با موفقیت حذف شد']);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/category/upload-logo', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function uploadLogo(Request $request): JsonResponse
|
||||
{
|
||||
$content = $request->getContent();
|
||||
$disposition = $request->headers->get('Content-Disposition', '');
|
||||
preg_match('/filename=["\']?([^"\';\s]+)["\']?/i', $disposition, $m);
|
||||
$filename = $m[1] ?? 'logo.jpg';
|
||||
|
||||
$tmpPath = sys_get_temp_dir() . '/' . uniqid('upload_', true);
|
||||
file_put_contents($tmpPath, $content);
|
||||
|
||||
try {
|
||||
$safeFilename = $this->fileValidator->sanitizeFilename($filename);
|
||||
$mime = $this->fileValidator->detectMimeType($tmpPath);
|
||||
|
||||
$year = date('Y'); $month = date('m');
|
||||
$dir = $this->projectDir . '/public/uploads/categories/logo/' . $year . '-' . $month;
|
||||
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||
|
||||
$storedName = uniqid('', true) . '_' . $safeFilename;
|
||||
rename($tmpPath, $dir . '/' . $storedName);
|
||||
|
||||
$url = '/uploads/categories/logo/' . $year . '-' . $month . '/' . $storedName;
|
||||
|
||||
return $this->success([
|
||||
'url' => $url,
|
||||
'uuid' => Uuid::v4()->toRfc4122(),
|
||||
'filename' => $safeFilename,
|
||||
'filemime' => $mime,
|
||||
'filesize' => strlen($content),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
if (file_exists($tmpPath)) unlink($tmpPath);
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,9 @@ class Category
|
||||
#[ORM\Column(name: 'social_media', type: 'json', nullable: true)]
|
||||
private ?array $socialMedia = null;
|
||||
|
||||
#[ORM\Column(name: 'logo_url', type: 'string', length: 500, nullable: true)]
|
||||
private ?string $logoUrl = null;
|
||||
|
||||
public function __construct(string $bundle, string $label)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
@@ -103,6 +106,7 @@ class Category
|
||||
public function getFooterDescription(): ?string { return $this->footerDescription; }
|
||||
public function getFooterDisclaimer(): ?string { return $this->footerDisclaimer; }
|
||||
public function getSocialMedia(): ?array { return $this->socialMedia; }
|
||||
public function getLogoUrl(): ?string { return $this->logoUrl; }
|
||||
|
||||
public function setBundle(string $bundle): self { $this->bundle = $bundle; return $this; }
|
||||
public function setLabel(?string $label): self { $this->label = $label; return $this; }
|
||||
@@ -121,6 +125,7 @@ class Category
|
||||
public function setFooterDescription(?string $v): self { $this->footerDescription = $v; return $this; }
|
||||
public function setFooterDisclaimer(?string $v): self { $this->footerDisclaimer = $v; return $this; }
|
||||
public function setSocialMedia(?array $v): self { $this->socialMedia = $v; return $this; }
|
||||
public function setLogoUrl(?string $v): self { $this->logoUrl = $v; return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
@@ -139,7 +144,12 @@ class Category
|
||||
if ($this->title !== null) {
|
||||
$data['title'] = $this->title;
|
||||
}
|
||||
if (in_array($this->bundle, ['insurance_type', 'supplementary_insurance'], true)) {
|
||||
$data['logo_url'] = $this->logoUrl;
|
||||
}
|
||||
|
||||
if ($this->bundle === 'city') {
|
||||
$data['representation_id'] = $this->representationId;
|
||||
$data['contact_phone'] = $this->contactPhone;
|
||||
$data['email'] = $this->email;
|
||||
$data['description'] = $this->description;
|
||||
|
||||
@@ -84,6 +84,8 @@ class CategoryService
|
||||
if (array_key_exists('footer_description', $data)) $category->setFooterDescription($data['footer_description']);
|
||||
if (array_key_exists('footer_disclaimer', $data)) $category->setFooterDisclaimer($data['footer_disclaimer']);
|
||||
if (array_key_exists('social_media', $data)) $category->setSocialMedia($data['social_media']);
|
||||
if (array_key_exists('representation_id', $data)) $category->setRepresentationId($data['representation_id'] !== null ? (int) $data['representation_id'] : null);
|
||||
if (array_key_exists('logo_url', $data)) $category->setLogoUrl($data['logo_url']);
|
||||
}
|
||||
|
||||
private function invalidate(string $bundle): void
|
||||
|
||||
Reference in New Issue
Block a user