feat: Partner hierarchy backoffice page (F41-F44 partial)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
New nav entry, route, page. Renders the four-level tree (Company/Project/Store/PaymentPoint) via AdminPartnerHierarchyFacade on top of the core built in the previous commit - TEST/LIVE toggle, suspend/activate, disable behind a confirmation (terminal + cascading, per contract §2), and a read-only credentials list stating plainly that only the public key is ever held. Reload-after-action rather than local patch on suspend/activate/disable: disable cascades to descendants server-side, so a local patch would leave children showing a status they no longer have. Company id is a constant for now (DEFAULT_COMPANY_ID) - company creation is out of band per contract §4.3, and there is no company-selector UI yet. Verified: 139/139 unit tests, arch:check clean, production build succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { PARTNER_HIERARCHY_GATEWAY } from '../../../../core/partner-hierarchy/services/partner-hierarchy-gateway.token';
|
||||
import { PartnerCredential } from '../../../../core/partner-hierarchy/models/partner-credential.model';
|
||||
import {
|
||||
Environment,
|
||||
NodeLevel,
|
||||
ProvisioningNode,
|
||||
childLevelOf,
|
||||
childrenOf,
|
||||
} from '../../../../core/partner-hierarchy/models/provisioning-node.model';
|
||||
|
||||
/** One node plus its depth, flattened for rendering a tree as a list. */
|
||||
export interface HierarchyRow {
|
||||
node: ProvisioningNode;
|
||||
depth: number;
|
||||
/** The level a child of this node would be, or null at the leaf. */
|
||||
childLevel: NodeLevel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read/act surface for the partner-provisioned hierarchy.
|
||||
* Contract: docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md §4.
|
||||
*
|
||||
* The company id is currently a constant: Company creation is out of band
|
||||
* (§4.3 - partners provision INSIDE a company we create commercially), and
|
||||
* there is no company-selector UI yet. When one exists this becomes an input.
|
||||
*/
|
||||
const DEFAULT_COMPANY_ID = 'cmp_1';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminPartnerHierarchyFacade {
|
||||
private readonly gateway = inject(PARTNER_HIERARCHY_GATEWAY);
|
||||
|
||||
readonly companyId = signal(DEFAULT_COMPANY_ID);
|
||||
readonly environment = signal<Environment>('TEST');
|
||||
readonly nodes = signal<ProvisioningNode[]>([]);
|
||||
readonly credentials = signal<PartnerCredential[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
/**
|
||||
* Depth-first flattening, so the template renders a tree without recursion.
|
||||
* Roots are nodes whose parent is absent from the loaded set - not
|
||||
* necessarily level 'company', so a partially-loaded subtree still renders
|
||||
* instead of coming back empty.
|
||||
*/
|
||||
readonly rows = computed<HierarchyRow[]>(() => {
|
||||
const all = this.nodes();
|
||||
const ids = new Set(all.map(n => n.id));
|
||||
const roots = all
|
||||
.filter(n => n.parentId === null || !ids.has(n.parentId))
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
|
||||
const rows: HierarchyRow[] = [];
|
||||
const walk = (node: ProvisioningNode, depth: number): void => {
|
||||
rows.push({ node, depth, childLevel: childLevelOf(node.level) });
|
||||
for (const child of childrenOf(node.id, all)) {
|
||||
walk(child, depth + 1);
|
||||
}
|
||||
};
|
||||
roots.forEach(root => walk(root, 0));
|
||||
return rows;
|
||||
});
|
||||
|
||||
readonly paymentPointCount = computed(
|
||||
() => this.nodes().filter(n => n.level === 'payment_point').length,
|
||||
);
|
||||
|
||||
readonly suspendedCount = computed(
|
||||
() => this.nodes().filter(n => n.status === 'suspended').length,
|
||||
);
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.gateway
|
||||
.loadHierarchy(this.companyId(), this.environment())
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: nodes => {
|
||||
this.nodes.set(nodes);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
// Empty the tree rather than leaving the previous environment's
|
||||
// nodes on screen labelled as the newly selected one.
|
||||
this.nodes.set([]);
|
||||
this.loading.set(false);
|
||||
this.error.set('Could not load the hierarchy.');
|
||||
},
|
||||
});
|
||||
|
||||
this.gateway
|
||||
.loadCredentials(this.companyId())
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: credentials => this.credentials.set(credentials),
|
||||
error: () => this.credentials.set([]),
|
||||
});
|
||||
}
|
||||
|
||||
setEnvironment(environment: Environment): void {
|
||||
if (environment === this.environment()) {
|
||||
return;
|
||||
}
|
||||
this.environment.set(environment);
|
||||
this.load();
|
||||
}
|
||||
|
||||
suspend(nodeId: string): void {
|
||||
this.act(this.gateway.setStatus(nodeId, 'suspended'));
|
||||
}
|
||||
|
||||
activate(nodeId: string): void {
|
||||
this.act(this.gateway.setStatus(nodeId, 'active'));
|
||||
}
|
||||
|
||||
/** Terminal and cascading - callers must confirm with the operator first. */
|
||||
disable(nodeId: string): void {
|
||||
this.act(this.gateway.disable(nodeId));
|
||||
}
|
||||
|
||||
private act(action: ReturnType<typeof this.gateway.disable>): void {
|
||||
this.error.set(null);
|
||||
action.pipe(take(1)).subscribe({
|
||||
// Reload rather than patching one node locally: disable cascades to
|
||||
// descendants server-side, so a local patch would leave children
|
||||
// showing a status they no longer have.
|
||||
next: () => this.load(),
|
||||
error: (err: Error) => this.error.set(err.message || 'Action failed.'),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user