feat: non-individual entity UI improvements — hide individual-only sections, fix legal form display, send extended fields to API
- Hide Family Members tab/section for non-individual entities in client view, create flow, and preview - Hide Financial & Classification section for non-individual entities in personal data and preview - Fix legal form column in clients list — fetch legalForm from client API for correct display - Add person/business icon before client name in list rows - Remove unsupportedNpd filter — send authorizedSignatory, additionalInfo, dateOfRegistration to API - Add registration number format hint (mat-hint) based on constitution type - Add spacing between verification row and constitution type dropdowns - Reduce table cell padding and prevent action buttons from being clipped - Add missing MPL-2.0 license headers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ab01111424
commit
b890d24bb7
+49
@@ -0,0 +1,49 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
-->
|
||||
|
||||
<div class="crop-dialog">
|
||||
<div class="crop-header">
|
||||
<div class="crop-header__left">
|
||||
<fa-icon icon="crop" class="crop-header__icon"></fa-icon>
|
||||
<span class="crop-header__title">{{ data.title }}</span>
|
||||
</div>
|
||||
<button class="crop-header__close" (click)="dialogRef.close()">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="crop-body">
|
||||
@if (!imageLoaded) {
|
||||
<div class="crop-loading">Loading image...</div>
|
||||
}
|
||||
|
||||
<div class="crop-workspace" [class.hidden]="!imageLoaded">
|
||||
<div class="crop-source">
|
||||
<canvas #sourceCanvas
|
||||
(mousedown)="onMouseDown($event)"
|
||||
(mousemove)="onMouseMove($event)"
|
||||
(mouseup)="onMouseUp()"
|
||||
(mouseleave)="onMouseUp()">
|
||||
</canvas>
|
||||
<div class="crop-hint">Drag to move. Drag corner to resize.</div>
|
||||
</div>
|
||||
|
||||
<div class="crop-preview-panel">
|
||||
<span class="crop-preview-label">Preview</span>
|
||||
<div class="crop-preview-box">
|
||||
<canvas #previewCanvas></canvas>
|
||||
</div>
|
||||
<span class="crop-preview-size">{{ data.maxWidth }}x{{ data.maxHeight }}px</span>
|
||||
</div>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions class="crop-actions">
|
||||
<button mat-stroked-button (click)="dialogRef.close()">Cancel</button>
|
||||
<button mat-flat-button color="primary" [disabled]="!imageLoaded" (click)="confirm()">
|
||||
<fa-icon icon="check" class="crop-actions__icon"></fa-icon>
|
||||
Crop & Use
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*/
|
||||
|
||||
$blue: #1353a0;
|
||||
$navy: #0f172a;
|
||||
|
||||
.crop-dialog {
|
||||
width: 620px;
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
.crop-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 16px;
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $navy;
|
||||
}
|
||||
|
||||
&__close {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
color: $navy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.crop-body {
|
||||
padding: 20px 24px !important;
|
||||
overflow: visible !important;
|
||||
max-height: 70vh !important;
|
||||
}
|
||||
|
||||
.crop-loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.crop-workspace {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.crop-source {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
canvas {
|
||||
cursor: crosshair;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.crop-hint {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.crop-preview-panel {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.crop-preview-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.crop-preview-box {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border: 2px solid $blue;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8fafc;
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.crop-preview-size {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
}
|
||||
|
||||
.crop-actions {
|
||||
padding: 12px 24px 16px !important;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
margin: 0 !important;
|
||||
display: flex !important;
|
||||
justify-content: flex-end !important;
|
||||
gap: 10px !important;
|
||||
|
||||
button {
|
||||
border-radius: 8px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
margin-right: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.crop-workspace {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.crop-preview-panel {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.crop-preview-box {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*/
|
||||
|
||||
import { Component, Inject, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
export interface ImageCropData {
|
||||
file: File;
|
||||
title: string;
|
||||
aspectRatio: number; // width/height, e.g. 1 for square, 1.5 for 3:2
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-image-crop-dialog',
|
||||
templateUrl: './image-crop-dialog.component.html',
|
||||
styleUrls: ['./image-crop-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
MatDialogContent,
|
||||
MatDialogActions
|
||||
]
|
||||
})
|
||||
export class ImageCropDialogComponent implements AfterViewInit {
|
||||
@ViewChild('sourceCanvas') sourceCanvas!: ElementRef<HTMLCanvasElement>;
|
||||
@ViewChild('previewCanvas') previewCanvas!: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
imageLoaded = false;
|
||||
private img = new Image();
|
||||
private imgWidth = 0;
|
||||
private imgHeight = 0;
|
||||
|
||||
// Crop box (relative to displayed image)
|
||||
cropX = 0;
|
||||
cropY = 0;
|
||||
cropW = 200;
|
||||
cropH = 200;
|
||||
|
||||
// Drag state
|
||||
private dragging = false;
|
||||
private dragStartX = 0;
|
||||
private dragStartY = 0;
|
||||
private dragStartCropX = 0;
|
||||
private dragStartCropY = 0;
|
||||
|
||||
// Resize state
|
||||
private resizing = false;
|
||||
|
||||
// Canvas display dimensions
|
||||
private canvasDisplayW = 0;
|
||||
private canvasDisplayH = 0;
|
||||
private scaleX = 1;
|
||||
private scaleY = 1;
|
||||
|
||||
zoom = 1;
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<ImageCropDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: ImageCropData
|
||||
) {}
|
||||
|
||||
ngAfterViewInit() {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e: any) => {
|
||||
this.img.onload = () => {
|
||||
this.imageLoaded = true;
|
||||
setTimeout(() => this.initCanvas());
|
||||
};
|
||||
this.img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(this.data.file);
|
||||
}
|
||||
|
||||
private initCanvas() {
|
||||
const canvas = this.sourceCanvas.nativeElement;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const container = canvas.parentElement!;
|
||||
|
||||
const maxW = container.clientWidth || 500;
|
||||
const maxH = 400;
|
||||
|
||||
// Scale image to fit container
|
||||
const ratio = Math.min(maxW / this.img.width, maxH / this.img.height, 1);
|
||||
this.canvasDisplayW = Math.floor(this.img.width * ratio);
|
||||
this.canvasDisplayH = Math.floor(this.img.height * ratio);
|
||||
this.imgWidth = this.img.width;
|
||||
this.imgHeight = this.img.height;
|
||||
|
||||
canvas.width = this.canvasDisplayW;
|
||||
canvas.height = this.canvasDisplayH;
|
||||
this.scaleX = this.img.width / this.canvasDisplayW;
|
||||
this.scaleY = this.img.height / this.canvasDisplayH;
|
||||
|
||||
// Init crop box centered
|
||||
const cropSize = Math.min(this.canvasDisplayW, this.canvasDisplayH) * 0.6;
|
||||
if (this.data.aspectRatio >= 1) {
|
||||
this.cropW = cropSize;
|
||||
this.cropH = cropSize / this.data.aspectRatio;
|
||||
} else {
|
||||
this.cropH = cropSize;
|
||||
this.cropW = cropSize * this.data.aspectRatio;
|
||||
}
|
||||
this.cropX = (this.canvasDisplayW - this.cropW) / 2;
|
||||
this.cropY = (this.canvasDisplayH - this.cropH) / 2;
|
||||
|
||||
this.drawCanvas();
|
||||
this.updatePreview();
|
||||
}
|
||||
|
||||
private drawCanvas() {
|
||||
const canvas = this.sourceCanvas.nativeElement;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(this.img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Dark overlay outside crop
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Clear crop area
|
||||
ctx.clearRect(this.cropX, this.cropY, this.cropW, this.cropH);
|
||||
ctx.drawImage(
|
||||
this.img,
|
||||
this.cropX * this.scaleX, this.cropY * this.scaleY,
|
||||
this.cropW * this.scaleX, this.cropH * this.scaleY,
|
||||
this.cropX, this.cropY, this.cropW, this.cropH
|
||||
);
|
||||
|
||||
// Crop border
|
||||
ctx.strokeStyle = '#1353a0';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(this.cropX, this.cropY, this.cropW, this.cropH);
|
||||
|
||||
// Corner handles
|
||||
const hs = 8;
|
||||
ctx.fillStyle = '#1353a0';
|
||||
const corners = [
|
||||
[this.cropX, this.cropY],
|
||||
[this.cropX + this.cropW, this.cropY],
|
||||
[this.cropX, this.cropY + this.cropH],
|
||||
[this.cropX + this.cropW, this.cropY + this.cropH]
|
||||
];
|
||||
corners.forEach(([x, y]) => {
|
||||
ctx.fillRect(x - hs / 2, y - hs / 2, hs, hs);
|
||||
});
|
||||
}
|
||||
|
||||
private updatePreview() {
|
||||
if (!this.previewCanvas) return;
|
||||
const pCanvas = this.previewCanvas.nativeElement;
|
||||
const pCtx = pCanvas.getContext('2d')!;
|
||||
const pw = this.data.maxWidth || 150;
|
||||
const ph = this.data.maxHeight || 150;
|
||||
pCanvas.width = pw;
|
||||
pCanvas.height = ph;
|
||||
|
||||
pCtx.clearRect(0, 0, pw, ph);
|
||||
pCtx.drawImage(
|
||||
this.img,
|
||||
this.cropX * this.scaleX, this.cropY * this.scaleY,
|
||||
this.cropW * this.scaleX, this.cropH * this.scaleY,
|
||||
0, 0, pw, ph
|
||||
);
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent) {
|
||||
const rect = this.sourceCanvas.nativeElement.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
// Check if near bottom-right corner for resize
|
||||
const cornerX = this.cropX + this.cropW;
|
||||
const cornerY = this.cropY + this.cropH;
|
||||
if (Math.abs(x - cornerX) < 12 && Math.abs(y - cornerY) < 12) {
|
||||
this.resizing = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if inside crop box for drag
|
||||
if (x >= this.cropX && x <= this.cropX + this.cropW && y >= this.cropY && y <= this.cropY + this.cropH) {
|
||||
this.dragging = true;
|
||||
this.dragStartX = x;
|
||||
this.dragStartY = y;
|
||||
this.dragStartCropX = this.cropX;
|
||||
this.dragStartCropY = this.cropY;
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent) {
|
||||
if (!this.dragging && !this.resizing) return;
|
||||
const rect = this.sourceCanvas.nativeElement.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
if (this.dragging) {
|
||||
this.cropX = Math.max(0, Math.min(this.canvasDisplayW - this.cropW, this.dragStartCropX + (x - this.dragStartX)));
|
||||
this.cropY = Math.max(0, Math.min(this.canvasDisplayH - this.cropH, this.dragStartCropY + (y - this.dragStartY)));
|
||||
}
|
||||
|
||||
if (this.resizing) {
|
||||
const newW = Math.max(40, Math.min(this.canvasDisplayW - this.cropX, x - this.cropX));
|
||||
const newH = newW / this.data.aspectRatio;
|
||||
if (this.cropY + newH <= this.canvasDisplayH) {
|
||||
this.cropW = newW;
|
||||
this.cropH = newH;
|
||||
}
|
||||
}
|
||||
|
||||
this.drawCanvas();
|
||||
this.updatePreview();
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
this.dragging = false;
|
||||
this.resizing = false;
|
||||
}
|
||||
|
||||
confirm() {
|
||||
// Create cropped image as blob
|
||||
const offscreen = document.createElement('canvas');
|
||||
offscreen.width = this.data.maxWidth || 300;
|
||||
offscreen.height = this.data.maxHeight || 300;
|
||||
const ctx = offscreen.getContext('2d')!;
|
||||
ctx.drawImage(
|
||||
this.img,
|
||||
this.cropX * this.scaleX, this.cropY * this.scaleY,
|
||||
this.cropW * this.scaleX, this.cropH * this.scaleY,
|
||||
0, 0, offscreen.width, offscreen.height
|
||||
);
|
||||
|
||||
offscreen.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const croppedFile = new File([blob], this.data.file.name, { type: 'image/png' });
|
||||
this.dialogRef.close({ croppedFile, previewUrl: offscreen.toDataURL('image/png') });
|
||||
}
|
||||
}, 'image/png');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user