From 738e4ac2a1bebe78e4b8db30ec7d04f393ee415b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=B4=20Quang=20=C4=90=E1=BB=A9c?= Date: Tue, 2 Sep 2025 02:26:49 +0700 Subject: [PATCH 1/2] add saved posts list page --- src/app/core/models/post.models.ts | 11 +++ .../post-vertical-menu.ts | 8 +-- .../core/services/api-service/post.service.ts | 20 ++++++ .../services/config-service/api.enpoints.ts | 4 ++ src/app/features/auth/pages/login/login.html | 4 +- .../pages/chat/chat.component.html | 2 +- .../post/pages/post-list/post-list.html | 30 +------- .../post/pages/post-list/post-list.ts | 29 ++++++++ .../saved-posts-list.component.html | 22 ++++++ .../saved-posts-list.component.scss | 70 +++++++++++++++++++ .../saved-posts-list.component.ts | 60 ++++++++++++++++ src/app/features/post/post-routing.module.ts | 7 ++ .../app-layout/app-layout.component.scss | 2 +- 13 files changed, 232 insertions(+), 37 deletions(-) create mode 100644 src/app/features/post/pages/saved-posts-list/saved-posts-list.component.html create mode 100644 src/app/features/post/pages/saved-posts-list/saved-posts-list.component.scss create mode 100644 src/app/features/post/pages/saved-posts-list/saved-posts-list.component.ts diff --git a/src/app/core/models/post.models.ts b/src/app/core/models/post.models.ts index 8c8dad19..938fb2ee 100644 --- a/src/app/core/models/post.models.ts +++ b/src/app/core/models/post.models.ts @@ -13,6 +13,7 @@ export interface PostCardInfo { downvote: number; public: boolean; allowComment: boolean; + isSaved?: boolean; } export interface Tag { @@ -164,3 +165,13 @@ export interface PostDataCreateRequest { isLectureVideo: boolean; isTextbook: boolean; } + +export type SavedPostResponse = { + id: string; + saveAt: string; + post: { + id: string; + postId: string; + title: string; + }; +}; diff --git a/src/app/core/router-manager/vetical-menu-dynamic/post-vertical-menu.ts b/src/app/core/router-manager/vetical-menu-dynamic/post-vertical-menu.ts index abb4fac5..0051ea14 100644 --- a/src/app/core/router-manager/vetical-menu-dynamic/post-vertical-menu.ts +++ b/src/app/core/router-manager/vetical-menu-dynamic/post-vertical-menu.ts @@ -3,14 +3,14 @@ import { SidebarItem } from '../../models/data-handle'; export function sidebarPosts(role: string): SidebarItem[] { return [ { - id: 'populat', - path: 'exercise/popular', - label: 'Bài viết phổ biến', + id: 'saved-posts', + path: '/post-features/saved-posts-list', + label: 'Bài viết đã lưu', icon: 'fas fa-file-alt', }, { id: 'exercise', - path: '/post-management/post-list', + path: '/post-features/post-list', label: 'Quản lý bài viết', icon: 'fas fa-tasks', }, diff --git a/src/app/core/services/api-service/post.service.ts b/src/app/core/services/api-service/post.service.ts index 1e03d934..bc54943e 100644 --- a/src/app/core/services/api-service/post.service.ts +++ b/src/app/core/services/api-service/post.service.ts @@ -11,6 +11,7 @@ import { CreatePostRequest, postData, PostResponse, + SavedPostResponse, } from '../../models/post.models'; @Injectable({ @@ -142,4 +143,23 @@ export class PostService { API_CONFIG.ENDPOINTS.DELETE.DELETE_POST(id) ); } + + savePost(postId: string) { + return this.api.post>( + API_CONFIG.ENDPOINTS.POST.SAVE_POST(postId), + null + ); + } + + unSavePost(postId: string) { + return this.api.delete>( + API_CONFIG.ENDPOINTS.POST.SAVE_POST(postId) + ); + } + + getSavedPosts(page: number, size: number) { + return this.api.get>>( + API_CONFIG.ENDPOINTS.GET.GET_SAVED_POSTS(page, size) + ); + } } diff --git a/src/app/core/services/config-service/api.enpoints.ts b/src/app/core/services/config-service/api.enpoints.ts index 7c0bca24..aacbc1e4 100644 --- a/src/app/core/services/config-service/api.enpoints.ts +++ b/src/app/core/services/config-service/api.enpoints.ts @@ -129,6 +129,8 @@ export const API_CONFIG = { GET_CHAT_MESSAGES: (page: number, size: number, conversationId: string) => `/chat/messages?page=${page}&size=${size}&conversationId=${conversationId}`, GET_POST_DETAILS: (postId: string) => `/post/${postId}`, + GET_SAVED_POSTS: (page: number, size: number) => + `/profile/posts/saved?page=${page}&size=${size}`, GET_COMMENT_BY_POST_ID: ( postId: string, page: number, @@ -202,6 +204,7 @@ export const API_CONFIG = { SET_ROLE_FOR_USER_CHAT: (groupId: string) => `/chat/conversation/group/${groupId}/role`, REACTION_POST: (postId: string) => `/post/${postId}/reaction/toggle`, + SAVE_POST: (postId: string) => `/profile/post/${postId}/save`, ADD_COMMENT_POST: (postId: string) => `/post/${postId}/comment`, ADD_REPLY_COMMENT_POST: (postId: string, commentId: string) => `/post/${postId}/comment/${commentId}`, @@ -242,6 +245,7 @@ export const API_CONFIG = { DELETE_GROUP_CHAT: (groupId: string) => `/chat/conversation/group/${groupId}`, DELETE_POST: (postId: string) => `/post/${postId}`, + UNSAVE_POST: (postId: string) => `/profile/post/${postId}/save`, DELETE_COMMENT_POST: (commentId: string) => `/post/comment/${commentId}`, UNSAVE_EXERCISE: (exerciseId: string) => `/profile/exercise/${exerciseId}/save`, diff --git a/src/app/features/auth/pages/login/login.html b/src/app/features/auth/pages/login/login.html index b1d014d3..e603a081 100644 --- a/src/app/features/auth/pages/login/login.html +++ b/src/app/features/auth/pages/login/login.html @@ -113,10 +113,10 @@

CodeCampus

Đăng nhập bằng Google - +
diff --git a/src/app/features/excercise/exercise-pages/code-submission/code-submission.component.ts b/src/app/features/excercise/exercise-pages/code-submission/code-submission.component.ts index 4113f85e..27ac101f 100644 --- a/src/app/features/excercise/exercise-pages/code-submission/code-submission.component.ts +++ b/src/app/features/excercise/exercise-pages/code-submission/code-submission.component.ts @@ -63,6 +63,7 @@ export class CodeSubmissionComponent { isRunning = false; isSubmitting = false; // Thêm trạng thái submit hasError = false; + submitted = false; //anti-cheat allowTab: boolean = true; @@ -188,6 +189,10 @@ export class CodeSubmissionComponent { }); }) ); + + this.submitted = JSON.parse( + sessionStorage.getItem('codeSubmitted') ? 'true' : 'false' + ); } ngAfterViewChecked() { @@ -342,6 +347,8 @@ export class CodeSubmissionComponent { // cleanup socket this.socketSubs.forEach((s) => s.unsubscribe()); this.socketService.disconnect(); + + sessionStorage.removeItem('codeSubmitted'); } // --- Các hàm xử lý UI --- @@ -434,6 +441,8 @@ export class CodeSubmissionComponent { }); this.isSubmitting = false; + this.submitted = true; + sessionStorage.setItem('codeSubmitted', 'true'); }, error: (err) => { console.log(err); @@ -453,6 +462,9 @@ export class CodeSubmissionComponent { () => this.submitCode() ); } + confirmScore() { + this.router.navigate(['/exercise/exercise-layout/list']); + } get formattedTimeLeft(): string { const minutes = Math.floor(this.timeLeftSeconds / 60); diff --git a/src/app/features/excercise/exercise-routing.module.ts b/src/app/features/excercise/exercise-routing.module.ts index 927ef99a..4adc2ef5 100644 --- a/src/app/features/excercise/exercise-routing.module.ts +++ b/src/app/features/excercise/exercise-routing.module.ts @@ -45,7 +45,11 @@ const routes: Routes = [ { path: 'exercise-code-details/add-new/:id', component: AddCodeDetailsComponent, - data: { breadcrumb: 'Thêm mới chi tiết bài tập lập trình' }, + data: { + breadcrumb: 'Thêm mới chi tiết bài tập lập trình', + roles: ['ADMIN', 'TEACHER'], + }, + canActivate: [RoleGuard], }, { path: 'list', @@ -92,7 +96,7 @@ const routes: Routes = [ component: AssignExerciseComponent, data: { breadcrumb: 'Giao bài tập', - roles: ['ROLE_TEACHER', 'ROLE_ADMIN'], + roles: ['TEACHER', 'ADMIN'], }, canActivate: [RoleGuard], }, @@ -101,7 +105,7 @@ const routes: Routes = [ component: AssignExerciseComponent, data: { breadcrumb: 'Giao bài tập', - roles: ['ROLE_TEACHER', 'ROLE_ADMIN'], + roles: ['TEACHER', 'ADMIN'], }, canActivate: [RoleGuard], }, diff --git a/src/app/features/organization/layout-organization/layout-organization.component.html b/src/app/features/organization/layout-organization/layout-organization.component.html index 677256b5..39bcb240 100644 --- a/src/app/features/organization/layout-organization/layout-organization.component.html +++ b/src/app/features/organization/layout-organization/layout-organization.component.html @@ -7,7 +7,6 @@ >
-
diff --git a/src/app/features/organization/layout-organization/layout-organization.component.ts b/src/app/features/organization/layout-organization/layout-organization.component.ts index d8be0a98..3dac60f5 100644 --- a/src/app/features/organization/layout-organization/layout-organization.component.ts +++ b/src/app/features/organization/layout-organization/layout-organization.component.ts @@ -4,7 +4,6 @@ import { sidebarOrganizations } from '../../../core/constants/menu-router.data'; import { CommonModule } from '@angular/common'; import { AdminRoutingModule } from '../../dashboard/dashboard-routing.module'; import { RouterOutlet } from '@angular/router'; -import { DetailsOrganizationComponent } from '../organization-component/details-organization/details-organization.component'; @Component({ selector: 'app-layout-organization', @@ -13,7 +12,6 @@ import { DetailsOrganizationComponent } from '../organization-component/details- CommonModule, MainSidebarComponent, AdminRoutingModule, - DetailsOrganizationComponent, ], templateUrl: './layout-organization.component.html', styleUrl: './layout-organization.component.scss', diff --git a/src/app/features/organization/organization-component/organization-create-modal/organization-create-modal.component.html b/src/app/features/organization/organization-component/organization-create-modal/organization-create-modal.component.html new file mode 100644 index 00000000..a13797c8 --- /dev/null +++ b/src/app/features/organization/organization-component/organization-create-modal/organization-create-modal.component.html @@ -0,0 +1,118 @@ + + + + + diff --git a/src/app/features/organization/organization-component/organization-create-modal/organization-create-modal.component.scss b/src/app/features/organization/organization-component/organization-create-modal/organization-create-modal.component.scss new file mode 100644 index 00000000..e370d925 --- /dev/null +++ b/src/app/features/organization/organization-component/organization-create-modal/organization-create-modal.component.scss @@ -0,0 +1,240 @@ +/* Backdrop */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(2px); + animation: fadeIn 0.3s ease forwards; + z-index: 1000; +} + +/* Modal container */ +.modal-container { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1010; + padding: 20px; +} + +/* Modal content */ +.modal-content { + background: #fff; + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); + width: 100%; + max-width: 640px; + animation: popIn 0.35s ease; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Header */ +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #eee; + + h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: #222; + } + + .close-btn { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #666; + transition: color 0.2s; + + &:hover { + color: #000; + } + } +} + +/* Body */ +.modal-body { + padding: 20px; + max-height: 75vh; + overflow-y: auto; +} + +/* Logo uploader */ +.logo-uploader { + margin-bottom: 20px; + + label { + font-weight: 500; + margin-bottom: 8px; + display: block; + color: #333; + } + + .uploader-box { + border: 2px dashed #bbb; + border-radius: 8px; + padding: 20px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: #007bff; + background: rgba(0, 123, 255, 0.05); + } + + .uploader-placeholder { + color: #777; + + i { + font-size: 2rem; + color: #007bff; + margin-bottom: 8px; + } + + span { + display: block; + font-size: 0.95rem; + margin-bottom: 4px; + } + + small { + font-size: 0.8rem; + color: #999; + } + } + + .logo-preview { + max-height: 120px; + max-width: 100%; + object-fit: contain; + border-radius: 6px; + } + } +} + +/* Form grid */ +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + + .form-group { + display: flex; + flex-direction: column; + + label { + font-weight: 500; + margin-bottom: 6px; + font-size: 0.9rem; + color: #444; + } + + input, + textarea, + select { + padding: 10px 12px; + border: 1px solid #ccc; + border-radius: 6px; + font-size: 0.95rem; + transition: border-color 0.2s, box-shadow 0.2s; + + &:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.15); + } + } + + textarea { + resize: vertical; + min-height: 80px; + } + + &.full-width { + grid-column: span 2; + } + } +} + +/* Footer */ +.modal-footer { + padding: 16px 20px; + border-top: 1px solid #eee; + display: flex; + justify-content: flex-end; + gap: 12px; + + .btn { + padding: 8px 16px; + border-radius: 6px; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.2s; + border: none; + + &.btn-secondary { + background: #f2f2f2; + color: #333; + + &:hover { + background: #e2e2e2; + } + } + + &.btn-primary { + background: #007bff; + color: #fff; + + &:hover { + background: #0069d9; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + } +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes popIn { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* Responsive */ +@media (max-width: 600px) { + .modal-content { + max-width: 95%; + } + + .form-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/app/features/organization/organization-component/organization-create-modal/organization-create-modal.component.ts b/src/app/features/organization/organization-component/organization-create-modal/organization-create-modal.component.ts new file mode 100644 index 00000000..a1c53424 --- /dev/null +++ b/src/app/features/organization/organization-component/organization-create-modal/organization-create-modal.component.ts @@ -0,0 +1,76 @@ +// organization-create-modal.component.ts +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + FormBuilder, + ReactiveFormsModule, + Validators, + FormGroup, +} from '@angular/forms'; +import { CreateOrgRequest } from '../../../../core/models/organization.model'; + +@Component({ + selector: 'app-organization-create-modal', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './organization-create-modal.component.html', + styleUrls: ['./organization-create-modal.component.scss'], +}) +export class OrganizationCreateModalComponent { + @Input() visible = false; + @Output() closed = new EventEmitter(); + @Output() submitted = new EventEmitter(); + + createForm: FormGroup; + // ... các thuộc tính khác + logoPreview: string | ArrayBuffer | null = null; + + constructor(private fb: FormBuilder) { + this.createForm = this.fb.group({ + name: ['', [Validators.required]], + description: [''], + email: ['', [Validators.required, Validators.email]], + phone: ['', [Validators.required]], + address: ['', [Validators.required]], + status: ['Active', [Validators.required]], + logo: [null as File | null], + }); + } + + close() { + this.closed.emit(); + this.logoPreview = null; + this.createForm.reset({ status: 'Active' }); + } + + onLogoSelected(event: Event) { + const file = (event.target as HTMLInputElement).files?.[0]; + if (file) { + // Cập nhật file vào form + this.createForm.patchValue({ logo: file }); + + // Tạo preview + const reader = new FileReader(); + reader.onload = () => { + this.logoPreview = reader.result; + }; + reader.readAsDataURL(file); + } + } + + submit() { + if (this.createForm.invalid) return; + const formValue = this.createForm.value; + const req: CreateOrgRequest = { + name: formValue.name!, + description: formValue.description || '', + email: formValue.email!, + phone: formValue.phone!, + address: formValue.address!, + status: formValue.status!, + logo: formValue.logo as File, + }; + this.submitted.emit(req); + this.close(); + } +} diff --git a/src/app/features/organization/organization-routing.module.ts b/src/app/features/organization/organization-routing.module.ts index e5ea22fd..5d8a97fd 100644 --- a/src/app/features/organization/organization-routing.module.ts +++ b/src/app/features/organization/organization-routing.module.ts @@ -4,6 +4,7 @@ import { RouterModule, Routes } from '@angular/router'; import { ListOrganizationsComponent } from './pages/list-organizations/list-organizations.component'; import { DetailsOrganizationComponent } from './organization-component/details-organization/details-organization.component'; import { LayoutOrganizationComponent } from './layout-organization/layout-organization.component'; +import { OrganizationManagementComponent } from './pages/organization-management/organization-management.component'; const routes: Routes = [ { @@ -13,12 +14,20 @@ const routes: Routes = [ data: { breadcrumb: 'Danh sách tổ chức' }, }, { - path: 'details/:id', + path: '', component: LayoutOrganizationComponent, - children: [], + children: [ + { + path: 'orgs-list', + component: OrganizationManagementComponent, + title: 'Quản lý tổ chức', + data: { breadcrumb: 'Quản lý tổ chức' }, + }, + ], title: 'Chi tiết tổ chức', data: { breadcrumb: 'Tổ chức của tôi' }, }, + { path: '', redirectTo: 'list', pathMatch: 'full' }, ]; diff --git a/src/app/features/organization/pages/organization-management/organization-management.component.html b/src/app/features/organization/pages/organization-management/organization-management.component.html new file mode 100644 index 00000000..a9804878 --- /dev/null +++ b/src/app/features/organization/pages/organization-management/organization-management.component.html @@ -0,0 +1,114 @@ +
+
+

Quản lý Tổ chức

+
+ + +
+
+ +
Đang tải dữ liệu...
+ +
+ + +

Không tìm thấy tổ chức nào.

+
+ +
+
+
+
+ + +
{{ org.name.charAt(0) }}
+
+

{{ org.name }}

+
+
+ + {{ org.status }} + + +
+
+ +
+

{{ org.description || "Không có mô tả." }}

+
+
+ Email: + {{ org.email }} +
+
+ Điện thoại: + {{ org.phone }} +
+
+ Tổng thành viên: + {{ getTotalMembers(org) }} +
+
+ +
+ Các khối: +
+ + {{ block.name }} ({{ block.members.totalElements }}) + +
+
+
+ + +
+
+ + + + + + +
+ + diff --git a/src/app/features/organization/pages/organization-management/organization-management.component.scss b/src/app/features/organization/pages/organization-management/organization-management.component.scss new file mode 100644 index 00000000..6fddfb12 --- /dev/null +++ b/src/app/features/organization/pages/organization-management/organization-management.component.scss @@ -0,0 +1,349 @@ +.org-page { + padding: 15px 15px 0 15px; + height: calc(100vh - 150px); + overflow: auto; + justify-content: space-between; + + .no-data { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + padding: 20px; + } + + .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.2rem; + + h2 { + color: var(--text-color); + margin: 0; + } + + .header-actions { + display: flex; + gap: 1rem; + align-items: center; + } + } + // Search Bar + .search-bar { + input { + padding: 0.6rem 1rem; + border: 1px solid var(--border-color); + border-radius: 25px; + width: 300px; + transition: all 0.3s ease; + + &:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(var(--primary-color), 0.2); + } + } + } + + .btn { + padding: 6px 12px; + border-radius: 6px; + border: none; + cursor: pointer; + transition: all 0.3s ease; + &.primary { + background: #007bff; + color: #fff; + &:hover { + background: #0056b3; + } + } + &.danger { + background: #dc3545; + color: #fff; + &:hover { + background: #a71d2a; + } + } + } + + .org-list { + display: grid; + overflow: auto; + flex: 1; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 16px; + height: calc(100vh - 250px); + overflow: auto; + &::-webkit-scrollbar { + width: 8px; // chiều rộng thanh cuộn + } + + &::-webkit-scrollbar-track { + background: transparent; // nền phía sau thanh cuộn + } + &::-webkit-scrollbar-button { + display: none; // ẩn mũi tên hai đầu + height: 0; + width: 0; + } + &::-webkit-scrollbar-button:single-button { + display: none; // ẩn mũi tên ↑ và ↓ + height: 0; + width: 0; + } + } + + .org-card { + background: #fff; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + display: flex; + flex-direction: column; + transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; + max-height: 400px; + min-height: 400px; + overflow: auto; + min-width: 300px; + + &:hover { + transform: translateY(-5px); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12); + } + + .card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); + + .card-title { + display: flex; + align-items: center; + gap: 1rem; + + h4 { + margin: 0; + font-size: 1.2rem; + color: var(--text-color); + } + } + .logo { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + } + .logo-placeholder { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: var(--primary-color); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 1.2rem; + } + + .card-actions { + display: flex; + align-items: center; + gap: 0.5rem; + } + } + + .card-body { + padding: 1rem; + flex-grow: 1; + max-height: 236px; + overflow: auto; + + &::-webkit-scrollbar { + width: 8px; // chiều rộng thanh cuộn + } + + &::-webkit-scrollbar-track { + background: transparent; // nền phía sau thanh cuộn + } + &::-webkit-scrollbar-button { + display: none; // ẩn mũi tên hai đầu + height: 0; + width: 0; + } + &::-webkit-scrollbar-button:single-button { + display: none; // ẩn mũi tên ↑ và ↓ + height: 0; + width: 0; + } + + .description { + color: var(--text-muted-color); + font-size: 0.9rem; + margin-bottom: 1.5rem; + } + + .info-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.75rem; + margin-bottom: 1.5rem; + } + + .info-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + + i { + color: var(--primary-color); + } + + span { + color: var(--text-muted-color); + } + } + + .blocks-section { + margin-top: 1rem; + + strong { + font-size: 0.9rem; + display: block; + margin-bottom: 0.5rem; + } + + .blocks-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + + .block-tag { + background-color: var(--border-color); + color: var(--text-muted-color); + padding: 0.25rem 0.75rem; + border-radius: 15px; + font-size: 0.8rem; + } + } + } + } + + .card-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color); + background-color: var(--border-color); + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; + + small { + color: var(--text-muted-color); + } + } + } + + // Status Badge + .status-badge { + padding: 0.25rem 0.75rem; + border-radius: 15px; + font-size: 0.8rem; + font-weight: 600; + + &.active { + background-color: rgba(var(--success-color), 0.1); + color: var(--success-color); + } + &.inactive { + background-color: rgba(var(--text-muted-color), 0.1); + color: var(--text-muted-color); + } + } + + // Buttons + .btn-icon { + background: none; + border: none; + cursor: pointer; + padding: 0.5rem; + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s; + + &:hover { + background-color: var(--border-color); + } + + &.danger i { + color: var(--error-color); + } + } + + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + animation: fadeIn 0.3s ease; + } + + .modal { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + .modal-content { + background: #fff; + border-radius: 8px; + padding: 20px; + width: 400px; + max-width: 90%; + animation: slideDown 0.3s ease; + h3 { + margin-top: 0; + } + form { + display: flex; + flex-direction: column; + gap: 10px; + } + .actions { + display: flex; + justify-content: flex-end; + gap: 10px; + } + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideDown { + from { + transform: translateY(-30px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} diff --git a/src/app/features/organization/pages/organization-management/organization-management.component.ts b/src/app/features/organization/pages/organization-management/organization-management.component.ts new file mode 100644 index 00000000..8bd7ab7f --- /dev/null +++ b/src/app/features/organization/pages/organization-management/organization-management.component.ts @@ -0,0 +1,182 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + FormsModule, + ReactiveFormsModule, + FormBuilder, + Validators, + FormGroup, + FormControl, +} from '@angular/forms'; +import { Subject, debounceTime, distinctUntilChanged, takeUntil } from 'rxjs'; + +import { OrganizationService } from '../../../../core/services/api-service/organization.service'; +import { + CreateOrgRequest, + FilterOrgs, // Import model FilterOrgs + OrganizationResponse, +} from '../../../../core/models/organization.model'; +import { PaginationComponent } from '../../../../shared/components/fxdonad-shared/pagination/pagination.component'; +import { lottieOptions2 } from '../../../../core/constants/value.constant'; +import { LottieComponent } from 'ngx-lottie'; +import { OrganizationCreateModalComponent } from '../../organization-component/organization-create-modal/organization-create-modal.component'; + +@Component({ + selector: 'app-organization-management', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + PaginationComponent, + LottieComponent, + OrganizationCreateModalComponent, + ], + templateUrl: './organization-management.component.html', + styleUrls: ['./organization-management.component.scss'], +}) +export class OrganizationManagementComponent implements OnInit, OnDestroy { + orgs: OrganizationResponse[] = []; + loading = false; + showCreateModal = false; + showDeleteConfirm = false; + selectedOrgId: string | null = null; + + lottieOptions = lottieOptions2; + + // Pagination state + page = 1; + size = 6; // Tăng size để hiển thị nhiều card hơn + totalData = 0; + + // Search and Filter state + searchControl = new FormControl(''); + private destroy$ = new Subject(); + + createForm!: FormGroup; + + constructor( + private orgService: OrganizationService, + private fb: FormBuilder + ) {} + + ngOnInit() { + this.initCreateForm(); + this.loadOrgs(); + this.listenToSearchChanges(); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + private initCreateForm() { + this.createForm = this.fb.group({ + name: ['', [Validators.required]], + description: [''], + email: ['', [Validators.required, Validators.email]], + phone: ['', [Validators.required]], + address: ['', [Validators.required]], + status: ['Active', [Validators.required]], + logo: [null as File | null], + }); + } + + // Lắng nghe sự kiện nhập liệu vào ô search + private listenToSearchChanges() { + this.searchControl.valueChanges + .pipe( + debounceTime(500), // Chờ 500ms sau khi người dùng ngừng gõ + distinctUntilChanged(), // Chỉ gọi API nếu giá trị thay đổi + takeUntil(this.destroy$) + ) + .subscribe(() => { + this.page = 1; // Reset về trang 1 mỗi khi tìm kiếm + this.loadOrgs(); + }); + } + + loadOrgs() { + this.loading = true; + const searchTerm = this.searchControl.value || ''; + + // Xây dựng bộ lọc để gửi đi + const filters: FilterOrgs = { + q: searchTerm, + status: 'Active', // Có thể thêm filter status ở đây + includeBlocks: true, // Yêu cầu API trả về thông tin blocks + blocksPage: 1, + blocksSize: 5, // Lấy tối đa 5 khối + includeUnassigned: true, + }; + + this.orgService.searchOrgsFilter(this.page, this.size, filters).subscribe({ + next: (res) => { + // Cập nhật dữ liệu và thông tin phân trang + this.orgs = res.result.data; + this.totalData = res.result.totalElements; + this.loading = false; + }, + error: () => (this.loading = false), + }); + } + + // Cập nhật hàm loadNextPage + loadNextPage(newPage: number) { + this.page = newPage; + this.loadOrgs(); + } + + // --- Các hàm modal giữ nguyên --- + openCreateModal() { + this.showCreateModal = true; + } + + closeCreateModal() { + this.showCreateModal = false; + this.createForm.reset({ status: 'Active' }); + } + + submitCreate(req: CreateOrgRequest) { + this.orgService.createOrg(req).subscribe({ + next: () => { + setTimeout(() => { + this.loadOrgs(); + }, 2000); + }, + }); + } + + confirmDelete(orgId: string) { + this.selectedOrgId = orgId; + this.showDeleteConfirm = true; + } + + deleteOrg() { + if (!this.selectedOrgId) return; + this.orgService.deleteOrg(this.selectedOrgId).subscribe({ + next: () => { + this.showDeleteConfirm = false; + this.selectedOrgId = null; + this.loadOrgs(); + }, + }); + } + + onLogoSelected(event: Event) { + const file = (event.target as HTMLInputElement).files?.[0]; + if (file) { + this.createForm.patchValue({ logo: file }); + } + } + + // Helper function để tính tổng thành viên + getTotalMembers(org: OrganizationResponse): number { + if (!org.blocks?.data) return 0; + return org.blocks.data.reduce( + (total, block) => total + block.members.totalElements, + 0 + ); + } +} diff --git a/src/app/features/post/pages/post-list/post-list.html b/src/app/features/post/pages/post-list/post-list.html index 1d487419..1ce5f2c9 100644 --- a/src/app/features/post/pages/post-list/post-list.html +++ b/src/app/features/post/pages/post-list/post-list.html @@ -81,7 +81,7 @@ -
+
diff --git a/src/app/features/post/pages/post-list/post-list.scss b/src/app/features/post/pages/post-list/post-list.scss index d7382bcd..81e42c6b 100644 --- a/src/app/features/post/pages/post-list/post-list.scss +++ b/src/app/features/post/pages/post-list/post-list.scss @@ -32,8 +32,9 @@ flex-direction: row; gap: 16px; align-items: center; - justify-content: center; + justify-content: start; transition: all 0.2s linear; + min-width: 142px; .button-add { width: 50px; @@ -134,6 +135,23 @@ max-height: calc(100vh - 160px); box-sizing: border-box; overflow: auto; + &::-webkit-scrollbar { + width: 8px; // chiều rộng thanh cuộn + } + + &::-webkit-scrollbar-track { + background: transparent; // nền phía sau thanh cuộn + } + &::-webkit-scrollbar-button { + display: none; // ẩn mũi tên hai đầu + height: 0; + width: 0; + } + &::-webkit-scrollbar-button:single-button { + display: none; // ẩn mũi tên ↑ và ↓ + height: 0; + width: 0; + } .note-card { width: 300px; height: 52px; diff --git a/src/app/features/post/pages/post-list/post-list.ts b/src/app/features/post/pages/post-list/post-list.ts index baa59fc6..39f9ba9f 100644 --- a/src/app/features/post/pages/post-list/post-list.ts +++ b/src/app/features/post/pages/post-list/post-list.ts @@ -2,7 +2,7 @@ import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { InputComponent } from '../../../../shared/components/fxdonad-shared/input/input'; import { DropdownButtonComponent } from '../../../../shared/components/fxdonad-shared/dropdown/dropdown.component'; import { PostCardComponent } from '../../../../shared/components/my-shared/post-card/post-card'; -import { NgFor, NgIf } from '@angular/common'; +import { CommonModule } from '@angular/common'; import { PostCardInfo, PostResponse, @@ -31,8 +31,7 @@ import { openModalNotification } from '../../../../shared/utils/notification'; InputComponent, DropdownButtonComponent, PostCardComponent, - NgFor, - NgIf, + CommonModule, PopularPostComponent, SkeletonLoadingComponent, TrendingComponent, diff --git a/src/app/features/post/post-layout/post-layout.component.scss b/src/app/features/post/post-layout/post-layout.component.scss index 457061ae..4794a50c 100644 --- a/src/app/features/post/post-layout/post-layout.component.scss +++ b/src/app/features/post/post-layout/post-layout.component.scss @@ -12,5 +12,22 @@ height: calc(100vh - 130px); margin: 0 16px 0 20px; overflow-y: auto; + &::-webkit-scrollbar { + width: 8px; // chiều rộng thanh cuộn + } + + &::-webkit-scrollbar-track { + background: transparent; // nền phía sau thanh cuộn + } + &::-webkit-scrollbar-button { + display: none; // ẩn mũi tên hai đầu + height: 0; + width: 0; + } + &::-webkit-scrollbar-button:single-button { + display: none; // ẩn mũi tên ↑ và ↓ + height: 0; + width: 0; + } } } diff --git a/src/app/features/post/post-layout/post-layout.component.ts b/src/app/features/post/post-layout/post-layout.component.ts index e7eec536..3923ec7c 100644 --- a/src/app/features/post/post-layout/post-layout.component.ts +++ b/src/app/features/post/post-layout/post-layout.component.ts @@ -19,7 +19,7 @@ export class PostLayoutComponent { showSidebar = true; constructor(private router: Router) { - const role = decodeJWT(localStorage.getItem('token') ?? '')?.payload.scope; - this.sidebarData = sidebarPosts(role); + const roles = decodeJWT(localStorage.getItem('token') ?? '')?.payload.roles; + this.sidebarData = sidebarPosts(roles); } } diff --git a/src/app/layouts/layout-pages/admin-layout/admin-layout.ts b/src/app/layouts/layout-pages/admin-layout/admin-layout.ts index 86712c09..60aaf4ec 100644 --- a/src/app/layouts/layout-pages/admin-layout/admin-layout.ts +++ b/src/app/layouts/layout-pages/admin-layout/admin-layout.ts @@ -33,7 +33,7 @@ import { decodeJWT } from '../../../shared/utils/stringProcess'; export class AdminLayoutComponent implements OnInit { visible = true; menuItems = getNavHorizontalItems( - decodeJWT(localStorage.getItem('token') ?? '')?.payload.scope + decodeJWT(localStorage.getItem('token') ?? '')?.payload.roles ); sidebarData = sidebarData; isCollapsed = true; diff --git a/src/app/layouts/layout-pages/app-layout/app-layout.component.ts b/src/app/layouts/layout-pages/app-layout/app-layout.component.ts index 3ed2b471..67d5d20f 100644 --- a/src/app/layouts/layout-pages/app-layout/app-layout.component.ts +++ b/src/app/layouts/layout-pages/app-layout/app-layout.component.ts @@ -57,9 +57,9 @@ export class AppLayoutComponent implements OnInit { } ngOnInit() { - const role = decodeJWT(localStorage.getItem('token') ?? '')?.payload.scope; + const roles = decodeJWT(localStorage.getItem('token') ?? '')?.payload.roles; // Cập nhật visible ngay khi khởi tạo dựa trên url hiện tại - this.menuItems = getNavHorizontalItems(role); + this.menuItems = getNavHorizontalItems(roles); const currentUrl = this.router.url; this.visible = currentUrl !== '/'; this.showFooter = currentUrl === '/' || currentUrl === ''; diff --git a/src/app/shared/components/fxdonad-shared/pagination/pagination.component.scss b/src/app/shared/components/fxdonad-shared/pagination/pagination.component.scss index 098143cb..995100b7 100644 --- a/src/app/shared/components/fxdonad-shared/pagination/pagination.component.scss +++ b/src/app/shared/components/fxdonad-shared/pagination/pagination.component.scss @@ -3,7 +3,7 @@ justify-content: space-between; align-items: center; width: 100%; - margin: 10px 0; + margin: 5px 0; .pagination-container { display: flex; @@ -13,7 +13,7 @@ display: flex; justify-content: space-between; align-items: center; - height: 40px; + height: 32px; .midle-page { display: flex; @@ -22,8 +22,8 @@ button, span { color: var(--text-color); - height: 40px; - width: 40px; + height: 32px; + width: 32px; margin: 3px; cursor: pointer; border: none; @@ -50,8 +50,8 @@ } button { - height: 40px; - width: 40px; + height: 32px; + width: 32px; display: flex; justify-content: center; align-items: center; @@ -90,7 +90,7 @@ align-items: center; justify-content: space-around; background-color: var(--background-color-secondary); - height: 40px; + height: 32px; border-radius: 8px; padding: 0 15px; color: var(--text-color); diff --git a/src/app/shared/components/my-shared/header/header.ts b/src/app/shared/components/my-shared/header/header.ts index 1daaabfd..4412e41b 100644 --- a/src/app/shared/components/my-shared/header/header.ts +++ b/src/app/shared/components/my-shared/header/header.ts @@ -34,7 +34,6 @@ export class HeaderComponent { showProfileMenu = false; isMenuVisible = false; timeExpiresAt: string = ''; - role: string = ''; avatarUrl: string = ''; avatarDefault = avatarUrlDefault; setPassword = false; @@ -54,9 +53,6 @@ export class HeaderComponent { decodeJWT(localStorage.getItem('token') ?? '')?.expiresAt || ''; const expiresAt = new Date(this.timeExpiresAt).getTime(); this.isLoggedIn = !isNaN(expiresAt) && Date.now() < expiresAt; - - this.role = - decodeJWT(localStorage.getItem('token') ?? '')?.payload.roles || ''; } ngOnInit() { @@ -75,6 +71,10 @@ export class HeaderComponent { if (!this.avatarUrl) { this.getUserInfo(); } + + this.needCreateNewPass = JSON.parse( + localStorage.getItem('needPasswordSetup') || 'false' + ); } organizations = [ diff --git a/src/app/shared/components/my-shared/header/profile-menu.component.ts b/src/app/shared/components/my-shared/header/profile-menu.component.ts index b3aa1f31..b823b4a3 100644 --- a/src/app/shared/components/my-shared/header/profile-menu.component.ts +++ b/src/app/shared/components/my-shared/header/profile-menu.component.ts @@ -91,6 +91,7 @@ export class ProfileMenuComponent { }); localStorage.removeItem('token'); localStorage.removeItem('refreshToken'); + localStorage.removeItem('needPasswordSetup'); sessionStorage.removeItem('avatar-url'); } } diff --git a/src/app/shared/utils/stringProcess.ts b/src/app/shared/utils/stringProcess.ts index 3c844346..02a03cc4 100644 --- a/src/app/shared/utils/stringProcess.ts +++ b/src/app/shared/utils/stringProcess.ts @@ -118,13 +118,38 @@ export function removeSpecialCharacters(str: string): string { } //Giải mã JWT token -export function decodeJWT(token: string): { +export function decodeJWT(token: string | null): { header: any; - payload: any; + payload: DecodedJwtPayload; issuedAt?: string; expiresAt?: string; -} | null { +} { try { + if (!token) { + return { + header: {}, + payload: { + sub: '', + permissions: [], + org_id: '', + org_role: '', + scope: '', + roles: [], + iss: '', + active: false, + exp: 0, + iat: 0, + token_type: '', + userId: '', + jti: '', + email: '', + username: '', + }, + issuedAt: undefined, + expiresAt: undefined, + }; + } + const [header64, payload64] = token.split('.'); const base64UrlDecode = (str: string) => { const base64 = str.replace(/-/g, '+').replace(/_/g, '/'); @@ -156,6 +181,27 @@ export function decodeJWT(token: string): { }; } catch (e) { console.error('Invalid JWT', e); - return null; + return { + header: {}, + payload: { + sub: '', + permissions: [], + org_id: '', + org_role: '', + scope: '', + roles: [], + iss: '', + active: false, + exp: 0, + iat: 0, + token_type: '', + userId: '', + jti: '', + email: '', + username: '', + }, + issuedAt: undefined, + expiresAt: undefined, + }; } } diff --git a/src/app/shared/utils/userInfo.ts b/src/app/shared/utils/userInfo.ts index 1e7121d9..e9a503fe 100644 --- a/src/app/shared/utils/userInfo.ts +++ b/src/app/shared/utils/userInfo.ts @@ -25,7 +25,7 @@ export function checkAuthenticated(): boolean { export function getUserRoles(): string[] { const token = localStorage.getItem('token'); - let roles = []; + let roles: string[] = []; if (token) { roles = decodeJWT(token)?.payload.roles; } @@ -35,12 +35,12 @@ export function getUserRoles(): string[] { export function getUserId(): string { const token = localStorage.getItem('token'); - let roles = []; + let id = ''; if (token) { - roles = decodeJWT(token)?.payload.userId; + id = decodeJWT(token)?.payload.userId; } - return roles; + return id; } export function getUserName(): string {