feat(tickets): implement ticket management features including listing, details, and configuration

This commit is contained in:
hossein taromi 2025-11-22 11:58:09 +03:30
parent f4aecefbe7
commit e4d5ac4736
16 changed files with 2389 additions and 42 deletions

View File

@ -66,6 +66,9 @@ const HeroSliderPage = lazy(() => import('./pages/landing-hero/HeroSliderPage'))
// Shipping Methods Pages
const ShippingMethodsListPage = lazy(() => import('./pages/shipping-methods/shipping-methods-list/ShippingMethodsListPage'));
const ShippingMethodFormPage = lazy(() => import('./pages/shipping-methods/shipping-method-form/ShippingMethodFormPage'));
const TicketsListPage = lazy(() => import('./pages/tickets/tickets-list/TicketsListPage'));
const TicketDetailPage = lazy(() => import('./pages/tickets/ticket-detail/TicketDetailPage'));
const TicketConfigPage = lazy(() => import('./pages/tickets/ticket-config/TicketConfigPage'));
const ProtectedRoute = ({ children }: { children: any }) => {
const { user, isLoading } = useAuth();
@ -147,6 +150,10 @@ const AppRoutes = () => {
<Route path="shipping-methods/create" element={<ShippingMethodFormPage />} />
<Route path="shipping-methods/:id/edit" element={<ShippingMethodFormPage />} />
<Route path="tickets" element={<TicketsListPage />} />
<Route path="tickets/config" element={<TicketConfigPage />} />
<Route path="tickets/:id" element={<TicketDetailPage />} />
{/* Products Routes */}
<Route path="products/create" element={<ProductFormPage />} />
<Route path="products/:id" element={<ProductDetailPage />} />

View File

@ -16,7 +16,8 @@ import {
ShoppingCart,
Users,
Truck,
X
X,
MessageSquare
} from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import { PermissionWrapper } from '../common/PermissionWrapper';
@ -28,6 +29,7 @@ interface MenuItem {
path?: string;
permission?: number;
children?: MenuItem[];
exact?: boolean;
}
const menuItems: MenuItem[] = [
@ -51,6 +53,23 @@ const menuItems: MenuItem[] = [
icon: BadgePercent,
path: '/discount-codes',
},
{
title: 'تیکت‌ها',
icon: MessageSquare,
children: [
{
title: 'لیست تیکت‌ها',
icon: MessageSquare,
path: '/tickets',
exact: true,
},
{
title: 'تنظیمات تیکت',
icon: Sliders,
path: '/tickets/config',
},
]
},
{
title: 'مدیریت محصولات',
icon: Package,
@ -159,6 +178,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
const menuContent = (
<NavLink
to={item.path!}
end={item.exact}
onClick={() => {
// Close mobile menu when clicking a link
if (window.innerWidth < 1024) {

View File

@ -15,7 +15,7 @@ export interface UploadedFile {
}
interface FileUploaderProps {
onUpload: (file: File) => Promise<{ id: string; url: string }>;
onUpload: (file: File) => Promise<{ id: string; url: string; mimeType?: string }>;
onRemove?: (fileId: string) => void;
acceptedTypes?: string[];
maxFileSize?: number;

View File

@ -6,6 +6,7 @@ import { FileUploader } from './FileUploader';
import { useFileUpload, useFileDelete } from '../../hooks/useFileUpload';
import { persianToEnglish, convertPersianNumbersInObject } from '../../utils/numberUtils';
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
import { toast } from "react-hot-toast";
const toPublicUrl = (img: any): ProductImage => {
const rawUrl: string = img?.url || '';
@ -16,15 +17,36 @@ const toPublicUrl = (img: any): ProductImage => {
? rawUrl
: rawUrl
? `${API_GATE_WAY}${rawUrl.startsWith('/') ? '' : '/'}${rawUrl}`
: '';
: '';
return {
id: (img?.id ?? img).toString(),
url,
alt: img?.alt || '',
order: img?.order ?? 0,
type: img?.mime_type || img?.type,
};
};
const IMAGE_MAX_SIZE = 2 * 1024 * 1024;
const VIDEO_MAX_SIZE = 25 * 1024 * 1024;
const isImageFileType = (file: File) => file.type?.startsWith('image/');
const isVideoFileType = (file: File) => file.type?.startsWith('video/');
const validateVariantMedia = (file: File) => {
if (isImageFileType(file)) {
if (file.size > IMAGE_MAX_SIZE) {
throw new Error('حجم تصویر نباید بیشتر از ۲ مگابایت باشد');
}
} else if (isVideoFileType(file)) {
if (file.size > VIDEO_MAX_SIZE) {
throw new Error('حجم ویدیو نباید بیشتر از ۲۵ مگابایت باشد');
}
} else {
throw new Error('فقط تصاویر یا ویدیو مجاز است');
}
};
interface ProductOption {
id: number;
title: string;
@ -112,22 +134,21 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
const handleFileUpload = async (file: File) => {
try {
validateVariantMedia(file);
const result = await uploadFile(file);
// Use functional update to avoid stale state when multiple files upload concurrently
setUploadedImages(prev => {
const newImage: ProductImage = {
id: result.id,
url: result.url,
alt: file.name,
order: prev.length
order: prev.length,
type: result.mimeType || file.type
};
return [...prev, newImage];
});
return result;
} catch (error) {
console.error('Upload error:', error);
} catch (error: any) {
toast.error(error?.message || 'خطا در آپلود فایل');
throw error;
}
};
@ -329,11 +350,11 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
<FileUploader
onUpload={handleFileUpload}
onRemove={handleFileRemove}
acceptedTypes={['image/*']}
maxFileSize={5 * 1024 * 1024}
acceptedTypes={['image/*', 'video/*']}
maxFileSize={25 * 1024 * 1024}
maxFiles={5}
label=""
description="تصاویر مخصوص این variant را آپلود کنید"
description="فایل‌های تصویری یا ویدیویی مخصوص این Variant را آپلود کنید"
/>
{uploadedImages.length > 0 && (
@ -341,11 +362,20 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{uploadedImages.map((image, index) => (
<div key={image.id} className="relative group">
<img
src={image.url}
alt={image.alt || `تصویر ${index + 1}`}
className="w-full h-20 object-cover rounded-lg border"
/>
{image.type?.startsWith('video') ? (
<video
src={image.url}
className="w-full h-20 object-cover rounded-lg border"
controls
muted
/>
) : (
<img
src={image.url}
alt={image.alt || `تصویر ${index + 1}`}
className="w-full h-20 object-cover rounded-lg border"
/>
)}
<button
type="button"
onClick={() => handleFileRemove(image.id)}

View File

@ -113,4 +113,24 @@ export const API_ROUTES = {
DELETE_USER: (id: string) => `users/${id}`,
VERIFY_USER: (id: string) => `users/${id}/verify`,
UNVERIFY_USER: (id: string) => `users/${id}/unverify`,
GET_TICKETS: "tickets",
GET_TICKET: (id: string) => `tickets/${id}`,
CREATE_TICKET_REPLY: (id: string) => `tickets/${id}/messages`,
UPDATE_TICKET_STATUS: (id: string) => `tickets/${id}/status`,
ASSIGN_TICKET: (id: string) => `tickets/${id}/assign`,
GET_TICKET_DEPARTMENTS: "tickets/config/departments",
GET_TICKET_DEPARTMENT: (id: string) => `tickets/config/departments/${id}`,
CREATE_TICKET_DEPARTMENT: "tickets/config/departments",
UPDATE_TICKET_DEPARTMENT: (id: string) => `tickets/config/departments/${id}`,
DELETE_TICKET_DEPARTMENT: (id: string) => `tickets/config/departments/${id}`,
GET_TICKET_STATUSES: "tickets/config/statuses",
GET_TICKET_STATUS: (id: string) => `tickets/config/statuses/${id}`,
CREATE_TICKET_STATUS: "tickets/config/statuses",
UPDATE_TICKET_STATUS_CONFIG: (id: string) => `tickets/config/statuses/${id}`,
DELETE_TICKET_STATUS: (id: string) => `tickets/config/statuses/${id}`,
GET_TICKET_SUBJECTS: "tickets/config/subjects",
GET_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`,
CREATE_TICKET_SUBJECT: "tickets/config/subjects",
UPDATE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`,
DELETE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`,
};

View File

@ -20,7 +20,7 @@ interface UploadResponse {
export const useFileUpload = () => {
return useMutation({
mutationFn: async (file: File): Promise<{ id: string; url: string }> => {
mutationFn: async (file: File): Promise<{ id: string; url: string; mimeType?: string }> => {
const formData = new FormData();
formData.append("file", file);
formData.append("name", "uploaded-file");
@ -46,6 +46,7 @@ export const useFileUpload = () => {
return {
id: response.data.file.id.toString(),
url: response.data.file.url,
mimeType: response.data.file.mime_type,
};
},
onError: (error: any) => {

View File

@ -6,6 +6,9 @@ export interface ProductImage {
url: string;
alt?: string;
order: number;
type?: string;
mime_type?: string;
size?: number;
}
export interface ProductVariant {
@ -44,6 +47,8 @@ export interface Product {
status?: string;
attributes?: Record<string, any>;
file_ids: ProductImage[];
explorer_file_ids?: number[];
explorer_files?: ProductImage[];
variants?: ProductVariant[];
product_variants?: ProductVariant[];
created_at: string;
@ -62,6 +67,8 @@ export interface ProductFormData {
variant_attribute_name?: string;
file_ids: ProductImage[];
variants: ProductVariantFormData[];
explorer_file_ids?: ProductImage[];
is_delete_latest_explorer_files?: boolean;
}
export interface ProductVariantFormData {
@ -99,6 +106,7 @@ export interface CreateProductRequest {
type: number;
attributes?: Record<string, any>;
file_ids?: number[];
explorer_file_ids?: number[];
variants?: CreateVariantRequest[];
}
@ -114,6 +122,8 @@ export interface UpdateProductRequest {
type: number;
attributes?: Record<string, any>;
file_ids?: number[];
explorer_file_ids?: number[];
is_delete_latest_explorer_files?: boolean;
variants?: UpdateVariantRequest[];
}

View File

@ -7,6 +7,7 @@ import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
import { useProduct } from '../core/_hooks';
import { PRODUCT_TYPE_LABELS } from '../core/_models';
import { PageContainer, PageTitle, SectionTitle } from '../../../components/ui/Typography';
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
const ProductDetailPage = () => {
const navigate = useNavigate();
@ -29,8 +30,58 @@ const ProductDetailPage = () => {
return new Intl.NumberFormat('fa-IR').format(num);
};
const resolveFileUrl = (file: any) => {
const rawUrl = file?.url || '';
const serveKey = file?.serve_key || file?.serveKey;
if (serveKey) {
return `${API_GATE_WAY}/${API_ROUTES.DOWNLOAD_FILE(serveKey)}`;
}
if (rawUrl?.startsWith('http')) return rawUrl;
if (rawUrl) {
return `${API_GATE_WAY}${rawUrl.startsWith('/') ? '' : '/'}${rawUrl}`;
}
return '';
};
const normalizeFiles = (items: any[]) =>
(items || []).map((file, index) => {
if (typeof file === 'number' || typeof file === 'string') {
return { id: file.toString(), url: '', alt: '', order: index };
}
if (file?.file) {
const nested = file.file;
return {
id: (nested.id ?? nested.FileID ?? index).toString(),
url: resolveFileUrl(nested),
alt: nested.original_name || '',
order: index,
};
}
if (file?.FileID) {
return {
id: file.FileID.toString(),
url: resolveFileUrl(file),
alt: file.original_name || '',
order: index,
};
}
return {
id: (file.id ?? index).toString(),
url: resolveFileUrl(file),
alt: file.original_name || '',
order: index,
};
});
// تصاویر محصول (حاصل تجمیع دو فیلد مختلف از API)
const images = (product.file_ids && product.file_ids.length > 0) ? product.file_ids : ((product as any).files || []);
const rawImages = (product.file_ids && product.file_ids.length > 0) ? product.file_ids : ((product as any).files || []);
const images = normalizeFiles(rawImages);
const rawExplorerFiles =
(product as any).explorer_files && (product as any).explorer_files.length > 0
? (product as any).explorer_files
: (product as any).explorer_file_ids || [];
const explorerImages = normalizeFiles(rawExplorerFiles).filter((item) => item.url);
// نسخه‌های محصول
const variants = (product.variants && product.variants.length > 0) ? product.variants : ((product as any).product_variants || []);
@ -222,6 +273,33 @@ const ProductDetailPage = () => {
</div>
)}
{explorerImages.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<SectionTitle className="flex items-center gap-2 mb-4">
<Image className="h-5 w-5" />
تصاویر Explorer
</SectionTitle>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
این تصاویر در بخش Explorer نمایش داده میشوند.
</p>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{explorerImages.map((image, index) => (
<div key={image.id || index} className="relative group">
<img
src={image.url}
alt={image.alt || `تصویر Explorer ${index + 1}`}
className="w-full h-32 object-cover rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer"
onClick={() => setPreviewImage(image.url)}
/>
<div className="absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center">
<Eye className="h-6 w-6 text-white" />
</div>
</div>
))}
</div>
</div>
)}
{/* نسخه‌های محصول */}
{(variants.length > 0) && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">

View File

@ -14,10 +14,11 @@ import { Input } from "@/components/ui/Input";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { FileUploader } from "@/components/ui/FileUploader";
import { VariantManager } from "@/components/ui/VariantManager";
import { ArrowRight, Package, X, Plus, Trash2 } from "lucide-react";
import { ArrowRight, X } from "lucide-react";
import { FormHeader, PageContainer, SectionTitle, Label } from '../../../components/ui/Typography';
import { createNumberTransform, createOptionalNumberTransform, convertPersianNumbersInObject } from '../../../utils/numberUtils';
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
import { toast } from "react-hot-toast";
const productSchema = yup.object({
name: yup.string().required('نام محصول الزامی است').min(2, 'نام محصول باید حداقل 2 کاراکتر باشد'),
@ -34,6 +35,8 @@ const productSchema = yup.object({
product_option_id: yup.number().transform(createOptionalNumberTransform()).nullable(),
file_ids: yup.array().of(yup.object()).default([]),
variants: yup.array().default([]),
explorer_file_ids: yup.array().of(yup.object()).default([]),
is_delete_latest_explorer_files: yup.boolean().optional(),
});
const toPublicUrl = (img: any): ProductImage => {
@ -51,9 +54,91 @@ const toPublicUrl = (img: any): ProductImage => {
url,
alt: img?.alt || '',
order: img?.order ?? 0,
type: img?.mime_type || img?.type,
};
};
const IMAGE_MAX_SIZE = 2 * 1024 * 1024;
const VIDEO_MAX_SIZE = 25 * 1024 * 1024;
const isImageFile = (file: File) => file.type?.startsWith('image/');
const isVideoFile = (file: File) => file.type?.startsWith('video/');
const ensureSquareImage = (file: File) =>
new Promise<void>((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
if (img.width === img.height) {
resolve();
} else {
reject(new Error('ابعاد تصویر Explorer باید ۱ در ۱ باشد'));
}
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('امکان بررسی ابعاد تصویر وجود ندارد'));
};
img.src = url;
});
const validateMediaFile = async (file: File, options?: { requireSquare?: boolean }) => {
if (isImageFile(file)) {
if (file.size > IMAGE_MAX_SIZE) {
throw new Error('حجم تصویر نباید بیشتر از ۲ مگابایت باشد');
}
if (options?.requireSquare) {
await ensureSquareImage(file);
}
} else if (isVideoFile(file)) {
if (file.size > VIDEO_MAX_SIZE) {
throw new Error('حجم ویدیو نباید بیشتر از ۲۵ مگابایت باشد');
}
} else {
throw new Error('فقط تصاویر یا ویدیو مجاز است');
}
};
const mapExplorerFiles = (entries: any[]): ProductImage[] => {
if (!entries || !Array.isArray(entries)) {
return [];
}
return entries.map((entry, index) => {
if (entry?.file) {
const media = toPublicUrl(entry.file);
return { ...media, order: index };
}
if (entry?.file_id) {
return {
id: entry.file_id.toString(),
url: entry.url || '',
alt: entry.name || '',
order: index,
type: entry.mime_type,
};
}
if (entry?.FileID) {
return {
id: entry.FileID.toString(),
url: entry.url || '',
alt: '',
order: index,
};
}
if (typeof entry === 'number' || typeof entry === 'string') {
return {
id: entry.toString(),
url: '',
alt: '',
order: index,
};
}
const normalized = toPublicUrl(entry);
return { ...normalized, order: index };
});
};
const ProductFormPage = () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
@ -61,6 +146,9 @@ const ProductFormPage = () => {
const [uploadedImages, setUploadedImages] = useState<ProductImage[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [explorerFiles, setExplorerFiles] = useState<ProductImage[]>([]);
const [isExplorerUploading, setIsExplorerUploading] = useState(false);
const [isDeleteExplorerFiles, setIsDeleteExplorerFiles] = useState(false);
const { data: product, isLoading: isLoadingProduct } = useProduct(id || '', isEdit);
const { data: categories, isLoading: isLoadingCategories } = useCategories();
@ -94,7 +182,9 @@ const ProductFormPage = () => {
category_ids: [],
product_option_id: undefined,
file_ids: [],
variants: []
variants: [],
explorer_file_ids: [],
is_delete_latest_explorer_files: false
}
});
@ -165,11 +255,20 @@ const ProductFormPage = () => {
const normalizedImages: ProductImage[] = (initialImages || []).map(toPublicUrl);
setUploadedImages(normalizedImages);
setValue('file_ids', normalizedImages, { shouldValidate: true, shouldDirty: false });
const explorerSource =
(product as any).explorer_files && (product as any).explorer_files.length > 0
? (product as any).explorer_files
: (product as any).explorer_file_ids || [];
const normalizedExplorer = mapExplorerFiles(explorerSource);
setExplorerFiles(normalizedExplorer);
setValue('explorer_file_ids', normalizedExplorer, { shouldValidate: true, shouldDirty: false });
setIsDeleteExplorerFiles(false);
}
}, [isEdit, product, reset]);
const handleFileUpload = async (file: File) => {
try {
await validateMediaFile(file);
const result = await uploadFile(file);
setUploadedImages(prev => {
@ -177,7 +276,8 @@ const ProductFormPage = () => {
id: result.id,
url: result.url,
alt: file.name,
order: prev.length
order: prev.length,
type: result.mimeType || file.type
};
const updated = [...prev, newImage];
setValue('file_ids', updated, { shouldValidate: true, shouldDirty: true });
@ -185,8 +285,8 @@ const ProductFormPage = () => {
});
return result;
} catch (error) {
console.error('Upload error:', error);
} catch (error: any) {
toast.error(error?.message || 'خطا در آپلود فایل');
throw error;
}
};
@ -198,6 +298,36 @@ const ProductFormPage = () => {
deleteFile(fileId);
};
const handleExplorerUpload = async (file: File) => {
try {
await validateMediaFile(file, { requireSquare: isImageFile(file) });
const result = await uploadFile(file);
setExplorerFiles(prev => {
const newImage: ProductImage = {
id: result.id,
url: result.url,
alt: file.name,
order: prev.length,
type: result.mimeType || file.type
};
const updated = [...prev, newImage];
setValue('explorer_file_ids', updated, { shouldValidate: true, shouldDirty: true });
return updated;
});
return result;
} catch (error: any) {
toast.error(error?.message || 'خطا در آپلود فایل');
throw error;
}
};
const handleExplorerRemove = (fileId: string) => {
const updatedImages = explorerFiles.filter(img => img.id !== fileId);
setExplorerFiles(updatedImages);
setValue('explorer_file_ids', updatedImages, { shouldValidate: true, shouldDirty: true });
deleteFile(fileId);
};
const onSubmit = (data: any) => {
@ -210,6 +340,13 @@ const ProductFormPage = () => {
})
.filter(id => id !== null);
const validExplorerIds = explorerFiles
.map(img => {
const numericId = Number(img.id);
return isNaN(numericId) ? null : numericId;
})
.filter(id => id !== null);
const baseSubmitData = {
name: convertedData.name,
description: convertedData.description || '',
@ -221,7 +358,12 @@ const ProductFormPage = () => {
attributes: {},
category_ids: convertedData.category_ids.length > 0 ? convertedData.category_ids : [],
product_option_id: convertedData.product_option_id || null,
file_ids: validImageIds
file_ids: validImageIds,
};
const submitBaseData = {
...baseSubmitData,
explorer_file_ids: validExplorerIds,
};
console.log('Submitting product data:', baseSubmitData);
@ -246,8 +388,9 @@ const ProductFormPage = () => {
updateProduct({
id: parseInt(id),
...baseSubmitData,
variants: updateVariants
...submitBaseData,
variants: updateVariants,
is_delete_latest_explorer_files: isDeleteExplorerFiles
}, {
onSuccess: () => {
navigate('/products');
@ -269,7 +412,7 @@ const ProductFormPage = () => {
})) || [];
createProduct({
...baseSubmitData,
...submitBaseData,
variants: createVariants
}, {
onSuccess: () => {
@ -449,12 +592,12 @@ const ProductFormPage = () => {
<FileUploader
onUpload={handleFileUpload}
onRemove={handleFileRemove}
acceptedTypes={['image/*']}
maxFileSize={5 * 1024 * 1024}
acceptedTypes={['image/*', 'video/*']}
maxFileSize={25 * 1024 * 1024}
maxFiles={10}
mode="multi"
label=""
description="تصاویر محصول را اینجا بکشید یا کلیک کنید"
description="تصاویر یا ویدیوهای محصول را آپلود کنید (حداکثر ۲ مگ برای تصویر و ۲۵ مگ برای ویدیو)"
onUploadStart={() => setIsUploading(true)}
onUploadComplete={() => setIsUploading(false)}
/>
@ -467,11 +610,20 @@ const ProductFormPage = () => {
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{uploadedImages.map((image, index) => (
<div key={image.id} className="relative group">
<img
src={image.url}
alt={image.alt || `تصویر ${index + 1}`}
className="w-full h-24 object-cover rounded-lg border border-gray-200 dark:border-gray-600"
/>
{image.type?.startsWith('video') ? (
<video
src={image.url}
className="w-full h-24 object-cover rounded-lg border border-gray-200 dark:border-gray-600"
controls
muted
/>
) : (
<img
src={image.url}
alt={image.alt || `تصویر ${index + 1}`}
className="w-full h-24 object-cover rounded-lg border border-gray-200 dark:border-gray-600"
/>
)}
<button
type="button"
onClick={() => handleFileRemove(image.id)}
@ -491,6 +643,80 @@ const ProductFormPage = () => {
)}
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
فایلهای Explorer
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
تنها تصاویر مربعی پذیرفته میشوند و میتوانید ویدیو نیز اضافه کنید.
</p>
<FileUploader
onUpload={handleExplorerUpload}
onRemove={handleExplorerRemove}
acceptedTypes={['image/*', 'video/*']}
maxFileSize={25 * 1024 * 1024}
maxFiles={5}
mode="multi"
label=""
description="فایل‌های Explorer را آپلود کنید (تصاویر مربعی با حداکثر ۲ مگ و ویدیوهای حداکثر ۲۵ مگ)"
onUploadStart={() => setIsExplorerUploading(true)}
onUploadComplete={() => setIsExplorerUploading(false)}
/>
{explorerFiles.length > 0 && (
<div className="mt-6">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
فایلهای Explorer ({explorerFiles.length})
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{explorerFiles.map((file, index) => (
<div key={file.id} className="relative group">
{file.url ? (
file.type?.startsWith('video') ? (
<video
src={file.url}
className="w-full h-24 object-cover rounded-lg border border-gray-200 dark:border-gray-600"
controls
muted
/>
) : (
<img
src={file.url}
alt={file.alt || `فایل ${index + 1}`}
className="w-full h-24 object-cover rounded-lg border border-gray-200 dark:border-gray-600"
/>
)
) : (
<div className="w-full h-24 rounded-lg border border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center text-xs text-gray-500 dark:text-gray-400">
پیشنمایش در دسترس نیست
</div>
)}
<button
type="button"
onClick={() => handleExplorerRemove(file.id)}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
</div>
)}
{isEdit && (
<div className="flex items-center gap-2 mt-4">
<input
type="checkbox"
className="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500"
checked={isDeleteExplorerFiles}
onChange={(e) => setIsDeleteExplorerFiles(e.target.checked)}
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
حذف فایلهای Explorer قبلی و جایگزینی با فایلهای جدید
</span>
</div>
)}
</div>
{/* Variants Management */}
<div>
<VariantManager
@ -511,11 +737,20 @@ const ProductFormPage = () => {
</h3>
<div className="flex gap-4">
{uploadedImages.length > 0 && (
<img
src={uploadedImages[0].url}
alt={formValues.name}
className="w-20 h-20 object-cover rounded-lg border"
/>
uploadedImages[0].type?.startsWith('video') ? (
<video
src={uploadedImages[0].url}
className="w-20 h-20 object-cover rounded-lg border"
controls
muted
/>
) : (
<img
src={uploadedImages[0].url}
alt={formValues.name}
className="w-20 h-20 object-cover rounded-lg border"
/>
)
)}
<div className="flex-1 space-y-2">
<div className="text-sm text-gray-600 dark:text-gray-400">
@ -581,7 +816,7 @@ const ProductFormPage = () => {
<Button
type="submit"
loading={isLoading}
disabled={!isValid || isLoading || isUploading}
disabled={!isValid || isLoading || isUploading || isExplorerUploading}
>
{isEdit ? 'به‌روزرسانی' : 'ایجاد محصول'}
</Button>

View File

@ -0,0 +1,316 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { QUERY_KEYS } from "@/utils/query-key";
import {
assignTicket,
createTicketDepartment,
createTicketStatus,
createTicketSubject,
deleteTicketDepartment,
deleteTicketStatus,
deleteTicketSubject,
getTicket,
getTicketDepartments,
getTicketStatuses,
getTickets,
getTicketSubjects,
replyToTicket,
updateTicketDepartment,
updateTicketStatus,
updateTicketStatusConfig,
updateTicketSubject,
} from "./_requests";
import {
TicketAssignRequest,
TicketDepartmentPayload,
TicketFilters,
TicketReplyRequest,
TicketStatusPayload,
TicketStatusUpdateRequest,
TicketSubjectPayload,
} from "./_models";
export const useTickets = (filters?: TicketFilters) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_TICKETS, filters],
queryFn: () => getTickets(filters),
});
};
export const useTicket = (id?: string) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_TICKET, id],
queryFn: () => getTicket(id || ""),
enabled: !!id,
});
};
export const useTicketDepartments = (options?: { activeOnly?: boolean }) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_TICKET_DEPARTMENTS, options?.activeOnly],
queryFn: () => getTicketDepartments({ activeOnly: options?.activeOnly }),
});
};
export const useTicketStatuses = (options?: { activeOnly?: boolean }) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_TICKET_STATUSES, options?.activeOnly],
queryFn: () => getTicketStatuses({ activeOnly: options?.activeOnly }),
});
};
export const useTicketSubjects = (options?: {
activeOnly?: boolean;
departmentId?: number;
}) => {
return useQuery({
queryKey: [
QUERY_KEYS.GET_TICKET_SUBJECTS,
options?.activeOnly,
options?.departmentId,
],
queryFn: () =>
getTicketSubjects({
activeOnly: options?.activeOnly,
departmentId: options?.departmentId,
}),
});
};
export const useReplyTicket = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
ticketId,
payload,
}: {
ticketId: string;
payload: TicketReplyRequest;
}) => replyToTicket(ticketId, payload),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_TICKET, variables.ticketId],
});
toast.success("پیام با موفقیت ارسال شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در ارسال پیام");
},
});
};
export const useUpdateTicketStatusMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
ticketId,
payload,
}: {
ticketId: string;
payload: TicketStatusUpdateRequest;
}) => updateTicketStatus(ticketId, payload),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_TICKET, variables.ticketId],
});
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_TICKETS],
});
toast.success("وضعیت تیکت با موفقیت به‌روزرسانی شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در به‌روزرسانی وضعیت تیکت");
},
});
};
export const useAssignTicket = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
ticketId,
payload,
}: {
ticketId: string;
payload: TicketAssignRequest;
}) => assignTicket(ticketId, payload),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_TICKET, variables.ticketId],
});
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_TICKETS],
});
toast.success("تیکت با موفقیت اختصاص داده شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در اختصاص تیکت");
},
});
};
export const useCreateTicketDepartment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: TicketDepartmentPayload) =>
createTicketDepartment(payload),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_TICKET_DEPARTMENTS],
});
toast.success("دپارتمان جدید اضافه شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در ایجاد دپارتمان");
},
});
};
export const useUpdateTicketDepartmentMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
payload,
}: {
id: string | number;
payload: TicketDepartmentPayload;
}) => updateTicketDepartment(id, payload),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_TICKET_DEPARTMENTS],
});
toast.success("دپارتمان به‌روزرسانی شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در به‌روزرسانی دپارتمان");
},
});
};
export const useDeleteTicketDepartmentMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string | number) => deleteTicketDepartment(id),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_TICKET_DEPARTMENTS],
});
toast.success("دپارتمان حذف شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در حذف دپارتمان");
},
});
};
export const useCreateTicketStatus = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: TicketStatusPayload) =>
createTicketStatus(payload),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_TICKET_STATUSES],
});
toast.success("وضعیت جدید اضافه شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در ایجاد وضعیت");
},
});
};
export const useUpdateTicketStatusConfigMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
payload,
}: {
id: string | number;
payload: TicketStatusPayload;
}) => updateTicketStatusConfig(id, payload),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_TICKET_STATUSES],
});
toast.success("وضعیت به‌روزرسانی شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در به‌روزرسانی وضعیت");
},
});
};
export const useDeleteTicketStatusMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string | number) => deleteTicketStatus(id),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_TICKET_STATUSES],
});
toast.success("وضعیت حذف شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در حذف وضعیت");
},
});
};
export const useCreateTicketSubject = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: TicketSubjectPayload) =>
createTicketSubject(payload),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_TICKET_SUBJECTS],
});
toast.success("موضوع جدید اضافه شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در ایجاد موضوع");
},
});
};
export const useUpdateTicketSubjectMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
payload,
}: {
id: string | number;
payload: TicketSubjectPayload;
}) => updateTicketSubject(id, payload),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_TICKET_SUBJECTS],
});
toast.success("موضوع به‌روزرسانی شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در به‌روزرسانی موضوع");
},
});
};
export const useDeleteTicketSubjectMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string | number) => deleteTicketSubject(id),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_TICKET_SUBJECTS],
});
toast.success("موضوع حذف شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در حذف موضوع");
},
});
};

View File

@ -0,0 +1,162 @@
export interface TicketDepartment {
id: number;
name: string;
slug: string;
position: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface TicketStatus {
id: number;
name: string;
slug: string;
position: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface TicketSubject {
id: number;
department_id: number;
name: string;
slug: string;
position: number;
is_active: boolean;
created_at: string;
updated_at: string;
department?: TicketDepartment;
}
export interface TicketAttachment {
id: number;
created_at: string;
updated_at: string;
name: string;
original_name: string;
size: number;
mime_type: string;
serve_key: string;
url: string;
type?: string;
}
export interface TicketMessage {
id: number;
sender_type: "user" | "admin";
message: string;
created_at: string;
attachments: TicketAttachment[];
}
export interface TicketAssignee {
id: number;
first_name?: string;
last_name?: string;
username?: string;
}
export interface TicketSummary {
id: number;
ticket_number: string;
title: string;
department?: TicketDepartment;
subject?: TicketSubject;
status?: TicketStatus;
updated_at: string;
assigned_to?: number;
assigned_user?: TicketAssignee;
created_at?: string;
}
export interface TicketDetail extends TicketSummary {
messages: TicketMessage[];
user?: {
id: number;
first_name?: string;
last_name?: string;
phone_number?: string;
};
}
export interface TicketListResponse {
tickets: TicketSummary[];
total: number;
}
export interface TicketDetailResponse {
ticket: TicketDetail;
}
export interface TicketDepartmentsResponse {
departments: TicketDepartment[];
}
export interface TicketDepartmentResponse {
department: TicketDepartment;
}
export interface TicketStatusesResponse {
statuses: TicketStatus[];
}
export interface TicketStatusResponse {
status: TicketStatus;
}
export interface TicketSubjectsResponse {
subjects: TicketSubject[];
}
export interface TicketSubjectResponse {
subject: TicketSubject;
}
export interface TicketFilters {
limit?: number;
offset?: number;
page?: number;
status_id?: number;
department_id?: number;
user_id?: number;
assigned_to?: number;
search?: string;
}
export interface TicketReplyRequest {
message: string;
file_ids?: number[];
}
export interface TicketStatusUpdateRequest {
status_id: number;
}
export interface TicketAssignRequest {
assigned_to: number;
}
export interface TicketDepartmentPayload {
name: string;
slug: string;
position: number;
is_active?: boolean;
}
export interface TicketStatusPayload {
name: string;
slug: string;
position: number;
is_active?: boolean;
}
export interface TicketSubjectPayload {
department_id: number;
name: string;
slug: string;
position: number;
is_active?: boolean;
}

View File

@ -0,0 +1,208 @@
import {
APIUrlGenerator,
httpDeleteRequest,
httpGetRequest,
httpPostRequest,
httpPutRequest,
} from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import {
TicketAssignRequest,
TicketDepartmentPayload,
TicketDepartmentResponse,
TicketDepartmentsResponse,
TicketDetail,
TicketDetailResponse,
TicketFilters,
TicketListResponse,
TicketReplyRequest,
TicketStatusPayload,
TicketStatusResponse,
TicketStatusesResponse,
TicketStatusUpdateRequest,
TicketSubjectPayload,
TicketSubjectResponse,
TicketSubjectsResponse,
} from "./_models";
export const getTickets = async (filters?: TicketFilters) => {
const queryParams: Record<string, string | number | null> = {};
const limitValue = filters?.limit || 20;
queryParams.limit = limitValue;
if (filters?.offset !== undefined && filters.offset !== null) {
queryParams.offset = filters.offset;
} else if (filters?.page) {
queryParams.offset = (filters.page - 1) * limitValue;
}
if (filters?.status_id) queryParams.status_id = filters.status_id;
if (filters?.department_id) queryParams.department_id = filters.department_id;
if (filters?.user_id) queryParams.user_id = filters.user_id;
if (filters?.assigned_to) queryParams.assigned_to = filters.assigned_to;
if (filters?.search) queryParams.search = filters.search;
const response = await httpGetRequest<TicketListResponse>(
APIUrlGenerator(API_ROUTES.GET_TICKETS, queryParams)
);
return response.data;
};
export const getTicket = async (id: string) => {
const response = await httpGetRequest<TicketDetailResponse>(
APIUrlGenerator(API_ROUTES.GET_TICKET(id))
);
return response.data.ticket as TicketDetail;
};
export const replyToTicket = async (
ticketId: string,
payload: TicketReplyRequest
) => {
const response = await httpPostRequest(
APIUrlGenerator(API_ROUTES.CREATE_TICKET_REPLY(ticketId)),
payload
);
return response.data;
};
export const updateTicketStatus = async (
ticketId: string,
payload: TicketStatusUpdateRequest
) => {
const response = await httpPutRequest(
APIUrlGenerator(API_ROUTES.UPDATE_TICKET_STATUS(ticketId)),
payload
);
return response.data;
};
export const assignTicket = async (
ticketId: string,
payload: TicketAssignRequest
) => {
const response = await httpPutRequest(
APIUrlGenerator(API_ROUTES.ASSIGN_TICKET(ticketId)),
payload
);
return response.data;
};
export const getTicketDepartments = async (params?: {
activeOnly?: boolean;
}) => {
const queryParams: Record<string, string | number | null> = {};
if (typeof params?.activeOnly === "boolean") {
queryParams.active_only = params.activeOnly ? "true" : "false";
}
const response = await httpGetRequest<TicketDepartmentsResponse>(
APIUrlGenerator(API_ROUTES.GET_TICKET_DEPARTMENTS, queryParams)
);
return response.data.departments;
};
export const createTicketDepartment = async (
payload: TicketDepartmentPayload
) => {
const response = await httpPostRequest<TicketDepartmentResponse>(
APIUrlGenerator(API_ROUTES.CREATE_TICKET_DEPARTMENT),
payload
);
return response.data.department;
};
export const updateTicketDepartment = async (
id: string | number,
payload: TicketDepartmentPayload
) => {
const response = await httpPutRequest<TicketDepartmentResponse>(
APIUrlGenerator(API_ROUTES.UPDATE_TICKET_DEPARTMENT(id.toString())),
payload
);
return response.data.department;
};
export const deleteTicketDepartment = async (id: string | number) => {
const response = await httpDeleteRequest<{ message: string }>(
APIUrlGenerator(API_ROUTES.DELETE_TICKET_DEPARTMENT(id.toString()))
);
return response.data;
};
export const getTicketStatuses = async (params?: { activeOnly?: boolean }) => {
const queryParams: Record<string, string | number | null> = {};
if (typeof params?.activeOnly === "boolean") {
queryParams.active_only = params.activeOnly ? "true" : "false";
}
const response = await httpGetRequest<TicketStatusesResponse>(
APIUrlGenerator(API_ROUTES.GET_TICKET_STATUSES, queryParams)
);
return response.data.statuses;
};
export const createTicketStatus = async (payload: TicketStatusPayload) => {
const response = await httpPostRequest<TicketStatusResponse>(
APIUrlGenerator(API_ROUTES.CREATE_TICKET_STATUS),
payload
);
return response.data.status;
};
export const updateTicketStatusConfig = async (
id: string | number,
payload: TicketStatusPayload
) => {
const response = await httpPutRequest<TicketStatusResponse>(
APIUrlGenerator(API_ROUTES.UPDATE_TICKET_STATUS_CONFIG(id.toString())),
payload
);
return response.data.status;
};
export const deleteTicketStatus = async (id: string | number) => {
const response = await httpDeleteRequest<{ message: string }>(
APIUrlGenerator(API_ROUTES.DELETE_TICKET_STATUS(id.toString()))
);
return response.data;
};
export const getTicketSubjects = async (params?: {
activeOnly?: boolean;
departmentId?: number;
}) => {
const queryParams: Record<string, string | number | null> = {};
if (typeof params?.activeOnly === "boolean") {
queryParams.active_only = params.activeOnly ? "true" : "false";
}
if (params?.departmentId) {
queryParams.department_id = params.departmentId;
}
const response = await httpGetRequest<TicketSubjectsResponse>(
APIUrlGenerator(API_ROUTES.GET_TICKET_SUBJECTS, queryParams)
);
return response.data.subjects;
};
export const createTicketSubject = async (payload: TicketSubjectPayload) => {
const response = await httpPostRequest<TicketSubjectResponse>(
APIUrlGenerator(API_ROUTES.CREATE_TICKET_SUBJECT),
payload
);
return response.data.subject;
};
export const updateTicketSubject = async (
id: string | number,
payload: TicketSubjectPayload
) => {
const response = await httpPutRequest<TicketSubjectResponse>(
APIUrlGenerator(API_ROUTES.UPDATE_TICKET_SUBJECT(id.toString())),
payload
);
return response.data.subject;
};
export const deleteTicketSubject = async (id: string | number) => {
const response = await httpDeleteRequest<{ message: string }>(
APIUrlGenerator(API_ROUTES.DELETE_TICKET_SUBJECT(id.toString()))
);
return response.data;
};

View File

@ -0,0 +1,617 @@
import { useMemo, useState } from "react";
import {
useCreateTicketDepartment,
useCreateTicketStatus,
useCreateTicketSubject,
useDeleteTicketDepartmentMutation,
useDeleteTicketStatusMutation,
useDeleteTicketSubjectMutation,
useTicketDepartments,
useTicketStatuses,
useTicketSubjects,
useUpdateTicketDepartmentMutation,
useUpdateTicketStatusConfigMutation,
useUpdateTicketSubjectMutation,
} from "../core/_hooks";
import {
TicketDepartment,
TicketStatus,
TicketSubject,
} from "../core/_models";
import { PageContainer, PageTitle, SectionTitle } from "@/components/ui/Typography";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Table } from "@/components/ui/Table";
import { TableColumn } from "@/types";
import { Settings, Edit3, Trash2 } from "lucide-react";
type TabKey = "departments" | "statuses" | "subjects";
const TicketConfigPage = () => {
const [activeTab, setActiveTab] = useState<TabKey>("departments");
const { data: departments } = useTicketDepartments({ activeOnly: true });
const { data: statuses } = useTicketStatuses({ activeOnly: false });
const { data: subjects } = useTicketSubjects({ activeOnly: false });
const { mutate: createDepartment, isPending: isCreatingDepartment } =
useCreateTicketDepartment();
const { mutate: updateDepartment, isPending: isUpdatingDepartment } =
useUpdateTicketDepartmentMutation();
const { mutate: deleteDepartment } = useDeleteTicketDepartmentMutation();
const { mutate: createStatus, isPending: isCreatingStatus } =
useCreateTicketStatus();
const { mutate: updateStatus, isPending: isUpdatingStatus } =
useUpdateTicketStatusConfigMutation();
const { mutate: deleteStatus } = useDeleteTicketStatusMutation();
const { mutate: createSubject, isPending: isCreatingSubject } =
useCreateTicketSubject();
const { mutate: updateSubject, isPending: isUpdatingSubject } =
useUpdateTicketSubjectMutation();
const { mutate: deleteSubject } = useDeleteTicketSubjectMutation();
const [departmentForm, setDepartmentForm] = useState({
id: null as number | null,
name: "",
slug: "",
position: "",
is_active: "true",
});
const [statusForm, setStatusForm] = useState({
id: null as number | null,
name: "",
slug: "",
position: "",
is_active: "true",
});
const [subjectForm, setSubjectForm] = useState({
id: null as number | null,
department_id: "",
name: "",
slug: "",
position: "",
is_active: "true",
});
const resetDepartmentForm = () =>
setDepartmentForm({
id: null,
name: "",
slug: "",
position: "",
is_active: "true",
});
const resetStatusForm = () =>
setStatusForm({
id: null,
name: "",
slug: "",
position: "",
is_active: "true",
});
const resetSubjectForm = () =>
setSubjectForm({
id: null,
department_id: "",
name: "",
slug: "",
position: "",
is_active: "true",
});
const handleDepartmentSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!departmentForm.name || !departmentForm.slug || !departmentForm.position)
return;
const payload = {
name: departmentForm.name,
slug: departmentForm.slug,
position: Number(departmentForm.position),
is_active: departmentForm.is_active === "true",
};
if (departmentForm.id) {
updateDepartment(
{ id: departmentForm.id, payload },
{ onSuccess: resetDepartmentForm }
);
} else {
createDepartment(payload, { onSuccess: resetDepartmentForm });
}
};
const handleStatusSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!statusForm.name || !statusForm.slug || !statusForm.position) return;
const payload = {
name: statusForm.name,
slug: statusForm.slug,
position: Number(statusForm.position),
is_active: statusForm.is_active === "true",
};
if (statusForm.id) {
updateStatus(
{ id: statusForm.id, payload },
{ onSuccess: resetStatusForm }
);
} else {
createStatus(payload, { onSuccess: resetStatusForm });
}
};
const handleSubjectSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (
!subjectForm.department_id ||
!subjectForm.name ||
!subjectForm.slug ||
!subjectForm.position
)
return;
const payload = {
department_id: Number(subjectForm.department_id),
name: subjectForm.name,
slug: subjectForm.slug,
position: Number(subjectForm.position),
is_active: subjectForm.is_active === "true",
};
if (subjectForm.id) {
updateSubject(
{ id: subjectForm.id, payload },
{ onSuccess: resetSubjectForm }
);
} else {
createSubject(payload, { onSuccess: resetSubjectForm });
}
};
const departmentColumns: TableColumn[] = useMemo(
() => [
{ key: "name", label: "نام", align: "right" },
{ key: "slug", label: "شناسه", align: "right" },
{ key: "position", label: "ترتیب", align: "center" },
{
key: "is_active",
label: "وضعیت",
render: (value: boolean) => (value ? "فعال" : "غیرفعال"),
},
{
key: "actions",
label: "عملیات",
render: (_val, row: TicketDepartment) => (
<div className="flex items-center justify-end gap-2">
<button
className="text-primary-600"
onClick={() =>
setDepartmentForm({
id: row.id,
name: row.name,
slug: row.slug,
position: row.position.toString(),
is_active: row.is_active ? "true" : "false",
})
}
>
<Edit3 className="h-4 w-4" />
</button>
<button
className="text-red-600"
onClick={() => deleteDepartment(row.id)}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
),
},
],
[deleteDepartment]
);
const statusColumns: TableColumn[] = useMemo(
() => [
{ key: "name", label: "نام", align: "right" },
{ key: "slug", label: "شناسه", align: "right" },
{ key: "position", label: "ترتیب", align: "center" },
{
key: "is_active",
label: "وضعیت",
render: (value: boolean) => (value ? "فعال" : "غیرفعال"),
},
{
key: "actions",
label: "عملیات",
render: (_val, row: TicketStatus) => (
<div className="flex items-center justify-end gap-2">
<button
className="text-primary-600"
onClick={() =>
setStatusForm({
id: row.id,
name: row.name,
slug: row.slug,
position: row.position.toString(),
is_active: row.is_active ? "true" : "false",
})
}
>
<Edit3 className="h-4 w-4" />
</button>
<button
className="text-red-600"
onClick={() => deleteStatus(row.id)}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
),
},
],
[deleteStatus]
);
const subjectColumns: TableColumn[] = useMemo(
() => [
{ key: "name", label: "نام", align: "right" },
{
key: "department",
label: "دپارتمان",
align: "right",
render: (_val, row: TicketSubject) => row.department?.name || "-",
},
{ key: "slug", label: "شناسه", align: "right" },
{ key: "position", label: "ترتیب", align: "center" },
{
key: "is_active",
label: "وضعیت",
render: (value: boolean) => (value ? "فعال" : "غیرفعال"),
},
{
key: "actions",
label: "عملیات",
render: (_val, row: TicketSubject) => (
<div className="flex items-center justify-end gap-2">
<button
className="text-primary-600"
onClick={() =>
setSubjectForm({
id: row.id,
department_id: row.department_id.toString(),
name: row.name,
slug: row.slug,
position: row.position.toString(),
is_active: row.is_active ? "true" : "false",
})
}
>
<Edit3 className="h-4 w-4" />
</button>
<button
className="text-red-600"
onClick={() => deleteSubject(row.id)}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
),
},
],
[deleteSubject]
);
const renderDepartments = () => (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card p-6 space-y-4">
<SectionTitle>لیست دپارتمانها</SectionTitle>
<Table columns={departmentColumns} data={(departments || []) as any[]} />
</div>
<div className="card p-6 space-y-4">
<SectionTitle>
{departmentForm.id ? "ویرایش دپارتمان" : "دپارتمان جدید"}
</SectionTitle>
<form className="space-y-4" onSubmit={handleDepartmentSubmit}>
<Input
label="نام"
value={departmentForm.name}
onChange={(e) =>
setDepartmentForm((prev) => ({ ...prev, name: e.target.value }))
}
/>
<Input
label="Slug"
value={departmentForm.slug}
onChange={(e) =>
setDepartmentForm((prev) => ({ ...prev, slug: e.target.value }))
}
/>
<Input
label="ترتیب"
type="number"
value={departmentForm.position}
onChange={(e) =>
setDepartmentForm((prev) => ({
...prev,
position: e.target.value,
}))
}
/>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
وضعیت
</label>
<select
value={departmentForm.is_active}
onChange={(e) =>
setDepartmentForm((prev) => ({
...prev,
is_active: e.target.value,
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
>
<option value="true">فعال</option>
<option value="false">غیرفعال</option>
</select>
</div>
<div className="flex gap-3">
<Button
type="submit"
variant="primary"
loading={isCreatingDepartment || isUpdatingDepartment}
disabled={
!departmentForm.name ||
!departmentForm.slug ||
!departmentForm.position
}
className="flex-1"
>
{departmentForm.id ? "ویرایش دپارتمان" : "ایجاد دپارتمان"}
</Button>
{departmentForm.id && (
<Button
type="button"
variant="secondary"
onClick={resetDepartmentForm}
className="flex-1"
>
انصراف
</Button>
)}
</div>
</form>
</div>
</div>
);
const renderStatuses = () => (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card p-6 space-y-4">
<SectionTitle>لیست وضعیتها</SectionTitle>
<Table columns={statusColumns} data={(statuses || []) as any[]} />
</div>
<div className="card p-6 space-y-4">
<SectionTitle>
{statusForm.id ? "ویرایش وضعیت" : "وضعیت جدید"}
</SectionTitle>
<form className="space-y-4" onSubmit={handleStatusSubmit}>
<Input
label="نام"
value={statusForm.name}
onChange={(e) =>
setStatusForm((prev) => ({ ...prev, name: e.target.value }))
}
/>
<Input
label="Slug"
value={statusForm.slug}
onChange={(e) =>
setStatusForm((prev) => ({ ...prev, slug: e.target.value }))
}
/>
<Input
label="ترتیب"
type="number"
value={statusForm.position}
onChange={(e) =>
setStatusForm((prev) => ({
...prev,
position: e.target.value,
}))
}
/>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
وضعیت
</label>
<select
value={statusForm.is_active}
onChange={(e) =>
setStatusForm((prev) => ({
...prev,
is_active: e.target.value,
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
>
<option value="true">فعال</option>
<option value="false">غیرفعال</option>
</select>
</div>
<div className="flex gap-3">
<Button
type="submit"
variant="primary"
loading={isCreatingStatus || isUpdatingStatus}
disabled={
!statusForm.name || !statusForm.slug || !statusForm.position
}
className="flex-1"
>
{statusForm.id ? "ویرایش وضعیت" : "ایجاد وضعیت"}
</Button>
{statusForm.id && (
<Button
type="button"
variant="secondary"
onClick={resetStatusForm}
className="flex-1"
>
انصراف
</Button>
)}
</div>
</form>
</div>
</div>
);
const renderSubjects = () => (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card p-6 space-y-4">
<SectionTitle>لیست موضوعات</SectionTitle>
<Table columns={subjectColumns} data={(subjects || []) as any[]} />
</div>
<div className="card p-6 space-y-4">
<SectionTitle>
{subjectForm.id ? "ویرایش موضوع" : "موضوع جدید"}
</SectionTitle>
<form className="space-y-4" onSubmit={handleSubjectSubmit}>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
دپارتمان
</label>
<select
value={subjectForm.department_id}
onChange={(e) =>
setSubjectForm((prev) => ({
...prev,
department_id: e.target.value,
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
>
<option value="">انتخاب دپارتمان</option>
{departments?.map((department) => (
<option key={department.id} value={department.id}>
{department.name}
</option>
))}
</select>
</div>
<Input
label="نام"
value={subjectForm.name}
onChange={(e) =>
setSubjectForm((prev) => ({ ...prev, name: e.target.value }))
}
/>
<Input
label="Slug"
value={subjectForm.slug}
onChange={(e) =>
setSubjectForm((prev) => ({ ...prev, slug: e.target.value }))
}
/>
<Input
label="ترتیب"
type="number"
value={subjectForm.position}
onChange={(e) =>
setSubjectForm((prev) => ({
...prev,
position: e.target.value,
}))
}
/>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
وضعیت
</label>
<select
value={subjectForm.is_active}
onChange={(e) =>
setSubjectForm((prev) => ({
...prev,
is_active: e.target.value,
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
>
<option value="true">فعال</option>
<option value="false">غیرفعال</option>
</select>
</div>
<div className="flex gap-3">
<Button
type="submit"
variant="primary"
loading={isCreatingSubject || isUpdatingSubject}
disabled={
!subjectForm.department_id ||
!subjectForm.name ||
!subjectForm.slug ||
!subjectForm.position
}
className="flex-1"
>
{subjectForm.id ? "ویرایش موضوع" : "ایجاد موضوع"}
</Button>
{subjectForm.id && (
<Button
type="button"
variant="secondary"
onClick={resetSubjectForm}
className="flex-1"
>
انصراف
</Button>
)}
</div>
</form>
</div>
</div>
);
return (
<PageContainer className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<PageTitle className="flex items-center gap-2">
<Settings className="h-6 w-6" />
تنظیمات تیکت
</PageTitle>
</div>
<div className="card p-2 flex flex-wrap gap-2">
<Button
variant={activeTab === "departments" ? "primary" : "secondary"}
onClick={() => setActiveTab("departments")}
>
دپارتمانها
</Button>
<Button
variant={activeTab === "statuses" ? "primary" : "secondary"}
onClick={() => setActiveTab("statuses")}
>
وضعیتها
</Button>
<Button
variant={activeTab === "subjects" ? "primary" : "secondary"}
onClick={() => setActiveTab("subjects")}
>
موضوعات
</Button>
</div>
{activeTab === "departments" && renderDepartments()}
{activeTab === "statuses" && renderStatuses()}
{activeTab === "subjects" && renderSubjects()}
</PageContainer>
);
};
export default TicketConfigPage;

View File

@ -0,0 +1,371 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
useAssignTicket,
useReplyTicket,
useTicket,
useTicketStatuses,
useUpdateTicketStatusMutation,
} from "../core/_hooks";
import { TicketStatus } from "../core/_models";
import { PageContainer, PageTitle, SectionTitle, Label } from "@/components/ui/Typography";
import { Button } from "@/components/ui/Button";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { FileUploader } from "@/components/ui/FileUploader";
import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload";
import { Input } from "@/components/ui/Input";
import {
ArrowRight,
MessageSquare,
Send,
UserCheck,
Paperclip,
} from "lucide-react";
const statusColor = (status?: TicketStatus) => {
if (!status) return "bg-gray-100 text-gray-800";
if (status.slug === "pending") return "bg-yellow-100 text-yellow-800";
if (status.slug === "answered") return "bg-green-100 text-green-800";
if (status.slug === "closed") return "bg-gray-200 text-gray-800";
return "bg-primary-50 text-primary-700";
};
const TicketDetailPage = () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { data: ticket, isLoading, error } = useTicket(id);
const { data: statuses } = useTicketStatuses({ activeOnly: false });
const { mutate: sendReply, isPending: isReplying } = useReplyTicket();
const { mutate: updateStatus, isPending: isUpdatingStatus } =
useUpdateTicketStatusMutation();
const { mutate: assignTicket, isPending: isAssigning } = useAssignTicket();
const { mutateAsync: uploadFile } = useFileUpload();
const { mutate: deleteFile } = useFileDelete();
const [statusId, setStatusId] = useState<number | undefined>();
const [assignedValue, setAssignedValue] = useState<string>("");
const [message, setMessage] = useState("");
const [attachments, setAttachments] = useState<
{ id: string; url: string }[]
>([]);
const [uploaderKey, setUploaderKey] = useState(0);
const [isUploading, setIsUploading] = useState(false);
useEffect(() => {
if (ticket?.status?.id) {
setStatusId(ticket.status.id);
}
if (ticket?.assigned_to) {
setAssignedValue(ticket.assigned_to.toString());
} else {
setAssignedValue("");
}
}, [ticket]);
const infoItems = useMemo(
() => [
{
label: "وضعیت",
value: (
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${statusColor(
ticket?.status
)}`}
>
{ticket?.status?.name || "-"}
</span>
),
},
{
label: "دپارتمان",
value: ticket?.department?.name || "-",
},
{
label: "موضوع",
value: ticket?.subject?.name || "-",
},
{
label: "مسئول",
value: ticket?.assigned_user
? `${ticket.assigned_user.first_name || ""} ${
ticket.assigned_user.last_name || ""
}`.trim() || ticket.assigned_user.username
: ticket?.assigned_to || "-",
},
{
label: "تاریخ ایجاد",
value: ticket?.created_at || "-",
},
{
label: "آخرین بروزرسانی",
value: ticket?.updated_at || "-",
},
],
[ticket]
);
const handleStatusUpdate = () => {
if (!id || !statusId) return;
updateStatus({
ticketId: id,
payload: { status_id: statusId },
});
};
const handleAssign = () => {
if (!id || !assignedValue) return;
assignTicket({
ticketId: id,
payload: { assigned_to: Number(assignedValue) },
});
};
const handleFileUpload = async (file: File) => {
const result = await uploadFile(file);
setAttachments((prev) => [...prev, result]);
return result;
};
const handleFileRemove = (fileId: string) => {
setAttachments((prev) => prev.filter((file) => file.id !== fileId));
deleteFile(fileId);
};
const handleReply = () => {
if (!id || !message.trim()) return;
sendReply(
{
ticketId: id,
payload: {
message: message.trim(),
file_ids: attachments
.map((file) => Number(file.id))
.filter((fileId) => !Number.isNaN(fileId)),
},
},
{
onSuccess: () => {
setMessage("");
setAttachments([]);
setUploaderKey((prev) => prev + 1);
},
}
);
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<LoadingSpinner />
</div>
);
}
if (error || !ticket) {
return (
<PageContainer>
<div className="card p-6 text-center text-red-600 dark:text-red-400">
خطا در بارگذاری تیکت
</div>
</PageContainer>
);
}
return (
<PageContainer className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Button
variant="secondary"
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowRight className="h-4 w-4" />
بازگشت
</Button>
<PageTitle>
تیکت {ticket.ticket_number} - {ticket.title}
</PageTitle>
</div>
</div>
<div className="card p-6 space-y-6">
<SectionTitle>اطلاعات تیکت</SectionTitle>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{infoItems.map((item) => (
<div
key={item.label}
className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg"
>
<p className="text-sm text-gray-500 dark:text-gray-400">
{item.label}
</p>
<div className="text-base font-medium text-gray-900 dark:text-gray-100 mt-1">
{item.value}
</div>
</div>
))}
</div>
</div>
<div className="card p-6 space-y-4">
<SectionTitle>مدیریت وضعیت و مسئول</SectionTitle>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-3">
<Label>تغییر وضعیت</Label>
<select
value={statusId || ""}
onChange={(e) =>
setStatusId(e.target.value ? Number(e.target.value) : undefined)
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
>
<option value="">انتخاب وضعیت</option>
{statuses?.map((status) => (
<option key={status.id} value={status.id}>
{status.name}
</option>
))}
</select>
<Button
variant="primary"
onClick={handleStatusUpdate}
disabled={!statusId || isUpdatingStatus}
loading={isUpdatingStatus}
>
بهروزرسانی وضعیت
</Button>
</div>
<div className="space-y-3">
<Label>اختصاص به</Label>
<Input
placeholder="شناسه کاربر مسئول"
type="number"
value={assignedValue}
onChange={(e) => setAssignedValue(e.target.value)}
/>
<Button
variant="primary"
onClick={handleAssign}
disabled={!assignedValue || isAssigning}
loading={isAssigning}
className="flex items-center gap-2"
>
<UserCheck className="h-4 w-4" />
ثبت مسئول
</Button>
</div>
</div>
</div>
<div className="card p-6 space-y-4">
<SectionTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
تاریخچه پیامها
</SectionTitle>
<div className="space-y-4">
{ticket.messages && ticket.messages.length > 0 ? (
ticket.messages.map((messageItem) => {
const isAdmin = messageItem.sender_type === "admin";
return (
<div
key={messageItem.id}
className={`flex ${isAdmin ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-3xl w-full sm:w-auto space-y-2 ${
isAdmin ? "text-white" : "text-gray-900 dark:text-gray-100"
}`}
>
<div
className={`rounded-2xl px-4 py-3 shadow-sm transition-colors ${
isAdmin
? "bg-primary-600 rounded-tl-2xl rounded-br-md"
: "bg-gray-100 dark:bg-gray-700 rounded-tr-2xl rounded-bl-md"
}`}
>
<div className="flex items-center justify-between text-xs opacity-80">
<span>{isAdmin ? "شما" : "کاربر"}</span>
<span>{messageItem.created_at}</span>
</div>
<p className="mt-2 text-sm leading-relaxed">
{messageItem.message}
</p>
{messageItem.attachments &&
messageItem.attachments.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{messageItem.attachments.map((attachment) => (
<a
key={attachment.id}
href={attachment.url}
target="_blank"
rel="noreferrer"
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs ${
isAdmin
? "border-white/30 text-white hover:bg-white/10"
: "border-gray-300 dark:border-gray-500 text-primary-600 dark:text-primary-300 hover:bg-white/40 dark:hover:bg-gray-600"
}`}
>
<Paperclip className="h-3.5 w-3.5" />
{attachment.original_name ||
`فایل ${attachment.id}`}
</a>
))}
</div>
)}
</div>
</div>
</div>
);
})
) : (
<div className="text-center text-gray-500 dark:text-gray-400">
پیامی ثبت نشده است
</div>
)}
</div>
</div>
<div className="card p-6 space-y-4">
<SectionTitle>ارسال پاسخ جدید</SectionTitle>
<div className="space-y-4">
<div>
<Label>متن پیام</Label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={4}
className="w-full border border-gray-300 dark:border-gray-600 rounded-lg p-3 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
placeholder="پاسخ خود را بنویسید"
/>
</div>
<FileUploader
key={uploaderKey}
onUpload={handleFileUpload}
onRemove={handleFileRemove}
acceptedTypes={["image/*", "application/*"]}
maxFileSize={25 * 1024 * 1024}
maxFiles={5}
label="ضمائم"
description="حداکثر ۵ فایل با مجموع حجم ۲۵ مگابایت"
onUploadStart={() => setIsUploading(true)}
onUploadComplete={() => setIsUploading(false)}
/>
<Button
variant="primary"
className="flex items-center gap-2"
onClick={handleReply}
disabled={!message.trim() || isReplying || isUploading}
loading={isReplying}
>
<Send className="h-4 w-4" />
ارسال پاسخ
</Button>
</div>
</div>
</PageContainer>
);
};
export default TicketDetailPage;

View File

@ -0,0 +1,267 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
useTicketDepartments,
useTicketStatuses,
useTickets,
} from "../core/_hooks";
import { TicketFilters, TicketStatus } from "../core/_models";
import { PageContainer, PageTitle } from "@/components/ui/Typography";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Table } from "@/components/ui/Table";
import { TableColumn } from "@/types";
import { Pagination } from "@/components/ui/Pagination";
import { MessageSquare, Settings, Search, Filter, Eye } from "lucide-react";
const statusColor = (status?: TicketStatus) => {
if (!status) return "bg-gray-100 text-gray-800";
if (status.slug === "pending") return "bg-yellow-100 text-yellow-800";
if (status.slug === "answered") return "bg-green-100 text-green-800";
if (status.slug === "closed") return "bg-gray-200 text-gray-800";
return "bg-primary-50 text-primary-700";
};
const TicketsListPage = () => {
const navigate = useNavigate();
const [filters, setFilters] = useState<TicketFilters>({
page: 1,
limit: 20,
search: "",
});
const { data, isLoading, error } = useTickets(filters);
const { data: departments } = useTicketDepartments({ activeOnly: true });
const { data: statuses } = useTicketStatuses({ activeOnly: true });
const columns: TableColumn[] = useMemo(
() => [
{
key: "ticket_number",
label: "شماره تیکت",
align: "right",
render: (value: string) => value || "-",
},
{
key: "title",
label: "عنوان",
align: "right",
render: (value: string) => value || "-",
},
{
key: "department",
label: "دپارتمان",
align: "right",
render: (_val, row: any) => row.department?.name || "-",
},
{
key: "status",
label: "وضعیت",
align: "right",
render: (_val, row: any) => (
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${statusColor(
row.status
)}`}
>
{row.status?.name || "-"}
</span>
),
},
{
key: "assigned_to",
label: "مسئول",
align: "right",
render: (_val, row: any) =>
row.assigned_user
? `${row.assigned_user.first_name || ""} ${
row.assigned_user.last_name || ""
}`.trim() || row.assigned_user.username
: row.assigned_to || "-",
},
{
key: "updated_at",
label: "آخرین بروزرسانی",
align: "right",
render: (value: string) => value || "-",
},
{
key: "actions",
label: "عملیات",
align: "right",
render: (_val, row: any) => (
<button
onClick={() => navigate(`/tickets/${row.id}`)}
className="text-primary-600 hover:text-primary-800"
>
<Eye className="h-4 w-4" />
</button>
),
},
],
[navigate]
);
const handlePageChange = (page: number) => {
setFilters((prev) => ({ ...prev, page }));
};
const handleFilterChange = (
key: keyof TicketFilters,
value: string | number | undefined
) => {
setFilters((prev) => ({
...prev,
[key]: value,
page: 1,
}));
};
if (error) {
return (
<PageContainer>
<div className="card p-6 text-center text-red-600 dark:text-red-400">
خطا در بارگذاری تیکتها
</div>
</PageContainer>
);
}
return (
<PageContainer>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<PageTitle className="flex items-center gap-2">
<MessageSquare className="h-6 w-6" />
مدیریت تیکتها
</PageTitle>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{data?.total || 0} تیکت ثبت شده
</p>
</div>
<div className="flex gap-3">
<Button
variant="secondary"
className="flex items-center gap-2"
onClick={() => navigate("/tickets/config")}
>
<Settings className="h-4 w-4" />
تنظیمات تیکت
</Button>
</div>
</div>
<div className="card p-4 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="relative">
<Search className="absolute right-3 top-3 h-4 w-4 text-gray-400" />
<input
value={filters.search || ""}
onChange={(e) => handleFilterChange("search", e.target.value)}
placeholder="جستجو در عنوان یا شماره تیکت"
className="w-full pr-9 pl-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<Input
label="شناسه مسئول"
type="number"
value={filters.assigned_to || ""}
onChange={(e) =>
handleFilterChange(
"assigned_to",
e.target.value ? Number(e.target.value) : undefined
)
}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
وضعیت
</label>
<select
value={filters.status_id || ""}
onChange={(e) =>
handleFilterChange(
"status_id",
e.target.value ? Number(e.target.value) : undefined
)
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
>
<option value="">همه وضعیتها</option>
{statuses?.map((status) => (
<option key={status.id} value={status.id}>
{status.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
دپارتمان
</label>
<select
value={filters.department_id || ""}
onChange={(e) =>
handleFilterChange(
"department_id",
e.target.value ? Number(e.target.value) : undefined
)
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
>
<option value="">همه دپارتمانها</option>
{departments?.map((department) => (
<option key={department.id} value={department.id}>
{department.name}
</option>
))}
</select>
</div>
<div className="flex items-end">
<Button
variant="secondary"
className="w-full flex items-center justify-center gap-2"
onClick={() =>
setFilters({
page: 1,
limit: 20,
search: "",
})
}
>
<Filter className="h-4 w-4" />
پاک کردن فیلترها
</Button>
</div>
</div>
</div>
{isLoading ? (
<Table columns={columns} data={[]} loading />
) : !data?.tickets || data.tickets.length === 0 ? (
<div className="card p-8 text-center text-gray-500 dark:text-gray-400">
تیکتی برای نمایش وجود ندارد
</div>
) : (
<>
<Table columns={columns} data={data.tickets as any[]} />
<Pagination
currentPage={filters.page || 1}
totalPages={Math.max(
1,
Math.ceil((data.total || 0) / (filters.limit || 20))
)}
onPageChange={handlePageChange}
itemsPerPage={filters.limit || 20}
totalItems={data.total || 0}
/>
</>
)}
</PageContainer>
);
};
export default TicketsListPage;

View File

@ -105,4 +105,9 @@ export const QUERY_KEYS = {
VERIFY_USER: "verify_user",
UNVERIFY_USER: "unverify_user",
USER_STATS: "user_stats",
GET_TICKETS: "get_tickets",
GET_TICKET: "get_ticket_details",
GET_TICKET_DEPARTMENTS: "get_ticket_departments",
GET_TICKET_STATUSES: "get_ticket_statuses",
GET_TICKET_SUBJECTS: "get_ticket_subjects",
};