diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 9b8c8caf..21670258 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -144,7 +144,7 @@ export const routes: Routes = [ import('./features/organization/organization.module').then( (m) => m.OrganizationModule ), - data: { roles: ['ROLE_ADMIN'] }, + data: { roles: ['ADMIN'] }, canActivate: [RoleGuard], }, ], diff --git a/src/app/core/guards/router-protected/role.guard.ts b/src/app/core/guards/router-protected/role.guard.ts index f7ae809e..68d96b6b 100644 --- a/src/app/core/guards/router-protected/role.guard.ts +++ b/src/app/core/guards/router-protected/role.guard.ts @@ -27,14 +27,14 @@ export class RoleGuard implements CanActivate { | Observable | Promise { const expectedRoles = next.data['roles'] as string[]; // lấy roles từ route - const userRole = decodeJWT(localStorage.getItem('token') ?? '')?.payload - .scope; + const userRoles = decodeJWT(localStorage.getItem('token') ?? '')?.payload + .roles; if (!expectedRoles || expectedRoles.length === 0) { return true; // không yêu cầu role -> cho vào } - if (expectedRoles.includes(userRole)) { + if (userRoles && userRoles.some((role) => expectedRoles.includes(role))) { return true; } else { sendNotification( @@ -60,7 +60,7 @@ export class RoleGuard implements CanActivate { import('./features/service-payment/service-and-payment.module').then( (m) => m.ServiceAndPaymentModule ), - data: { roles: ['ROLE_ADMIN'] }, + data: { roles: ['ADMIN'] }, canActivate: [RoleGuard], }, diff --git a/src/app/core/models/organization.model.ts b/src/app/core/models/organization.model.ts index 0d35617a..9e9c2fb6 100644 --- a/src/app/core/models/organization.model.ts +++ b/src/app/core/models/organization.model.ts @@ -1,3 +1,6 @@ +import { IPaginationResponse } from './api-response'; +import { UserBasicInfo } from './exercise.model'; + export type OrganizationInfo = { id: string; name: string; @@ -9,3 +12,68 @@ export type OrganizationInfo = { logoUrl: string | null; status: number; // 0 Active, 1 Inactive, 2 Pending }; + +export type CreateOrgRequest = { + name: string; + description: string; + email: string; + phone: string; + address: string; + status: string; + logo: File; +}; + +//Search response +export type MemberResponse = { + user: UserBasicInfo; + role: string; + active: boolean; +}; + +export type BlockResponse = { + id: string; + orgId: string; + name: string; + code: string; + description: string; + createdAt: string; + updatedAt: string; + members: IPaginationResponse; +}; + +export type OrganizationResponse = { + id: string; + name: string; + description: string; + logoUrl: string | null; + email: string; + phone: string; + address: string; + status: string; + createdAt: string; + updatedAt: string; + blocks: IPaginationResponse; +}; + +//filter +export type FilterOrgs = { + q?: string; + status?: string; + includeBlocks?: boolean; + blocksPage?: number; + blocksSize?: number; + membersPage?: number; + membersSize?: number; + activeOnlyMembers?: boolean; + includeUnassigned?: boolean; +}; + +//edit basic info +export type EditOrgRequest = { + description?: string; + email?: string; + phone?: string; + address?: string; + status?: string; + logo?: File; +}; 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/horizontal-menu.ts b/src/app/core/router-manager/horizontal-menu.ts index 982004e3..c0a792d4 100644 --- a/src/app/core/router-manager/horizontal-menu.ts +++ b/src/app/core/router-manager/horizontal-menu.ts @@ -1,8 +1,9 @@ import { SidebarItem } from '../models/data-handle'; -export function getNavHorizontalItems(role: string): SidebarItem[] { - const auth_lv1 = ['ROLE_ADMIN', 'ROLE_TEACHER']; - const auth_lv2 = ['ROLE_ADMIN']; +export function getNavHorizontalItems(roles: string[]): SidebarItem[] { + const auth_lv1 = ['ADMIN', 'TEACHER']; + const auth_lv2 = ['ADMIN']; + console.log(roles); return [ { @@ -28,20 +29,21 @@ export function getNavHorizontalItems(role: string): SidebarItem[] { path: 'conversations/chat', label: 'Tin nhắn', icon: 'fas fa-comments', + isVisible: !(roles.length !== 0), }, { id: 'statistics', path: '/statistics', label: 'Thống kê', icon: 'fas fa-chart-bar', - isVisible: !auth_lv2.includes(role), + isVisible: !roles.includes(auth_lv2[0]), }, { id: 'management', path: 'management/admin', label: 'Admin quản lý', icon: 'fas fa-user-shield', - isVisible: !auth_lv2.includes(role), + isVisible: !roles.includes(auth_lv2[0]), }, { id: 'payment', @@ -51,10 +53,10 @@ export function getNavHorizontalItems(role: string): SidebarItem[] { }, { id: 'organization ', - path: '/organization/list', + path: '/organization/orgs-list', label: 'Tổ chức', icon: 'fa-solid fa-building-user', - isVisible: !auth_lv2.includes(role), + isVisible: !roles.includes(auth_lv2[0]), }, ]; } diff --git a/src/app/core/router-manager/vetical-menu-dynamic/org-vertical-menu.ts b/src/app/core/router-manager/vetical-menu-dynamic/org-vertical-menu.ts new file mode 100644 index 00000000..4d3b284c --- /dev/null +++ b/src/app/core/router-manager/vetical-menu-dynamic/org-vertical-menu.ts @@ -0,0 +1,12 @@ +import { SidebarItem } from '../../models/data-handle'; + +export function sidebarOrgRouter(roles: string[]): SidebarItem[] { + return [ + { + id: 'list-orgs', + path: '/organization/orgs-list', + label: 'Nạp tiền', + icon: 'fa-solid fa-tasks', + }, + ]; +} 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..ec81fc5c 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 @@ -1,16 +1,16 @@ import { SidebarItem } from '../../models/data-handle'; -export function sidebarPosts(role: string): SidebarItem[] { +export function sidebarPosts(roles: 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/organization.service.ts b/src/app/core/services/api-service/organization.service.ts new file mode 100644 index 00000000..89e0ba6e --- /dev/null +++ b/src/app/core/services/api-service/organization.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@angular/core'; +import { ApiMethod } from '../config-service/api.methods'; +import { ApiResponse, IPaginationResponse } from '../../models/api-response'; +import { API_CONFIG } from '../config-service/api.enpoints'; +import { + CreateOrgRequest, + EditOrgRequest, + FilterOrgs, + OrganizationInfo, + OrganizationResponse, +} from '../../models/organization.model'; +import { PostResponse } from '../../models/post.models'; + +@Injectable({ + providedIn: 'root', +}) +export class OrganizationService { + constructor(private api: ApiMethod) {} + + createOrg(dataCreate: CreateOrgRequest) { + const { logo, ...otherData } = dataCreate; + return this.api.postWithFormData>( + API_CONFIG.ENDPOINTS.POST.CREATE_ORGANIZATION, + otherData, // các field string + { logo } + ); + } + + searchOrgsFilter(page: number, size: number, search?: FilterOrgs) { + const endpoint = API_CONFIG.ENDPOINTS.GET.SEARCH_ORGS_FILTER( + page, + size, + search ? search : null + ); + + return this.api.get< + ApiResponse> + >(endpoint); + } + + getOrgDetails(orgId: string) { + return this.api.get>( + API_CONFIG.ENDPOINTS.GET.GET_ORG_DETAILS_BY_ID(orgId) + ); + } + + editOrg(orgId: string, data: EditOrgRequest) { + return this.api.put>( + API_CONFIG.ENDPOINTS.PUT.EDIT_ORG(orgId), + data + ); + } + + deleteOrg(orgId: string) { + return this.api.delete>( + API_CONFIG.ENDPOINTS.DELETE.DELETE_ORG(orgId) + ); + } +} diff --git a/src/app/core/services/api-service/playground.service.ts b/src/app/core/services/api-service/playground.service.ts deleted file mode 100644 index 731a544e..00000000 --- a/src/app/core/services/api-service/playground.service.ts +++ /dev/null @@ -1,49 +0,0 @@ -// import { Injectable } from '@angular/core'; -// import { Observable } from 'rxjs'; - -// // Import auto-generated từ ts-proto -// import { -// PlaygroundServiceClientImpl, -// RunRequest, -// RunUpdate, -// } from '../grpc/grpc-generated/Playground'; -// import { Metadata } from 'pdfjs-dist/types/src/display/metadata'; - -// @Injectable({ -// providedIn: 'root', -// }) -// export class PlaygroundService { -// private client: PlaygroundServiceClientImpl; - -// constructor() { -// // Khởi tạo gRPC-Web client -// const rpc = new GrpcWebImpl('http://localhost:8080', { -// transport: undefined, // có thể dùng cross-browser transport -// debug: true, -// metadata: new Headers({ Authorization: 'Bearer token-demo' }) as unknown as Metadata, -// }); -// this.client = new PlaygroundServiceClientImpl(rpc); -// } - -// runCode(requestData: { -// language: string; -// source_code: string; -// stdin: string; -// memory_mb: number; -// cpus: number; -// time_limit_sec: number; -// }): Observable { -// // Với ts-proto, request chỉ là object thường (không cần new) -// const request: RunRequest = { -// language: requestData.language, -// sourceCode: requestData.source_code, -// stdin: requestData.stdin, -// memoryMb: requestData.memory_mb, -// cpus: requestData.cpus, -// timeLimitSec: requestData.time_limit_sec, -// }; - -// // Hàm Run() trả về Observable -// return this.client.Run(request); -// } -// } 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..c3fb9f09 100644 --- a/src/app/core/services/config-service/api.enpoints.ts +++ b/src/app/core/services/config-service/api.enpoints.ts @@ -1,5 +1,6 @@ import { environment } from '../../../../environments/environment'; import { EnumType } from '../../models/data-handle'; +import { FilterOrgs } from '../../models/organization.model'; import { SearchingUser } from '../../models/user.models'; export const version = '/v1'; @@ -129,6 +130,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, @@ -143,6 +146,30 @@ export const API_CONFIG = { `/payment/purchase-history?page=${page}&size=${size}`, GET_TRANSACTION_HISTORY: (page: number, size: number) => `/payment/history?page=${page}&size=${size}`, + SEARCH_ORGS_FILTER: ( + page: number, + size: number, + search: FilterOrgs | null + ) => { + let query = `/search/organizations/filter?page=${page}&size=${size}`; + if (search?.includeBlocks) + query += `&includeBlocks=${search.includeBlocks}`; + if (search?.blocksPage && search?.blocksSize) + query += `&blocksPage=${search?.blocksPage}&blocksSize=${search?.blocksSize}`; + if (search?.membersPage && search?.membersSize) + query += `&membersPage=${search?.membersPage}&membersSize=${search?.membersSize}`; + if (search?.activeOnlyMembers) + query += `&activeOnlyMembers=${search?.activeOnlyMembers}`; + if (search?.includeUnassigned) + query += `&includeUnassigned=${search?.includeUnassigned}`; + if (search?.q) query += `&q=${encodeURIComponent(search?.q)}`; + if (search?.status) + query += `&status=${encodeURIComponent(search?.status)}`; + + return query; + }, + GET_ORG_DETAILS_BY_ID: (orgId: string) => + `/org/api/Organization/${orgId}`, }, POST: { LOGIN: '/identity/auth/login', @@ -202,14 +229,17 @@ 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}`, TOPUP: '/payment/topup', PURCHASE: '/payment/purchase', + CREATE_ORGANIZATION: '/org/organization', }, PUT: { EDIT_FILE: (id: string) => `/file/api/FileDocument/edit/${id}`, + EDIT_ORG: (id: string) => `/org/api/Organization/${id}`, }, PATCH: { UPDATE_EXERCISE: (exerciseId: string) => @@ -242,9 +272,11 @@ 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`, + DELETE_ORG: (orgId: string) => `/org/organization/${orgId}`, }, }, HEADERS: { 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 87913ee7..1ce5f2c9 100644 --- a/src/app/features/post/pages/post-list/post-list.html +++ b/src/app/features/post/pages/post-list/post-list.html @@ -75,12 +75,13 @@ (upvote)="handleUpVote(post.id)" (downvote)="handleDownVote(post.id)" (mainClick)="goToDetail($event)" + (save)="handleToggleSave(post.id)" > -
+
@@ -96,40 +97,11 @@

Không có dữ liệu

- - -