feat(tickets): implement ticket management features including listing, details, and configuration
This commit is contained in:
parent
f4aecefbe7
commit
e4d5ac4736
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 || '';
|
||||
|
|
@ -22,9 +23,30 @@ 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 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">
|
||||
{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)}
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
{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 && (
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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 || "خطا در حذف موضوع");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue