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:
hamed
2026-06-10 13:02:47 +03:30
parent 942634c98e
commit 06ab875693
10 changed files with 931 additions and 158 deletions
@@ -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}
/>
);
}
+659 -120
View File
@@ -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;
}
+43 -37
View File
@@ -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>
+17
View File
@@ -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 {
+4
View File
@@ -73,3 +73,7 @@ services:
App\Blog\Controller\BlogController:
arguments:
$projectDir: '%kernel.project_dir%'
App\Category\Controller\CategoryController:
arguments:
$projectDir: '%kernel.project_dir%'
+31
View File
@@ -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');
}
}
+2 -1
View File
@@ -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);
}
}
}
+10
View File
@@ -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;
+2
View File
@@ -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