diff --git a/api.ts b/api.ts index 5232437..d3069b2 100644 --- a/api.ts +++ b/api.ts @@ -7,7 +7,8 @@ export const API = { MEDIA: `${API_URL_ROOT}/users/current/media`, Update: `${API_URL_ROOT}/users/current`, CHANGE_PASSWORD: `${API_URL_ROOT}/users/current/password`, - APPLICATION: `${API_URL_ROOT}/users/current/application` + APPLICATION: `${API_URL_ROOT}/users/current/application`, + CURRENT_PROJECT: `${API_URL_ROOT}/users/current/project` }, Media:{ GET_MEDIA: `${API_URL_ROOT}/media`, @@ -41,5 +42,20 @@ export const API = { CREATE_CV: `${API_URL_ROOT}/historian/application`, APPLICATION: `${API_URL_ROOT}/historian/application`, DELETE_CV: (Id: number | string) => `${API_URL_ROOT}/historian/application/${Id}`, - } + }, + Project: { + GET_CURRENT_PROJECT: `${API_URL_ROOT}/users/current/project`, + CREATE: `${API_URL_ROOT}/projects`, + GET_ALL: `${API_URL_ROOT}/projects`, + GET_DETAIL: (id: number | string) => `${API_URL_ROOT}/projects/${id}`, + UPDATE: (id: number | string) => `${API_URL_ROOT}/projects/${id}`, + DELETE: (id: number | string) => `${API_URL_ROOT}/projects/${id}`, + ADD_MEMBER: (id: number | string) => `${API_URL_ROOT}/projects/${id}/members`, + UPDATE_MEMBER: (id: number | string, userId: number | string) => `${API_URL_ROOT}/projects/${id}/members/${userId}`, + REMOVE_MEMBER: (id: number | string, userId: number | string) => `${API_URL_ROOT}/projects/${id}/members/${userId}`, + CHANGE_OWNER: (id: number | string) => `${API_URL_ROOT}/projects/${id}/change-owner`, + CREATE_COMMIT: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits`, + GET_COMMITS: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits`, + RESTORE_COMMIT: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits/restore`, + }, } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 0ac2ecc..a1a4aa0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,15 @@ services: - history-admin: + history-user-master: build: context: . dockerfile: Dockerfile - container_name: history-admin + container_name: history-user-master restart: unless-stopped ports: - - "3011:3000" + - "3014:3000" networks: - - history-admin-network + - history-user-master-network networks: - history-admin-network: + history-user-master-network: driver: bridge diff --git a/src/app/user/account/applications/page.tsx b/src/app/user/account/applications/page.tsx index c2399fd..501ee5a 100644 --- a/src/app/user/account/applications/page.tsx +++ b/src/app/user/account/applications/page.tsx @@ -16,6 +16,7 @@ import "yet-another-react-lightbox/styles.css"; import "yet-another-react-lightbox/plugins/captions.css"; import Image from "next/image"; import { URL_MEDIA } from "../../../../../api"; +import { MediaItem } from "@/components/tables/MediaTable"; export default function ApplicationDetailPage() { const application = useSelector( @@ -39,7 +40,7 @@ export default function ApplicationDetailPage() { const config = statusConfig[application.status] || statusConfig.PENDING; - const isImageFile = (file: any) => { + const isImageFile = (file: MediaItem) => { const isImageMime = file.mime_type?.startsWith("image/"); const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key); return isImageMime || isImageExt; @@ -47,13 +48,13 @@ export default function ApplicationDetailPage() { const imageMediaOnly = (application.media || []).filter(isImageFile); - const imageSlides = imageMediaOnly.map((item: any) => ({ + const imageSlides = imageMediaOnly.map((item: MediaItem) => ({ src: `${URL_MEDIA}${item.storage_key}`, title: item.original_name, description: `Dung lượng: ${(item.size / 1024).toFixed(2)} KB`, })); - const handleMediaClick = (item: any) => { + const handleMediaClick = (item: MediaItem) => { const fileUrl = `${URL_MEDIA}${item.storage_key}`; if (isImageFile(item)) { const photoIndex = imageMediaOnly.findIndex( @@ -167,7 +168,7 @@ export default function ApplicationDetailPage() { Tài liệu đính kèm ({application.media.length})
- {application.media.map((item: any) => { + {application.media.map((item: MediaItem) => { const isImg = isImageFile(item); return (
(null); - const [applications, setApplications] = useState([]); + const [applications, setApplications] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { @@ -58,7 +59,7 @@ export default function LibraryPage() {
{(mediaData?.data?.length ?? 0) > 0 && (
- +
)} diff --git a/src/app/user/projects/page.tsx b/src/app/user/projects/page.tsx new file mode 100644 index 0000000..8aeffd6 --- /dev/null +++ b/src/app/user/projects/page.tsx @@ -0,0 +1,190 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import Link from "next/link"; +import PageBreadcrumb from "@/components/common/PageBreadCrumb"; +import ComponentCard from "@/components/common/ComponentCard"; +import { getCurrentProject, apiCreateProject, CreateProjectPayload } from "@/service/projectService"; +import { toast } from "sonner"; +import { useModal } from "@/hooks/useModal"; +import { Modal } from "@/components/ui/modal"; +import Button from "@/components/ui/button/Button"; +import Label from "@/components/form/Label"; +import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table"; +import Badge from "@/components/ui/badge/Badge"; +import { Project } from "@/interface/project"; + +export default function ProjectsPage() { + const [projects, setProjects] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { isOpen, openModal, closeModal } = useModal(); + const [formData, setFormData] = useState({ title: "", description: "", project_status: "PRIVATE" }); + + const fetchProjects = async () => { + try { + setIsLoading(true); + const res = await getCurrentProject(); + setProjects(res?.data?.items || res?.data || []); + } catch (error) { + console.error("Lỗi khi tải danh sách dự án:", error); + toast.error("Không thể tải danh sách dự án. Vui lòng thử lại!"); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchProjects(); + }, []); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleCreateProject = async (e: React.FormEvent) => { + e.preventDefault(); + if (!formData.title.trim()) { + toast.warning("Vui lòng nhập tên dự án!"); + return; + } + try { + setIsSubmitting(true); + await apiCreateProject(formData); + toast.success("Tạo dự án mới thành công!"); + closeModal(); + setFormData({ title: "", description: "", project_status: "PRIVATE" }); + fetchProjects(); // Tải lại danh sách sau khi tạo + } catch (error) { + console.error("Lỗi tạo dự án:", error); + toast.error("Có lỗi xảy ra khi tạo dự án."); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ + +
+ + + Tạo dự án mới + + } + > +
+ {isLoading && ( +
+
+
+ )} + + {projects.length > 0 ? ( +
+
+
+ + + + + Tên dự án + + + Mô tả + + + Trạng thái + + + Thao tác + + + + + {projects.map((project) => ( + + + {project.title} + + +

{project.description || "Chưa có mô tả cho dự án này..."}

+
+ + + {project.project_status || "N/A"} + + + + + Thao tác + + +
+ ))} +
+
+
+
+
+ ) : !isLoading && ( +
+

Bạn chưa có dự án nào.

+ +
+ )} +
+
+
+ + {/* Modal Tạo Dự án */} + +
+

Tạo dự án mới

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/user/role-upgrade/page.tsx b/src/app/user/role-upgrade/page.tsx index 48392e7..ca8c1b5 100644 --- a/src/app/user/role-upgrade/page.tsx +++ b/src/app/user/role-upgrade/page.tsx @@ -17,6 +17,7 @@ import "yet-another-react-lightbox/plugins/captions.css"; import { createHistorianCV } from "@/service/historianService"; import { toast } from "sonner"; import Swal from "sweetalert2"; +import { PresignedUrlResponse } from "@/interface/media"; type PendingFile = { id: string; @@ -26,7 +27,7 @@ type PendingFile = { size: number; type: "image" | "document"; extension: string; - presigned?: any; + presigned?: PresignedUrlResponse; }; export default function RoleUpgrade() { @@ -131,6 +132,9 @@ export default function RoleUpgrade() { setIsSubmitting(true); const uploadPromises = pendingFiles.map(async (item) => { + if (!item.presigned) { + throw new Error(`Không thể lấy URL tải lên cho tệp: ${item.name}`); + } await uploadFileToS3(item.file, item.presigned); const confirmRes = await confirmUpload(item.presigned.token_id); return confirmRes?.data?.id || confirmRes?.id; diff --git a/src/components/tables/ApplicationTable.tsx b/src/components/tables/ApplicationTable.tsx index 4bf8de7..db63a1f 100644 --- a/src/components/tables/ApplicationTable.tsx +++ b/src/components/tables/ApplicationTable.tsx @@ -8,7 +8,7 @@ import { TableRow, } from "../ui/table"; import Badge from "../ui/badge/Badge"; -import { ApplicationDto } from "@/interface/historian"; +import { Application } from "@/interface/historian"; export type AppSortColumn = | "created_at" @@ -19,9 +19,9 @@ export type AppSortColumn = | "updated_at"; interface ApplicationTableProps { - data: ApplicationDto[]; + data: Application[]; onSort: (column: AppSortColumn) => void; - onViewDetail: (app: ApplicationDto) => void; + onViewDetail: (app: Application) => void; sortBy?: AppSortColumn; sortOrder?: "asc" | "desc"; } diff --git a/src/components/tables/UserDetailModal.tsx b/src/components/tables/UserDetailModal.tsx index ca81329..1c3600f 100644 --- a/src/components/tables/UserDetailModal.tsx +++ b/src/components/tables/UserDetailModal.tsx @@ -80,7 +80,7 @@ export default function UserDetailModal({ ) : ( <> {(mediaData?.data?.length ?? 0) > 0 ? ( - + ) : (
Người dùng này chưa có dữ liệu media. diff --git a/src/components/user-profile/ApplicationList.tsx b/src/components/user-profile/ApplicationList.tsx index 527870d..2caa63d 100644 --- a/src/components/user-profile/ApplicationList.tsx +++ b/src/components/user-profile/ApplicationList.tsx @@ -56,7 +56,7 @@ export default function ApplicationList({ const handleViewDetail = (app: any) => { dispatch(setSelectedApplication(app)); - router.push(`/account/applications`); + router.push(`/user/account/applications`); }; const StatusIcons: Record = { diff --git a/src/interface/common.ts b/src/interface/common.ts new file mode 100644 index 0000000..8f3089d --- /dev/null +++ b/src/interface/common.ts @@ -0,0 +1,29 @@ +export interface CommonResponse { + status: boolean; + message: string; + data: T; + errors?: any; // Or a more specific error type +} + +export interface PaginatedResponse { + status: boolean; + message: string; + data: T[]; + pagination: { + current_page: number; + page_size: number; + total_records: number; + total_pages: number; + }; + errors?: any; +} + +export interface CursorPaginatedResponse { + status: boolean; + message: string; + data: { + items: T[]; + next_cursor_id?: string; + }; + errors?: any; +} \ No newline at end of file diff --git a/src/interface/historian.ts b/src/interface/historian.ts index 2e927e2..8c8a835 100644 --- a/src/interface/historian.ts +++ b/src/interface/historian.ts @@ -1,62 +1,29 @@ -export interface MediaDto { +import { MediaItem } from "@/components/tables/MediaTable"; + +export interface Reviewer { id: string; - storage_key: string; - original_name: string; - mime_type: string; - size: number; - created_at: string; + display_name: string; + email: string; + avatar_url?: string; } -export interface ApplicationDto { - id: string; - user_id?: string; - verify_type: string | number; +export interface ApplicationUser { + id?: string; + display_name: string; + email?: string; + avatar_url?: string; +} + +export interface Application { + id:string; content: string; - is_deleted: boolean; - status: string | number; - reviewed_by: string; - review_note: string; - reviewed_at: string | null; + status: "PENDING" | "APPROVED" | "REJECTED" | string | number; + verify_type: string | string[] | number | number[]; + user: ApplicationUser; + media: MediaItem[]; + reviewer?: Reviewer; + reviewed_at?: string; + review_note?: string; created_at: string; - updated_at?: string; - media: any[]; - user: { - display_name?: string; - avatar_url?: string; - full_name?: string; - id?: string; - email?: string; - }; - reviewer?: { - display_name?: string; - avatar_url?: string; - full_name?: string; - id?: string; - email?: string; - }; -} - -export interface GetApplicationsParams { - page?: number; - limit?: number; - search?: string; - sort?: string; - order?: "asc" | "desc"; - statuses?: string[]; - verify_types?: string; - created_from?: string; - created_to?: string; - reviewed_by?: string; -} - -export interface ApplicationResponse { - status: boolean; - message: string; - data: ApplicationDto[]; - pagination: { - current_page: number; - page_size: number; - total_records: number; - total_pages: number; - }; -} + updated_at: string; +} \ No newline at end of file diff --git a/src/interface/media.ts b/src/interface/media.ts index f7213cf..eec10a2 100644 --- a/src/interface/media.ts +++ b/src/interface/media.ts @@ -1,21 +1,20 @@ -interface item{ - file_metadata:string; - id:string; - mime_type:string; - original_name:string; - size:number; - updated_at:string; - user_id:string; - storage_key:string; +import { MediaItem } from "@/components/tables/MediaTable"; + +export interface PresignedUrlResponse { + token_id: string; + upload_url: string; + storage_key: string; + signed_headers: Record; } export interface MediaDto { - data?: item[]; - message?:string; - status?:boolean; -} - -export interface payloadPresignedMedia { - filename:string; - content_type:string; + status: boolean; + message: string; + data: MediaItem[]; + pagination: { + current_page: number; + page_size: number; + total_records: number; + total_pages: number; + }; } \ No newline at end of file diff --git a/src/interface/project.ts b/src/interface/project.ts new file mode 100644 index 0000000..2f81edb --- /dev/null +++ b/src/interface/project.ts @@ -0,0 +1,9 @@ +export interface Project { + id: string; + title: string; + description: string; + project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE"; + created_at: string; + updated_at: string; + // You can add other fields like 'members' if they are part of the response +} \ No newline at end of file diff --git a/src/layout/AppSidebar.tsx b/src/layout/AppSidebar.tsx index 13b81e1..3f3048f 100644 --- a/src/layout/AppSidebar.tsx +++ b/src/layout/AppSidebar.tsx @@ -38,6 +38,11 @@ const ALL_NAV_ITEMS: NavItem[] = [ // subItems: [{ name: "Ecommerce", path: "/", pro: false }], path: "/", }, + { + icon: , + name: "Dự Án", + path: "/user/projects", + }, { icon: , name: "Thư Viện", diff --git a/src/service/mediaService.ts b/src/service/mediaService.ts index 3a1358a..2a42f9f 100644 --- a/src/service/mediaService.ts +++ b/src/service/mediaService.ts @@ -1,7 +1,13 @@ import api from "@/config/config"; import { API } from "../../api"; -import { payloadPresignedMedia } from "@/interface/media"; import axios from "axios"; +import { PresignedUrlResponse } from "@/interface/media"; + +export interface payloadPresignedMedia { + fileName?: string; + content_type?: string; + size?: number; +} export const apiGetCurrentUserMedia = async ( payload: payloadPresignedMedia, @@ -41,16 +47,9 @@ export const getFileType = (mime: string): FileType => { return "other"; }; -type PreSignedResponse = { - token_id: string; - upload_url: string; - storage_key: string; - signed_headers: Record; -}; - export const uploadFileToS3 = async ( file: File, - presigned: PreSignedResponse, + presigned: PresignedUrlResponse, ) => { const res = await axios.put(presigned.upload_url, file, { headers: { @@ -70,7 +69,7 @@ export const confirmUpload = async (token_id: string) => { }; export const uploadMedia = async (file: File) => { - const { data: presigned } = await api.get( + const { data: presigned } = await api.get( "/media/presigned", { params: { @@ -90,7 +89,7 @@ export const uploadMedia = async (file: File) => { }; export const getPresignedUrl = async (file: File) => { - const { data: presigned } = await api.get( + const { data: presigned } = await api.get( "/media/presigned", { params: { diff --git a/src/service/projectService.ts b/src/service/projectService.ts new file mode 100644 index 0000000..50f7367 --- /dev/null +++ b/src/service/projectService.ts @@ -0,0 +1,114 @@ +import api from "@/config/config"; +import { API } from "../../api"; +import { Project } from "@/interface/project"; +import { CommonResponse, CursorPaginatedResponse } from "@/interface/common"; + +// ========================================== +// TYPES & INTERFACES (Cơ bản theo logic chuẩn) +// ========================================== + +export interface CreateProjectPayload { + title: string; + description?: string; + project_status?: "PRIVATE" | "PUBLIC" | "ARCHIVE"; +} + +export interface UpdateProjectPayload { + title?: string; + description?: string; + project_status?: "PRIVATE" | "PUBLIC" | "ARCHIVE"; +} + +export interface AddMemberPayload { + user_id: string; + role: "EDITOR" | "VIEWER"; +} + +export interface UpdateMemberRolePayload { + role: "EDITOR" | "VIEWER"; +} + +export interface ChangeOwnerPayload { + new_owner_id: string; +} + +export interface CreateCommitPayload { + edit_summary: string; + snapshot_json: number[]; +} + +export interface RestoreCommitPayload { + commit_id: string; +} + +// ========================================== +// 1. NHÓM: QUẢN LÝ DỰ ÁN (PROJECTS) +// ========================================== + +export const apiCreateProject = async (payload: CreateProjectPayload): Promise> => { + const response = await api.post(API.Project.CREATE, payload); + return response?.data; +}; + +export const apiGetProjectDetail = async (id: string): Promise> => { + const response = await api.get(API.Project.GET_DETAIL(id)); + return response?.data; +}; + +export const apiUpdateProject = async (id: string, payload: UpdateProjectPayload): Promise> => { + const response = await api.put(API.Project.UPDATE(id), payload); + return response?.data; +}; + +export const apiDeleteProject = async (id: string): Promise => { + const response = await api.delete(API.Project.DELETE(id)); + return response?.data; +}; + +// ========================================== +// 2. NHÓM: QUẢN LÝ THÀNH VIÊN (MEMBERS) +// ========================================== + +export const apiAddProjectMember = async (id: string, payload: AddMemberPayload): Promise => { + const response = await api.post(API.Project.ADD_MEMBER(id), payload); + return response?.data; +}; + +export const apiUpdateProjectMemberRole = async (id: string, userId: string, payload: UpdateMemberRolePayload): Promise => { + const response = await api.put(API.Project.UPDATE_MEMBER(id, userId), payload); + return response?.data; +}; + +export const apiRemoveProjectMember = async (id: string, userId: string): Promise => { + const response = await api.delete(API.Project.REMOVE_MEMBER(id, userId)); + return response?.data; +}; + +export const apiChangeProjectOwner = async (id: string, payload: ChangeOwnerPayload): Promise => { + const response = await api.put(API.Project.CHANGE_OWNER(id), payload); + return response?.data; +}; + +// ========================================== +// 3. NHÓM: LỊCH SỬ BẢN LƯU (COMMITS) +// ========================================== + +export const apiCreateProjectCommit = async (id: string, payload: CreateCommitPayload): Promise => { + const response = await api.post(API.Project.CREATE_COMMIT(id), payload); + return response?.data; +}; + +export const apiGetProjectCommits = async (id: string): Promise => { // Assuming it returns a list of commits + const response = await api.get(API.Project.GET_COMMITS(id)); + return response?.data; +}; + +export const apiRestoreProjectCommit = async (id: string, payload: RestoreCommitPayload): Promise => { + const response = await api.post(API.Project.RESTORE_COMMIT(id), payload); + return response?.data; +}; + +export const getCurrentProject = async (params?: { cursor_id?: string; limit?: number }): Promise> => { + const response = await api.get(API.Project.GET_CURRENT_PROJECT, { params }); + return response?.data; +}; \ No newline at end of file