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 */}
+
+
+
+
+ );
+}
\ 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