fix(clients): stale signature bug, monthly turnover/annual income fields, senior citizen/PWD classification

- Fix signature-document reads picking the oldest upload instead of the latest across 7 call
  sites (CKYC tab, view/delete-signature dialogs, edit-wizard preview, cashier transaction entry,
  signatory service, record-master) by sorting on document id instead of an unsorted find().
- Add Monthly Turnover and Annual Income fields alongside the renamed Yearly Turnover field.
- Add Senior Citizen (auto-detected from DOB) and Person with Disability classification, with a
  required disability-certificate upload gate before client creation can proceed.
- Add Graduate and Diploma as distinct Education Levels, with diploma-specific degree options.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 10:49:28 +05:30
co-authored by Claude Sonnet 5
parent a9c60083bf
commit cb4260b7a3
11 changed files with 367 additions and 16 deletions
@@ -394,7 +394,12 @@ export class JointHolderDialogComponent implements OnInit {
this.kyc = kycArr[0] ?? null;
this.photo = photo ?? null;
const docs: any[] = Array.isArray(documents) ? documents : [];
const sigDoc = docs.find((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'));
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
// signature before uploads became single-slot, so pick the highest id (latest) rather than
// the first unordered match.
const sigDoc = docs
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
.sort((a: any, b: any) => b.id - a.id)[0];
this.loading = false;
if (sigDoc) {
this.http
@@ -1868,7 +1873,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.clientAddresses = Array.isArray(r.addresses) ? r.addresses : [];
this.clientPhotoUrl = r.photo ?? null;
const docs: any[] = Array.isArray(r.documents) ? r.documents : [];
const sigDoc = docs.find((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'));
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
// signature before uploads became single-slot, so pick the highest id (latest) rather than
// the first unordered match.
const sigDoc = docs
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
.sort((a: any, b: any) => b.id - a.id)[0];
if (sigDoc) {
this.http
.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
@@ -446,8 +446,12 @@ export class RecordMasterComponent implements OnInit {
};
}
// 2. Signature — client document named 'clientSignature'
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
// 2. Signature — client document named 'clientSignature'. Multiple rows can exist for
// clients who re-cropped/replaced their signature before uploads became single-slot, so
// pick the highest id (latest) rather than the first unordered match.
const signatureDoc = (documents as any[])
?.filter((d: any) => d?.name === 'clientSignature')
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (signatureDoc) {
this.viewDetails.docs.signature = {
present: true,
@@ -66,9 +66,12 @@ export class SignatoryService {
fetchClientSignature(clientId: number | string): Observable<string | null> {
return this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(
switchMap((docs: any[]) => {
const sigDoc = (docs || []).find(
(d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature')
);
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced
// their signature before uploads became single-slot, so pick the highest id (latest)
// rather than the first unordered match.
const sigDoc = (docs || [])
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
.sort((a: any, b: any) => b.id - a.id)[0];
if (!sigDoc) {
return of(null);
}
@@ -358,6 +358,9 @@
@if (isMinor) {
<span class="minor-badge">Minor</span>
}
@if (isSeniorCitizen) {
<span class="senior-citizen-badge">Senior Citizen</span>
}
</div>
}
</div>
@@ -1217,7 +1220,38 @@
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Annual Turnover (Last FY)</mat-label>
<mat-label>Annual Income</mat-label>
<span matPrefix class="rupee-prefix">&#8377;</span>
<input matInput type="number" formControlName="annualIncome" min="0" />
@if (
createClientForm.get('annualIncome')?.hasError('pattern') && createClientForm.get('annualIncome')?.touched
) {
<mat-error>Enter a valid amount</mat-error>
} @else if (
createClientForm.get('annualIncome')?.hasError('min') && createClientForm.get('annualIncome')?.touched
) {
<mat-error>Amount cannot be negative</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Monthly Turnover</mat-label>
<span matPrefix class="rupee-prefix">&#8377;</span>
<input matInput type="number" formControlName="monthlyTurnover" min="0" />
@if (
createClientForm.get('monthlyTurnover')?.hasError('pattern') &&
createClientForm.get('monthlyTurnover')?.touched
) {
<mat-error>Enter a valid amount</mat-error>
} @else if (
createClientForm.get('monthlyTurnover')?.hasError('min') && createClientForm.get('monthlyTurnover')?.touched
) {
<mat-error>Amount cannot be negative</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Yearly Turnover</mat-label>
<span matPrefix class="rupee-prefix">&#8377;</span>
<input matInput type="number" formControlName="annualTurnover" min="0" />
@if (
@@ -1232,6 +1266,11 @@
}
</mat-form-field>
<div class="full-width subsection-title">
<fa-icon icon="coins" class="subsection-icon"></fa-icon>
Other Income
</div>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Agriculture Income</mat-label>
<span matPrefix class="rupee-prefix">&#8377;</span>
@@ -1265,6 +1304,76 @@
<mat-error>Amount cannot be negative</mat-error>
}
</mat-form-field>
<div class="full-width subsection-title">
<fa-icon icon="user-shield" class="subsection-icon"></fa-icon>
Senior Citizen / Person with Disability
</div>
@if (isSeniorCitizen) {
<div class="full-width senior-citizen-auto-note">
<fa-icon icon="check-circle"></fa-icon>
Senior Citizen (60+ years) — detected automatically from Date of Birth
</div>
<div class="full-width doc-upload-field">
<label class="doc-upload-label">
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onSeniorCitizenProofSelected($event)" />
<fa-icon icon="file-upload"></fa-icon>
@if (seniorCitizenProofFile) {
<span>{{ seniorCitizenProofFile.name }}</span>
} @else {
<span>Upload Senior Citizen Proof (Age / ID Proof) &mdash; optional</span>
}
</label>
@if (seniorCitizenProofFile) {
<button type="button" mat-icon-button (click)="removeSeniorCitizenProof()" matTooltip="Remove">
<mat-icon>close</mat-icon>
</button>
}
</div>
}
<div class="checkbox-field full-width">
<mat-checkbox formControlName="isPwd">Person with Disability (PWD)</mat-checkbox>
</div>
@if (createClientForm.get('isPwd')?.value) {
<mat-form-field appearance="outline" class="form-field">
<mat-label>Disability Type</mat-label>
<mat-select formControlName="pwdCategoryId">
@for (category of pwdCategoryOptions; track category.id) {
<mat-option [value]="category.id">{{ category.name }}</mat-option>
}
</mat-select>
@if (seniorPwdDocsTouched && !createClientForm.get('pwdCategoryId')?.value) {
<mat-error>Disability type is required</mat-error>
}
</mat-form-field>
<div class="full-width doc-upload-field">
<label
class="doc-upload-label"
[class.doc-upload-label--missing]="seniorPwdDocsTouched && !pwdCertificateFile"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onPwdCertificateSelected($event)" />
<fa-icon icon="file-upload"></fa-icon>
@if (pwdCertificateFile) {
<span>{{ pwdCertificateFile.name }}</span>
} @else {
<span>Upload Disability Certificate <span class="doc-upload-req">*</span></span>
}
</label>
@if (pwdCertificateFile) {
<button type="button" mat-icon-button (click)="removePwdCertificate()" matTooltip="Remove">
<mat-icon>close</mat-icon>
</button>
}
@if (seniorPwdDocsTouched && !pwdCertificateFile) {
<span class="doc-upload-error">Required when PWD is checked</span>
}
</div>
}
</div>
</div>
}
@@ -56,6 +56,78 @@ form {
grid-column: 1 / -1;
}
.senior-citizen-auto-note {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #1074b9;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
padding: 8px 12px;
margin-bottom: 8px;
}
.doc-upload-field {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.doc-upload-label {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
padding: 10px 14px;
border: 1px dashed #cbd5e1;
border-radius: 8px;
font-size: 13px;
color: #475569;
cursor: pointer;
transition: border-color 0.15s;
&:hover {
border-color: #1074b9;
}
&.doc-upload-label--missing {
border-color: #dc2626;
color: #dc2626;
}
}
.doc-upload-req {
color: #dc2626;
font-weight: 700;
}
.doc-upload-error {
font-size: 11px;
color: #dc2626;
}
.subsection-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #6b7280;
margin: 20px 0 4px;
padding-top: 16px;
border-top: 1px solid #e5e7eb;
.subsection-icon {
color: var(--md-sys-color-primary, #1074b9);
font-size: 12px;
}
}
.checkbox-field {
display: flex;
align-items: center;
@@ -234,6 +306,18 @@ form {
letter-spacing: 0.5px;
}
.senior-citizen-badge {
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
background: #eff6ff;
color: #1074b9;
margin-left: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ═══ Guardian Section (Redesigned) ═══ */
.guardian-section {
margin-top: 12px;
@@ -93,6 +93,54 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
/** Expose enum to template */
readonly LegalFormId = LegalFormId;
/** Senior Citizen proof is optional; the PWD certificate is mandatory (see seniorPwdDocsComplete). Both, when
* attached, are uploaded after client creation. */
seniorCitizenProofFile: File | null = null;
pwdCertificateFile: File | null = null;
/** Disability categories (per the Rights of Persons with Disabilities Act, 2016) — fetched from
* m_code_value (code "PwdCategory") via the client template, same as occupationCategoryOptions.
* Admins can add/reorder/deactivate entries from System -> Manage Codes without a code change. */
pwdCategoryOptions: any[] = [];
/** Set true after a save attempt so the UI can highlight a missing required document */
seniorPwdDocsTouched = false;
/** Senior Citizen proof is optional. The PWD certificate + disability type are mandatory
* whenever the PWD checkbox is set. */
get seniorPwdDocsComplete(): boolean {
const pwdOk =
!this.createClientForm.get('isPwd')?.value ||
(!!this.pwdCertificateFile && !!this.createClientForm.get('pwdCategoryId')?.value);
return pwdOk;
}
markSeniorPwdDocsTouched(): void {
this.seniorPwdDocsTouched = true;
}
onSeniorCitizenProofSelected(event: any): void {
const file = event.target?.files?.[0];
if (file) {
this.seniorCitizenProofFile = file;
}
}
removeSeniorCitizenProof(): void {
this.seniorCitizenProofFile = null;
}
onPwdCertificateSelected(event: any): void {
const file = event.target?.files?.[0];
if (file) {
this.pwdCertificateFile = file;
}
}
removePwdCertificate(): void {
this.pwdCertificateFile = null;
}
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
@@ -157,6 +205,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
calculatedAgeDetail: string | null = null;
/** Whether client is a minor (age < 10) */
isMinor = false;
/** Whether client is a senior citizen (age >= 60) — auto-detected from Date of Birth */
isSeniorCitizen = false;
/** Age breakdown for DB storage */
ageBreakdown = { years: 0, months: 0, days: 0 };
@@ -525,7 +575,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
occupationCategoryId: data.occupationCategory?.id || data.occupationCategoryId || '',
occupationId: data.occupation?.id || data.occupationId || '',
monthlyIncome: data.monthlyIncome || '',
annualIncome: data.annualIncome || '',
annualTurnover: data.annualTurnover || '',
monthlyTurnover: data.monthlyTurnover || '',
isPwd: data.isPwd || false,
pwdCategoryId: data.pwdCategory?.id || '',
agricultureIncome: data.agricultureIncome || '',
nonAgricultureIncome: data.nonAgricultureIncome || '',
submittedOnDate: data.timeline?.submittedOnDate
@@ -665,6 +719,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
Validators.min(0)
]
],
annualIncome: [
'',
[
Validators.pattern(/^\d+(\.\d{1,2})?$/),
Validators.min(0)
]
],
annualTurnover: [
'',
[
@@ -672,6 +733,15 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
Validators.min(0)
]
],
monthlyTurnover: [
'',
[
Validators.pattern(/^\d+(\.\d{1,2})?$/),
Validators.min(0)
]
],
isPwd: [false],
pwdCategoryId: [''],
agricultureIncome: [
'',
[
@@ -709,6 +779,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.occupationCategoryOptions = this.clientTemplate.occupationCategoryOptions || [];
this.occupationOptions = this.clientTemplate.occupationOptions || [];
this.salutationOptions = this.clientTemplate.salutationOptions || [];
this.pwdCategoryOptions = this.clientTemplate.pwdCategoryOptions || [];
}
/**
@@ -825,6 +896,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.ageBreakdown = { years, months, days };
const wasMinor = this.isMinor;
this.isMinor = years < 10;
this.isSeniorCitizen = years >= 60;
// Add/remove guardian form group based on minor status
if (this.isMinor && !wasMinor) {
this.createClientForm.addControl(
@@ -850,12 +922,14 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
} else {
this.calculatedAgeDetail = null;
this.isMinor = false;
this.isSeniorCitizen = false;
this.ageBreakdown = { years: 0, months: 0, days: 0 };
this.createClientForm.removeControl('guardianDetails');
}
} else {
this.calculatedAgeDetail = null;
this.isMinor = false;
this.isSeniorCitizen = false;
this.ageBreakdown = { years: 0, months: 0, days: 0 };
this.createClientForm.removeControl('guardianDetails');
}
@@ -1548,6 +1622,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
if (n.includes('postgrad') || n === 'pg' || n.includes('master')) return 'postgraduate';
if (n.includes('undergrad') || n === 'ug' || n.includes('bachelor') || n.includes('graduate'))
return 'undergraduate';
// Diploma is its own Education Level, distinct from School — checked before the school
// keywords below so it doesn't get swept into that bucket.
if (n.includes('diploma') || n.includes('polytechnic')) return 'diploma';
if (
n.includes('school') ||
n.includes('10th') ||
@@ -1556,7 +1633,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
n.includes('hsc') ||
n.includes('matric') ||
n.includes('intermediate') ||
n.includes('diploma') ||
n.includes('iti') ||
n.includes('below')
)
@@ -1634,8 +1710,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
}
/**
* Degrees filtered by the currently selected Education Level.
* Returns all degrees if no level is selected, or category can't be determined.
* Degrees filtered by the currently selected Education Level. Each DegreeQualification code
* value is tagged with its owning level in `description` (e.g. "Undergraduate") when seeded —
* match on that authoritative field first. Only fall back to the name-keyword heuristic
* (getDegreeQualificationCategory) for older/untagged rows that predate this tagging, so
* degrees never silently disappear because an admin added one without a description.
* Returns all degrees if no level is selected.
*/
get filteredDegreeQualificationOptions(): any[] {
const level = this.getSelectedEducationLevel();
@@ -1643,6 +1723,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
const category = this.getEducationLevelCategory(level.name);
if (!category) return this.degreeQualificationOptions;
const filtered = this.degreeQualificationOptions.filter((d: any) => {
if (d.description) {
return this.getEducationLevelCategory(d.description) === category;
}
const c = this.getDegreeQualificationCategory(d.name);
return c === category || c === '';
});
@@ -1805,6 +1888,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// Store extended fields separately (not yet supported by Fineract API)
// Add isMinor flag
generalDetails.isMinor = this.isMinor;
// isSeniorCitizen is auto-detected from DOB (not a form control) — add it explicitly
generalDetails.isSeniorCitizen = this.isSeniorCitizen;
// Add guardian clientId for minors
if (this.isMinor && generalDetails.guardianDetails) {
@@ -845,7 +845,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
if (!this.clientId) return;
this.clientsService.getClientDocuments(this.clientId).subscribe({
next: (documents: any[]) => {
const sigDoc = documents?.find((d: any) => d.name === 'clientSignature');
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced
// their signature before uploads became single-slot, so pick the highest id (latest)
// rather than the first unordered match.
const sigDoc = documents
?.filter((d: any) => d.name === 'clientSignature')
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (sigDoc?.id) {
this.existingSignatureDocId = sigDoc.id.toString();
this.clientsService.getClientSignatureImage(this.clientId, this.existingSignatureDocId).subscribe({
@@ -234,8 +234,12 @@ export class CkycTabComponent implements OnInit, OnDestroy {
};
}
// 2. Signature — client document with name='clientSignature'
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
// 2. Signature — client document with name='clientSignature'. Multiple rows can exist
// for clients who re-cropped/replaced their signature before uploads became single-slot,
// so pick the highest id (latest) rather than the first unordered match.
const signatureDoc = (documents as any[])
?.filter((d: any) => d?.name === 'clientSignature')
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (signatureDoc) {
this.existing.CLIENT_SIGNATURE = {
present: true,
@@ -47,7 +47,13 @@ export class DeleteSignatureDialogComponent {
* @param {any} data Documents data
*/
constructor() {
const signature = this.data.find((document: any) => document.name === 'clientSignature') || {};
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
// signature before uploads became single-slot, so pick the highest id (latest) rather than
// the first unordered match.
const signature =
this.data
.filter((document: any) => document.name === 'clientSignature')
.sort((a: any, b: any) => b.id - a.id)[0] || {};
this.signatureId = signature.id;
}
}
@@ -65,7 +65,13 @@ export class ViewSignatureDialogComponent implements OnInit {
* @param {any} data Documents data
*/
constructor() {
const signature = this.data.documents.find((document: any) => document.name === 'clientSignature') || {};
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
// signature before uploads became single-slot, so pick the highest id (latest) rather than
// the first unordered match.
const signature =
this.data.documents
.filter((document: any) => document.name === 'clientSignature')
.sort((a: any, b: any) => b.id - a.id)[0] || {};
this.signatureId = signature.id;
this.clientId = this.data.id;
}
@@ -331,6 +331,18 @@ export class CreateClientComponent {
return;
}
// PWD gate — disability type + certificate are mandatory when PWD is checked
// (Senior Citizen proof upload is optional, not gated here)
if (!this.clientGeneralStep?.seniorPwdDocsComplete) {
this.clientGeneralStep?.markSeniorPwdDocsTouched();
this.snackBar.open(
'Select the disability type and upload the Disability Certificate before continuing.',
'Close',
{ duration: 5000 }
);
return;
}
// Sanctions verification gate — Verify Client must be clicked before save
if (!this.clientGeneralStep?.sanctionsVerified) {
this.dialog
@@ -469,6 +481,29 @@ export class CreateClientComponent {
clearInterval(progressInterval);
this.saveProgress = 100;
this.createdClientId = response.resourceId;
// Senior Citizen / PWD supporting documents — uploaded after creation, same pattern as
// the KYC step's photo/signature uploads (the generic /clients/{id}/documents endpoint
// needs a real clientId, which only exists once createClient() has returned).
if (this.clientGeneralStep?.seniorCitizenProofFile) {
const seniorCitizenProofForm = new FormData();
seniorCitizenProofForm.append('file', this.clientGeneralStep.seniorCitizenProofFile);
seniorCitizenProofForm.append('name', 'seniorCitizenProof');
seniorCitizenProofForm.append('description', 'Senior Citizen Proof (Age/ID Proof)');
this.clientsService
.uploadClientDocument(this.createdClientId, seniorCitizenProofForm)
.subscribe({ error: () => {} });
}
if (this.clientGeneralStep?.pwdCertificateFile) {
const pwdCertificateForm = new FormData();
pwdCertificateForm.append('file', this.clientGeneralStep.pwdCertificateFile);
pwdCertificateForm.append('name', 'pwdCertificate');
pwdCertificateForm.append('description', 'Disability Certificate');
this.clientsService
.uploadClientDocument(this.createdClientId, pwdCertificateForm)
.subscribe({ error: () => {} });
}
// Brief delay to show 100% before transitioning
setTimeout(() => {
this.savingClient = false;