diff --git a/BACKEND/Modules/Admin/app/Http/Controllers/ProfileController.php b/BACKEND/Modules/Admin/app/Http/Controllers/ProfileController.php index d32762b..e1c812e 100644 --- a/BACKEND/Modules/Admin/app/Http/Controllers/ProfileController.php +++ b/BACKEND/Modules/Admin/app/Http/Controllers/ProfileController.php @@ -10,9 +10,14 @@ use App\Traits\HasFilterRequest; use App\Traits\HasOrderByRequest; use App\Traits\HasSearchRequest; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\Log; +use Modules\Admin\app\Models\Admin; use Modules\Admin\app\Models\Sprint; use Modules\Admin\app\Models\UserCriteria; +use App\Models\Files; +use App\DataTransferObjects\FileData; class ProfileController extends Controller { @@ -28,6 +33,7 @@ class ProfileController extends Controller $this->jiraService = $jiraService; } + public function getProfilesData(Request $request) { $user = auth('admins')->user(); @@ -125,7 +131,7 @@ class ProfileController extends Controller $rootFolder = rtrim($rootFolder, '/') . '/'; // Get all files and directories in the specified root folder - $fileList = $this->getDirectoryTree(public_path($rootFolder), env('APP_ENV') === 'local' ? $rootFolder: 'image'.$rootFolder); + $fileList = $this->getDirectoryTree(public_path($rootFolder), env('APP_ENV') === 'local' ? $rootFolder : 'image' . $rootFolder); return response()->json(['data' => $fileList, 'status' => true]); } @@ -185,6 +191,10 @@ class ProfileController extends Controller if (!Storage::disk('public')->exists($othersDirectory)) { Storage::disk('public')->makeDirectory($othersDirectory); } + + $adminEmails = Admin::where('permission', 'like', '%admin%')->pluck('email')->toArray(); + $currentUser = auth('admins')->user(); + if ($request->hasFile('files')) { foreach ($request->file('files') as $file) { // Store the file and get its path @@ -197,6 +207,32 @@ class ProfileController extends Controller $path = $file->storeAs($baseDirectory, $originalFilename, 'public'); } $uploadedFiles[] = $path; + + // Tạo URL đầy đủ cho file + $fileUrl = (env('APP_ENV') === 'prod' || env('APP_ENV') === 'production') + ? env('APP_URL') . '/image/' . str_replace('/storage/', '', Storage::url($path)) + : env('APP_URL') . str_replace('/storage/', '', Storage::url($path)); + + // // Gửi email thông báo cho admin + // foreach ($adminEmails as $adminEmail) { + // $admin = Admin::where('email', $adminEmail)->first(); + // if ($admin) { + // $this->sendFileUploadNotification( + // $admin, + // "File {$originalFilename} đã được tải lên bởi {$currentUser->name}", + // $fileUrl, + // "[APAC Tech] {$currentUser->name} - Đã tải lên file mới" + // ); + // } + // } + + // // Gửi email xác nhận cho người tải lên + // $this->sendFileUploadNotification( + // $currentUser, + // "Bạn đã tải lên file {$originalFilename} thành công", + // $fileUrl, + // "[APAC Tech] {$currentUser->name} - Tải file thành công" + // ); } } @@ -237,4 +273,195 @@ class ProfileController extends Controller 'message' => 'File not found', ], 404); } + + public function sendFileUploadNotification($user, $description, $url, $subject, $note) + { + try { + // Gửi email bất đồng bộ không cần job + dispatch(function() use ($user, $description, $url, $subject, $note) { + Mail::send('emails.file_upload_notification', [ + 'user' => $user, + 'description' => $description, + 'url' => $url, + 'note' => $note + ], function ($message) use ($user, $subject) { + $message->to($user->email) + ->subject($subject); + }); + })->afterResponse(); + + return true; + } catch (\Exception $e) { + Log::error('Error dispatching file upload notification email: ' . $e->getMessage()); + return false; + } + } + + public function uploadFiles(Request $request) + { + try { + $request->validate([ + 'file' => 'required|file|mimes:jpg,jpeg,png,pdf,doc,docx,xls,xlsx,csv|max:5120', + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'user_name' => 'required|string|max:255' + ]); + + $file = $request->file('file'); + $user = auth('admins')->user(); + + // Tạo thư mục cho user nếu chưa tồn tại + $userFolder = 'files/' . $request->user_name; + if (!Storage::disk('public')->exists($userFolder)) { + Storage::disk('public')->makeDirectory($userFolder); + } + + $path = $file->store($userFolder, 'public'); + + $fileRecord = Files::create([ + 'name' => $request->name, + 'url' => $path, + 'type' => $this->getFileType($file->getClientOriginalName()), + 'description' => $request->description, + 'user_id' => Admin::where('name', $request->user_name)->first()->id + ]); + + $currentUser = Admin::where('name', $request->user_name)->first(); + // Gửi email thông báo cho người upload + $fileUrl = (env('APP_ENV') === 'prod' || env('APP_ENV') === 'production') + ? env('APP_URL') . '/image' . Storage::url($path) + : env('APP_URL') . Storage::url($path); + $this->sendFileUploadNotification( + $user, + 'Bạn đã tải lên file "' . $request->name . '" thành công', + $fileUrl, + "[APAC Tech] {$currentUser->name} - Đã tải lên file mới", + $request->description ?? 'No description' + ); + + // Gửi email thông báo cho tất cả admin khác + $otherAdmins = Admin::where('permission', 'like', '%admin%')->get(); + foreach ($otherAdmins as $admin) { + $this->sendFileUploadNotification( + $admin, + 'File "' . $request->name . '" đã được tải lên bởi ' . $user->name, + $fileUrl, + "[APAC Tech] {$currentUser->name} - Đã tải lên file mới", + $request->description ?? 'No description' + ); + } + + return response()->json([ + 'status' => true, + 'message' => 'File uploaded successfully', + 'data' => [ + 'id' => $fileRecord->id, + 'name' => $fileRecord->name, + 'url' => Storage::url($path), + 'type' => $fileRecord->type, + 'description' => $fileRecord->description + ] + ]); + + } catch (\Exception $e) { + return response()->json([ + 'status' => false, + 'message' => $e->getMessage() + ], 500); + } + } + + public function getFiles() + { + try { + $files = Files::with('user')->get() + ->map(function($file) { + return [ + 'id' => $file->id, + 'name' => $file->name, + 'url' => Storage::url($file->url), + 'type' => $file->type, + 'description' => $file->description, + 'created_at' => $file->created_at, + 'user_id' => $file->user_id, + 'user_name' => $file->user->name + ]; + }); + + // Gom nhóm files theo tên user + $groupedFiles = $files->groupBy('user_name') + ->map(function($files) { + return $files->map(function(array $file) { + return (object)[ + 'id' => $file['id'], + 'name' => $file['name'], + 'url' => $file['url'], + 'type' => $file['type'], + 'description' => $file['description'], + 'created_at' => $file['created_at'], + 'user_id' => $file['user_id'] + ]; + }); + }); + + return response()->json([ + 'status' => true, + 'data' => $groupedFiles + ]); + + } catch (\Exception $e) { + return response()->json([ + 'status' => false, + 'message' => $e->getMessage() + ], 500); + } + } + + public function deleteFile($id) + { + try { + $file = Files::findOrFail($id); + $user = auth('admins')->user(); + + if ($file->user_id !== $user->id) { + return response()->json([ + 'status' => false, + 'message' => 'Unauthorized' + ], 403); + } + + Storage::disk('public')->delete($file->url); + $file->delete(); + + return response()->json([ + 'status' => true, + 'message' => 'File deleted successfully' + ]); + + } catch (\Exception $e) { + return response()->json([ + 'status' => false, + 'message' => $e->getMessage() + ], 500); + } + } + + private function getFileType($filename) + { + $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + + $typeMap = [ + 'pdf' => 'document', + 'doc' => 'document', + 'docx' => 'document', + 'jpg' => 'image', + 'jpeg' => 'image', + 'png' => 'image', + 'xls' => 'spreadsheet', + 'xlsx' => 'spreadsheet', + 'csv' => 'spreadsheet' + ]; + + return $typeMap[$extension] ?? 'other'; + } } diff --git a/BACKEND/Modules/Admin/routes/api.php b/BACKEND/Modules/Admin/routes/api.php index b561918..8f9ee2c 100755 --- a/BACKEND/Modules/Admin/routes/api.php +++ b/BACKEND/Modules/Admin/routes/api.php @@ -174,6 +174,9 @@ Route::middleware('api') Route::get('/all-files', [ProfileController::class, 'listFiles'])->middleware('check.permission:admin.hr.staff.accountant'); Route::post('/update-profile', [ProfileController::class, 'updateProfile'])->middleware('check.permission:admin.hr.staff.accountant'); Route::get('/delete-profile-file', [ProfileController::class, 'removeFile'])->middleware('check.permission:admin.hr.staff.accountant'); + Route::get('/files', [ProfileController::class, 'getFiles'])->middleware('check.permission:admin.hr.staff.accountant'); + Route::post('/upload-files', [ProfileController::class, 'uploadFiles'])->middleware('check.permission:admin.hr.staff.accountant'); + Route::delete('/files/{id}', [ProfileController::class, 'deleteFile'])->middleware('check.permission:admin.hr.staff.accountant'); }); Route::group([ diff --git a/BACKEND/app/Models/Files.php b/BACKEND/app/Models/Files.php new file mode 100644 index 0000000..64be814 --- /dev/null +++ b/BACKEND/app/Models/Files.php @@ -0,0 +1,25 @@ +belongsTo(User::class); + } +} diff --git a/BACKEND/database/migrations/2025_03_27_131933_create_files_table.php b/BACKEND/database/migrations/2025_03_27_131933_create_files_table.php new file mode 100644 index 0000000..0951ec1 --- /dev/null +++ b/BACKEND/database/migrations/2025_03_27_131933_create_files_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->string('url'); + $table->string('type'); + $table->text('description')->nullable(); + $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('files'); + } +}; diff --git a/BACKEND/database/seeders/DatabaseSeeder.php b/BACKEND/database/seeders/DatabaseSeeder.php index a9f4519..c067f12 100755 --- a/BACKEND/database/seeders/DatabaseSeeder.php +++ b/BACKEND/database/seeders/DatabaseSeeder.php @@ -18,5 +18,10 @@ class DatabaseSeeder extends Seeder // 'name' => 'Test User', // 'email' => 'test@example.com', // ]); + + $this->call([ + UserSeeder::class, + FileSeeder::class, + ]); } } diff --git a/BACKEND/resources/views/emails/file_upload_notification.blade.php b/BACKEND/resources/views/emails/file_upload_notification.blade.php new file mode 100644 index 0000000..3bbad65 --- /dev/null +++ b/BACKEND/resources/views/emails/file_upload_notification.blade.php @@ -0,0 +1,72 @@ + + + + + Thông báo tải lên file mới + + + +
+
+

Thông báo tải lên file mới

+
+ +
+

Xin chào {{ $user->name }},

+ +

{{ $description }} ở hệ thống APAC Tech.

+

Note: {{ $note }}

+

Vui lòng kiểm tra ngay thông tin bằng cách nhấn nút bên dưới:

+ +
+ Kiểm tra ngay +
+ +

Trân trọng,
Đội ngũ APAC Tech

+
+ + +
+ + \ No newline at end of file diff --git a/FRONTEND/src/api/Admin.ts b/FRONTEND/src/api/Admin.ts index ef404fe..b1c613f 100755 --- a/FRONTEND/src/api/Admin.ts +++ b/FRONTEND/src/api/Admin.ts @@ -119,3 +119,8 @@ export const deleteDocument = API_URL + 'v1/admin/document/delete' // Download File export const downloadFile = API_URL + 'v1/admin/download-file' + +// Files APIs +export const getFiles = API_URL + 'v1/admin/profile/files' +export const uploadFiles = API_URL + 'v1/admin/profile/upload-files' +export const deleteFileById = API_URL + 'v1/admin/profile/files' diff --git a/FRONTEND/src/pages/AllProfiles/AllProfiles.tsx b/FRONTEND/src/pages/AllProfiles/AllProfiles.tsx index 1893703..9873dda 100644 --- a/FRONTEND/src/pages/AllProfiles/AllProfiles.tsx +++ b/FRONTEND/src/pages/AllProfiles/AllProfiles.tsx @@ -1,498 +1,330 @@ -import { - deleteFile, - getAllFilesInProfiles, - updateProfileFolder, -} from '@/api/Admin' -import { Xdelete } from '@/rtk/helpers/CRUD' +import { getFiles, uploadFiles } from '@/api/Admin' import { get } from '@/rtk/helpers/apiService' import { getAccessToken } from '@/rtk/localStorage' import { Box, Button, - FileInput, + Card, + Collapse, Group, Modal, - RenderTreeNodePayload, Stack, Text, TextInput, - Tooltip, - Tree, + Title, } from '@mantine/core' +import { notifications } from '@mantine/notifications' import { + IconChevronDown, + IconDownload, IconFileTypeDocx, IconFileTypePdf, IconFolder, - IconFolderOpen, - IconFolderX, IconListCheck, IconPhoto, + IconSearch, + IconTrash, } from '@tabler/icons-react' import axios from 'axios' import { useEffect, useState } from 'react' +import FileUploadForm from '../Profile/components/FileUploadForm' import classes from './AllProfiles.module.css' -interface FileIconProps { +interface FileData { + id: number name: string - isFolder: boolean - expanded: boolean + url: string + type: string + description?: string + created_at: string } -type TFileProfile = { - label: string - type: string - value: string - children?: TFileProfile[] +interface GroupedFiles { + [key: string]: FileData[] } const AllProfiles = () => { - const [treeData, setTreeData] = useState([]) - const [cv, setCv] = useState() - const [idCard, setIdCard] = useState() - const [transcript, setTranscript] = useState() - const [universityDiploma, setUniversityDiploma] = useState() - const [otherFiles, setOtherFiles] = useState([{ file: null, type: '' }]) - const [data, setData] = useState([]) - const [currentName, setCurrentName] = useState('') + const [groupedFiles, setGroupedFiles] = useState({}) + const [currentUser, setCurrentUser] = useState('') const [openedProfile, setOpenedProfile] = useState(false) - function FileIcon({ name, isFolder, expanded }: FileIconProps) { - if (name.endsWith('.pdf')) { - return - } + const [selectedFile, setSelectedFile] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [expandedFolders, setExpandedFolders] = useState<{ + [key: string]: boolean + }>({}) + const [searchTerms, setSearchTerms] = useState<{ [key: string]: string }>({}) - if (name.endsWith('.doc') || name.endsWith('.docx')) { - return - } - - if ( - name.endsWith('.jpg') || - name.endsWith('.png') || - name.endsWith('.jpeg') || - name.endsWith('.webp') - ) { - return - } - - if (isFolder) { - return expanded ? ( - - ) : ( - - ) - } - - return ( - - ) + const toggleFolder = (userName: string) => { + setExpandedFolders((prev) => ({ + ...prev, + [userName]: !prev[userName], + })) } - function Leaf({ - node, - expanded, - hasChildren, - elementProps, - }: RenderTreeNodePayload) { - return ( - - {!node.children ? ( - - - {node.label} - - ) : ( - <> - - {node.label} - - { - setCurrentName(node.label!.toString()) - setOpenedProfile(true) - }} - /> - - - )} - - ) + const getFileIcon = (type: string) => { + switch (type) { + case 'document': + return + case 'image': + return + default: + return + } } - const handleOtherFileChange = ( - index: number, - field: string, - value: File | string, + const handleSubmit = async ( + e: React.FormEvent, + fileName: string, + description: string, + currentUser: string ) => { - const updatedFiles: any = [...otherFiles] - updatedFiles[index][field] = value - setOtherFiles(updatedFiles) - } - - const addOtherFileInput = () => { - setOtherFiles([...otherFiles, { file: null, type: '' }]) - } - - const handleSubmit = async (e: any) => { e.preventDefault() + setIsLoading(true) const formData = new FormData() + if (selectedFile) { + formData.append('file', selectedFile) + formData.append('name', fileName) + formData.append('description', description) + formData.append('user_name', currentUser) - // Append each selected file to FormData - for (let i = 0; i < otherFiles.length; i++) { - if (otherFiles[i].file !== null && otherFiles[i].type !== '') { - formData.append( - 'files[]', - handleChangeFileName(otherFiles[i].file!, `__${otherFiles[i].type}`)!, - ) + try { + const token = await getAccessToken() + const response = await axios.post(uploadFiles, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + Authorization: `Bearer ${token}`, + }, + }) + + if (response.status === 200) { + setSelectedFile(null) + await getAllFiles() + return true + } + return false + } catch (error) { + console.error('Error uploading file:', error) + throw error + } finally { + setIsLoading(false) } } + return false + } - if (cv) { - formData.append('files[]', cv) - } - - if (idCard) { - formData.append('files[]', idCard) - } - - if (transcript) { - formData.append('files[]', transcript) - } - - if (universityDiploma) { - formData.append('files[]', universityDiploma) - } - - formData.append('name', currentName) - - const token = await getAccessToken() + const getAllFiles = async () => { try { - const response = await axios.post(updateProfileFolder, formData, { + const res = await get(getFiles) + if (res.status === true) { + setGroupedFiles(res.data) + } + } catch (error) { + console.log(error) + } + } + + const removeFile = async (id: number) => { + try { + const token = await getAccessToken(); + const response = await axios.delete(`${import.meta.env.VITE_BACKEND_URL}api/v1/admin/profile/files/${id}`, { headers: { - 'Content-Type': 'multipart/form-data', Authorization: `Bearer ${token}`, }, - }) + }); if (response.status === 200) { - getAllFile() - getTree() - setOtherFiles([]) + notifications.show({ + title: 'Thành công', + message: 'Xóa file thành công', + color: 'green', + }); + await getAllFiles(); } } catch (error) { - console.error('Error uploading files', error) + console.log(error); + notifications.show({ + title: 'Lỗi', + message: 'Không thể xóa file', + color: 'red', + }); } } - const getAllFile = async () => { - try { - const res = await get(getAllFilesInProfiles, { - root_folder: '/storage/profiles/' + currentName, - }) - if (res.status === true) { - setData(res.data) - } - } catch (error) { - console.log(error) - } - } - - const removeFile = async (url: string) => { - try { - await Xdelete(deleteFile, { file_url: url }, getAllFile) - getTree() - } catch (error) { - console.log(error) - } - } - const getTree = async () => { - try { - const res = await get(getAllFilesInProfiles, { - root_folder: '/storage/profiles', - }) - if (res.status === true) { - setTreeData(res.data) - } - } catch (error) { - console.log(error) - } - } - - const handleChangeFileName = (e: File, newName: string) => { - const originalFile = e // Get the original file - const extend = originalFile.name.split('.')[1] - if (originalFile) { - const newFileName = `${newName}.${extend}` // Create new file name - const newFile = new File([originalFile], newFileName, { - type: originalFile.type, - }) // Create new file object - - return newFile // Save the new file object for further processing - } - } - - const checkFileExist = (nameField: string) => { - const file = data.find((f) => f.label.includes(nameField)) - return file - } - useEffect(() => { - getTree() + getAllFiles() }, []) - useEffect(() => { - getAllFile() - }, [currentName]) + const filterFiles = (files: FileData[], searchTerm: string) => { + return files.filter( + (file) => + file.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (file.description && + file.description.toLowerCase().includes(searchTerm.toLowerCase())), + ) + } + return (

- Admin/ - Profiles + Admin/ + Files Management

- - } - /> + + + {Object.entries(groupedFiles).map(([userName, files]) => ( + + toggleFolder(userName)} + style={{ cursor: 'pointer' }} + > + + + {userName} + + + + + + + + + } + value={searchTerms[userName] || ''} + onChange={(e) => + setSearchTerms((prev) => ({ + ...prev, + [userName]: e.target.value, + })) + } + onClick={(e) => e.stopPropagation()} + /> + {filterFiles(files, searchTerms[userName] || '') + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .map((file: FileData) => ( + + + + {getFileIcon(file.type)} + + + {file.name} + + {file.description && ( + + {file.description} + + )} + + Uploaded:{' '} + {new Date(file.created_at).toLocaleDateString()} + + + + + + + + + + ), + )} + + + + ))} + + { setOpenedProfile(false) + setCurrentUser('') + setSelectedFile(null) }} > -
- - - CV - - {`: ${checkFileExist('cv')?.label}`} - - - - { - 0 - setCv(handleChangeFileName(e!, 'cv')) - }} - accept=".pdf,.doc,.docx" - /> - - CCCD - - {`: ${checkFileExist('idCard')?.label}`} - - - - - { - setIdCard(handleChangeFileName(e!, 'idCard')) - }} - accept=".jpg,.jpeg,.png,.pdf" - /> - - Bảng điểm - - {`: ${checkFileExist('transcript')?.label}`} - - - - { - setTranscript(handleChangeFileName(e!, 'transcript')) - }} - accept=".pdf" - /> - - - Bằng đại học - - {`: ${ - checkFileExist('universityDiploma')?.label - }`} - - - - { - setUniversityDiploma( - handleChangeFileName(e!, 'universityDiploma'), - ) - }} - accept=".pdf,.jpg,.jpeg,.png" - /> - - Danh sách file khác: - - {data - .find((f) => f.label === 'others') - ?.children?.map((c, index) => { - return ( - - - {`${c?.label}`} - - - - ) - })} - - {otherFiles.map((fileInput, index) => ( - - - handleOtherFileChange(index, 'file', file!) - } - w={'30%'} - /> - - handleOtherFileChange( - index, - 'type', - e.currentTarget.value, - ) - } - /> - - ))} - - - - -
+ file && setSelectedFile(file)} + removeFile={removeFile} + isLoading={isLoading} + currentUser={currentUser} + />
diff --git a/FRONTEND/src/pages/Profile/Profile.tsx b/FRONTEND/src/pages/Profile/Profile.tsx index 14c6210..e839e65 100644 --- a/FRONTEND/src/pages/Profile/Profile.tsx +++ b/FRONTEND/src/pages/Profile/Profile.tsx @@ -1,18 +1,16 @@ import { - deleteFile, - getAllFilesInProfiles, + getFiles, getProfilesData, listUserTechnical, - updateProfileFolder, updateProfilesData, updateUserTechnical, + uploadFiles } from '@/api/Admin' import { changePassword } from '@/api/Auth' import DataTableAll from '@/components/DataTable/DataTable' import PasswordRequirementInput from '@/components/PasswordRequirementInput/PasswordRequirementInput' import ProjectInvolvement from '@/components/ProjectInvolvement/ProjectInvolvement' import { logout } from '@/rtk/dispatches/auth' -import { Xdelete } from '@/rtk/helpers/CRUD' import { get, post, postImage } from '@/rtk/helpers/apiService' import { requirementsPassword } from '@/rtk/helpers/variables' import { useAppDispatch, useAppSelector } from '@/rtk/hooks' @@ -22,16 +20,13 @@ import { Avatar, Box, Button, - FileInput, Flex, - Group, Loader, Modal, PasswordInput, - Stack, Text, TextInput, - Title, + Title } from '@mantine/core' import { notifications } from '@mantine/notifications' import { @@ -46,15 +41,26 @@ import moment from 'moment' import { useCallback, useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import classes from './Profile.module.css' +import FileUploadForm from './components/FileUploadForm' const isCompactMenu = false -type TFileProfile = { - label: string - type: string - value: string - children?: TFileProfile[] +// type TFileProfile = { +// label: string +// type: string +// value: string +// children?: TFileProfile[] +// } + +interface FileData { + id: number; + name: string; + url: string; + type: string; + description?: string; + created_at: string; } + const Profile = () => { const user = useAppSelector((state) => state.authentication.user) const userData = getUser() @@ -77,6 +83,11 @@ const Profile = () => { const navigate = useNavigate() const dispatch = useAppDispatch() + const [selectedFile, setSelectedFile] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [data, setData] = useState([]) + const [openedProfile, setOpenedProfile] = useState(false) + const updateAvatar = async (file: File) => { try { const res = await postImage(updateProfilesData, file, 'post') @@ -137,102 +148,113 @@ const Profile = () => { return [] } - const [cv, setCv] = useState() - const [idCard, setIdCard] = useState() - const [transcript, setTranscript] = useState() - const [universityDiploma, setUniversityDiploma] = useState() - const [otherFiles, setOtherFiles] = useState([{ file: null, type: '' }]) - const [data, setData] = useState([]) - const [openedProfile, setOpenedProfile] = useState(false) - const handleOtherFileChange = ( - index: number, - field: string, - value: File | string, + // const [cv, setCv] = useState() + // const [idCard, setIdCard] = useState() + // const [transcript, setTranscript] = useState() + // const [universityDiploma, setUniversityDiploma] = useState() + // const [otherFiles, setOtherFiles] = useState([{ file: null, type: '' }]) + // const handleOtherFileChange = ( + // index: number, + // field: string, + // value: File | string, + // ) => { + // const updatedFiles: any = [...otherFiles] + // updatedFiles[index][field] = value + // setOtherFiles(updatedFiles) + // } + + // const addOtherFileInput = () => { + // setOtherFiles([...otherFiles, { file: null, type: '' }]) + // } + + const handleSubmit = async ( + e: React.FormEvent, + fileName: string, + description: string, ) => { - const updatedFiles: any = [...otherFiles] - updatedFiles[index][field] = value - setOtherFiles(updatedFiles) - } - - const addOtherFileInput = () => { - setOtherFiles([...otherFiles, { file: null, type: '' }]) - } - - const handleSubmit = async (e: any) => { e.preventDefault() + setIsLoading(true) const formData = new FormData() - // Append each selected file to FormData - for (let i = 0; i < otherFiles.length; i++) { - if (otherFiles[i].file !== null && otherFiles[i].type !== '') { - formData.append( - 'files[]', - handleChangeFileName(otherFiles[i].file!, `__${otherFiles[i].type}`)!, - ) + if (selectedFile) { + formData.append('file', selectedFile) + formData.append('name', fileName) + formData.append('description', description) + formData.append('user_name', user.user.name) + formData.append('user_id', user.user.id.toString()) + + try { + const token = await getAccessToken() + const response = await axios.post(uploadFiles, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + Authorization: `Bearer ${token}`, + }, + }) + + if (response.status === 200) { + await getAllFile() + setSelectedFile(null) + } + } catch (error) { + console.error('Error uploading file:', error) + } finally { + setIsLoading(false) } } - - if (cv) { - formData.append('files[]', cv) - } - - if (idCard) { - formData.append('files[]', idCard) - } - - if (transcript) { - formData.append('files[]', transcript) - } - - if (universityDiploma) { - formData.append('files[]', universityDiploma) - } - - const token = await getAccessToken() - try { - const response = await axios.post(updateProfileFolder, formData, { - headers: { - 'Content-Type': 'multipart/form-data', - Authorization: `Bearer ${token}`, - }, - }) - - if(response.status === 200){ - getAllFile() - setOtherFiles([]) - } - } catch (error) { - console.error('Error uploading files', error) - } } const getAllFile = async () => { try { - const res = await get(getAllFilesInProfiles, { - root_folder: '/storage/profiles/' + JSON.parse(getUser())?.user?.name, - }) + const res = await get(getFiles) if (res.status === true) { - setData(res.data) + const userFiles = res.data[user.user.name] || []; + setData(userFiles); } } catch (error) { console.log(error) + notifications.show({ + title: 'Lỗi', + message: 'Không thể tải danh sách file', + color: 'red', + }) } } - const removeFile = async (url: string) => { + const removeFile = async (id: number) => { try { - await Xdelete(deleteFile, {file_url: url}, getAllFile) + const token = await getAccessToken(); + const response = await axios.delete(`${import.meta.env.VITE_BACKEND_URL}api/v1/admin/profile/files/${id}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response.status === 200) { + notifications.show({ + title: 'Thành công', + message: 'Xóa file thành công', + color: 'green', + }); + await getAllFile(); + } } catch (error) { - console.log(error) + console.log(error); + notifications.show({ + title: 'Lỗi', + message: 'Không thể xóa file', + color: 'red', + }); } - } + }; + useEffect(() => { const fetchData = async () => { const result = await getListProfilesData() setDataProfile(result ?? []) + await getAllFile() } fetchData() - getAllFile() }, []) const handleChangePassword = async () => { @@ -306,23 +328,19 @@ const Profile = () => { dispatch(logout(navigate)) }, [dispatch, navigate]) - const handleChangeFileName = (e: File, newName: string) => { - const originalFile = e // Get the original file - const extend = originalFile.name.split('.')[1] - if (originalFile) { - const newFileName = `${newName}.${extend}` // Create new file name - const newFile = new File([originalFile], newFileName, { - type: originalFile.type, - }) // Create new file object + // const handleChangeFileName = (e: File, newName: string): File => { + // const originalFile = e; + // const extend = originalFile.name.split('.')[1]; + // const newFileName = `${newName}.${extend}`; + // return new File([originalFile], newFileName, { + // type: originalFile.type, + // }); + // }; - return newFile // Save the new file object for further processing - } - } - - const checkFileExist = (nameField: string) => { - const file = data.find((f) => f.label.includes(nameField)) - return file - } + // const checkFileExist = (nameField: string) => { + // const file = data.find((f) => f.name.includes(nameField)); + // return file; + // }; return (
@@ -578,162 +596,14 @@ const Profile = () => { }} > -
- - - CV - - {`: ${checkFileExist('cv')?.label}`} - - - - { - 0 - setCv(handleChangeFileName(e!, 'cv')) - }} - accept=".pdf,.doc,.docx" - /> - - CCCD - - {`: ${checkFileExist('idCard')?.label}`} - - - - - { - setIdCard(handleChangeFileName(e!, 'idCard')) - }} - accept=".jpg,.jpeg,.png,.pdf" - /> - - Bảng điểm - - {`: ${checkFileExist('transcript')?.label}`} - - - - { - setTranscript(handleChangeFileName(e!, 'transcript')) - }} - accept=".pdf" - /> - - - Bằng đại học - - {`: ${ - checkFileExist('universityDiploma')?.label - }`} - - - - { - setUniversityDiploma( - handleChangeFileName(e!, 'universityDiploma'), - ) - }} - accept=".pdf,.jpg,.jpeg,.png" - /> - - Danh sách file khác: - - {data.find((f)=>f.label === 'others')?.children?.map((c, index)=>{ - return - - {`${ - c?.label - }`} - - - - })} - - {otherFiles.map((fileInput, index) => ( - - - handleOtherFileChange(index, 'file', file!) - } - w={'30%'} - /> - - handleOtherFileChange( - index, - 'type', - e.currentTarget.value, - ) - } - /> - - ))} - - - - -
+ file && setSelectedFile(file)} + removeFile={removeFile} + isLoading={isLoading} + currentUser={user.user.name} + />
diff --git a/FRONTEND/src/pages/Profile/components/FileUploadForm.module.css b/FRONTEND/src/pages/Profile/components/FileUploadForm.module.css new file mode 100644 index 0000000..a02278b --- /dev/null +++ b/FRONTEND/src/pages/Profile/components/FileUploadForm.module.css @@ -0,0 +1,121 @@ +.fileContainer { + padding: 12px; + margin: 8px 0; + border: 1px solid #e9ecef; + border-radius: 6px; + transition: all 0.2s ease; + display: flex; + flex-direction: column; + gap: 4px; +} + +.fileContainer:hover { + background-color: #f8f9fa; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.fileHeader { + display: flex; + justify-content: space-between; + align-items: center; +} + +.fileName { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; +} + +.fileDescription { + font-size: 13px; + color: #666; + margin: 2px 0; +} + +.fileActions { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.fileLink { + color: #228be6; + text-decoration: none; + font-size: 13px; + transition: color 0.2s ease; + padding: 4px 8px; + border-radius: 4px; + background-color: #e7f5ff; +} + +.fileLink:hover { + color: #1c7ed6; + background-color: #d0ebff; +} + +.deleteButton { + padding: 4px 8px; + font-size: 13px; +} + +.fileInputGroup { + padding: 16px; + margin: 16px 0; + border: 2px dashed #e9ecef; + border-radius: 6px; + background-color: #f8f9fa; +} + +.fileInput, +.fileNameInput, +.descriptionInput { + margin-bottom: 12px; +} + +.saveButton { + margin-top: 16px; + width: 100%; + max-width: 180px; +} + +.saveButton:disabled { + background-color: #e9ecef; + cursor: not-allowed; +} + +.loadingOverlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.loadingSpinner { + width: 32px; + height: 32px;Ø + border: 3px solid #f3f3f3; + border-top: 3px solid #3498db; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.sectionTitle { + font-size: 16px; + font-weight: 600; + margin-bottom: 12px; + color: #343a40; + padding-bottom: 8px; + border-bottom: 1px solid #e9ecef; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} \ No newline at end of file diff --git a/FRONTEND/src/pages/Profile/components/FileUploadForm.tsx b/FRONTEND/src/pages/Profile/components/FileUploadForm.tsx new file mode 100644 index 0000000..d8315e7 --- /dev/null +++ b/FRONTEND/src/pages/Profile/components/FileUploadForm.tsx @@ -0,0 +1,251 @@ +import { + Box, + Button, + Card, + FileInput, + Group, + Stack, + Text, + TextInput, + Textarea, +} from '@mantine/core' +import { notifications } from '@mantine/notifications' +import { + IconDownload, + IconFileTypeDocx, + IconFileTypePdf, + IconFileTypeXls, + IconPhoto, + IconSearch, + IconTrash, +} from '@tabler/icons-react' +import { useState } from 'react' +import classes from './FileUploadForm.module.css' + +// type TFileProfile = { +// label: string +// type: string +// value: string +// children?: TFileProfile[] +// } + +interface FileData { + id: number + name: string + url: string + type: string + description?: string + created_at: string +} + +type FileUploadFormProps = { + data: FileData[]; + handleSubmit: (e: React.FormEvent, fileName: string, description: string, currentUser: string) => Promise; + handleFileChange: (file: File | null) => void; + removeFile: (id: number) => Promise; + isLoading: boolean; + currentUser: string; +}; + +const FileUploadForm = ({ + data, + handleSubmit, + handleFileChange, + removeFile, + isLoading, + currentUser, +}: FileUploadFormProps) => { + const [selectedFile, setSelectedFile] = useState(null) + const [fileName, setFileName] = useState('') + const [description, setDescription] = useState('') + const [isUploading, setIsUploading] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + + const handleFileSelect = (file: File | null) => { + setSelectedFile(file) + handleFileChange(file) + if (file) { + // Set default name as file name without extension + setFileName(file.name.split('.')[0]) + } + } + + const handleFormSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setIsUploading(true) + + try { + await handleSubmit(e, fileName, description, currentUser) + notifications.show({ + title: 'Thành công', + message: 'Tải file lên thành công', + color: 'green', + }) + setFileName('') + setDescription('') + setSelectedFile(null) + } catch (error) { + console.error('Error submitting form:', error) + notifications.show({ + title: 'Lỗi', + message: 'Không thể tải file lên', + color: 'red', + }) + } finally { + setIsUploading(false) + } + } + + const getFileIcon = (type: string) => { + switch (type) { + case 'document': + return + case 'image': + return + case 'spreadsheet': + return + default: + return + } + } + + const filteredFiles = data.filter( + (file) => + file.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (file.description && + file.description.toLowerCase().includes(searchTerm.toLowerCase())), + ) + + return ( + <> + {isLoading && ( +
+
+
+ )} + +
+ + Tài liệu + + + + + setFileName(e.target.value)} + className={classes.fileNameInput} + required + /> + +