diff --git a/API/ROLAC.API/Controllers/MinistriesController.cs b/API/ROLAC.API/Controllers/MinistriesController.cs index e0f665a..019cfe0 100644 --- a/API/ROLAC.API/Controllers/MinistriesController.cs +++ b/API/ROLAC.API/Controllers/MinistriesController.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using ROLAC.API.Authorization; +using ROLAC.API.DTOs.Ministry; using ROLAC.API.Services; namespace ROLAC.API.Controllers; @@ -13,6 +15,31 @@ public class MinistriesController : ControllerBase public MinistriesController(IMinistryService svc) => _svc = svc; [HttpGet] + [HasPermission(Modules.Ministries, PermissionActions.Read)] public async Task GetAll([FromQuery] bool includeInactive = false) => Ok(await _svc.GetAllAsync(includeInactive)); + + [HttpPost] + [HasPermission(Modules.Ministries, PermissionActions.Write)] + public async Task Create([FromBody] CreateMinistryRequest request) + { + var id = await _svc.CreateAsync(request); + return CreatedAtAction(nameof(GetAll), new { id }, new { id }); + } + + [HttpPut("{id:int}")] + [HasPermission(Modules.Ministries, PermissionActions.Write)] + public async Task Update(int id, [FromBody] UpdateMinistryRequest request) + { + try { await _svc.UpdateAsync(id, request); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + } + + [HttpDelete("{id:int}")] + [HasPermission(Modules.Ministries, PermissionActions.Delete)] + public async Task Deactivate(int id) + { + try { await _svc.DeactivateAsync(id); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + } } diff --git a/API/ROLAC.API/DTOs/Ministry/CreateMinistryRequest.cs b/API/ROLAC.API/DTOs/Ministry/CreateMinistryRequest.cs new file mode 100644 index 0000000..dabcde5 --- /dev/null +++ b/API/ROLAC.API/DTOs/Ministry/CreateMinistryRequest.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Ministry; + +public class CreateMinistryRequest +{ + [Required, MaxLength(200)] public string Name_en { get; set; } = ""; + [MaxLength(200)] public string? Name_zh { get; set; } + [MaxLength(500)] public string? Description_en { get; set; } + [MaxLength(500)] public string? Description_zh { get; set; } + public int SortOrder { get; set; } +} diff --git a/API/ROLAC.API/DTOs/Ministry/MinistryDto.cs b/API/ROLAC.API/DTOs/Ministry/MinistryDto.cs index a8ebe30..752ab2f 100644 --- a/API/ROLAC.API/DTOs/Ministry/MinistryDto.cs +++ b/API/ROLAC.API/DTOs/Ministry/MinistryDto.cs @@ -5,6 +5,8 @@ public class MinistryDto public int Id { get; set; } public string Name_en { get; set; } = ""; public string? Name_zh { get; set; } + public string? Description_en { get; set; } + public string? Description_zh { get; set; } public int SortOrder { get; set; } public bool IsActive { get; set; } } diff --git a/API/ROLAC.API/DTOs/Ministry/UpdateMinistryRequest.cs b/API/ROLAC.API/DTOs/Ministry/UpdateMinistryRequest.cs new file mode 100644 index 0000000..8861583 --- /dev/null +++ b/API/ROLAC.API/DTOs/Ministry/UpdateMinistryRequest.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Ministry; + +public class UpdateMinistryRequest +{ + [Required, MaxLength(200)] public string Name_en { get; set; } = ""; + [MaxLength(200)] public string? Name_zh { get; set; } + [MaxLength(500)] public string? Description_en { get; set; } + [MaxLength(500)] public string? Description_zh { get; set; } + public bool IsActive { get; set; } = true; + public int SortOrder { get; set; } +} diff --git a/API/ROLAC.API/Data/DbSeeder.cs b/API/ROLAC.API/Data/DbSeeder.cs index 31c040c..96b1234 100644 --- a/API/ROLAC.API/Data/DbSeeder.cs +++ b/API/ROLAC.API/Data/DbSeeder.cs @@ -94,6 +94,22 @@ public static class DbSeeder ("pastor", Modules.AuditLogs, true, false, false, false), ("finance", Modules.AuditLogs, true, false, false, false), ("board_member", Modules.AuditLogs, true, false, false, false), + + // Ministries — secretary maintains the list; coworker_chair edits; ministry + // leaders and pastor read. + ("secretary", Modules.Ministries, true, true, true, false), + ("coworker_chair", Modules.Ministries, true, true, false, false), + ("ministry_leader", Modules.Ministries, true, false, false, false), + ("pastor", Modules.Ministries, true, false, false, false), + + // Meal attendance — secretary and coworkers record; finance and pastor read. + ("secretary", Modules.MealAttendance, true, true, false, false), + ("coworker", Modules.MealAttendance, true, true, false, false), + ("finance", Modules.MealAttendance, true, false, false, false), + ("pastor", Modules.MealAttendance, true, false, false, false), + + // Users, Permissions, and Settings are intentionally super_admin-only: + // super_admin bypasses all checks, so no seed rows are needed here. ]; public static async Task SeedRolePermissionsAsync(AppDbContext db) diff --git a/API/ROLAC.API/Services/IMinistryService.cs b/API/ROLAC.API/Services/IMinistryService.cs index cf6586e..503c9f9 100644 --- a/API/ROLAC.API/Services/IMinistryService.cs +++ b/API/ROLAC.API/Services/IMinistryService.cs @@ -4,4 +4,7 @@ namespace ROLAC.API.Services; public interface IMinistryService { Task> GetAllAsync(bool includeInactive); + Task CreateAsync(CreateMinistryRequest request); + Task UpdateAsync(int id, UpdateMinistryRequest request); + Task DeactivateAsync(int id); // soft-disable: IsActive = false } diff --git a/API/ROLAC.API/Services/MinistryService.cs b/API/ROLAC.API/Services/MinistryService.cs index b5d4992..77c535c 100644 --- a/API/ROLAC.API/Services/MinistryService.cs +++ b/API/ROLAC.API/Services/MinistryService.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using ROLAC.API.Data; using ROLAC.API.DTOs.Ministry; +using ROLAC.API.Entities; namespace ROLAC.API.Services; @@ -18,8 +19,40 @@ public class MinistryService : IMinistryService .Select(m => new MinistryDto { Id = m.Id, Name_en = m.Name_en, Name_zh = m.Name_zh, + Description_en = m.Description_en, Description_zh = m.Description_zh, SortOrder = m.SortOrder, IsActive = m.IsActive, }) .ToListAsync(); } + + public async Task CreateAsync(CreateMinistryRequest r) + { + var entity = new Ministry + { + Name_en = r.Name_en, Name_zh = r.Name_zh, + Description_en = r.Description_en, Description_zh = r.Description_zh, + SortOrder = r.SortOrder, IsActive = true, + }; + _db.Ministries.Add(entity); + await _db.SaveChangesAsync(); + return entity.Id; + } + + public async Task UpdateAsync(int id, UpdateMinistryRequest r) + { + var m = await _db.Ministries.FindAsync(id) + ?? throw new KeyNotFoundException($"Ministry {id} not found."); + m.Name_en = r.Name_en; m.Name_zh = r.Name_zh; + m.Description_en = r.Description_en; m.Description_zh = r.Description_zh; + m.IsActive = r.IsActive; m.SortOrder = r.SortOrder; + await _db.SaveChangesAsync(); + } + + public async Task DeactivateAsync(int id) + { + var m = await _db.Ministries.FindAsync(id) + ?? throw new KeyNotFoundException($"Ministry {id} not found."); + m.IsActive = false; + await _db.SaveChangesAsync(); + } } diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts index 9602005..e99500b 100644 --- a/APP/src/app/app.routes.ts +++ b/APP/src/app/app.routes.ts @@ -7,6 +7,7 @@ import { PermissionGuard } from './core/guards/permission.guard'; import { PermissionModules } from './core/models/permission.model'; import { PermissionsPageComponent } from './features/permissions/pages/permissions-page/permissions-page.component'; import { MembersPageComponent } from './features/members/pages/members-page/members-page.component'; +import { MinistriesPageComponent } from './features/ministry/pages/ministries-page/ministries-page.component'; import { UsersPageComponent } from './features/users/pages/users-page/users-page.component'; import { GivingCategoriesPageComponent } from './features/giving/pages/giving-categories-page/giving-categories-page.component'; import { GivingsPageComponent } from './features/giving/pages/givings-page/givings-page.component'; @@ -65,6 +66,15 @@ export const routes: Routes = [ title: 'Member Management', titleZh: '會友管理', section: 'Admin', }, }, + { + path: 'admin/ministries', + component: MinistriesPageComponent, + canActivate: [PermissionGuard], + data: { + permission: { module: PermissionModules.Ministries, action: 'read' }, + title: 'Ministry Management', titleZh: '事工管理', section: 'Admin', + }, + }, { path: 'admin/users', component: UsersPageComponent, diff --git a/APP/src/app/features/ministry/models/ministry.model.ts b/APP/src/app/features/ministry/models/ministry.model.ts new file mode 100644 index 0000000..7e4514d --- /dev/null +++ b/APP/src/app/features/ministry/models/ministry.model.ts @@ -0,0 +1,22 @@ +// ── Ministries ──────────────────────────────────────────────────── +export interface MinistryDto { + id: number; + name_en: string; + name_zh: string | null; + description_en: string | null; + description_zh: string | null; + isActive: boolean; + sortOrder: number; + /** Display-only bilingual label, computed in the API service. */ + label?: string; +} +export interface CreateMinistryRequest { + name_en: string; + name_zh: string | null; + description_en: string | null; + description_zh: string | null; + sortOrder: number; +} +export interface UpdateMinistryRequest extends CreateMinistryRequest { + isActive: boolean; +} diff --git a/APP/src/app/features/ministry/pages/ministries-page/ministries-page.component.html b/APP/src/app/features/ministry/pages/ministries-page/ministries-page.component.html new file mode 100644 index 0000000..a531c66 --- /dev/null +++ b/APP/src/app/features/ministry/pages/ministries-page/ministries-page.component.html @@ -0,0 +1,55 @@ +
+ + + + + + + + + + + {{ m.isActive ? 'Yes' : 'No' }} + + + + + + + + + + +
+ + + + + + +
+ + + + +
+
diff --git a/APP/src/app/features/ministry/pages/ministries-page/ministries-page.component.scss b/APP/src/app/features/ministry/pages/ministries-page/ministries-page.component.scss new file mode 100644 index 0000000..c12655f --- /dev/null +++ b/APP/src/app/features/ministry/pages/ministries-page/ministries-page.component.scss @@ -0,0 +1,12 @@ +.header-actions { + display: flex; + align-items: center; + gap: 1rem; +} + +.inactive-toggle { + display: flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; +} diff --git a/APP/src/app/features/ministry/pages/ministries-page/ministries-page.component.ts b/APP/src/app/features/ministry/pages/ministries-page/ministries-page.component.ts new file mode 100644 index 0000000..c2d1d3b --- /dev/null +++ b/APP/src/app/features/ministry/pages/ministries-page/ministries-page.component.ts @@ -0,0 +1,75 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { GridModule } from '@progress/kendo-angular-grid'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { MinistryApiService } from '../../services/ministry-api.service'; +import { + MinistryDto, CreateMinistryRequest, UpdateMinistryRequest, +} from '../../models/ministry.model'; +import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive'; + +@Component({ + selector: 'app-ministries-page', + standalone: true, + imports: [CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule, DialogsModule, PageHeaderActionsDirective], + templateUrl: './ministries-page.component.html', + styleUrls: ['./ministries-page.component.scss'], +}) +export class MinistriesPageComponent implements OnInit { + data: MinistryDto[] = []; + isLoading = false; + includeInactive = false; + + showDialog = false; + editing: MinistryDto | null = null; + form: UpdateMinistryRequest = this.blankForm(); + + constructor(private api: MinistryApiService) {} + + ngOnInit(): void { this.load(); } + + load(): void { + this.isLoading = true; + this.api.getAll(this.includeInactive).subscribe({ + next: rows => { this.data = rows; this.isLoading = false; }, + error: () => { this.isLoading = false; }, + }); + } + + openAdd(): void { this.editing = null; this.form = this.blankForm(); this.showDialog = true; } + + openEdit(m: MinistryDto): void { + this.editing = m; + this.form = { + name_en: m.name_en, name_zh: m.name_zh, + description_en: m.description_en, description_zh: m.description_zh, + isActive: m.isActive, sortOrder: m.sortOrder, + }; + this.showDialog = true; + } + + save(): void { + if (this.editing) { + this.api.update(this.editing.id, this.form).subscribe(() => { this.showDialog = false; this.load(); }); + } else { + const create: CreateMinistryRequest = { + name_en: this.form.name_en, name_zh: this.form.name_zh, + description_en: this.form.description_en, description_zh: this.form.description_zh, + sortOrder: this.form.sortOrder, + }; + this.api.create(create).subscribe(() => { this.showDialog = false; this.load(); }); + } + } + + deactivate(m: MinistryDto): void { + if (!confirm(`Deactivate "${m.name_en}"?`)) return; + this.api.deactivate(m.id).subscribe(() => this.load()); + } + + private blankForm(): UpdateMinistryRequest { + return { name_en: '', name_zh: null, description_en: null, description_zh: null, isActive: true, sortOrder: 0 }; + } +} diff --git a/APP/src/app/features/ministry/services/ministry-api.service.ts b/APP/src/app/features/ministry/services/ministry-api.service.ts new file mode 100644 index 0000000..c777202 --- /dev/null +++ b/APP/src/app/features/ministry/services/ministry-api.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, map } from 'rxjs'; +import { bilingual } from '../../../shared/i18n/bilingual'; +import { ApiConfigService } from '../../../core/services/api-config.service'; +import { + MinistryDto, CreateMinistryRequest, UpdateMinistryRequest, +} from '../models/ministry.model'; + +@Injectable({ providedIn: 'root' }) +export class MinistryApiService { + private readonly endpoint: string; + constructor(private http: HttpClient, apiConfig: ApiConfigService) { + this.endpoint = apiConfig.getApiUrl('ministries'); + } + + getAll(includeInactive = false): Observable { + const params = new HttpParams().set('includeInactive', includeInactive); + return this.http.get(this.endpoint, { params }).pipe( + map(list => list.map(m => ({ ...m, label: bilingual(m.name_en, m.name_zh) }))), + ); + } + create(request: CreateMinistryRequest): Observable<{ id: number }> { + return this.http.post<{ id: number }>(this.endpoint, request); + } + update(id: number, request: UpdateMinistryRequest): Observable { + return this.http.put(`${this.endpoint}/${id}`, request); + } + deactivate(id: number): Observable { + return this.http.delete(`${this.endpoint}/${id}`); + } +} diff --git a/APP/src/app/portals/user-portal/user-portal.component.ts b/APP/src/app/portals/user-portal/user-portal.component.ts index bdac1b3..6e77afc 100644 --- a/APP/src/app/portals/user-portal/user-portal.component.ts +++ b/APP/src/app/portals/user-portal/user-portal.component.ts @@ -81,6 +81,8 @@ export class UserPortalComponent implements OnInit, OnDestroy { public memberAdminNavItems: NavItem[] = [ { text: 'Members', icon: groupIcon, path: '/user-portal/admin/members', permission: { module: PermissionModules.Members, action: 'read' } }, + { text: 'Ministries', icon: groupIcon, path: '/user-portal/admin/ministries', + permission: { module: PermissionModules.Ministries, action: 'read' } }, ]; public userAdminNavItems: NavItem[] = [