@@ -581,7 +816,7 @@ const ProductFormPage = () => {
diff --git a/src/pages/tickets/core/_hooks.ts b/src/pages/tickets/core/_hooks.ts
new file mode 100644
index 0000000..c8ad2db
--- /dev/null
+++ b/src/pages/tickets/core/_hooks.ts
@@ -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 || "خطا در حذف موضوع");
+ },
+ });
+};
+
diff --git a/src/pages/tickets/core/_models.ts b/src/pages/tickets/core/_models.ts
new file mode 100644
index 0000000..58d76e7
--- /dev/null
+++ b/src/pages/tickets/core/_models.ts
@@ -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;
+}
+
diff --git a/src/pages/tickets/core/_requests.ts b/src/pages/tickets/core/_requests.ts
new file mode 100644
index 0000000..1be5f7c
--- /dev/null
+++ b/src/pages/tickets/core/_requests.ts
@@ -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
= {};
+ 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(
+ APIUrlGenerator(API_ROUTES.GET_TICKETS, queryParams)
+ );
+ return response.data;
+};
+
+export const getTicket = async (id: string) => {
+ const response = await httpGetRequest(
+ 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 = {};
+ if (typeof params?.activeOnly === "boolean") {
+ queryParams.active_only = params.activeOnly ? "true" : "false";
+ }
+ const response = await httpGetRequest(
+ APIUrlGenerator(API_ROUTES.GET_TICKET_DEPARTMENTS, queryParams)
+ );
+ return response.data.departments;
+};
+
+export const createTicketDepartment = async (
+ payload: TicketDepartmentPayload
+) => {
+ const response = await httpPostRequest(
+ APIUrlGenerator(API_ROUTES.CREATE_TICKET_DEPARTMENT),
+ payload
+ );
+ return response.data.department;
+};
+
+export const updateTicketDepartment = async (
+ id: string | number,
+ payload: TicketDepartmentPayload
+) => {
+ const response = await httpPutRequest(
+ 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 = {};
+ if (typeof params?.activeOnly === "boolean") {
+ queryParams.active_only = params.activeOnly ? "true" : "false";
+ }
+ const response = await httpGetRequest(
+ APIUrlGenerator(API_ROUTES.GET_TICKET_STATUSES, queryParams)
+ );
+ return response.data.statuses;
+};
+
+export const createTicketStatus = async (payload: TicketStatusPayload) => {
+ const response = await httpPostRequest(
+ APIUrlGenerator(API_ROUTES.CREATE_TICKET_STATUS),
+ payload
+ );
+ return response.data.status;
+};
+
+export const updateTicketStatusConfig = async (
+ id: string | number,
+ payload: TicketStatusPayload
+) => {
+ const response = await httpPutRequest(
+ 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 = {};
+ if (typeof params?.activeOnly === "boolean") {
+ queryParams.active_only = params.activeOnly ? "true" : "false";
+ }
+ if (params?.departmentId) {
+ queryParams.department_id = params.departmentId;
+ }
+ const response = await httpGetRequest(
+ APIUrlGenerator(API_ROUTES.GET_TICKET_SUBJECTS, queryParams)
+ );
+ return response.data.subjects;
+};
+
+export const createTicketSubject = async (payload: TicketSubjectPayload) => {
+ const response = await httpPostRequest(
+ APIUrlGenerator(API_ROUTES.CREATE_TICKET_SUBJECT),
+ payload
+ );
+ return response.data.subject;
+};
+
+export const updateTicketSubject = async (
+ id: string | number,
+ payload: TicketSubjectPayload
+) => {
+ const response = await httpPutRequest(
+ 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;
+};
+
diff --git a/src/pages/tickets/ticket-config/TicketConfigPage.tsx b/src/pages/tickets/ticket-config/TicketConfigPage.tsx
new file mode 100644
index 0000000..1a87003
--- /dev/null
+++ b/src/pages/tickets/ticket-config/TicketConfigPage.tsx
@@ -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("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) => (
+
+
+
+
+ ),
+ },
+ ],
+ [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) => (
+
+
+
+
+ ),
+ },
+ ],
+ [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) => (
+
+
+
+
+ ),
+ },
+ ],
+ [deleteSubject]
+ );
+
+ const renderDepartments = () => (
+
+
+
+
+ {departmentForm.id ? "ویرایش دپارتمان" : "دپارتمان جدید"}
+
+
+
+
+ );
+
+ const renderStatuses = () => (
+
+
+
+
+ {statusForm.id ? "ویرایش وضعیت" : "وضعیت جدید"}
+
+
+
+
+ );
+
+ const renderSubjects = () => (
+
+
+
+
+ {subjectForm.id ? "ویرایش موضوع" : "موضوع جدید"}
+
+
+
+
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+ {activeTab === "departments" && renderDepartments()}
+ {activeTab === "statuses" && renderStatuses()}
+ {activeTab === "subjects" && renderSubjects()}
+
+ );
+};
+
+export default TicketConfigPage;
+
diff --git a/src/pages/tickets/ticket-detail/TicketDetailPage.tsx b/src/pages/tickets/ticket-detail/TicketDetailPage.tsx
new file mode 100644
index 0000000..27af413
--- /dev/null
+++ b/src/pages/tickets/ticket-detail/TicketDetailPage.tsx
@@ -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();
+ const [assignedValue, setAssignedValue] = useState("");
+ 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: (
+
+ {ticket?.status?.name || "-"}
+
+ ),
+ },
+ {
+ 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 (
+
+
+
+ );
+ }
+
+ if (error || !ticket) {
+ return (
+
+
+ خطا در بارگذاری تیکت
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ تیکت {ticket.ticket_number} - {ticket.title}
+
+
+
+
+
+
اطلاعات تیکت
+
+ {infoItems.map((item) => (
+
+
+ {item.label}
+
+
+ {item.value}
+
+
+ ))}
+
+
+
+
+
مدیریت وضعیت و مسئول
+
+
+
+
+
+
+
+
+ setAssignedValue(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+ تاریخچه پیامها
+
+
+ {ticket.messages && ticket.messages.length > 0 ? (
+ ticket.messages.map((messageItem) => {
+ const isAdmin = messageItem.sender_type === "admin";
+ return (
+
+
+
+
+ {isAdmin ? "شما" : "کاربر"}
+ {messageItem.created_at}
+
+
+ {messageItem.message}
+
+ {messageItem.attachments &&
+ messageItem.attachments.length > 0 && (
+
+ )}
+
+
+
+ );
+ })
+ ) : (
+
+ پیامی ثبت نشده است
+
+ )}
+
+
+
+
+
ارسال پاسخ جدید
+
+
+
+
+
setIsUploading(true)}
+ onUploadComplete={() => setIsUploading(false)}
+ />
+
+
+
+
+ );
+};
+
+export default TicketDetailPage;
+
diff --git a/src/pages/tickets/tickets-list/TicketsListPage.tsx b/src/pages/tickets/tickets-list/TicketsListPage.tsx
new file mode 100644
index 0000000..12e1d7b
--- /dev/null
+++ b/src/pages/tickets/tickets-list/TicketsListPage.tsx
@@ -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({
+ 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) => (
+
+ {row.status?.name || "-"}
+
+ ),
+ },
+ {
+ 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) => (
+
+ ),
+ },
+ ],
+ [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 (
+
+
+ خطا در بارگذاری تیکتها
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ مدیریت تیکتها
+
+
+ {data?.total || 0} تیکت ثبت شده
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ ) : !data?.tickets || data.tickets.length === 0 ? (
+
+ تیکتی برای نمایش وجود ندارد
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ );
+};
+
+export default TicketsListPage;
+
diff --git a/src/utils/query-key.ts b/src/utils/query-key.ts
index 9a9b023..9e00373 100644
--- a/src/utils/query-key.ts
+++ b/src/utils/query-key.ts
@@ -105,4 +105,9 @@ export const QUERY_KEYS = {
VERIFY_USER: "verify_user",
UNVERIFY_USER: "unverify_user",
USER_STATS: "user_stats",
+ GET_TICKETS: "get_tickets",
+ GET_TICKET: "get_ticket_details",
+ GET_TICKET_DEPARTMENTS: "get_ticket_departments",
+ GET_TICKET_STATUSES: "get_ticket_statuses",
+ GET_TICKET_SUBJECTS: "get_ticket_subjects",
};