diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index 63fb83315..ad6a5f682 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -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' }) diff --git a/src/app/ckyc/record-master/record-master.component.ts b/src/app/ckyc/record-master/record-master.component.ts index 9f7fda2a0..8db148d1e 100644 --- a/src/app/ckyc/record-master/record-master.component.ts +++ b/src/app/ckyc/record-master/record-master.component.ts @@ -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, diff --git a/src/app/clearing-network/signatory.service.ts b/src/app/clearing-network/signatory.service.ts index ab896cc80..79dab3c89 100644 --- a/src/app/clearing-network/signatory.service.ts +++ b/src/app/clearing-network/signatory.service.ts @@ -66,9 +66,12 @@ export class SignatoryService { fetchClientSignature(clientId: number | string): Observable { return this.http.get(`/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); } diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html index a75eb76b2..fc4fa013b 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html @@ -358,6 +358,9 @@ @if (isMinor) { Minor } + @if (isSeniorCitizen) { + Senior Citizen + } } @@ -1217,7 +1220,38 @@ - Annual Turnover (Last FY) + Annual Income + ₹ + + @if ( + createClientForm.get('annualIncome')?.hasError('pattern') && createClientForm.get('annualIncome')?.touched + ) { + Enter a valid amount + } @else if ( + createClientForm.get('annualIncome')?.hasError('min') && createClientForm.get('annualIncome')?.touched + ) { + Amount cannot be negative + } + + + + Monthly Turnover + ₹ + + @if ( + createClientForm.get('monthlyTurnover')?.hasError('pattern') && + createClientForm.get('monthlyTurnover')?.touched + ) { + Enter a valid amount + } @else if ( + createClientForm.get('monthlyTurnover')?.hasError('min') && createClientForm.get('monthlyTurnover')?.touched + ) { + Amount cannot be negative + } + + + + Yearly Turnover ₹ @if ( @@ -1232,6 +1266,11 @@ } +
+ + Other Income +
+ Agriculture Income ₹ @@ -1265,6 +1304,76 @@ Amount cannot be negative } + +
+ + Senior Citizen / Person with Disability +
+ + @if (isSeniorCitizen) { +
+ + Senior Citizen (60+ years) — detected automatically from Date of Birth +
+ +
+ + @if (seniorCitizenProofFile) { + + } +
+ } + +
+ Person with Disability (PWD) +
+ + @if (createClientForm.get('isPwd')?.value) { + + Disability Type + + @for (category of pwdCategoryOptions; track category.id) { + {{ category.name }} + } + + @if (seniorPwdDocsTouched && !createClientForm.get('pwdCategoryId')?.value) { + Disability type is required + } + + +
+ + @if (pwdCertificateFile) { + + } + @if (seniorPwdDocsTouched && !pwdCertificateFile) { + Required when PWD is checked + } +
+ } } diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss b/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss index e2caf5e84..b2ce73059 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss @@ -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; diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 0ef4e187e..5cf897508 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -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) { diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts index 97f850e9d..ed8047b55 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts @@ -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({ diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts index 8c7ecca1a..adcd3d486 100644 --- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts @@ -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, diff --git a/src/app/clients/clients-view/custom-dialogs/delete-signature-dialog/delete-signature-dialog.component.ts b/src/app/clients/clients-view/custom-dialogs/delete-signature-dialog/delete-signature-dialog.component.ts index bbb9ad77c..7776db962 100644 --- a/src/app/clients/clients-view/custom-dialogs/delete-signature-dialog/delete-signature-dialog.component.ts +++ b/src/app/clients/clients-view/custom-dialogs/delete-signature-dialog/delete-signature-dialog.component.ts @@ -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; } } diff --git a/src/app/clients/clients-view/custom-dialogs/view-signature-dialog/view-signature-dialog.component.ts b/src/app/clients/clients-view/custom-dialogs/view-signature-dialog/view-signature-dialog.component.ts index 155a8bc14..0356d5d67 100644 --- a/src/app/clients/clients-view/custom-dialogs/view-signature-dialog/view-signature-dialog.component.ts +++ b/src/app/clients/clients-view/custom-dialogs/view-signature-dialog/view-signature-dialog.component.ts @@ -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; } diff --git a/src/app/clients/create-client/create-client.component.ts b/src/app/clients/create-client/create-client.component.ts index 976918766..5c1283c57 100644 --- a/src/app/clients/create-client/create-client.component.ts +++ b/src/app/clients/create-client/create-client.component.ts @@ -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;