feat(registratie): WP-36 — admin cases page + admin delete
Admin-only overview of all cases across owners + an admin delete, gated by a new
`cases:manage` capability (Authz role→cap + CanManageCases + CasesAdmin gate;
FE capability + guard + nav + role.interceptor prefix — the org-template/stamdata
recipe). Backend adds ApplicationStore.ListAll()/DeleteAny() and GET /admin/cases +
DELETE /admin/cases/{id}; admin delete removes ANY case incl. submitted. Page lives
in registratie/ui (owns the Aanvraag aggregate; reuses aanvraag-view + parse),
routed /beheer/zaken; delete guarded by a native confirm, optimistic with rollback.
Typed client regenerated (documents the new endpoints + owner field).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -96,7 +96,8 @@ public sealed record AanvraagStatusDto(
|
|||||||
public sealed record ApplicationSummaryDto(
|
public sealed record ApplicationSummaryDto(
|
||||||
string Id, string Type, AanvraagStatusDto Status,
|
string Id, string Type, AanvraagStatusDto Status,
|
||||||
IReadOnlyList<string> DocumentIds,
|
IReadOnlyList<string> DocumentIds,
|
||||||
string CreatedAt, string UpdatedAt, string? SubmittedAt);
|
string CreatedAt, string UpdatedAt, string? SubmittedAt,
|
||||||
|
string? Owner = null); // populated for the admin cross-owner list (WP-36); the user's own list ignores it
|
||||||
|
|
||||||
public sealed record ApplicationDetailDto(
|
public sealed record ApplicationDetailDto(
|
||||||
string Id, string Type, AanvraagStatusDto Status,
|
string Id, string Type, AanvraagStatusDto Status,
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ public static class Mappers
|
|||||||
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
|
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
|
||||||
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
|
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
|
||||||
|
|
||||||
|
/// Admin summary — same shape plus the owner (WP-36; the user-facing list leaves Owner null).
|
||||||
|
public static ApplicationSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) =>
|
||||||
|
a.ToSummaryDto(now) with { Owner = a.Owner };
|
||||||
|
|
||||||
public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
|
public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
|
||||||
a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds,
|
a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds,
|
||||||
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
|
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
|
||||||
|
|||||||
@@ -80,6 +80,19 @@ public static class ApplicationStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Admin: every case across all owners (WP-36). The per-owner List is the norm; this
|
||||||
|
/// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint.
|
||||||
|
public static IReadOnlyList<Aanvraag> ListAll()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
|
// Order client-side: SQLite can't ORDER BY a DateTimeOffset (same constraint the
|
||||||
|
// rest of the store sidesteps by never sorting in the query).
|
||||||
|
return db.Applications.ToList().OrderByDescending(a => a.UpdatedAt).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable.
|
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable.
|
||||||
public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList<string>? documentIds)
|
public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList<string>? documentIds)
|
||||||
{
|
{
|
||||||
@@ -116,6 +129,28 @@ public static class ApplicationStore
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Admin: delete ANY case regardless of owner or submitted state (WP-36). The
|
||||||
|
/// user-facing Delete refuses a submitted aanvraag and is owner-scoped; an admin
|
||||||
|
/// managing the register may remove any case. Cascades to the case's documents
|
||||||
|
/// using its own owner. Returns false only when the id doesn't exist.
|
||||||
|
public static bool DeleteAny(string id)
|
||||||
|
{
|
||||||
|
string owner;
|
||||||
|
List<string> docs;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
|
var a = db.Applications.Find(id);
|
||||||
|
if (a is null) return false;
|
||||||
|
owner = a.Owner;
|
||||||
|
docs = a.DocumentIds.ToList();
|
||||||
|
db.Applications.Remove(a);
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Submit transition. reject != null → Afgewezen; else accepted (In behandeling,
|
/// Submit transition. reject != null → Afgewezen; else accepted (In behandeling,
|
||||||
/// auto-advancing to Goedgekeurd after the window when autoApprovable). Returns null
|
/// auto-advancing to Goedgekeurd after the window when autoApprovable). Returns null
|
||||||
/// if the aanvraag is gone or already submitted (idempotency guard).
|
/// if the aanvraag is gone or already submitted (idempotency guard).
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public static class Authz
|
|||||||
public static IReadOnlyList<string> RoleCapabilities(Principal principal) => principal.Role switch
|
public static IReadOnlyList<string> RoleCapabilities(Principal principal) => principal.Role switch
|
||||||
{
|
{
|
||||||
PrincipalRole.Approver => new[] { "brief:approve", "brief:reject", "brief:send" },
|
PrincipalRole.Approver => new[] { "brief:approve", "brief:reject", "brief:send" },
|
||||||
PrincipalRole.Admin => new[] { "orgtemplate:edit", "stamdata:edit" },
|
PrincipalRole.Admin => new[] { "orgtemplate:edit", "stamdata:edit", "cases:manage" },
|
||||||
_ => Array.Empty<string>(),
|
_ => Array.Empty<string>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,6 +69,11 @@ public static class Authz
|
|||||||
/// the maintenance editor consumes; the actual edit lands as a reviewed PR, not a write here.
|
/// the maintenance editor consumes; the actual edit lands as a reviewed PR, not a write here.
|
||||||
public static bool CanEditStamdata(Principal principal) => principal.Role == PrincipalRole.Admin;
|
public static bool CanEditStamdata(Principal principal) => principal.Role == PrincipalRole.Admin;
|
||||||
|
|
||||||
|
/// Case management (WP-36): admin-only, resource-independent — same shape as
|
||||||
|
/// org-template / stamdata (role IS the decision). Gates the cross-owner /admin/cases
|
||||||
|
/// list + admin delete.
|
||||||
|
public static bool CanManageCases(Principal principal) => principal.Role == PrincipalRole.Admin;
|
||||||
|
|
||||||
/// Field-level PII (PRD-0002 §5c, phase P2): the case screen's BIG-nummer ships
|
/// Field-level PII (PRD-0002 §5c, phase P2): the case screen's BIG-nummer ships
|
||||||
/// masked by default; only the behandelaar (Drafter) composing the case — the actor
|
/// masked by default; only the behandelaar (Drafter) composing the case — the actor
|
||||||
/// whose behandel-scherm shows the field — may reveal it. Role-based in the POC; a
|
/// whose behandel-scherm shows the field — may reveal it. Role-based in the POC; a
|
||||||
|
|||||||
@@ -309,6 +309,27 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
|||||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
// --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. ---
|
||||||
|
api.MapGet("/admin/cases", (HttpContext ctx) => CasesAdmin(ctx, () =>
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
return Results.Ok(ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList());
|
||||||
|
}))
|
||||||
|
.Produces<List<ApplicationSummaryDto>>()
|
||||||
|
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing
|
||||||
|
// DELETE /applications/{id}. A missing id is a 404.
|
||||||
|
api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () =>
|
||||||
|
{
|
||||||
|
if (!ApplicationStore.DeleteAny(id)) return Results.NotFound();
|
||||||
|
app.Logger.LogInformation("admin case delete id={Id}", id);
|
||||||
|
return Results.NoContent();
|
||||||
|
}))
|
||||||
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
|
.Produces(StatusCodes.Status404NotFound)
|
||||||
|
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT
|
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT
|
||||||
// tied to a specific brief's live status — see BriefDecisionsDto for that).
|
// tied to a specific brief's live status — see BriefDecisionsDto for that).
|
||||||
api.MapGet("/me", (HttpContext ctx) => new MeDto(Authz.RoleCapabilities(Authz.ResolvePrincipal(ctx))))
|
api.MapGet("/me", (HttpContext ctx) => new MeDto(Authz.RoleCapabilities(Authz.ResolvePrincipal(ctx))))
|
||||||
@@ -515,6 +536,17 @@ IResult StamdataAdmin(HttpContext ctx, Func<IResult> action)
|
|||||||
statusCode: StatusCodes.Status403Forbidden);
|
statusCode: StatusCodes.Status403Forbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One gate for every admin-cases endpoint — the enforce twin of the `cases:manage`
|
||||||
|
// capability RoleCapabilities emits (single Authz source, WP-36). A denial is audited.
|
||||||
|
IResult CasesAdmin(HttpContext ctx, Func<IResult> action)
|
||||||
|
{
|
||||||
|
var principal = Authz.ResolvePrincipal(ctx);
|
||||||
|
if (Authz.CanManageCases(principal)) return action();
|
||||||
|
AuditAuthz(ctx, "cases:manage", "cases", false, principal);
|
||||||
|
return Results.Problem(detail: "Alleen een beheerder mag aanvragen beheren.",
|
||||||
|
statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
static StamdataColumnDto ToColumnDto(StamdataColumn c) => new(c.Name, c.Type, c.IsKey, c.Options);
|
static StamdataColumnDto ToColumnDto(StamdataColumn c) => new(c.Name, c.Type, c.IsKey, c.Options);
|
||||||
|
|
||||||
// Authorization audit (PRD-0002 §8): access-relevant decisions recorded with NO PII —
|
// Authorization audit (PRD-0002 §8): access-relevant decisions recorded with NO PII —
|
||||||
|
|||||||
@@ -734,6 +734,73 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/admin/cases": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ApplicationSummaryDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/cases/{id}": {
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No Content"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/me": {
|
"/api/v1/me": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1370,6 +1437,10 @@
|
|||||||
"submittedAt": {
|
"submittedAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
},
|
||||||
|
"owner": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using BigRegister.Api.Contracts;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
|
/// WP-36: admin cross-owner case list + admin delete, gated by `cases:manage`.
|
||||||
|
public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
|
private HttpRequestMessage Admin(HttpMethod method, string path)
|
||||||
|
{
|
||||||
|
var req = new HttpRequestMessage(method, path);
|
||||||
|
req.Headers.Add("X-Role", "admin");
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ApplicationDetailDto> Create(string type)
|
||||||
|
{
|
||||||
|
var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type });
|
||||||
|
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
|
||||||
|
return (await res.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_lists_every_case_with_its_owner()
|
||||||
|
{
|
||||||
|
var a = await Create("herregistratie");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = await _client.SendAsync(Admin(HttpMethod.Get, "/api/v1/admin/cases"));
|
||||||
|
list.EnsureSuccessStatusCode();
|
||||||
|
var cases = (await list.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
|
||||||
|
var mine = cases.Single(x => x.Id == a.Id);
|
||||||
|
Assert.False(string.IsNullOrEmpty(mine.Owner)); // admin list carries the owner
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _client.SendAsync(Admin(HttpMethod.Delete, $"/api/v1/admin/cases/{a.Id}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Non_admin_is_forbidden()
|
||||||
|
{
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.GetAsync("/api/v1/admin/cases")).StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync("/api/v1/admin/cases/anything")).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_can_delete_a_submitted_case()
|
||||||
|
{
|
||||||
|
var a = await Create("registratie");
|
||||||
|
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" }))
|
||||||
|
.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
// The user-facing DELETE refuses a submitted case (409); admin delete removes it.
|
||||||
|
var del = await _client.SendAsync(Admin(HttpMethod.Delete, $"/api/v1/admin/cases/{a.Id}"));
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deleting_a_missing_case_is_not_found()
|
||||||
|
{
|
||||||
|
var del = await _client.SendAsync(Admin(HttpMethod.Delete, "/api/v1/admin/cases/does-not-exist"));
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, del.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -201,7 +201,7 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
|
|||||||
{
|
{
|
||||||
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/me", role: "admin"));
|
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/me", role: "admin"));
|
||||||
var me = await res.Content.ReadFromJsonAsync<MeDto>();
|
var me = await res.Content.ReadFromJsonAsync<MeDto>();
|
||||||
Assert.Equal(new[] { "orgtemplate:edit", "stamdata:edit" }, me!.Capabilities);
|
Assert.Equal(new[] { "orgtemplate:edit", "stamdata:edit", "cases:manage" }, me!.Capabilities);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ for its existing violations, so every WP ends green.
|
|||||||
| [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done |
|
| [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done |
|
||||||
| [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done |
|
| [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done |
|
||||||
| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done |
|
| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done |
|
||||||
| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | todo |
|
| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | done |
|
||||||
|
|
||||||
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
|
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
|
||||||
03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed
|
03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# WP-36 — Admin cases page + admin delete
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Phase: 7 — refinements
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Admins can maintain stamdata and org-templates but have no view of the cases (aanvragen) in the
|
||||||
|
register, and no way to remove an erroneous one. This WP adds an admin-only overview of **all**
|
||||||
|
cases across owners and an admin **delete** that can remove any case — the back-office counterpart
|
||||||
|
of the user's own dashboard.
|
||||||
|
|
||||||
|
## Decisions (made while building — no spec existed; flagged for review)
|
||||||
|
|
||||||
|
- **Single capability `cases:manage`** covers both the list and the delete (one back-office
|
||||||
|
concern), following the `orgtemplate:edit` / `stamdata:edit` precedent exactly (Authz role→cap +
|
||||||
|
a `CanManageCases` gate + a `CasesAdmin(ctx,…)` helper; FE `Capability` union + `me.adapter`
|
||||||
|
`KNOWN` + `capabilityGuard` + nav item + `role.interceptor` prefix).
|
||||||
|
- **Page lives in `registratie` (not `beheer`).** `registratie` owns the `Aanvraag` aggregate, so
|
||||||
|
the admin view reuses its `aanvraag-view` labels + `parseApplications` trust boundary instead of
|
||||||
|
duplicating them — and it respects the layer boundary (`beheer` may not import `registratie`).
|
||||||
|
This matches the existing pattern (stamdata-admin lives in `beheer` because `beheer` owns
|
||||||
|
stamdata; org-template-admin in `brief`). Routed at `/beheer/zaken` for a legible admin URL.
|
||||||
|
- **Admin delete removes ANY case** — any owner, submitted or not — unlike the user-facing
|
||||||
|
`DELETE /applications/{id}` (owner-scoped, 409 on a submitted case). That is the admin power.
|
||||||
|
- **Native `confirm()` guards the delete.** No confirm-dialog component exists (the only precedent
|
||||||
|
is a native `confirm()` in behandel-scherm); the delete is irreversible, so it gets a prompt
|
||||||
|
rather than the dashboard's no-confirm optimistic cancel.
|
||||||
|
- **Single owner in practice.** Only `DemoOwner` exists, so the list shows that owner's cases with
|
||||||
|
an Owner column; no fake multi-user seed was added (the endpoint is cross-owner-capable —
|
||||||
|
`ListAll()` — so real multi-owner data would just appear).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Backend: `ApplicationStore.ListAll()` + `DeleteAny(id)`; `ApplicationSummaryDto.Owner` +
|
||||||
|
`ToAdminSummaryDto`; `Authz` cap + `CanManageCases`; `Program.cs` `CasesAdmin` gate + `GET
|
||||||
|
/admin/cases` + `DELETE /admin/cases/{id}`; `AdminCasesTests` (+ update the org-template `/me`
|
||||||
|
cap-list assertion). SQLite can't `ORDER BY DateTimeOffset` → `ListAll` sorts client-side.
|
||||||
|
- FE: `capability.ts` + `me.adapter` `KNOWN` + `role.interceptor` (`/api/v1/admin/cases`);
|
||||||
|
`aanvraag.ts` `owner?`; `applications.adapter` `listAll`/`deleteAny` + parse owner;
|
||||||
|
`registratie/application/admin-cases.store.ts` (+spec); `registratie/ui/admin-cases.page.ts`;
|
||||||
|
route in `app.routes.ts`; nav item in `site-header`; new `$localize` ids + English targets.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Admin-only page at `/beheer/zaken` lists all cases (owner + type + status), gated by
|
||||||
|
`cases:manage` (denial alert for non-admins; server re-enforces via `CasesAdmin`).
|
||||||
|
- [x] Admin delete removes any case (incl. submitted); confirmed, optimistic with rollback.
|
||||||
|
- [x] `npm run ci` green (336 FE tests, backend 129, localized build, drift clean after commit).
|
||||||
@@ -74,6 +74,16 @@ export const routes: Routes = [
|
|||||||
canActivate: [capabilityGuard('stamdata:edit')],
|
canActivate: [capabilityGuard('stamdata:edit')],
|
||||||
loadComponent: () => import('@beheer/ui/stamdata.page').then((m) => m.StamdataPage),
|
loadComponent: () => import('@beheer/ui/stamdata.page').then((m) => m.StamdataPage),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'beheer/zaken',
|
||||||
|
// Admin-only cases overview + delete (WP-36): capabilityGuard denies-by-default
|
||||||
|
// unless GET /me resolved `cases:manage` (Admin role). Backend re-enforces via the
|
||||||
|
// CasesAdmin gate — the guard just avoids loading a page that would 403. The page
|
||||||
|
// lives in registratie/ui (which owns the Aanvraag aggregate); routed under /beheer.
|
||||||
|
canActivate: [capabilityGuard('cases:manage')],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('@registratie/ui/admin-cases.page').then((m) => m.AdminCasesPage),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'concepts',
|
path: 'concepts',
|
||||||
loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage),
|
loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage),
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
|
||||||
|
import { AdminCasesStore } from './admin-cases.store';
|
||||||
|
|
||||||
|
const summary = (id: string) => ({
|
||||||
|
id,
|
||||||
|
type: 'registratie',
|
||||||
|
status: { tag: 'Concept', stepIndex: 0, stepCount: 3 },
|
||||||
|
documentIds: [],
|
||||||
|
createdAt: '2026-07-23T10:00:00Z',
|
||||||
|
updatedAt: '2026-07-23T10:00:00Z',
|
||||||
|
owner: '19012345601',
|
||||||
|
});
|
||||||
|
|
||||||
|
function setup(adapter: Partial<ApplicationsAdapter>): AdminCasesStore {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [{ provide: ApplicationsAdapter, useValue: adapter }],
|
||||||
|
});
|
||||||
|
return TestBed.inject(AdminCasesStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AdminCasesStore', () => {
|
||||||
|
it('loads and parses the cross-owner list', async () => {
|
||||||
|
const store = setup({ listAll: () => Promise.resolve([summary('a'), summary('b')]) });
|
||||||
|
await store.load();
|
||||||
|
const s = store.cases();
|
||||||
|
expect(s.tag).toBe('Success');
|
||||||
|
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes optimistically and confirms via the admin endpoint', async () => {
|
||||||
|
const deleteAny = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const store = setup({
|
||||||
|
listAll: () => Promise.resolve([summary('a'), summary('b')]),
|
||||||
|
deleteAny,
|
||||||
|
});
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
await store.delete('a');
|
||||||
|
expect(deleteAny).toHaveBeenCalledWith('a');
|
||||||
|
const s = store.cases();
|
||||||
|
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back the removal when the delete fails', async () => {
|
||||||
|
const deleteAny = vi.fn().mockRejectedValue(new Error('boom'));
|
||||||
|
const store = setup({ listAll: () => Promise.resolve([summary('a')]), deleteAny });
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
await store.delete('a');
|
||||||
|
const s = store.cases();
|
||||||
|
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['a']); // reappears
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { RemoteData } from '@shared/application/remote-data';
|
||||||
|
import { Aanvraag } from '@registratie/domain/aanvraag';
|
||||||
|
import {
|
||||||
|
ApplicationsAdapter,
|
||||||
|
parseApplications,
|
||||||
|
} from '@registratie/infrastructure/applications.adapter';
|
||||||
|
|
||||||
|
type Err = Error | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office
|
||||||
|
* counterpart of the user-facing `ApplicationsStore`. Same shape: one root singleton
|
||||||
|
* owns the list as a writable RemoteData signal, delete removes the row synchronously
|
||||||
|
* (optimistic) and rolls back on error. Admin delete removes any case (any owner,
|
||||||
|
* submitted or not — the server enforces the capability).
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AdminCasesStore {
|
||||||
|
private adapter = inject(ApplicationsAdapter);
|
||||||
|
|
||||||
|
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
|
||||||
|
readonly cases = this.state.asReadonly();
|
||||||
|
|
||||||
|
/** Fetch + parse at the trust boundary, then publish as RemoteData. Keeps the
|
||||||
|
last-good value on a resync (only shows Loading on the first load). */
|
||||||
|
async load() {
|
||||||
|
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
|
||||||
|
try {
|
||||||
|
const parsed = parseApplications(await this.adapter.listAll());
|
||||||
|
this.state.set(
|
||||||
|
parsed.ok
|
||||||
|
? { tag: 'Success', value: parsed.value }
|
||||||
|
: { tag: 'Failure', error: new Error(parsed.error) },
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
this.state.set({ tag: 'Failure', error: e as Error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reload() {
|
||||||
|
void this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error. */
|
||||||
|
async delete(id: string) {
|
||||||
|
const before = this.state();
|
||||||
|
if (before.tag === 'Success') {
|
||||||
|
this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.adapter.deleteAny(id);
|
||||||
|
} catch {
|
||||||
|
this.state.set(before); // roll back: the row reappears
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ export interface Aanvraag {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
submittedAt?: string;
|
submittedAt?: string;
|
||||||
|
/** The case owner (a BSN). Only populated by the admin cross-owner list (WP-36);
|
||||||
|
the user's own list leaves it undefined. */
|
||||||
|
owner?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Detail adds the opaque wizard snapshot used to resume a Concept. */
|
/** Detail adds the opaque wizard snapshot used to resume a Concept. */
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ export class ApplicationsAdapter {
|
|||||||
return this.client.applicationsAll();
|
return this.client.applicationsAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin: every case across all owners (WP-36; `cases:manage`). Parsed at the boundary. */
|
||||||
|
listAll(): Promise<ApplicationSummaryDto[]> {
|
||||||
|
return this.client.casesAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin: delete ANY case (any owner, submitted or not — WP-36). */
|
||||||
|
deleteAny(id: string): Promise<void> {
|
||||||
|
return this.client.cases(id);
|
||||||
|
}
|
||||||
|
|
||||||
detail(id: string): Promise<ApplicationDetailDto> {
|
detail(id: string): Promise<ApplicationDetailDto> {
|
||||||
return this.client.applicationsGET(id);
|
return this.client.applicationsGET(id);
|
||||||
}
|
}
|
||||||
@@ -100,6 +110,7 @@ function parseCommon(dto: ApplicationSummaryDto): Result<string, Aanvraag> {
|
|||||||
createdAt: dto.createdAt,
|
createdAt: dto.createdAt,
|
||||||
updatedAt: dto.updatedAt,
|
updatedAt: dto.updatedAt,
|
||||||
submittedAt: dto.submittedAt,
|
submittedAt: dto.submittedAt,
|
||||||
|
owner: dto.owner, // only present on the admin cross-owner list (WP-36)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Component, computed, effect, inject } from '@angular/core';
|
||||||
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
|
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
|
||||||
|
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||||
|
import { ASYNC } from '@shared/ui/async/async.component';
|
||||||
|
import { AccessStore } from '@shared/application/access.store';
|
||||||
|
import { formatDatumNl } from '@shared/kernel/datum';
|
||||||
|
import { Aanvraag } from '@registratie/domain/aanvraag';
|
||||||
|
import { TYPE_LABELS, statusLabel, referentie } from '@registratie/domain/aanvraag-view';
|
||||||
|
import { AdminCasesStore } from '@registratie/application/admin-cases.store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin page: every case across all owners, with an admin delete (WP-36). Lives in
|
||||||
|
* `registratie` (which owns the Aanvraag aggregate) — the back-office counterpart of the
|
||||||
|
* user's dashboard, reusing the same view labels + trust-boundary parse. Deny-by-default
|
||||||
|
* capability gate (`cases:manage`): a denial alert for non-admins, the list for admins.
|
||||||
|
* Delete is guarded by a native confirm — it is irreversible and may remove submitted cases.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-admin-cases-page',
|
||||||
|
imports: [
|
||||||
|
PageShellComponent,
|
||||||
|
AlertComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
DataBlockComponent,
|
||||||
|
DataRowComponent,
|
||||||
|
...ASYNC,
|
||||||
|
],
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.case {
|
||||||
|
margin-block-end: var(--rhc-space-max-lg);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<app-page-shell [heading]="heading" [intro]="intro" backLink="/dashboard">
|
||||||
|
@if (!access.ready()) {
|
||||||
|
<!-- wait for /me before deciding — avoids flashing the denial to an admin -->
|
||||||
|
} @else if (!canManage()) {
|
||||||
|
<app-alert type="error">{{ deniedText }}</app-alert>
|
||||||
|
} @else {
|
||||||
|
<app-async [data]="store.cases()">
|
||||||
|
<ng-template appAsyncError>
|
||||||
|
<app-alert type="error">{{ failedText }}</app-alert>
|
||||||
|
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template appAsyncLoaded>
|
||||||
|
@if (cases().length === 0) {
|
||||||
|
<app-alert type="info">{{ emptyText }}</app-alert>
|
||||||
|
} @else {
|
||||||
|
@for (c of cases(); track c.id) {
|
||||||
|
<div class="case">
|
||||||
|
<app-data-block [heading]="typeLabel(c)" [level]="2">
|
||||||
|
@for (row of rows(c); track row.key) {
|
||||||
|
<div app-data-row [key]="row.key" [value]="row.value"></div>
|
||||||
|
}
|
||||||
|
</app-data-block>
|
||||||
|
<app-button variant="secondary" (click)="confirmDelete(c)">{{
|
||||||
|
deleteText
|
||||||
|
}}</app-button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
</app-async>
|
||||||
|
}
|
||||||
|
</app-page-shell>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class AdminCasesPage {
|
||||||
|
protected store = inject(AdminCasesStore);
|
||||||
|
protected access = inject(AccessStore);
|
||||||
|
|
||||||
|
protected canManage = computed(() => this.access.can('cases:manage'));
|
||||||
|
protected cases = computed(() => {
|
||||||
|
const rd = this.store.cases();
|
||||||
|
return rd.tag === 'Success' ? rd.value : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
protected heading = $localize`:@@adminCases.heading:Aanvragen beheren`;
|
||||||
|
protected intro = $localize`:@@adminCases.intro:Alle aanvragen in het register. Een aanvraag verwijderen kan niet ongedaan worden gemaakt.`;
|
||||||
|
protected deniedText = $localize`:@@adminCases.denied:U hebt geen rechten om aanvragen te beheren.`;
|
||||||
|
protected failedText = $localize`:@@adminCases.failed:De aanvragen konden niet worden geladen.`;
|
||||||
|
protected emptyText = $localize`:@@adminCases.empty:Er zijn geen aanvragen.`;
|
||||||
|
protected retryText = $localize`:@@adminCases.retry:Opnieuw proberen`;
|
||||||
|
protected deleteText = $localize`:@@adminCases.delete:Verwijderen`;
|
||||||
|
|
||||||
|
private ownerKey = $localize`:@@adminCases.owner:Eigenaar (BSN)`;
|
||||||
|
private statusKey = $localize`:@@adminCases.status:Status`;
|
||||||
|
private refKey = $localize`:@@adminCases.referentie:Referentie`;
|
||||||
|
private ingediendKey = $localize`:@@adminCases.ingediend:Ingediend op`;
|
||||||
|
|
||||||
|
protected typeLabel = (c: Aanvraag) => TYPE_LABELS[c.type];
|
||||||
|
|
||||||
|
/** Key/value rows for one case (owner + lifecycle facts; the type is the block heading). */
|
||||||
|
protected rows(c: Aanvraag): { key: string; value: string }[] {
|
||||||
|
return [
|
||||||
|
{ key: this.ownerKey, value: c.owner ?? '—' },
|
||||||
|
{ key: this.statusKey, value: statusLabel(c.status) },
|
||||||
|
{ key: this.refKey, value: referentie(c.status) || '—' },
|
||||||
|
{ key: this.ingediendKey, value: c.submittedAt ? formatDatumNl(c.submittedAt) : '—' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadRequested = false;
|
||||||
|
constructor() {
|
||||||
|
// Load once the capability resolves to allowed (a 403 GET would be wasted otherwise).
|
||||||
|
// Depends only on canManage() + a plain flag — never the store model (WP-26 loop lesson).
|
||||||
|
effect(() => {
|
||||||
|
if (this.canManage() && !this.loadRequested) {
|
||||||
|
this.loadRequested = true;
|
||||||
|
void this.store.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected reload() {
|
||||||
|
void this.store.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Native confirm — no dialog component exists, and admin delete is irreversible. */
|
||||||
|
protected confirmDelete(c: Aanvraag) {
|
||||||
|
const msg = $localize`:@@adminCases.confirm:Deze aanvraag definitief verwijderen?`;
|
||||||
|
if (confirm(msg)) void this.store.delete(c.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,4 +3,9 @@
|
|||||||
* Server-resolved and opaque to the FE — never derived from a role client-side.
|
* Server-resolved and opaque to the FE — never derived from a role client-side.
|
||||||
*/
|
*/
|
||||||
export type Capability =
|
export type Capability =
|
||||||
'brief:approve' | 'brief:reject' | 'brief:send' | 'orgtemplate:edit' | 'stamdata:edit';
|
| 'brief:approve'
|
||||||
|
| 'brief:reject'
|
||||||
|
| 'brief:send'
|
||||||
|
| 'orgtemplate:edit'
|
||||||
|
| 'stamdata:edit'
|
||||||
|
| 'cases:manage';
|
||||||
|
|||||||
@@ -1032,6 +1032,94 @@ export class ApiClient {
|
|||||||
return Promise.resolve<SubmitApplicationResponse>(null as any);
|
return Promise.resolve<SubmitApplicationResponse>(null as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OK
|
||||||
|
*/
|
||||||
|
casesAll(): Promise<ApplicationSummaryDto[]> {
|
||||||
|
let url_ = this.baseUrl + "/api/v1/admin/cases";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processCasesAll(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processCasesAll(response: Response): Promise<ApplicationSummaryDto[]> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationSummaryDto[];
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status === 403) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result403: any = null;
|
||||||
|
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||||
|
return throwException("Forbidden", status, _responseText, _headers, result403);
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<ApplicationSummaryDto[]>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return No Content
|
||||||
|
*/
|
||||||
|
cases(id: string): Promise<void> {
|
||||||
|
let url_ = this.baseUrl + "/api/v1/admin/cases/{id}";
|
||||||
|
if (id === undefined || id === null)
|
||||||
|
throw new globalThis.Error("The parameter 'id' must be defined.");
|
||||||
|
url_ = url_.replace("{id}", encodeURIComponent("" + id));
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processCases(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processCases(response: Response): Promise<void> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
} else if (status === 403) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result403: any = null;
|
||||||
|
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||||
|
return throwException("Forbidden", status, _responseText, _headers, result403);
|
||||||
|
});
|
||||||
|
} else if (status === 404) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("Not Found", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<void>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return OK
|
* @return OK
|
||||||
*/
|
*/
|
||||||
@@ -1674,6 +1762,7 @@ export interface ApplicationSummaryDto {
|
|||||||
createdAt?: string | undefined;
|
createdAt?: string | undefined;
|
||||||
updatedAt?: string | undefined;
|
updatedAt?: string | undefined;
|
||||||
submittedAt?: string | undefined;
|
submittedAt?: string | undefined;
|
||||||
|
owner?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BriefDecisionsDto {
|
export interface BriefDecisionsDto {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const KNOWN: readonly Capability[] = [
|
|||||||
'brief:send',
|
'brief:send',
|
||||||
'orgtemplate:edit',
|
'orgtemplate:edit',
|
||||||
'stamdata:edit',
|
'stamdata:edit',
|
||||||
|
'cases:manage',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { currentRole } from './role';
|
|||||||
const ROLE_AWARE = [
|
const ROLE_AWARE = [
|
||||||
'/api/v1/brief',
|
'/api/v1/brief',
|
||||||
'/api/v1/admin/org-template',
|
'/api/v1/admin/org-template',
|
||||||
|
'/api/v1/admin/cases',
|
||||||
'/api/v1/stamdata',
|
'/api/v1/stamdata',
|
||||||
'/api/v1/me',
|
'/api/v1/me',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ const ADMIN_NAV_ITEMS: readonly (HeaderNavItem & { readonly cap: Capability })[]
|
|||||||
to: '/beheer/stamdata',
|
to: '/beheer/stamdata',
|
||||||
cap: 'stamdata:edit',
|
cap: 'stamdata:edit',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: $localize`:@@header.nav.zaken:Aanvragen`,
|
||||||
|
to: '/beheer/zaken',
|
||||||
|
cap: 'cases:manage',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Organism: CIBG Huisstijl site header — logo block, robijn titlebar (breadcrumb +
|
/** Organism: CIBG Huisstijl site header — logo block, robijn titlebar (breadcrumb +
|
||||||
|
|||||||
@@ -3638,6 +3638,58 @@
|
|||||||
<context context-type="linenumber">27</context>
|
<context context-type="linenumber">27</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="header.nav.zaken" datatype="html">
|
||||||
|
<source>Aanvragen</source>
|
||||||
|
<target datatype="html">Cases</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.heading" datatype="html">
|
||||||
|
<source>Aanvragen beheren</source>
|
||||||
|
<target datatype="html">Manage cases</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.intro" datatype="html">
|
||||||
|
<source>Alle aanvragen in het register. Een aanvraag verwijderen kan niet ongedaan worden gemaakt.</source>
|
||||||
|
<target datatype="html">All cases in the register. Deleting a case cannot be undone.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.denied" datatype="html">
|
||||||
|
<source>U hebt geen rechten om aanvragen te beheren.</source>
|
||||||
|
<target datatype="html">You do not have permission to manage cases.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.failed" datatype="html">
|
||||||
|
<source>De aanvragen konden niet worden geladen.</source>
|
||||||
|
<target datatype="html">The cases could not be loaded.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.empty" datatype="html">
|
||||||
|
<source>Er zijn geen aanvragen.</source>
|
||||||
|
<target datatype="html">There are no cases.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.retry" datatype="html">
|
||||||
|
<source>Opnieuw proberen</source>
|
||||||
|
<target datatype="html">Try again</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.delete" datatype="html">
|
||||||
|
<source>Verwijderen</source>
|
||||||
|
<target datatype="html">Delete</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.owner" datatype="html">
|
||||||
|
<source>Eigenaar (BSN)</source>
|
||||||
|
<target datatype="html">Owner (BSN)</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.status" datatype="html">
|
||||||
|
<source>Status</source>
|
||||||
|
<target datatype="html">Status</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.referentie" datatype="html">
|
||||||
|
<source>Referentie</source>
|
||||||
|
<target datatype="html">Reference</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.ingediend" datatype="html">
|
||||||
|
<source>Ingediend op</source>
|
||||||
|
<target datatype="html">Submitted on</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.confirm" datatype="html">
|
||||||
|
<source>Deze aanvraag definitief verwijderen?</source>
|
||||||
|
<target datatype="html">Permanently delete this case?</target>
|
||||||
|
</trans-unit>
|
||||||
|
|
||||||
<trans-unit id="beheer.undo" datatype="html">
|
<trans-unit id="beheer.undo" datatype="html">
|
||||||
<source>Ongedaan maken</source>
|
<source>Ongedaan maken</source>
|
||||||
|
|||||||
+95
-4
@@ -1794,6 +1794,90 @@
|
|||||||
<context context-type="linenumber">95</context>
|
<context context-type="linenumber">95</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.heading" datatype="html">
|
||||||
|
<source>Aanvragen beheren</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/registratie/ui/admin-cases.page.ts</context>
|
||||||
|
<context context-type="linenumber">83</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.intro" datatype="html">
|
||||||
|
<source>Alle aanvragen in het register. Een aanvraag verwijderen kan niet ongedaan worden gemaakt.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/registratie/ui/admin-cases.page.ts</context>
|
||||||
|
<context context-type="linenumber">84</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.denied" datatype="html">
|
||||||
|
<source>U hebt geen rechten om aanvragen te beheren.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/registratie/ui/admin-cases.page.ts</context>
|
||||||
|
<context context-type="linenumber">85</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.failed" datatype="html">
|
||||||
|
<source>De aanvragen konden niet worden geladen.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/registratie/ui/admin-cases.page.ts</context>
|
||||||
|
<context context-type="linenumber">86</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.empty" datatype="html">
|
||||||
|
<source>Er zijn geen aanvragen.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/registratie/ui/admin-cases.page.ts</context>
|
||||||
|
<context context-type="linenumber">87</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.retry" datatype="html">
|
||||||
|
<source>Opnieuw proberen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/registratie/ui/admin-cases.page.ts</context>
|
||||||
|
<context context-type="linenumber">88</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.delete" datatype="html">
|
||||||
|
<source>Verwijderen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/registratie/ui/admin-cases.page.ts</context>
|
||||||
|
<context context-type="linenumber">89</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.owner" datatype="html">
|
||||||
|
<source>Eigenaar (BSN)</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/registratie/ui/admin-cases.page.ts</context>
|
||||||
|
<context context-type="linenumber">91</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.status" datatype="html">
|
||||||
|
<source>Status</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/registratie/ui/admin-cases.page.ts</context>
|
||||||
|
<context context-type="linenumber">92</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.referentie" datatype="html">
|
||||||
|
<source>Referentie</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/registratie/ui/admin-cases.page.ts</context>
|
||||||
|
<context context-type="linenumber">93</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.ingediend" datatype="html">
|
||||||
|
<source>Ingediend op</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/registratie/ui/admin-cases.page.ts</context>
|
||||||
|
<context context-type="linenumber">94</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="adminCases.confirm" datatype="html">
|
||||||
|
<source>Deze aanvraag definitief verwijderen?</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/registratie/ui/admin-cases.page.ts</context>
|
||||||
|
<context context-type="linenumber">126</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="changeRequest.success" datatype="html">
|
<trans-unit id="changeRequest.success" datatype="html">
|
||||||
<source> Uw wijziging is ontvangen (referentie <x id="INTERPOLATION" equiv-text="{{ referentie() }}"/>). U ontvangt binnen 5 werkdagen bericht. </source>
|
<source> Uw wijziging is ontvangen (referentie <x id="INTERPOLATION" equiv-text="{{ referentie() }}"/>). U ontvangt binnen 5 werkdagen bericht. </source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
@@ -2691,32 +2775,39 @@
|
|||||||
<context context-type="linenumber">32</context>
|
<context context-type="linenumber">32</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="header.nav.zaken" datatype="html">
|
||||||
|
<source>Aanvragen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
|
||||||
|
<context context-type="linenumber">37</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="header.sender" datatype="html">
|
<trans-unit id="header.sender" datatype="html">
|
||||||
<source>BIG-register</source>
|
<source>BIG-register</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
|
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
|
||||||
<context context-type="linenumber">70,71</context>
|
<context context-type="linenumber">75,76</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="header.ministry" datatype="html">
|
<trans-unit id="header.ministry" datatype="html">
|
||||||
<source>Ministerie van Volksgezondheid, Welzijn en Sport</source>
|
<source>Ministerie van Volksgezondheid, Welzijn en Sport</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
|
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
|
||||||
<context context-type="linenumber">72,74</context>
|
<context context-type="linenumber">77,79</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="header.uitloggen" datatype="html">
|
<trans-unit id="header.uitloggen" datatype="html">
|
||||||
<source> Uitloggen </source>
|
<source> Uitloggen </source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
|
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
|
||||||
<context context-type="linenumber">94,95</context>
|
<context context-type="linenumber">99,100</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="header.navAria" datatype="html">
|
<trans-unit id="header.navAria" datatype="html">
|
||||||
<source>Hoofdnavigatie</source>
|
<source>Hoofdnavigatie</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
|
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
|
||||||
<context context-type="linenumber">102,103</context>
|
<context context-type="linenumber">107,108</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="wizard.naarStap" datatype="html">
|
<trans-unit id="wizard.naarStap" datatype="html">
|
||||||
|
|||||||
Reference in New Issue
Block a user