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 React, { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { TrashIcon, PlusIcon, PencilIcon } from '@heroicons/react/24/outline';
|
import {
|
||||||
import { useForm } from 'react-hook-form';
|
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 { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import type { ApiResponse } from '../lib/api';
|
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||||
import type { Category, CategoryBundle } from '../types';
|
import type { Category, CategoryBundle, Representation } from '../types';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
import DataTable, { Column } from '../components/ui/DataTable';
|
import DataTable, { Column } from '../components/ui/DataTable';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
import Modal from '../components/ui/Modal';
|
import Modal from '../components/ui/Modal';
|
||||||
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
|
|
||||||
const TABS: { key: CategoryBundle; label: string }[] = [
|
// ── Tab config ──────────────────────────────────────────────────────────────
|
||||||
{ key: 'state', label: 'استانها' },
|
|
||||||
{ key: 'city', label: 'شهرها' },
|
interface TabConfig {
|
||||||
{ key: 'specially_doctor', label: 'تخصصها' },
|
key: CategoryBundle;
|
||||||
{ key: 'doctor_services', label: 'خدمات پزشک' },
|
label: string;
|
||||||
{ key: 'insurance_type', label: 'نوع بیمه' },
|
icon: React.ElementType;
|
||||||
{ key: 'supplementary_insurance', label: 'بیمه تکمیلی' },
|
color: string;
|
||||||
{ key: 'tag', label: 'تگهای بلاگ' },
|
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({
|
// ── Zod schema ──────────────────────────────────────────────────────────────
|
||||||
label: z.string().min(1, 'نام الزامی است'),
|
|
||||||
parent_id: z.string().optional(),
|
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() {
|
export default function CategoriesPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [activeTab, setActiveTab] = useState<CategoryBundle>('state');
|
const [activeTab, setActiveTab] = useState<CategoryBundle>('state');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
const [editTarget, setEditTarget] = useState<Category | null>(null);
|
const [editTarget, setEditTarget] = useState<Category | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = 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({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['categories', activeTab],
|
queryKey: ['categories', activeTab],
|
||||||
queryFn: () =>
|
queryFn: () => api.get<ApiResponse<{ data: Category[] }>>(`/api/v1/categorys/${activeTab}`),
|
||||||
api.get<ApiResponse<{ data: Category[] }>>(`/api/v1/categorys/${activeTab}`),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { register, handleSubmit, reset, setValue, formState: { errors, isSubmitting } } = useForm<FormData>({
|
// Parent list (state for city, specially_doctor for sub-specialties)
|
||||||
resolver: zodResolver(schema),
|
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({
|
const createMutation = useMutation({
|
||||||
mutationFn: (d: FormData) =>
|
mutationFn: (d: FormData) =>
|
||||||
api.post<ApiResponse<Category>>('/api/v1/category', {
|
api.post<ApiResponse<Category>>('/api/v1/category', buildPayload(d, activeTab, logoUrl)),
|
||||||
label: d.label,
|
|
||||||
bundle: activeTab,
|
|
||||||
...(d.parent_id ? { parent_id: parseInt(d.parent_id) } : {}),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('دستهبندی اضافه شد');
|
toast.success('دستهبندی اضافه شد');
|
||||||
setAddOpen(false);
|
closeAdd();
|
||||||
reset();
|
|
||||||
qc.invalidateQueries({ queryKey: ['categories', activeTab] });
|
qc.invalidateQueries({ queryKey: ['categories', activeTab] });
|
||||||
},
|
},
|
||||||
onError: (err: Error) => toast.error(err.message),
|
onError: (err: Error) => toast.error(err.message),
|
||||||
@@ -65,11 +462,10 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ id, d }: { id: number; d: FormData }) =>
|
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: () => {
|
onSuccess: () => {
|
||||||
toast.success('دستهبندی بروزرسانی شد');
|
toast.success('دستهبندی بروزرسانی شد');
|
||||||
setEditTarget(null);
|
closeEdit();
|
||||||
reset();
|
|
||||||
qc.invalidateQueries({ queryKey: ['categories', activeTab] });
|
qc.invalidateQueries({ queryKey: ['categories', activeTab] });
|
||||||
},
|
},
|
||||||
onError: (err: Error) => toast.error(err.message),
|
onError: (err: Error) => toast.error(err.message),
|
||||||
@@ -87,86 +483,215 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
const openEdit = (c: Category) => {
|
const openEdit = (c: Category) => {
|
||||||
setEditTarget(c);
|
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
|
const filtered = search
|
||||||
? allItems.filter((c) => c.label.includes(search))
|
? allItems.filter((c) => (c.label ?? '').toLowerCase().includes(search.toLowerCase()))
|
||||||
: allItems;
|
: allItems;
|
||||||
|
|
||||||
const columns: Column<Category>[] = [
|
// ── Per-bundle columns ──────────────────────────────────────────────────
|
||||||
{ key: 'label', header: 'نام', render: (c) => <span className="font-medium">{c.label}</span> },
|
const baseColumns: Column<Category>[] = [
|
||||||
{
|
{
|
||||||
key: 'bundle',
|
key: 'id',
|
||||||
header: 'نوع',
|
header: 'شناسه',
|
||||||
render: (c) => (
|
className: 'w-16',
|
||||||
<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>
|
render: (c) => <span className="text-xs text-slate-400 dark:text-slate-500 font-mono">{c.id}</span>,
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'label',
|
||||||
header: 'وضعیت',
|
header: 'نام',
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
<div className="flex items-center gap-2.5">
|
||||||
c.status === 1 ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
|
{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" />
|
||||||
{c.status === 1 ? 'فعال' : 'غیرفعال'}
|
)}
|
||||||
</span>
|
{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) => {
|
const parentCol: Column<Category> = {
|
||||||
setActiveTab(tab);
|
key: 'parent_id',
|
||||||
setSearch('');
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="دستهبندیها"
|
title="دستهبندیها"
|
||||||
|
description={tab.description}
|
||||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'دستهبندیها' }]}
|
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'دستهبندیها' }]}
|
||||||
action={
|
action={
|
||||||
<button onClick={() => setAddOpen(true)}
|
<button onClick={() => { reset({ status: '1', weight: '0' }); setLogoUrl(null); setAddOpen(true); }} className="cp-btn-primary">
|
||||||
className="cp-btn-primary">
|
|
||||||
<PlusIcon className="w-4 h-4" />
|
<PlusIcon className="w-4 h-4" />
|
||||||
افزودن
|
افزودن {tab.label.slice(0, -1) || tab.label}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="cp-card">
|
<div className="cp-card overflow-hidden">
|
||||||
<div className="flex flex-wrap border-b border-slate-200 dark:border-gray-700 px-6 pt-4 gap-1">
|
{/* ── Tab bar ── */}
|
||||||
{TABS.map((t) => (
|
<div className="flex overflow-x-auto border-b border-slate-200 dark:border-gray-700 px-2 pt-2 gap-1 scrollbar-none">
|
||||||
<button key={t.key} onClick={() => handleTabChange(t.key)}
|
{TABS.map((t) => {
|
||||||
className={`pb-3 px-4 text-sm font-medium border-b-2 transition-colors -mb-px ${
|
const Icon = t.icon;
|
||||||
activeTab === t.key
|
const isActive = t.key === activeTab;
|
||||||
? 'border-primary-600 text-primary-600'
|
return (
|
||||||
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'
|
<button
|
||||||
}`}>
|
key={t.key}
|
||||||
{t.label}
|
onClick={() => handleTabChange(t.key)}
|
||||||
</button>
|
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>
|
||||||
|
|
||||||
<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>
|
<DataTable<Category>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={filtered}
|
data={filtered}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
searchValue={search}
|
searchValue={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="جستجو..."
|
searchPlaceholder={`جستجو در ${tab.label}...`}
|
||||||
emptyMessage="هیچ موردی یافت نشد"
|
emptyMessage={`هیچ ${tab.label.slice(0, -1) || tab.label}ی یافت نشد`}
|
||||||
actions={(cat) => (
|
actions={(cat) => (
|
||||||
<>
|
<>
|
||||||
<button onClick={() => openEdit(cat)}
|
<button onClick={() => openEdit(cat)} className="cp-action-edit" title="ویرایش">
|
||||||
className="cp-action-edit" title="ویرایش">
|
|
||||||
<PencilIcon className="w-4 h-4" />
|
<PencilIcon className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setDeleteTarget(cat)}
|
<button onClick={() => setDeleteTarget(cat)} className="cp-action-delete" title="حذف">
|
||||||
className="cp-action-delete" title="حذف">
|
|
||||||
<TrashIcon className="w-4 h-4" />
|
<TrashIcon className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -175,71 +700,53 @@ export default function CategoriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Modal */}
|
{/* ── Add Modal ── */}
|
||||||
<Modal open={addOpen} title={`افزودن — ${TABS.find((t) => t.key === activeTab)?.label}`}
|
<Modal
|
||||||
onClose={() => { setAddOpen(false); reset(); }}
|
open={addOpen}
|
||||||
|
title={`افزودن — ${tab.label}`}
|
||||||
|
size={modalSize}
|
||||||
|
onClose={closeAdd}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<button onClick={() => { setAddOpen(false); reset(); }}
|
<button onClick={closeAdd} className="cp-btn-secondary">لغو</button>
|
||||||
className="cp-btn-secondary">
|
<button form="cat-add-form" type="submit" disabled={createMutation.isPending} className="cp-btn-primary">
|
||||||
لغو
|
{createMutation.isPending ? 'در حال ذخیره...' : 'افزودن'}
|
||||||
</button>
|
|
||||||
<button form="cat-form" type="submit" disabled={isSubmitting}
|
|
||||||
className="cp-btn-primary">
|
|
||||||
ذخیره
|
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form id="cat-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))} className="space-y-4">
|
<form id="cat-add-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))}>
|
||||||
<div>
|
<CategoryFormFields {...formSharedProps} />
|
||||||
<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>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* ── Edit Modal ── */}
|
||||||
<Modal open={!!editTarget} title="ویرایش دستهبندی"
|
<Modal
|
||||||
onClose={() => { setEditTarget(null); reset(); }}
|
open={!!editTarget}
|
||||||
|
title={`ویرایش — ${editTarget?.label ?? ''}`}
|
||||||
|
size={modalSize}
|
||||||
|
onClose={closeEdit}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<button onClick={() => { setEditTarget(null); reset(); }}
|
<button onClick={closeEdit} className="cp-btn-secondary">لغو</button>
|
||||||
className="cp-btn-secondary">
|
<button form="cat-edit-form" type="submit" disabled={updateMutation.isPending} className="cp-btn-primary">
|
||||||
لغو
|
{updateMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
|
||||||
</button>
|
|
||||||
<button form="edit-cat-form" type="submit" disabled={isSubmitting}
|
|
||||||
className="cp-btn-primary">
|
|
||||||
ذخیره
|
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form id="edit-cat-form"
|
<form
|
||||||
|
id="cat-edit-form"
|
||||||
onSubmit={handleSubmit((d) => editTarget && updateMutation.mutate({ id: editTarget.id, d }))}
|
onSubmit={handleSubmit((d) => editTarget && updateMutation.mutate({ id: editTarget.id, d }))}
|
||||||
className="space-y-4">
|
>
|
||||||
<div>
|
<CategoryFormFields {...formSharedProps} />
|
||||||
<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>
|
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
title="حذف دستهبندی"
|
title="حذف دستهبندی"
|
||||||
message={`آیا از حذف "${deleteTarget?.label}" اطمینان دارید؟`}
|
message={`آیا از حذف "${deleteTarget?.label}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
|
||||||
confirmLabel="حذف"
|
confirmLabel="حذف"
|
||||||
danger
|
danger
|
||||||
loading={deleteMutation.isPending}
|
loading={deleteMutation.isPending}
|
||||||
@@ -249,3 +756,35 @@ export default function CategoriesPage() {
|
|||||||
</div>
|
</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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { EyeIcon, TrashIcon, PlusIcon } from '@heroicons/react/24/outline';
|
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 { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -16,11 +16,12 @@ import { ActiveBadge } from '../components/ui/StatusBadge';
|
|||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
import Modal from '../components/ui/Modal';
|
import Modal from '../components/ui/Modal';
|
||||||
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
full_name: z.string().min(2, 'نام الزامی است'),
|
full_name: z.string().min(2, 'نام الزامی است'),
|
||||||
mobile_number: z.string().min(10, 'شماره موبایل معتبر نیست'),
|
mobile_number: z.string().min(10, 'شماره موبایل معتبر نیست'),
|
||||||
city_id: z.coerce.number().nullable().optional(),
|
city_id: z.number().nullable().optional(),
|
||||||
commission_percent: z.coerce.number().min(0).max(100),
|
commission_percent: z.coerce.number().min(0).max(100),
|
||||||
});
|
});
|
||||||
type FormData = z.infer<typeof schema>;
|
type FormData = z.infer<typeof schema>;
|
||||||
@@ -30,7 +31,7 @@ export default function RepresentationsPage() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [cityFilter, setCityFilter] = useState('');
|
const [cityFilter, setCityFilter] = useState<number | null>(null);
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Representation | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Representation | null>(null);
|
||||||
const limit = 15;
|
const limit = 15;
|
||||||
@@ -42,18 +43,19 @@ export default function RepresentationsPage() {
|
|||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
const cities: Category[] = (citiesQuery.data?.data as any)?.data ?? [];
|
const cities: Category[] = (citiesQuery.data?.data as any)?.data ?? [];
|
||||||
|
const cityOptions = cities.map((c) => ({ value: c.id, label: c.label }));
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['representations', page, search, cityFilter],
|
queryKey: ['representations', page, search, cityFilter],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
if (search) params.set('search', search);
|
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}`);
|
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),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: { commission_percent: 10 },
|
defaultValues: { commission_percent: 10 },
|
||||||
});
|
});
|
||||||
@@ -63,7 +65,7 @@ export default function RepresentationsPage() {
|
|||||||
api.post<ApiResponse<Representation>>('/api/v1/representation', {
|
api.post<ApiResponse<Representation>>('/api/v1/representation', {
|
||||||
full_name: d.full_name,
|
full_name: d.full_name,
|
||||||
mobile_number: d.mobile_number,
|
mobile_number: d.mobile_number,
|
||||||
city_id: d.city_id || null,
|
city_id: d.city_id ?? null,
|
||||||
commission_percent: d.commission_percent,
|
commission_percent: d.commission_percent,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -112,8 +114,7 @@ export default function RepresentationsPage() {
|
|||||||
title="نمایندگان"
|
title="نمایندگان"
|
||||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'نمایندگان' }]}
|
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'نمایندگان' }]}
|
||||||
action={
|
action={
|
||||||
<button onClick={() => setAddOpen(true)}
|
<button onClick={() => setAddOpen(true)} className="cp-btn-primary">
|
||||||
className="cp-btn-primary">
|
|
||||||
<PlusIcon className="w-4 h-4" />
|
<PlusIcon className="w-4 h-4" />
|
||||||
افزودن نماینده
|
افزودن نماینده
|
||||||
</button>
|
</button>
|
||||||
@@ -123,19 +124,20 @@ export default function RepresentationsPage() {
|
|||||||
<div className="cp-card p-6">
|
<div className="cp-card p-6">
|
||||||
{/* City filter */}
|
{/* City filter */}
|
||||||
<div className="mb-4 flex items-center gap-3">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<select
|
<div className="min-w-[220px]">
|
||||||
value={cityFilter}
|
<SearchableSelect
|
||||||
onChange={(e) => { setCityFilter(e.target.value); setPage(1); }}
|
options={cityOptions}
|
||||||
className="cp-input min-w-[160px]"
|
value={cityFilter}
|
||||||
>
|
onChange={(val) => { setCityFilter(val as number | null); setPage(1); }}
|
||||||
<option value="">همه شهرها</option>
|
placeholder="فیلتر بر اساس شهر..."
|
||||||
{cities.map((c) => (
|
isClearable
|
||||||
<option key={c.id} value={String(c.id)}>{c.label}</option>
|
isLoading={citiesQuery.isLoading}
|
||||||
))}
|
noOptionsMessage="هیچ شهری یافت نشد"
|
||||||
</select>
|
/>
|
||||||
{cityFilter && (
|
</div>
|
||||||
|
{cityFilter !== null && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setCityFilter(''); setPage(1); }}
|
onClick={() => { setCityFilter(null); setPage(1); }}
|
||||||
className="text-xs text-gray-400 hover:text-gray-600 transition-colors"
|
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(); }}
|
<Modal open={addOpen} title="افزودن نماینده" onClose={() => { setAddOpen(false); reset(); }}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<button onClick={() => { setAddOpen(false); reset(); }}
|
<button onClick={() => { setAddOpen(false); reset(); }} className="cp-btn-secondary">
|
||||||
className="cp-btn-secondary">
|
|
||||||
لغو
|
لغو
|
||||||
</button>
|
</button>
|
||||||
<button form="add-rep-form" type="submit" disabled={isSubmitting}
|
<button form="add-rep-form" type="submit" disabled={isSubmitting} className="cp-btn-primary">
|
||||||
className="cp-btn-primary">
|
|
||||||
{isSubmitting ? 'در حال ذخیره...' : 'ذخیره'}
|
{isSubmitting ? 'در حال ذخیره...' : 'ذخیره'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -184,25 +184,31 @@ export default function RepresentationsPage() {
|
|||||||
<form id="add-rep-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))} className="space-y-4">
|
<form id="add-rep-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="cp-label">نام کامل</label>
|
<label className="cp-label">نام کامل</label>
|
||||||
<input {...register('full_name')} placeholder="علی محمدی"
|
<input {...register('full_name')} placeholder="علی محمدی" className="cp-input h-11" />
|
||||||
className="cp-input h-11" />
|
|
||||||
{errors.full_name && <p className="text-red-500 text-xs mt-1">{errors.full_name.message}</p>}
|
{errors.full_name && <p className="text-red-500 text-xs mt-1">{errors.full_name.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="cp-label">شماره موبایل</label>
|
<label className="cp-label">شماره موبایل</label>
|
||||||
<input {...register('mobile_number')} dir="ltr" placeholder="09xxxxxxxxx"
|
<input {...register('mobile_number')} dir="ltr" placeholder="09xxxxxxxxx" className="cp-input h-11" />
|
||||||
className="cp-input h-11" />
|
|
||||||
{errors.mobile_number && <p className="text-red-500 text-xs mt-1">{errors.mobile_number.message}</p>}
|
{errors.mobile_number && <p className="text-red-500 text-xs mt-1">{errors.mobile_number.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="cp-label">شهر</label>
|
<label className="cp-label">شهر</label>
|
||||||
<select {...register('city_id')}
|
<Controller
|
||||||
className="cp-input h-11">
|
name="city_id"
|
||||||
<option value="">انتخاب شهر</option>
|
control={control}
|
||||||
{cities.map((c) => (
|
render={({ field }) => (
|
||||||
<option key={c.id} value={c.id}>{c.label}</option>
|
<SearchableSelect
|
||||||
))}
|
options={cityOptions}
|
||||||
</select>
|
value={field.value ?? null}
|
||||||
|
onChange={(val) => field.onChange(val as number | null)}
|
||||||
|
placeholder="انتخاب شهر..."
|
||||||
|
isClearable
|
||||||
|
isLoading={citiesQuery.isLoading}
|
||||||
|
noOptionsMessage="هیچ شهری یافت نشد"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="cp-label">درصد کمیسیون</label>
|
<label className="cp-label">درصد کمیسیون</label>
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export interface Settlement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Representation {
|
export interface Representation {
|
||||||
|
id: number;
|
||||||
uuid: string;
|
uuid: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
@@ -180,6 +181,22 @@ export interface Category {
|
|||||||
bundle: CategoryBundle;
|
bundle: CategoryBundle;
|
||||||
status: number;
|
status: number;
|
||||||
weight: 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 {
|
export interface Blog {
|
||||||
|
|||||||
@@ -73,3 +73,7 @@ services:
|
|||||||
App\Blog\Controller\BlogController:
|
App\Blog\Controller\BlogController:
|
||||||
arguments:
|
arguments:
|
||||||
$projectDir: '%kernel.project_dir%'
|
$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');
|
$cityId = $request->query->get('city_id');
|
||||||
|
|
||||||
$qb = $this->em->createQueryBuilder()
|
$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')
|
->from(Representation::class, 'r')
|
||||||
->leftJoin(Category::class, 'c', 'WITH', 'c.id = r.cityId')
|
->leftJoin(Category::class, 'c', 'WITH', 'c.id = r.cityId')
|
||||||
->orderBy('r.createdAt', 'DESC');
|
->orderBy('r.createdAt', 'DESC');
|
||||||
@@ -361,6 +361,7 @@ class AdminApiController extends BaseController
|
|||||||
->getQuery()->getArrayResult();
|
->getQuery()->getArrayResult();
|
||||||
|
|
||||||
$items = array_map(fn(array $r) => [
|
$items = array_map(fn(array $r) => [
|
||||||
|
'id' => (int) $r['id'],
|
||||||
'uuid' => $r['uuid'],
|
'uuid' => $r['uuid'],
|
||||||
'domain' => $r['fullName'],
|
'domain' => $r['fullName'],
|
||||||
'full_name' => $r['fullName'],
|
'full_name' => $r['fullName'],
|
||||||
|
|||||||
@@ -7,16 +7,20 @@ use App\Category\Repository\CategoryRepository;
|
|||||||
use App\Category\Service\CategoryService;
|
use App\Category\Service\CategoryService;
|
||||||
use App\Shared\Constant\ErrorCodes;
|
use App\Shared\Constant\ErrorCodes;
|
||||||
use App\Shared\Controller\BaseController;
|
use App\Shared\Controller\BaseController;
|
||||||
|
use App\Shared\Service\FileValidatorService;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
class CategoryController extends BaseController
|
class CategoryController extends BaseController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CategoryRepository $repository,
|
private readonly CategoryRepository $repository,
|
||||||
private readonly CategoryService $service,
|
private readonly CategoryService $service,
|
||||||
|
private readonly FileValidatorService $fileValidator,
|
||||||
|
private readonly string $projectDir,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
#[Route('/api/v1/categorys/tag', methods: ['GET'])]
|
#[Route('/api/v1/categorys/tag', methods: ['GET'])]
|
||||||
@@ -120,4 +124,42 @@ class CategoryController extends BaseController
|
|||||||
|
|
||||||
return $this->success(['message' => 'دستهبندی با موفقیت حذف شد']);
|
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)]
|
#[ORM\Column(name: 'social_media', type: 'json', nullable: true)]
|
||||||
private ?array $socialMedia = null;
|
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)
|
public function __construct(string $bundle, string $label)
|
||||||
{
|
{
|
||||||
$this->uuid = Uuid::v4()->toRfc4122();
|
$this->uuid = Uuid::v4()->toRfc4122();
|
||||||
@@ -103,6 +106,7 @@ class Category
|
|||||||
public function getFooterDescription(): ?string { return $this->footerDescription; }
|
public function getFooterDescription(): ?string { return $this->footerDescription; }
|
||||||
public function getFooterDisclaimer(): ?string { return $this->footerDisclaimer; }
|
public function getFooterDisclaimer(): ?string { return $this->footerDisclaimer; }
|
||||||
public function getSocialMedia(): ?array { return $this->socialMedia; }
|
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 setBundle(string $bundle): self { $this->bundle = $bundle; return $this; }
|
||||||
public function setLabel(?string $label): self { $this->label = $label; 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 setFooterDescription(?string $v): self { $this->footerDescription = $v; return $this; }
|
||||||
public function setFooterDisclaimer(?string $v): self { $this->footerDisclaimer = $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 setSocialMedia(?array $v): self { $this->socialMedia = $v; return $this; }
|
||||||
|
public function setLogoUrl(?string $v): self { $this->logoUrl = $v; return $this; }
|
||||||
|
|
||||||
public function toArray(): array
|
public function toArray(): array
|
||||||
{
|
{
|
||||||
@@ -139,7 +144,12 @@ class Category
|
|||||||
if ($this->title !== null) {
|
if ($this->title !== null) {
|
||||||
$data['title'] = $this->title;
|
$data['title'] = $this->title;
|
||||||
}
|
}
|
||||||
|
if (in_array($this->bundle, ['insurance_type', 'supplementary_insurance'], true)) {
|
||||||
|
$data['logo_url'] = $this->logoUrl;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->bundle === 'city') {
|
if ($this->bundle === 'city') {
|
||||||
|
$data['representation_id'] = $this->representationId;
|
||||||
$data['contact_phone'] = $this->contactPhone;
|
$data['contact_phone'] = $this->contactPhone;
|
||||||
$data['email'] = $this->email;
|
$data['email'] = $this->email;
|
||||||
$data['description'] = $this->description;
|
$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_description', $data)) $category->setFooterDescription($data['footer_description']);
|
||||||
if (array_key_exists('footer_disclaimer', $data)) $category->setFooterDisclaimer($data['footer_disclaimer']);
|
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('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
|
private function invalidate(string $bundle): void
|
||||||
|
|||||||
Reference in New Issue
Block a user