WIP
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
Reference in New Issue
Block a user