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
|
// Shipping Methods Pages
|
||||||
const ShippingMethodsListPage = lazy(() => import('./pages/shipping-methods/shipping-methods-list/ShippingMethodsListPage'));
|
const ShippingMethodsListPage = lazy(() => import('./pages/shipping-methods/shipping-methods-list/ShippingMethodsListPage'));
|
||||||
const ShippingMethodFormPage = lazy(() => import('./pages/shipping-methods/shipping-method-form/ShippingMethodFormPage'));
|
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 ProtectedRoute = ({ children }: { children: any }) => {
|
||||||
const { user, isLoading } = useAuth();
|
const { user, isLoading } = useAuth();
|
||||||
|
|
@ -147,6 +150,10 @@ const AppRoutes = () => {
|
||||||
<Route path="shipping-methods/create" element={<ShippingMethodFormPage />} />
|
<Route path="shipping-methods/create" element={<ShippingMethodFormPage />} />
|
||||||
<Route path="shipping-methods/:id/edit" 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 */}
|
{/* Products Routes */}
|
||||||
<Route path="products/create" element={<ProductFormPage />} />
|
<Route path="products/create" element={<ProductFormPage />} />
|
||||||
<Route path="products/:id" element={<ProductDetailPage />} />
|
<Route path="products/:id" element={<ProductDetailPage />} />
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ import {
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Users,
|
Users,
|
||||||
Truck,
|
Truck,
|
||||||
X
|
X,
|
||||||
|
MessageSquare
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { PermissionWrapper } from '../common/PermissionWrapper';
|
import { PermissionWrapper } from '../common/PermissionWrapper';
|
||||||
|
|
@ -28,6 +29,7 @@ interface MenuItem {
|
||||||
path?: string;
|
path?: string;
|
||||||
permission?: number;
|
permission?: number;
|
||||||
children?: MenuItem[];
|
children?: MenuItem[];
|
||||||
|
exact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
|
|
@ -51,6 +53,23 @@ const menuItems: MenuItem[] = [
|
||||||
icon: BadgePercent,
|
icon: BadgePercent,
|
||||||
path: '/discount-codes',
|
path: '/discount-codes',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'تیکتها',
|
||||||
|
icon: MessageSquare,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'لیست تیکتها',
|
||||||
|
icon: MessageSquare,
|
||||||
|
path: '/tickets',
|
||||||
|
exact: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'تنظیمات تیکت',
|
||||||
|
icon: Sliders,
|
||||||
|
path: '/tickets/config',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'مدیریت محصولات',
|
title: 'مدیریت محصولات',
|
||||||
icon: Package,
|
icon: Package,
|
||||||
|
|
@ -159,6 +178,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||||
const menuContent = (
|
const menuContent = (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={item.path!}
|
to={item.path!}
|
||||||
|
end={item.exact}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Close mobile menu when clicking a link
|
// Close mobile menu when clicking a link
|
||||||
if (window.innerWidth < 1024) {
|
if (window.innerWidth < 1024) {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export interface UploadedFile {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileUploaderProps {
|
interface FileUploaderProps {
|
||||||
onUpload: (file: File) => Promise<{ id: string; url: string }>;
|
onUpload: (file: File) => Promise<{ id: string; url: string; mimeType?: string }>;
|
||||||
onRemove?: (fileId: string) => void;
|
onRemove?: (fileId: string) => void;
|
||||||
acceptedTypes?: string[];
|
acceptedTypes?: string[];
|
||||||
maxFileSize?: number;
|
maxFileSize?: number;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { FileUploader } from './FileUploader';
|
||||||
import { useFileUpload, useFileDelete } from '../../hooks/useFileUpload';
|
import { useFileUpload, useFileDelete } from '../../hooks/useFileUpload';
|
||||||
import { persianToEnglish, convertPersianNumbersInObject } from '../../utils/numberUtils';
|
import { persianToEnglish, convertPersianNumbersInObject } from '../../utils/numberUtils';
|
||||||
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
|
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
|
||||||
const toPublicUrl = (img: any): ProductImage => {
|
const toPublicUrl = (img: any): ProductImage => {
|
||||||
const rawUrl: string = img?.url || '';
|
const rawUrl: string = img?.url || '';
|
||||||
|
|
@ -16,15 +17,36 @@ const toPublicUrl = (img: any): ProductImage => {
|
||||||
? rawUrl
|
? rawUrl
|
||||||
: rawUrl
|
: rawUrl
|
||||||
? `${API_GATE_WAY}${rawUrl.startsWith('/') ? '' : '/'}${rawUrl}`
|
? `${API_GATE_WAY}${rawUrl.startsWith('/') ? '' : '/'}${rawUrl}`
|
||||||
: '';
|
: '';
|
||||||
return {
|
return {
|
||||||
id: (img?.id ?? img).toString(),
|
id: (img?.id ?? img).toString(),
|
||||||
url,
|
url,
|
||||||
alt: img?.alt || '',
|
alt: img?.alt || '',
|
||||||
order: img?.order ?? 0,
|
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 {
|
interface ProductOption {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -112,22 +134,21 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
||||||
|
|
||||||
const handleFileUpload = async (file: File) => {
|
const handleFileUpload = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
|
validateVariantMedia(file);
|
||||||
const result = await uploadFile(file);
|
const result = await uploadFile(file);
|
||||||
|
|
||||||
// Use functional update to avoid stale state when multiple files upload concurrently
|
|
||||||
setUploadedImages(prev => {
|
setUploadedImages(prev => {
|
||||||
const newImage: ProductImage = {
|
const newImage: ProductImage = {
|
||||||
id: result.id,
|
id: result.id,
|
||||||
url: result.url,
|
url: result.url,
|
||||||
alt: file.name,
|
alt: file.name,
|
||||||
order: prev.length
|
order: prev.length,
|
||||||
|
type: result.mimeType || file.type
|
||||||
};
|
};
|
||||||
return [...prev, newImage];
|
return [...prev, newImage];
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Upload error:', error);
|
toast.error(error?.message || 'خطا در آپلود فایل');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -329,11 +350,11 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
||||||
<FileUploader
|
<FileUploader
|
||||||
onUpload={handleFileUpload}
|
onUpload={handleFileUpload}
|
||||||
onRemove={handleFileRemove}
|
onRemove={handleFileRemove}
|
||||||
acceptedTypes={['image/*']}
|
acceptedTypes={['image/*', 'video/*']}
|
||||||
maxFileSize={5 * 1024 * 1024}
|
maxFileSize={25 * 1024 * 1024}
|
||||||
maxFiles={5}
|
maxFiles={5}
|
||||||
label=""
|
label=""
|
||||||
description="تصاویر مخصوص این variant را آپلود کنید"
|
description="فایلهای تصویری یا ویدیویی مخصوص این Variant را آپلود کنید"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{uploadedImages.length > 0 && (
|
{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">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
{uploadedImages.map((image, index) => (
|
{uploadedImages.map((image, index) => (
|
||||||
<div key={image.id} className="relative group">
|
<div key={image.id} className="relative group">
|
||||||
<img
|
{image.type?.startsWith('video') ? (
|
||||||
src={image.url}
|
<video
|
||||||
alt={image.alt || `تصویر ${index + 1}`}
|
src={image.url}
|
||||||
className="w-full h-20 object-cover rounded-lg border"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleFileRemove(image.id)}
|
onClick={() => handleFileRemove(image.id)}
|
||||||
|
|
|
||||||
|
|
@ -113,4 +113,24 @@ export const API_ROUTES = {
|
||||||
DELETE_USER: (id: string) => `users/${id}`,
|
DELETE_USER: (id: string) => `users/${id}`,
|
||||||
VERIFY_USER: (id: string) => `users/${id}/verify`,
|
VERIFY_USER: (id: string) => `users/${id}/verify`,
|
||||||
UNVERIFY_USER: (id: string) => `users/${id}/unverify`,
|
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 = () => {
|
export const useFileUpload = () => {
|
||||||
return useMutation({
|
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();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
formData.append("name", "uploaded-file");
|
formData.append("name", "uploaded-file");
|
||||||
|
|
@ -46,6 +46,7 @@ export const useFileUpload = () => {
|
||||||
return {
|
return {
|
||||||
id: response.data.file.id.toString(),
|
id: response.data.file.id.toString(),
|
||||||
url: response.data.file.url,
|
url: response.data.file.url,
|
||||||
|
mimeType: response.data.file.mime_type,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ export interface ProductImage {
|
||||||
url: string;
|
url: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
order: number;
|
order: number;
|
||||||
|
type?: string;
|
||||||
|
mime_type?: string;
|
||||||
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductVariant {
|
export interface ProductVariant {
|
||||||
|
|
@ -44,6 +47,8 @@ export interface Product {
|
||||||
status?: string;
|
status?: string;
|
||||||
attributes?: Record<string, any>;
|
attributes?: Record<string, any>;
|
||||||
file_ids: ProductImage[];
|
file_ids: ProductImage[];
|
||||||
|
explorer_file_ids?: number[];
|
||||||
|
explorer_files?: ProductImage[];
|
||||||
variants?: ProductVariant[];
|
variants?: ProductVariant[];
|
||||||
product_variants?: ProductVariant[];
|
product_variants?: ProductVariant[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|
@ -62,6 +67,8 @@ export interface ProductFormData {
|
||||||
variant_attribute_name?: string;
|
variant_attribute_name?: string;
|
||||||
file_ids: ProductImage[];
|
file_ids: ProductImage[];
|
||||||
variants: ProductVariantFormData[];
|
variants: ProductVariantFormData[];
|
||||||
|
explorer_file_ids?: ProductImage[];
|
||||||
|
is_delete_latest_explorer_files?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductVariantFormData {
|
export interface ProductVariantFormData {
|
||||||
|
|
@ -99,6 +106,7 @@ export interface CreateProductRequest {
|
||||||
type: number;
|
type: number;
|
||||||
attributes?: Record<string, any>;
|
attributes?: Record<string, any>;
|
||||||
file_ids?: number[];
|
file_ids?: number[];
|
||||||
|
explorer_file_ids?: number[];
|
||||||
variants?: CreateVariantRequest[];
|
variants?: CreateVariantRequest[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,6 +122,8 @@ export interface UpdateProductRequest {
|
||||||
type: number;
|
type: number;
|
||||||
attributes?: Record<string, any>;
|
attributes?: Record<string, any>;
|
||||||
file_ids?: number[];
|
file_ids?: number[];
|
||||||
|
explorer_file_ids?: number[];
|
||||||
|
is_delete_latest_explorer_files?: boolean;
|
||||||
variants?: UpdateVariantRequest[];
|
variants?: UpdateVariantRequest[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
||||||
import { useProduct } from '../core/_hooks';
|
import { useProduct } from '../core/_hooks';
|
||||||
import { PRODUCT_TYPE_LABELS } from '../core/_models';
|
import { PRODUCT_TYPE_LABELS } from '../core/_models';
|
||||||
import { PageContainer, PageTitle, SectionTitle } from '../../../components/ui/Typography';
|
import { PageContainer, PageTitle, SectionTitle } from '../../../components/ui/Typography';
|
||||||
|
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
|
||||||
|
|
||||||
const ProductDetailPage = () => {
|
const ProductDetailPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -29,8 +30,58 @@ const ProductDetailPage = () => {
|
||||||
return new Intl.NumberFormat('fa-IR').format(num);
|
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)
|
// تصاویر محصول (حاصل تجمیع دو فیلد مختلف از 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 || []);
|
const variants = (product.variants && product.variants.length > 0) ? product.variants : ((product as any).product_variants || []);
|
||||||
|
|
@ -222,6 +273,33 @@ const ProductDetailPage = () => {
|
||||||
</div>
|
</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) && (
|
{(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">
|
<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 { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||||
import { FileUploader } from "@/components/ui/FileUploader";
|
import { FileUploader } from "@/components/ui/FileUploader";
|
||||||
import { VariantManager } from "@/components/ui/VariantManager";
|
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 { FormHeader, PageContainer, SectionTitle, Label } from '../../../components/ui/Typography';
|
||||||
import { createNumberTransform, createOptionalNumberTransform, convertPersianNumbersInObject } from '../../../utils/numberUtils';
|
import { createNumberTransform, createOptionalNumberTransform, convertPersianNumbersInObject } from '../../../utils/numberUtils';
|
||||||
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
|
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
|
||||||
const productSchema = yup.object({
|
const productSchema = yup.object({
|
||||||
name: yup.string().required('نام محصول الزامی است').min(2, 'نام محصول باید حداقل 2 کاراکتر باشد'),
|
name: yup.string().required('نام محصول الزامی است').min(2, 'نام محصول باید حداقل 2 کاراکتر باشد'),
|
||||||
|
|
@ -34,6 +35,8 @@ const productSchema = yup.object({
|
||||||
product_option_id: yup.number().transform(createOptionalNumberTransform()).nullable(),
|
product_option_id: yup.number().transform(createOptionalNumberTransform()).nullable(),
|
||||||
file_ids: yup.array().of(yup.object()).default([]),
|
file_ids: yup.array().of(yup.object()).default([]),
|
||||||
variants: yup.array().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 => {
|
const toPublicUrl = (img: any): ProductImage => {
|
||||||
|
|
@ -51,9 +54,91 @@ const toPublicUrl = (img: any): ProductImage => {
|
||||||
url,
|
url,
|
||||||
alt: img?.alt || '',
|
alt: img?.alt || '',
|
||||||
order: img?.order ?? 0,
|
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 ProductFormPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
@ -61,6 +146,9 @@ const ProductFormPage = () => {
|
||||||
|
|
||||||
const [uploadedImages, setUploadedImages] = useState<ProductImage[]>([]);
|
const [uploadedImages, setUploadedImages] = useState<ProductImage[]>([]);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
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: product, isLoading: isLoadingProduct } = useProduct(id || '', isEdit);
|
||||||
const { data: categories, isLoading: isLoadingCategories } = useCategories();
|
const { data: categories, isLoading: isLoadingCategories } = useCategories();
|
||||||
|
|
@ -94,7 +182,9 @@ const ProductFormPage = () => {
|
||||||
category_ids: [],
|
category_ids: [],
|
||||||
product_option_id: undefined,
|
product_option_id: undefined,
|
||||||
file_ids: [],
|
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);
|
const normalizedImages: ProductImage[] = (initialImages || []).map(toPublicUrl);
|
||||||
setUploadedImages(normalizedImages);
|
setUploadedImages(normalizedImages);
|
||||||
setValue('file_ids', normalizedImages, { shouldValidate: true, shouldDirty: false });
|
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]);
|
}, [isEdit, product, reset]);
|
||||||
|
|
||||||
const handleFileUpload = async (file: File) => {
|
const handleFileUpload = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
|
await validateMediaFile(file);
|
||||||
const result = await uploadFile(file);
|
const result = await uploadFile(file);
|
||||||
|
|
||||||
setUploadedImages(prev => {
|
setUploadedImages(prev => {
|
||||||
|
|
@ -177,7 +276,8 @@ const ProductFormPage = () => {
|
||||||
id: result.id,
|
id: result.id,
|
||||||
url: result.url,
|
url: result.url,
|
||||||
alt: file.name,
|
alt: file.name,
|
||||||
order: prev.length
|
order: prev.length,
|
||||||
|
type: result.mimeType || file.type
|
||||||
};
|
};
|
||||||
const updated = [...prev, newImage];
|
const updated = [...prev, newImage];
|
||||||
setValue('file_ids', updated, { shouldValidate: true, shouldDirty: true });
|
setValue('file_ids', updated, { shouldValidate: true, shouldDirty: true });
|
||||||
|
|
@ -185,8 +285,8 @@ const ProductFormPage = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Upload error:', error);
|
toast.error(error?.message || 'خطا در آپلود فایل');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -198,6 +298,36 @@ const ProductFormPage = () => {
|
||||||
deleteFile(fileId);
|
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) => {
|
const onSubmit = (data: any) => {
|
||||||
|
|
@ -210,6 +340,13 @@ const ProductFormPage = () => {
|
||||||
})
|
})
|
||||||
.filter(id => id !== null);
|
.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 = {
|
const baseSubmitData = {
|
||||||
name: convertedData.name,
|
name: convertedData.name,
|
||||||
description: convertedData.description || '',
|
description: convertedData.description || '',
|
||||||
|
|
@ -221,7 +358,12 @@ const ProductFormPage = () => {
|
||||||
attributes: {},
|
attributes: {},
|
||||||
category_ids: convertedData.category_ids.length > 0 ? convertedData.category_ids : [],
|
category_ids: convertedData.category_ids.length > 0 ? convertedData.category_ids : [],
|
||||||
product_option_id: convertedData.product_option_id || null,
|
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);
|
console.log('Submitting product data:', baseSubmitData);
|
||||||
|
|
@ -246,8 +388,9 @@ const ProductFormPage = () => {
|
||||||
|
|
||||||
updateProduct({
|
updateProduct({
|
||||||
id: parseInt(id),
|
id: parseInt(id),
|
||||||
...baseSubmitData,
|
...submitBaseData,
|
||||||
variants: updateVariants
|
variants: updateVariants,
|
||||||
|
is_delete_latest_explorer_files: isDeleteExplorerFiles
|
||||||
}, {
|
}, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
navigate('/products');
|
navigate('/products');
|
||||||
|
|
@ -269,7 +412,7 @@ const ProductFormPage = () => {
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
createProduct({
|
createProduct({
|
||||||
...baseSubmitData,
|
...submitBaseData,
|
||||||
variants: createVariants
|
variants: createVariants
|
||||||
}, {
|
}, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
@ -449,12 +592,12 @@ const ProductFormPage = () => {
|
||||||
<FileUploader
|
<FileUploader
|
||||||
onUpload={handleFileUpload}
|
onUpload={handleFileUpload}
|
||||||
onRemove={handleFileRemove}
|
onRemove={handleFileRemove}
|
||||||
acceptedTypes={['image/*']}
|
acceptedTypes={['image/*', 'video/*']}
|
||||||
maxFileSize={5 * 1024 * 1024}
|
maxFileSize={25 * 1024 * 1024}
|
||||||
maxFiles={10}
|
maxFiles={10}
|
||||||
mode="multi"
|
mode="multi"
|
||||||
label=""
|
label=""
|
||||||
description="تصاویر محصول را اینجا بکشید یا کلیک کنید"
|
description="تصاویر یا ویدیوهای محصول را آپلود کنید (حداکثر ۲ مگ برای تصویر و ۲۵ مگ برای ویدیو)"
|
||||||
onUploadStart={() => setIsUploading(true)}
|
onUploadStart={() => setIsUploading(true)}
|
||||||
onUploadComplete={() => setIsUploading(false)}
|
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">
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
{uploadedImages.map((image, index) => (
|
{uploadedImages.map((image, index) => (
|
||||||
<div key={image.id} className="relative group">
|
<div key={image.id} className="relative group">
|
||||||
<img
|
{image.type?.startsWith('video') ? (
|
||||||
src={image.url}
|
<video
|
||||||
alt={image.alt || `تصویر ${index + 1}`}
|
src={image.url}
|
||||||
className="w-full h-24 object-cover rounded-lg border border-gray-200 dark:border-gray-600"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleFileRemove(image.id)}
|
onClick={() => handleFileRemove(image.id)}
|
||||||
|
|
@ -491,6 +643,80 @@ const ProductFormPage = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Variants Management */}
|
||||||
<div>
|
<div>
|
||||||
<VariantManager
|
<VariantManager
|
||||||
|
|
@ -511,11 +737,20 @@ const ProductFormPage = () => {
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{uploadedImages.length > 0 && (
|
{uploadedImages.length > 0 && (
|
||||||
<img
|
uploadedImages[0].type?.startsWith('video') ? (
|
||||||
src={uploadedImages[0].url}
|
<video
|
||||||
alt={formValues.name}
|
src={uploadedImages[0].url}
|
||||||
className="w-20 h-20 object-cover rounded-lg border"
|
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="flex-1 space-y-2">
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
|
@ -581,7 +816,7 @@ const ProductFormPage = () => {
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={!isValid || isLoading || isUploading}
|
disabled={!isValid || isLoading || isUploading || isExplorerUploading}
|
||||||
>
|
>
|
||||||
{isEdit ? 'بهروزرسانی' : 'ایجاد محصول'}
|
{isEdit ? 'بهروزرسانی' : 'ایجاد محصول'}
|
||||||
</Button>
|
</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",
|
VERIFY_USER: "verify_user",
|
||||||
UNVERIFY_USER: "unverify_user",
|
UNVERIFY_USER: "unverify_user",
|
||||||
USER_STATS: "user_stats",
|
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