This commit is contained in:
Chris Chen
2026-06-24 18:45:22 -07:00
parent e908e35530
commit 9dbb1d38d8
14 changed files with 312 additions and 0 deletions
@@ -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<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive));
[HttpPost]
[HasPermission(Modules.Ministries, PermissionActions.Write)]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Deactivate(int id)
{
try { await _svc.DeactivateAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
+16
View File
@@ -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)
@@ -4,4 +4,7 @@ namespace ROLAC.API.Services;
public interface IMinistryService
{
Task<List<MinistryDto>> GetAllAsync(bool includeInactive);
Task<int> CreateAsync(CreateMinistryRequest request);
Task UpdateAsync(int id, UpdateMinistryRequest request);
Task DeactivateAsync(int id); // soft-disable: IsActive = false
}
+33
View File
@@ -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<int> 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();
}
}
+10
View File
@@ -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,
@@ -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;
}
@@ -0,0 +1,55 @@
<div class="page">
<ng-template appPageHeaderActions>
<label class="inactive-toggle">
<input type="checkbox" [(ngModel)]="includeInactive" (change)="load()" /> Show inactive
</label>
<button kendoButton themeColor="primary" (click)="openAdd()">+ Add</button>
</ng-template>
<kendo-grid [data]="data" [loading]="isLoading">
<kendo-grid-column field="sortOrder" title="#" [width]="60"></kendo-grid-column>
<kendo-grid-column field="name_en" title="Name (EN)"></kendo-grid-column>
<kendo-grid-column field="name_zh" title="名稱 (中)"></kendo-grid-column>
<kendo-grid-column field="isActive" title="Active" [width]="90">
<ng-template kendoGridCellTemplate let-m>{{ m.isActive ? 'Yes' : 'No' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="160">
<ng-template kendoGridCellTemplate let-m>
<button kendoButton fillMode="flat" (click)="openEdit(m)">Edit</button>
<button kendoButton fillMode="flat" *ngIf="m.isActive" (click)="deactivate(m)">Deactivate</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<kendo-dialog *ngIf="showDialog" [title]="editing ? 'Edit Ministry' : 'Add Ministry'" (close)="showDialog=false" [width]="480" [maxWidth]="'95vw'">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1">
Name (EN) *
<kendo-textbox [(ngModel)]="form.name_en"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
名稱 (中)
<kendo-textbox [(ngModel)]="form.name_zh"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Description (EN)
<kendo-textbox [(ngModel)]="form.description_en"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
說明 (中)
<kendo-textbox [(ngModel)]="form.description_zh"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Sort order
<kendo-numerictextbox [(ngModel)]="form.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
<label *ngIf="editing" class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [(ngModel)]="form.isActive" /> Active
</label>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="showDialog=false">Cancel</button>
<button kendoButton themeColor="primary" [disabled]="!form.name_en" (click)="save()">Save</button>
</kendo-dialog-actions>
</kendo-dialog>
</div>
@@ -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;
}
@@ -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 };
}
}
@@ -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<MinistryDto[]> {
const params = new HttpParams().set('includeInactive', includeInactive);
return this.http.get<MinistryDto[]>(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<void> {
return this.http.put<void>(`${this.endpoint}/${id}`, request);
}
deactivate(id: number): Observable<void> {
return this.http.delete<void>(`${this.endpoint}/${id}`);
}
}
@@ -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[] = [