diff --git a/api.ts b/api.ts index d3069b2..9ab4373 100644 --- a/api.ts +++ b/api.ts @@ -58,4 +58,11 @@ export const API = { GET_COMMITS: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits`, RESTORE_COMMIT: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits/restore`, }, + Submission:{ + GET_ALL: `${API_URL_ROOT}/submissions`, + GET_DETAIL: (id: number | string) => `${API_URL_ROOT}/submissions/${id}`, + UPDATE_STATUS: (id: number | string) => `${API_URL_ROOT}/submissions/${id}/status`, + CREATE: `${API_URL_ROOT}/submissions`, + DELETE: (id: string) => `${API_URL_ROOT}/submissions/${id}`, + } } \ No newline at end of file diff --git a/src/app/(admin)/(others-pages)/submissions/page.tsx b/src/app/(admin)/(others-pages)/submissions/page.tsx new file mode 100644 index 0000000..6e44c09 --- /dev/null +++ b/src/app/(admin)/(others-pages)/submissions/page.tsx @@ -0,0 +1,492 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import ComponentCard from "@/components/common/ComponentCard"; +import PageBreadcrumb from "@/components/common/PageBreadCrumb"; +import Pagination from "@/components/tables/Pagination"; +import { toast } from "sonner"; +import { useRouter } from "next/navigation"; +import CustomDateRangePicker from "@/components/common/CustomDateRangePicker"; +import SubmissionsTable, { + SubmissionItem, + SubmissionSortColumn, +} from "@/components/tables/SubmissionsTable"; +import { + getSubmissionPayload, + updateSubmissionPayload, +} from "@/interface/submission"; +import { apiGetSubmission, updateProject } from "@/service/submisisonService"; +import { LIMIT_ITEM_TABLE } from "../../../../../constant"; + +const formatDateTimeToISO = ( + dateStr: string, + timeStr: string, + isEndOfDay: boolean = false, +): string | undefined => { + if (!dateStr) return undefined; + const time = timeStr || (isEndOfDay ? "23:59" : "00:00"); + return `${dateStr}T${time}:00.000000+07:00`; +}; + +interface SubmissionsResponseData { + data: SubmissionItem[]; + pagination: { + current_page: number; + page_size: number; + total_records: number; + total_pages: number; + }; +} + +export default function Page() { + const router = useRouter(); + const [page, setPage] = useState(1); + const [limitInput, setLimitInput] = useState( + LIMIT_ITEM_TABLE.toString(), + ); + + const [searchTerm, setSearchTerm] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [userIdsFilter, setUserIdsFilter] = useState(""); + const [fromDate, setFromDate] = useState(""); + const [fromTime, setFromTime] = useState(""); + const [toDate, setToDate] = useState(""); + const [toTime, setToTime] = useState(""); + + const [resetKey, setResetKey] = useState(0); + + const [debouncedParams, setDebouncedParams] = useState({ + search: "", + limit: LIMIT_ITEM_TABLE, + statuses: "", + userIds: "", + fromDate: "", + fromTime: "", + toDate: "", + toTime: "", + }); + + const [tableData, setTableData] = useState( + null, + ); + const [loading, setLoading] = useState(true); + + const [sortBy, setSortBy] = useState("created_at"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + const [updatePayload, setUpdatePayload] = useState({ + review_note: "", + status: "APPROVED", + }); + + const handleReset = () => { + setSearchTerm(""); + setStatusFilter(""); + setUserIdsFilter(""); + setLimitInput(LIMIT_ITEM_TABLE.toString()); + setFromDate(""); + setFromTime(""); + setToDate(""); + setToTime(""); + setPage(1); + setResetKey((prev) => prev + 1); + }; + + const handleDateFilterChange = ( + startD: string, + endD: string, + startT: string, + endT: string, + ) => { + setFromDate(startD); + setToDate(endD); + setFromTime(startT); + setToTime(endT); + }; + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedParams({ + search: searchTerm, + limit: parseInt(limitInput) || LIMIT_ITEM_TABLE, + statuses: statusFilter, + userIds: userIdsFilter, + fromDate, + fromTime, + toDate, + toTime, + }); + setPage(1); + }, 100); + return () => clearTimeout(handler); + }, [ + searchTerm, + limitInput, + statusFilter, + userIdsFilter, + fromDate, + fromTime, + toDate, + toTime, + ]); + + const fetchSubmissionsData = useCallback(async () => { + setLoading(true); + try { + const payload: Partial = { + page: page, + limit: debouncedParams.limit, + search: debouncedParams.search || undefined, + sort: sortBy, + order: sortOrder, + statuses: debouncedParams.statuses + ? [debouncedParams.statuses] + : undefined, + user_ids: debouncedParams.userIds + ? debouncedParams.userIds + .split(",") + .map((id) => id.trim()) + .filter(Boolean) + : undefined, + }; + + const createdFrom = formatDateTimeToISO( + debouncedParams.fromDate, + debouncedParams.fromTime, + ); + if (createdFrom) payload.created_from = createdFrom; + + const createdTo = formatDateTimeToISO( + debouncedParams.toDate, + debouncedParams.toTime, + true, + ); + if (createdTo) payload.created_to = createdTo; + + const response = await apiGetSubmission(payload as getSubmissionPayload); + + if (response?.status && response?.data) { + setTableData(response.data); + } else { + setTableData(null); + } + } catch (err) { + toast.error("Lỗi lấy danh sách bài nộp"); + console.error("Lỗi lấy danh sách bài nộp:", err); + setTableData(null); + } finally { + setLoading(false); + } + }, [page, debouncedParams, sortBy, sortOrder]); + + useEffect(() => { + fetchSubmissionsData(); + }, [fetchSubmissionsData]); + + const handleSort = (column: SubmissionSortColumn) => { + setPage(1); + if (sortBy === column) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(column); + setSortOrder("desc"); + } + }; + + const handleViewDetails = (id: string) => { + router.push(`/submissions/${id}`); + }; + + const handleOpenActionModal = (item: SubmissionItem) => { + setSelectedItem(item); + setUpdatePayload({ + status: item.status, + review_note: "", + }); + setIsModalOpen(true); + }; + + const handleUpdateSubmit = async () => { + if (!selectedItem) return; + + setIsSubmitting(true); + try { + const response = await updateProject(selectedItem.id, updatePayload); + + if (response?.status) { + toast.success("Cập nhật thành công!"); + setIsModalOpen(false); + fetchSubmissionsData(); + } else { + if (response?.errors && Array.isArray(response.errors)) { + toast.error(response.errors.message || "Lỗi dữ liệu không hợp lệ"); + } else { + toast.error(response?.message || "Cập nhật thất bại"); + } + } + } catch (error: any) { + const apiData = error.response?.data; + if (apiData?.errors && Array.isArray(apiData.errors)) { + apiData.errors.forEach((err: any) => { + if (err.message === "review_note is too short (min 10)") { + toast.error("Nội dung phải có ít nhất 10 ký tự"); + } else { + toast.error(err.message || "Dữ liệu không hợp lệ"); + } + }); + } else { + toast.error("Lỗi hệ thống khi cập nhật"); + } + console.error(error); + } finally { + setIsSubmitting(false); + } + }; + + const pagination = tableData?.pagination; + + return ( +
+ + +
+ + + + + + } + > +
+
+ + setSearchTerm(e.target.value)} + className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500" + /> +
+ +
+ + +
+ +
+ + setUserIdsFilter(e.target.value)} + className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500" + /> +
+ +
+ + +
+ +
+ + setLimitInput(e.target.value)} + className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500" + /> +
+
+
+ + +
+ {loading && ( +
+
+
+ )} + + +
+ +
+

+ Hiển thị {pagination?.total_records || 0} bản ghi +

+ + {pagination && pagination.total_pages > 1 && ( + setPage(newPage)} + /> + )} +
+
+
+ + {isModalOpen && ( +
+
+
+

+ Đánh giá yêu cầu +

+ +
+ +
+
+ + +
+ +
+ +