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
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(curl -sk -X POST \"https://localhost:8443/fineract-provider/api/v1/clients/2/kyc\" -H \"Content-Type: application/json\" -H \"Fineract-Platform-TenantId: default\" -u \"mifos:password\" -d '{\"\"locale\"\":\"\"en\"\",\"\"kycType\"\":1,\"\"riskCategory\"\":1,\"\"panNumber\"\":\"\"ABCDE1234F\"\",\"\"aadhaarNumberMasked\"\":\"\"XXXXXXXX1234\"\"}')",
|
||||
"Bash(npm run build)",
|
||||
"Bash(node -e \"const icons = require\\(''@fortawesome/free-solid-svg-icons''\\); const names = [''faNewspaper'',''faRss'',''faFilter'',''faBullhorn'',''faScroll'',''faGavel'']; names.forEach\\(n => console.log\\(n, !!icons[n]\\)\\)\")",
|
||||
"Bash(node -e \"const i = require\\(''@fortawesome/free-solid-svg-icons''\\); console.log\\(''newspaper'', typeof i.faNewspaper\\); console.log\\(''rss'', typeof i.faRss\\); console.log\\(''filter'', typeof i.faFilter\\); console.log\\(''bullhorn'', typeof i.faBullhorn\\); console.log\\(''globe'', typeof i.faGlobe\\); console.log\\(''fileAlt'', typeof i.faFileAlt\\);\")",
|
||||
"Bash(curl -k -s -o /dev/null -w \"%{http_code}\" https://localhost:8443/fineract-provider/api/v1/rbi-feeds)",
|
||||
"Bash(curl -k -s -H \"Fineract-Platform-TenantId: default\" -H \"Authorization: Basic bWlmb3M6cGFzc3dvcmQ=\" https://localhost:8443/fineract-provider/api/v1/rbi-feeds)",
|
||||
"Bash(curl -k -s -X POST -H \"Fineract-Platform-TenantId: default\" -H \"Authorization: Basic bWlmb3M6cGFzc3dvcmQ=\" https://localhost:8443/fineract-provider/api/v1/rbi-feeds/fetch)",
|
||||
"Bash(node -e \"const a = require\\(''./angular.json''\\); console.log\\(JSON.stringify\\(a.projects[''web-app''].architect.build.options.styles, null, 2\\)\\)\")",
|
||||
"Bash(node -e \"const a = require\\(''./angular.json''\\); const keys = Object.keys\\(a.projects\\); console.log\\(keys\\); const proj = a.projects[keys[0]]; console.log\\(JSON.stringify\\(proj.architect.build.options.styles, null, 2\\)\\)\")",
|
||||
"Bash(node -e \"const p = require\\(''./package.json''\\); console.log\\(''@angular/material:'', p.dependencies[''@angular/material'']\\); console.log\\(''@angular/core:'', p.dependencies[''@angular/core'']\\)\")",
|
||||
"Bash(npx prettier:*)",
|
||||
"Bash(grep -c \"ms$\")",
|
||||
"Bash(find c:UsersadityMusicCBSUIweb-appsrcappcoreshell -type f \\\\\\(-name *.ts -o -name *.html -o -name *.scss \\\\\\))",
|
||||
"Bash(while read:*)",
|
||||
"Bash(do wc:*)",
|
||||
"Bash(done)",
|
||||
"Bash(xargs wc:*)",
|
||||
"Read(//c/Users/adity/.config/Code/User/**)",
|
||||
"Read(//c/Users/adity/Music/CBS/UI/web-app/$APPDATA/Code/User/**)",
|
||||
"Read(//c/Users/adity/AppData/Roaming/Code/User/**)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"C:\\Users\\adity\\AppData\\Roaming\\Code\\User"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx ng build --configuration=development)",
|
||||
"Bash(npx ng build)",
|
||||
"Bash(curl -sk -X POST \"https://localhost:8443/fineract-provider/api/v1/clients/2/kyc\" -H \"Content-Type: application/json\" -H \"Fineract-Platform-TenantId: default\" -u \"mifos:password\" -d '{\"\"kycType\"\":1,\"\"riskCategory\"\":1,\"\"panNumber\"\":\"\"ABCDE1234F\"\",\"\"aadhaarNumberMasked\"\":\"\"XXXXXXXX1234\"\",\"\"remarks\"\":\"\"Test KYC\"\"}')",
|
||||
"Bash(curl -sk -X POST \"https://localhost:8443/fineract-provider/api/v1/clients/2/kyc\" -H \"Content-Type: application/json\" -H \"Fineract-Platform-TenantId: default\" -u \"mifos:password\" -d '{\"\"kycType\"\":1,\"\"riskCategory\"\":1,\"\"panNumber\"\":\"\"ABCDE1234F\"\",\"\"aadhaarNumberMasked\"\":\"\"XXXXXXXX1234\"\",\"\"remarks\"\":\"\"Test KYC\"\",\"\"locale\"\":\"\"en\"\"}')",
|
||||
"Bash(xargs grep -l \"ClientKycCommandFromApiJsonDeserializer\\\\|initiateClientKyc\")",
|
||||
"Bash(npx ng build --configuration development)",
|
||||
"Bash(xargs grep -l \"print\\\\|report\\\\|export\\\\|pdf\")",
|
||||
"Bash(xargs grep -l \"export.*pdf\\\\|download.*pdf\\\\|print\")",
|
||||
"Bash(xargs grep -l \"print\\\\|pdf\\\\|export\")",
|
||||
"Bash(xargs grep -l -i \"nominee\\\\|nomination\\\\|nominate\")",
|
||||
"Bash(git -C \"c:/Users/adity/Music/CBS/UI/web-app\" log --oneline --all -n 50)",
|
||||
"Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); console.log\\(''heading:'', !!j.labels?.heading?.[''Fund Details'']\\); console.log\\(''inputs:'', !!j.labels?.inputs?.[''Fund Details'']\\);\")",
|
||||
"Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading[''Fund Details'']; const i=j.labels.inputs[''Fund Details'']; console.log\\(''heading:'', h\\); console.log\\(''inputs:'', i\\);\")",
|
||||
"Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const keys=Object.keys\\(j.labels\\); for\\(const k of keys\\){if\\(j.labels[k][''Fund Details'']\\){console.log\\(''Found in:'',k\\)}}\")",
|
||||
"Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; const keys=Object.keys\\(h\\).filter\\(k=>k.startsWith\\(''Fu''\\)\\); console.log\\(keys\\);\")",
|
||||
"Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; console.log\\(''Create Loan Originator:'', h[''Create Loan Originator'']\\); console.log\\(''Edit Loan Originator:'', h[''Edit Loan Originator'']\\); console.log\\(''Loan Originator Details:'', h[''Loan Originator Details'']\\); console.log\\(''Loan Originators:'', h[''Loan Originators'']\\);\")",
|
||||
"Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; console.log\\(''Create Fund:'', h[''Create Fund'']\\); console.log\\(''Edit Fund:'', h[''Edit Fund'']\\); console.log\\(''Manage Funds:'', h[''Manage Funds'']\\);\")",
|
||||
"Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); console.log\\(''Edit Employee in heading:'', j.labels.heading[''Edit Employee'']\\);\")",
|
||||
"Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); for\\(const k of Object.keys\\(j.labels\\)\\){if\\(j.labels[k][''Edit Employee'']\\){console.log\\(''Found in:'',k\\)}}\")",
|
||||
"Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; const edits=Object.keys\\(h\\).filter\\(k=>k.startsWith\\(''Edit''\\)\\); console.log\\(edits\\);\")",
|
||||
"Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; console.log\\(''Create Fund:'', h[''Create Fund'']\\); console.log\\(''Edit Fund:'', h[''Edit Fund'']\\); console.log\\(''Fund Details:'', h[''Fund Details'']\\); console.log\\(''Create Loan Originator:'', h[''Create Loan Originator'']\\); console.log\\(''Edit Loan Originator:'', h[''Edit Loan Originator'']\\); console.log\\(''Loan Originator Details:'', h[''Loan Originator Details'']\\);\")",
|
||||
"Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; console.log\\(''Create Teller:'', h[''Create Teller'']\\); console.log\\(''Edit Teller:'', h[''Edit Teller'']\\); console.log\\(''Teller Details:'', h[''Teller Details'']\\); console.log\\(''Tellers:'', h[''Teller / Cashier Management'']\\);\")",
|
||||
"Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); console.log\\(''Cashiers:'', j.labels.heading[''Cashiers'']\\);\")",
|
||||
"Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; console.log\\(''Create Cashier:'', h[''Create Cashier'']\\); console.log\\(''Edit Cashier:'', h[''Edit Cashier'']\\); console.log\\(''Cashier Details:'', h[''Cashier Details'']\\); console.log\\(''Cashier Assignment:'', h[''Cashier Assignment'']\\); console.log\\(''Schedule:'', h[''Schedule'']\\);\")",
|
||||
"Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; console.log\\(''Allocate Cash:'', h[''Allocate Cash'']\\); console.log\\(''Settle Cash:'', h[''Settle Cash'']\\); console.log\\(''Cash Transactions:'', h[''Cash Transactions'']\\); console.log\\(''Transaction Details:'', h[''Transaction Details'']\\); console.log\\(''Cashier Summary:'', h[''Cashier Summary'']\\);\")",
|
||||
"Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; console.log\\(''Create Payment Type:'', h[''Create Payment Type'']\\); console.log\\(''Edit Payment Type:'', h[''Edit Payment Type'']\\); console.log\\(''Payment Type Details:'', h[''Payment Type Details'']\\); console.log\\(''Payment Type:'', h[''Payment Type'']\\);\")",
|
||||
"Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); console.log\\(''Products:'', j.labels.heading[''Products'']\\);\")",
|
||||
"Bash(node -e \"const j=require\\(''./src/assets/translations/en-US.json''\\); const h=j.labels.heading; console.log\\(''Create Savings Product:'', h[''Create Savings Product'']\\); console.log\\(''Edit Savings Product:'', h[''Edit Savings Product'']\\);\")",
|
||||
"Bash(grep -n '\"\"inputs\"\"' src/assets/translations/en-US.json)",
|
||||
"Bash(for key:*)",
|
||||
"Bash(do)",
|
||||
"Bash(if grep:*)",
|
||||
"Bash(then)",
|
||||
"Bash(echo \"EXISTS: $key\")",
|
||||
"Bash(node -e \":*)",
|
||||
"Bash(npx ng:*)",
|
||||
"Bash(npm start:*)",
|
||||
"Bash(node -e \"const j = require\\(''./src/assets/translations/en-US.json''\\); console.log\\(''heading key:'', j.labels?.heading?.[''Create Recurring Deposit Product'']\\); console.log\\(''inputs key:'', j.labels?.inputs?.[''Create Recurring Deposit Product'']\\);\")",
|
||||
"Bash(node -e \"const fs = require\\(''fs''\\); const t = fs.readFileSync\\(''./src/assets/translations/en-US.json'',''utf8''\\); const j = JSON.parse\\(t\\); const keys = Object.keys\\(j.labels?.heading || {}\\); console.log\\(''heading keys count:'', keys.length\\); console.log\\(''last 5 keys:'', keys.slice\\(-5\\)\\); const cKeys = keys.filter\\(k => k.startsWith\\(''Create''\\)\\); console.log\\(''Create keys:'', cKeys\\);\")",
|
||||
"Bash(node -e \"const j = require\\(''./src/assets/translations/en-US.json''\\); console.log\\(''heading:'', j.labels?.heading?.[''Create Recurring Deposit Product'']\\); console.log\\(''inputs:'', j.labels?.inputs?.[''Create Recurring Deposit Product'']\\);\")",
|
||||
"Bash(do node:*)",
|
||||
"Bash(grep -n '\"\"Tax Group\"\":' src/assets/translations/en-US.json)",
|
||||
"Bash(node -e \"console.log\\(require\\(''./src/assets/translations/en-US.json''\\).labels?.heading?.[''System'']\\)\")",
|
||||
"Bash(npm list:*)",
|
||||
"Bash(find c:UsersadityMusicCBS -type d -name *fineract*)",
|
||||
"Bash(xargs -I {} basename {})"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* CBS Nexora — India Address Domain Object
|
||||
* Package: org.apache.fineract.portfolio.client.domain
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "m_client_address_india", uniqueConstraints = {
|
||||
@UniqueConstraint(columnNames = { "client_id", "address_type" })
|
||||
})
|
||||
public class ClientAddressIndia {
|
||||
|
||||
public enum AddressType {
|
||||
PERMANENT, CURRENT, OFFICE, CORRESPONDENCE
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
private Client client;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "address_type", nullable = false, length = 20)
|
||||
private AddressType addressType;
|
||||
|
||||
@Column(name = "address_line_1", length = 500)
|
||||
private String addressLine1;
|
||||
|
||||
@Column(name = "address_line_2", length = 500)
|
||||
private String addressLine2;
|
||||
|
||||
@Column(name = "pin_code", length = 6)
|
||||
private String pinCode;
|
||||
|
||||
@Column(name = "locality", length = 200)
|
||||
private String locality;
|
||||
|
||||
@Column(name = "city", length = 200)
|
||||
private String city;
|
||||
|
||||
@Column(name = "district", length = 200)
|
||||
private String district;
|
||||
|
||||
@Column(name = "state", length = 200)
|
||||
private String state;
|
||||
|
||||
@Column(name = "country", length = 100)
|
||||
private String country = "India";
|
||||
|
||||
@Column(name = "landmark", length = 300)
|
||||
private String landmark;
|
||||
|
||||
// Permanent-specific
|
||||
@Column(name = "proof_type", length = 100)
|
||||
private String proofType;
|
||||
|
||||
@Column(name = "proof_doc_number", length = 100)
|
||||
private String proofDocNumber;
|
||||
|
||||
// Current-specific
|
||||
@Column(name = "residence_type", length = 50)
|
||||
private String residenceType;
|
||||
|
||||
@Column(name = "years_at_address")
|
||||
private Integer yearsAtAddress;
|
||||
|
||||
@Column(name = "same_as_permanent")
|
||||
private Boolean sameAsPermanent = false;
|
||||
|
||||
// Office-specific
|
||||
@Column(name = "org_name", length = 300)
|
||||
private String orgName;
|
||||
|
||||
@Column(name = "department", length = 200)
|
||||
private String department;
|
||||
|
||||
@Column(name = "office_phone", length = 20)
|
||||
private String officePhone;
|
||||
|
||||
@Column(name = "employment_type", length = 100)
|
||||
private String employmentType;
|
||||
|
||||
@Column(name = "employee_id", length = 50)
|
||||
private String employeeId;
|
||||
|
||||
@Column(name = "years_with_org")
|
||||
private Integer yearsWithOrg;
|
||||
|
||||
// Correspondence-specific
|
||||
@Column(name = "comm_mode", length = 50)
|
||||
private String commMode;
|
||||
|
||||
@Column(name = "contact_number", length = 20)
|
||||
private String contactNumber;
|
||||
|
||||
// Audit
|
||||
@Column(name = "created_date", updatable = false)
|
||||
private LocalDateTime createdDate = LocalDateTime.now();
|
||||
|
||||
@Column(name = "last_modified_date")
|
||||
private LocalDateTime lastModifiedDate = LocalDateTime.now();
|
||||
|
||||
protected ClientAddressIndia() {}
|
||||
|
||||
public static ClientAddressIndia create(Client client, AddressType type) {
|
||||
ClientAddressIndia addr = new ClientAddressIndia();
|
||||
addr.client = client;
|
||||
addr.addressType = type;
|
||||
return addr;
|
||||
}
|
||||
|
||||
public void update(java.util.Map<String, Object> data) {
|
||||
if (data.containsKey("addressLine1")) this.addressLine1 = (String) data.get("addressLine1");
|
||||
if (data.containsKey("addressLine2")) this.addressLine2 = (String) data.get("addressLine2");
|
||||
if (data.containsKey("pinCode")) this.pinCode = (String) data.get("pinCode");
|
||||
if (data.containsKey("locality")) this.locality = (String) data.get("locality");
|
||||
if (data.containsKey("city")) this.city = (String) data.get("city");
|
||||
if (data.containsKey("district")) this.district = (String) data.get("district");
|
||||
if (data.containsKey("state")) this.state = (String) data.get("state");
|
||||
if (data.containsKey("country")) this.country = (String) data.get("country");
|
||||
if (data.containsKey("landmark")) this.landmark = (String) data.get("landmark");
|
||||
if (data.containsKey("proofType")) this.proofType = (String) data.get("proofType");
|
||||
if (data.containsKey("proofDocNumber")) this.proofDocNumber = (String) data.get("proofDocNumber");
|
||||
if (data.containsKey("residenceType")) this.residenceType = (String) data.get("residenceType");
|
||||
if (data.containsKey("yearsAtAddress")) this.yearsAtAddress = toInt(data.get("yearsAtAddress"));
|
||||
if (data.containsKey("sameAsPermanent")) this.sameAsPermanent = (Boolean) data.get("sameAsPermanent");
|
||||
if (data.containsKey("orgName")) this.orgName = (String) data.get("orgName");
|
||||
if (data.containsKey("department")) this.department = (String) data.get("department");
|
||||
if (data.containsKey("officePhone")) this.officePhone = (String) data.get("officePhone");
|
||||
if (data.containsKey("employmentType")) this.employmentType = (String) data.get("employmentType");
|
||||
if (data.containsKey("employeeId")) this.employeeId = (String) data.get("employeeId");
|
||||
if (data.containsKey("yearsWithOrg")) this.yearsWithOrg = toInt(data.get("yearsWithOrg"));
|
||||
if (data.containsKey("commMode")) this.commMode = (String) data.get("commMode");
|
||||
if (data.containsKey("contactNumber")) this.contactNumber = (String) data.get("contactNumber");
|
||||
this.lastModifiedDate = LocalDateTime.now();
|
||||
}
|
||||
|
||||
private Integer toInt(Object val) {
|
||||
if (val == null) return null;
|
||||
if (val instanceof Integer) return (Integer) val;
|
||||
return Integer.parseInt(val.toString());
|
||||
}
|
||||
|
||||
// Getters
|
||||
public Long getId() { return id; }
|
||||
public Client getClient() { return client; }
|
||||
public AddressType getAddressType() { return addressType; }
|
||||
public String getAddressLine1() { return addressLine1; }
|
||||
public String getAddressLine2() { return addressLine2; }
|
||||
public String getPinCode() { return pinCode; }
|
||||
public String getLocality() { return locality; }
|
||||
public String getCity() { return city; }
|
||||
public String getDistrict() { return district; }
|
||||
public String getState() { return state; }
|
||||
public String getCountry() { return country; }
|
||||
public String getLandmark() { return landmark; }
|
||||
public String getProofType() { return proofType; }
|
||||
public String getResidenceType() { return residenceType; }
|
||||
public Boolean getSameAsPermanent() { return sameAsPermanent; }
|
||||
public String getOrgName() { return orgName; }
|
||||
public String getEmploymentType() { return employmentType; }
|
||||
public String getCommMode() { return commMode; }
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* CBS Nexora — India Address REST Controller
|
||||
* Package: org.apache.fineract.portfolio.client.api
|
||||
*
|
||||
* Endpoints:
|
||||
* POST /clients/{clientId}/india-address — Save all 4 address types
|
||||
* GET /clients/{clientId}/india-address — Get all addresses
|
||||
* GET /clients/{clientId}/india-address/{type} — Get specific type
|
||||
* PUT /clients/{clientId}/india-address/{type} — Update specific type
|
||||
* DELETE /clients/{clientId}/india-address — Delete all addresses
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.portfolio.client.domain.*;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Path("/v1/clients/{clientId}/india-address")
|
||||
@Component
|
||||
@Tag(name = "Client India Address", description = "India-specific multi-type address management")
|
||||
@RequiredArgsConstructor
|
||||
public class ClientAddressIndiaApiResource {
|
||||
|
||||
private final ClientRepositoryWrapper clientRepository;
|
||||
private final ClientAddressIndiaRepository addressRepository;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
|
||||
/**
|
||||
* POST /clients/{clientId}/india-address
|
||||
* Body: { "permanent": {...}, "current": {...}, "office": {...}, "correspondence": {...} }
|
||||
*/
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
@Operation(summary = "Save all address types", description = "Create or update Permanent, Current, Office, and Correspondence addresses")
|
||||
public Response saveAllAddresses(
|
||||
@PathParam("clientId") Long clientId,
|
||||
String jsonBody) {
|
||||
|
||||
final Client client = clientRepository.findOneWithNotFoundDetection(clientId);
|
||||
final JsonObject json = fromJsonHelper.parse(jsonBody).getAsJsonObject();
|
||||
|
||||
List<Long> savedIds = new ArrayList<>();
|
||||
|
||||
for (ClientAddressIndia.AddressType type : ClientAddressIndia.AddressType.values()) {
|
||||
String key = type.name().toLowerCase();
|
||||
if (json.has(key) && json.get(key).isJsonObject()) {
|
||||
JsonObject addrJson = json.getAsJsonObject(key);
|
||||
Map<String, Object> data = toMap(addrJson);
|
||||
|
||||
Optional<ClientAddressIndia> existing = addressRepository.findByClientIdAndAddressType(clientId, type);
|
||||
ClientAddressIndia addr;
|
||||
|
||||
if (existing.isPresent()) {
|
||||
addr = existing.get();
|
||||
addr.update(data);
|
||||
} else {
|
||||
addr = ClientAddressIndia.create(client, type);
|
||||
addr.update(data);
|
||||
}
|
||||
|
||||
addressRepository.saveAndFlush(addr);
|
||||
savedIds.add(addr.getId());
|
||||
}
|
||||
}
|
||||
|
||||
return Response.ok(Map.of(
|
||||
"clientId", clientId,
|
||||
"savedAddressIds", savedIds
|
||||
)).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /clients/{clientId}/india-address
|
||||
*/
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Get all addresses", description = "Retrieve all 4 address types for a client")
|
||||
public Response getAllAddresses(@PathParam("clientId") Long clientId) {
|
||||
clientRepository.findOneWithNotFoundDetection(clientId);
|
||||
List<ClientAddressIndia> addresses = addressRepository.findByClientId(clientId);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("clientId", clientId);
|
||||
|
||||
for (ClientAddressIndia addr : addresses) {
|
||||
result.put(addr.getAddressType().name().toLowerCase(), toResponseMap(addr));
|
||||
}
|
||||
|
||||
return Response.ok(result).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /clients/{clientId}/india-address/{type}
|
||||
*/
|
||||
@GET
|
||||
@Path("/{type}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Get specific address type")
|
||||
public Response getAddress(
|
||||
@PathParam("clientId") Long clientId,
|
||||
@PathParam("type") String type) {
|
||||
|
||||
clientRepository.findOneWithNotFoundDetection(clientId);
|
||||
ClientAddressIndia.AddressType addrType = ClientAddressIndia.AddressType.valueOf(type.toUpperCase());
|
||||
Optional<ClientAddressIndia> addr = addressRepository.findByClientIdAndAddressType(clientId, addrType);
|
||||
|
||||
if (addr.isEmpty()) {
|
||||
return Response.status(Response.Status.NOT_FOUND)
|
||||
.entity(Map.of("error", "No " + type + " address found for client " + clientId))
|
||||
.build();
|
||||
}
|
||||
|
||||
return Response.ok(toResponseMap(addr.get())).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /clients/{clientId}/india-address/{type}
|
||||
*/
|
||||
@PUT
|
||||
@Path("/{type}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
@Operation(summary = "Update specific address type")
|
||||
public Response updateAddress(
|
||||
@PathParam("clientId") Long clientId,
|
||||
@PathParam("type") String type,
|
||||
String jsonBody) {
|
||||
|
||||
final Client client = clientRepository.findOneWithNotFoundDetection(clientId);
|
||||
ClientAddressIndia.AddressType addrType = ClientAddressIndia.AddressType.valueOf(type.toUpperCase());
|
||||
final JsonObject json = fromJsonHelper.parse(jsonBody).getAsJsonObject();
|
||||
final Map<String, Object> data = toMap(json);
|
||||
|
||||
Optional<ClientAddressIndia> existing = addressRepository.findByClientIdAndAddressType(clientId, addrType);
|
||||
ClientAddressIndia addr;
|
||||
|
||||
if (existing.isPresent()) {
|
||||
addr = existing.get();
|
||||
addr.update(data);
|
||||
} else {
|
||||
addr = ClientAddressIndia.create(client, addrType);
|
||||
addr.update(data);
|
||||
}
|
||||
|
||||
addressRepository.saveAndFlush(addr);
|
||||
|
||||
return Response.ok(Map.of(
|
||||
"resourceId", addr.getId(),
|
||||
"clientId", clientId,
|
||||
"addressType", type.toUpperCase()
|
||||
)).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /clients/{clientId}/india-address
|
||||
*/
|
||||
@DELETE
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
@Operation(summary = "Delete all addresses")
|
||||
public Response deleteAllAddresses(@PathParam("clientId") Long clientId) {
|
||||
clientRepository.findOneWithNotFoundDetection(clientId);
|
||||
addressRepository.deleteByClientId(clientId);
|
||||
return Response.ok(Map.of("clientId", clientId, "deleted", true)).build();
|
||||
}
|
||||
|
||||
private Map<String, Object> toResponseMap(ClientAddressIndia addr) {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("id", addr.getId());
|
||||
map.put("addressType", addr.getAddressType().name());
|
||||
map.put("addressLine1", addr.getAddressLine1());
|
||||
map.put("addressLine2", addr.getAddressLine2());
|
||||
map.put("pinCode", addr.getPinCode());
|
||||
map.put("locality", addr.getLocality());
|
||||
map.put("city", addr.getCity());
|
||||
map.put("district", addr.getDistrict());
|
||||
map.put("state", addr.getState());
|
||||
map.put("country", addr.getCountry());
|
||||
map.put("landmark", addr.getLandmark());
|
||||
map.put("proofType", addr.getProofType());
|
||||
map.put("residenceType", addr.getResidenceType());
|
||||
map.put("sameAsPermanent", addr.getSameAsPermanent());
|
||||
map.put("orgName", addr.getOrgName());
|
||||
map.put("employmentType", addr.getEmploymentType());
|
||||
map.put("commMode", addr.getCommMode());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> toMap(JsonObject json) {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
json.entrySet().forEach(e -> {
|
||||
if (e.getValue().isJsonNull()) {
|
||||
map.put(e.getKey(), null);
|
||||
} else if (e.getValue().isJsonPrimitive()) {
|
||||
var prim = e.getValue().getAsJsonPrimitive();
|
||||
if (prim.isBoolean()) map.put(e.getKey(), prim.getAsBoolean());
|
||||
else if (prim.isNumber()) map.put(e.getKey(), prim.getAsInt());
|
||||
else map.put(e.getKey(), prim.getAsString());
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* CBS Nexora — India Address Repository
|
||||
* Package: org.apache.fineract.portfolio.client.domain
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ClientAddressIndiaRepository extends JpaRepository<ClientAddressIndia, Long> {
|
||||
|
||||
List<ClientAddressIndia> findByClientId(Long clientId);
|
||||
|
||||
Optional<ClientAddressIndia> findByClientIdAndAddressType(Long clientId, ClientAddressIndia.AddressType addressType);
|
||||
|
||||
void deleteByClientId(Long clientId);
|
||||
|
||||
boolean existsByClientId(Long clientId);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* CBS Nexora — Entity KYC Domain Object
|
||||
* Package: org.apache.fineract.portfolio.client.domain
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "m_client_entity_kyc", uniqueConstraints = {
|
||||
@UniqueConstraint(columnNames = { "client_id" })
|
||||
})
|
||||
public class ClientEntityKyc {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
private Client client;
|
||||
|
||||
@Column(name = "constitution_type", length = 50)
|
||||
private String constitutionType;
|
||||
|
||||
// ── MSME ──
|
||||
@Column(name = "is_msme")
|
||||
private Boolean isMsme = false;
|
||||
|
||||
@Column(name = "udyam_number", length = 19)
|
||||
private String udyamNumber;
|
||||
|
||||
@Column(name = "enterprise_name", length = 300)
|
||||
private String enterpriseName;
|
||||
|
||||
@Column(name = "business_sector", length = 50)
|
||||
private String businessSector;
|
||||
|
||||
@Column(name = "msme_turnover", precision = 18, scale = 2)
|
||||
private BigDecimal msmeTurnover;
|
||||
|
||||
@Column(name = "msme_investment", precision = 18, scale = 2)
|
||||
private BigDecimal msmeInvestment;
|
||||
|
||||
@Column(name = "msme_type", length = 20)
|
||||
private String msmeType;
|
||||
|
||||
// ── Entity KYC Document ──
|
||||
@Column(name = "entity_kyc_doc_type", length = 100)
|
||||
private String entityKycDocType;
|
||||
|
||||
@Column(name = "entity_kyc_doc_number", length = 100)
|
||||
private String entityKycDocNumber;
|
||||
|
||||
@Column(name = "entity_kyc_doc_issue_date")
|
||||
private LocalDate entityKycDocIssueDate;
|
||||
|
||||
@Column(name = "entity_kyc_doc_expiry_date")
|
||||
private LocalDate entityKycDocExpiryDate;
|
||||
|
||||
// ── Authorized Signatory ──
|
||||
@Column(name = "signatory_name", length = 200)
|
||||
private String signatoryName;
|
||||
|
||||
@Column(name = "signatory_designation", length = 100)
|
||||
private String signatoryDesignation;
|
||||
|
||||
@Column(name = "signatory_pan", length = 10)
|
||||
private String signatoryPan;
|
||||
|
||||
@Column(name = "signatory_aadhaar_masked", length = 12)
|
||||
private String signatoryAadhaarMasked;
|
||||
|
||||
@Column(name = "signatory_kyc_doc_type", length = 100)
|
||||
private String signatoryKycDocType;
|
||||
|
||||
@Column(name = "signatory_kyc_doc_number", length = 100)
|
||||
private String signatoryKycDocNumber;
|
||||
|
||||
// ── Authorization Document ──
|
||||
@Column(name = "authorization_doc_type", length = 100)
|
||||
private String authorizationDocType;
|
||||
|
||||
@Column(name = "authorization_doc_number", length = 100)
|
||||
private String authorizationDocNumber;
|
||||
|
||||
@Column(name = "authorization_date")
|
||||
private LocalDate authorizationDate;
|
||||
|
||||
// ── Risk ──
|
||||
@Column(name = "entity_risk_category")
|
||||
private Integer entityRiskCategory = 1;
|
||||
|
||||
@Column(name = "entity_remarks", columnDefinition = "TEXT")
|
||||
private String entityRemarks;
|
||||
|
||||
// ── Audit ──
|
||||
@Column(name = "created_date", updatable = false)
|
||||
private LocalDateTime createdDate = LocalDateTime.now();
|
||||
|
||||
@Column(name = "last_modified_date")
|
||||
private LocalDateTime lastModifiedDate = LocalDateTime.now();
|
||||
|
||||
protected ClientEntityKyc() {}
|
||||
|
||||
public static ClientEntityKyc create(Client client, String constitutionType) {
|
||||
ClientEntityKyc kyc = new ClientEntityKyc();
|
||||
kyc.client = client;
|
||||
kyc.constitutionType = constitutionType;
|
||||
return kyc;
|
||||
}
|
||||
|
||||
public void update(java.util.Map<String, Object> data) {
|
||||
if (data.containsKey("constitutionType")) this.constitutionType = (String) data.get("constitutionType");
|
||||
if (data.containsKey("isMsme")) this.isMsme = (Boolean) data.get("isMsme");
|
||||
if (data.containsKey("udyamNumber")) this.udyamNumber = (String) data.get("udyamNumber");
|
||||
if (data.containsKey("enterpriseName")) this.enterpriseName = (String) data.get("enterpriseName");
|
||||
if (data.containsKey("businessSector")) this.businessSector = (String) data.get("businessSector");
|
||||
if (data.containsKey("msmeTurnover")) this.msmeTurnover = toBigDecimal(data.get("msmeTurnover"));
|
||||
if (data.containsKey("msmeInvestment")) this.msmeInvestment = toBigDecimal(data.get("msmeInvestment"));
|
||||
if (data.containsKey("msmeType")) this.msmeType = (String) data.get("msmeType");
|
||||
if (data.containsKey("entityKycDocType")) this.entityKycDocType = (String) data.get("entityKycDocType");
|
||||
if (data.containsKey("entityKycDocNumber")) this.entityKycDocNumber = (String) data.get("entityKycDocNumber");
|
||||
if (data.containsKey("signatoryName")) this.signatoryName = (String) data.get("signatoryName");
|
||||
if (data.containsKey("signatoryDesignation")) this.signatoryDesignation = (String) data.get("signatoryDesignation");
|
||||
if (data.containsKey("signatoryPan")) this.signatoryPan = (String) data.get("signatoryPan");
|
||||
if (data.containsKey("signatoryAadhaar")) {
|
||||
String raw = (String) data.get("signatoryAadhaar");
|
||||
this.signatoryAadhaarMasked = raw != null && raw.length() == 12 ? "XXXXXXXX" + raw.substring(8) : raw;
|
||||
}
|
||||
if (data.containsKey("signatoryKycDocType")) this.signatoryKycDocType = (String) data.get("signatoryKycDocType");
|
||||
if (data.containsKey("signatoryKycDocNumber")) this.signatoryKycDocNumber = (String) data.get("signatoryKycDocNumber");
|
||||
if (data.containsKey("authorizationDocType")) this.authorizationDocType = (String) data.get("authorizationDocType");
|
||||
if (data.containsKey("authorizationDocNumber")) this.authorizationDocNumber = (String) data.get("authorizationDocNumber");
|
||||
if (data.containsKey("entityRiskCategory")) this.entityRiskCategory = (Integer) data.get("entityRiskCategory");
|
||||
if (data.containsKey("entityRemarks")) this.entityRemarks = (String) data.get("entityRemarks");
|
||||
this.lastModifiedDate = LocalDateTime.now();
|
||||
}
|
||||
|
||||
private BigDecimal toBigDecimal(Object val) {
|
||||
if (val == null) return null;
|
||||
if (val instanceof BigDecimal) return (BigDecimal) val;
|
||||
return new BigDecimal(val.toString());
|
||||
}
|
||||
|
||||
// Getters
|
||||
public Long getId() { return id; }
|
||||
public Client getClient() { return client; }
|
||||
public String getConstitutionType() { return constitutionType; }
|
||||
public Boolean getIsMsme() { return isMsme; }
|
||||
public String getUdyamNumber() { return udyamNumber; }
|
||||
public String getEnterpriseName() { return enterpriseName; }
|
||||
public String getBusinessSector() { return businessSector; }
|
||||
public BigDecimal getMsmeTurnover() { return msmeTurnover; }
|
||||
public BigDecimal getMsmeInvestment() { return msmeInvestment; }
|
||||
public String getMsmeType() { return msmeType; }
|
||||
public String getEntityKycDocType() { return entityKycDocType; }
|
||||
public String getEntityKycDocNumber() { return entityKycDocNumber; }
|
||||
public LocalDate getEntityKycDocIssueDate() { return entityKycDocIssueDate; }
|
||||
public LocalDate getEntityKycDocExpiryDate() { return entityKycDocExpiryDate; }
|
||||
public String getSignatoryName() { return signatoryName; }
|
||||
public String getSignatoryDesignation() { return signatoryDesignation; }
|
||||
public String getSignatoryPan() { return signatoryPan; }
|
||||
public String getSignatoryAadhaarMasked() { return signatoryAadhaarMasked; }
|
||||
public String getAuthorizationDocType() { return authorizationDocType; }
|
||||
public String getAuthorizationDocNumber() { return authorizationDocNumber; }
|
||||
public LocalDate getAuthorizationDate() { return authorizationDate; }
|
||||
public Integer getEntityRiskCategory() { return entityRiskCategory; }
|
||||
public String getEntityRemarks() { return entityRemarks; }
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* CBS Nexora — Entity KYC REST Controller
|
||||
* Package: org.apache.fineract.portfolio.client.api
|
||||
*
|
||||
* Endpoints:
|
||||
* POST /clients/{clientId}/entity-kyc — Create or update entity KYC + MSME
|
||||
* GET /clients/{clientId}/entity-kyc — Retrieve entity KYC data
|
||||
* DELETE /clients/{clientId}/entity-kyc — Delete entity KYC data
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.portfolio.client.domain.*;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@Path("/v1/clients/{clientId}/entity-kyc")
|
||||
@Component
|
||||
@Tag(name = "Client Entity KYC", description = "Non-Individual client MSME & KYC management")
|
||||
@RequiredArgsConstructor
|
||||
public class ClientEntityKycApiResource {
|
||||
|
||||
private final ClientRepositoryWrapper clientRepository;
|
||||
private final ClientEntityKycRepository entityKycRepository;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
@Operation(summary = "Create or Update Entity KYC", description = "Save MSME details, entity KYC document, signatory KYC, authorization document, and risk assessment")
|
||||
public Response saveEntityKyc(
|
||||
@PathParam("clientId") Long clientId,
|
||||
String jsonBody) {
|
||||
|
||||
final Client client = clientRepository.findOneWithNotFoundDetection(clientId);
|
||||
final JsonObject json = fromJsonHelper.parse(jsonBody).getAsJsonObject();
|
||||
final Map<String, Object> data = toMap(json);
|
||||
|
||||
Optional<ClientEntityKyc> existing = entityKycRepository.findByClientId(clientId);
|
||||
ClientEntityKyc entityKyc;
|
||||
|
||||
if (existing.isPresent()) {
|
||||
entityKyc = existing.get();
|
||||
entityKyc.update(data);
|
||||
} else {
|
||||
String constitutionType = json.has("constitutionType") ? json.get("constitutionType").getAsString() : null;
|
||||
entityKyc = ClientEntityKyc.create(client, constitutionType);
|
||||
entityKyc.update(data);
|
||||
}
|
||||
|
||||
entityKycRepository.saveAndFlush(entityKyc);
|
||||
|
||||
return Response.ok(Map.of(
|
||||
"resourceId", entityKyc.getId(),
|
||||
"clientId", clientId
|
||||
)).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Retrieve Entity KYC", description = "Get MSME, signatory, authorization, and risk data for a non-individual client")
|
||||
public Response getEntityKyc(@PathParam("clientId") Long clientId) {
|
||||
clientRepository.findOneWithNotFoundDetection(clientId);
|
||||
Optional<ClientEntityKyc> entityKyc = entityKycRepository.findByClientId(clientId);
|
||||
|
||||
if (entityKyc.isEmpty()) {
|
||||
return Response.status(Response.Status.NOT_FOUND)
|
||||
.entity(Map.of("error", "No entity KYC found for client " + clientId))
|
||||
.build();
|
||||
}
|
||||
|
||||
ClientEntityKyc kyc = entityKyc.get();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("id", kyc.getId());
|
||||
result.put("clientId", clientId);
|
||||
result.put("constitutionType", kyc.getConstitutionType());
|
||||
result.put("isMsme", kyc.getIsMsme());
|
||||
result.put("udyamNumber", kyc.getUdyamNumber());
|
||||
result.put("enterpriseName", kyc.getEnterpriseName());
|
||||
result.put("businessSector", kyc.getBusinessSector());
|
||||
result.put("msmeTurnover", kyc.getMsmeTurnover());
|
||||
result.put("msmeInvestment", kyc.getMsmeInvestment());
|
||||
result.put("msmeType", kyc.getMsmeType());
|
||||
result.put("entityKycDocType", kyc.getEntityKycDocType());
|
||||
result.put("entityKycDocNumber", kyc.getEntityKycDocNumber());
|
||||
result.put("entityKycDocIssueDate", kyc.getEntityKycDocIssueDate());
|
||||
result.put("entityKycDocExpiryDate", kyc.getEntityKycDocExpiryDate());
|
||||
result.put("signatoryName", kyc.getSignatoryName());
|
||||
result.put("signatoryDesignation", kyc.getSignatoryDesignation());
|
||||
result.put("signatoryPan", kyc.getSignatoryPan());
|
||||
result.put("signatoryAadhaarMasked", kyc.getSignatoryAadhaarMasked());
|
||||
result.put("authorizationDocType", kyc.getAuthorizationDocType());
|
||||
result.put("authorizationDocNumber", kyc.getAuthorizationDocNumber());
|
||||
result.put("authorizationDate", kyc.getAuthorizationDate());
|
||||
result.put("entityRiskCategory", kyc.getEntityRiskCategory());
|
||||
result.put("entityRemarks", kyc.getEntityRemarks());
|
||||
|
||||
return Response.ok(result).build();
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
@Operation(summary = "Delete Entity KYC")
|
||||
public Response deleteEntityKyc(@PathParam("clientId") Long clientId) {
|
||||
clientRepository.findOneWithNotFoundDetection(clientId);
|
||||
entityKycRepository.deleteByClientId(clientId);
|
||||
return Response.ok(Map.of("clientId", clientId, "deleted", true)).build();
|
||||
}
|
||||
|
||||
private Map<String, Object> toMap(JsonObject json) {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
json.entrySet().forEach(e -> {
|
||||
if (e.getValue().isJsonNull()) {
|
||||
map.put(e.getKey(), null);
|
||||
} else if (e.getValue().isJsonPrimitive()) {
|
||||
var prim = e.getValue().getAsJsonPrimitive();
|
||||
if (prim.isBoolean()) map.put(e.getKey(), prim.getAsBoolean());
|
||||
else if (prim.isNumber()) {
|
||||
if (prim.getAsString().contains(".")) map.put(e.getKey(), prim.getAsBigDecimal());
|
||||
else map.put(e.getKey(), prim.getAsInt());
|
||||
}
|
||||
else map.put(e.getKey(), prim.getAsString());
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* CBS Nexora — Entity KYC Repository
|
||||
* Package: org.apache.fineract.portfolio.client.domain
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ClientEntityKycRepository extends JpaRepository<ClientEntityKyc, Long> {
|
||||
|
||||
Optional<ClientEntityKyc> findByClientId(Long clientId);
|
||||
|
||||
boolean existsByClientId(Long clientId);
|
||||
|
||||
void deleteByClientId(Long clientId);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# CBS Nexora — Backend Schema & API Changes
|
||||
|
||||
## Files in this directory
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `V1__client_schema_extensions.sql` | DB migration — run on Fineract MySQL/MariaDB |
|
||||
| `ClientEntityKyc.java` | JPA Entity for `m_client_entity_kyc` table |
|
||||
| `ClientAddressIndia.java` | JPA Entity for `m_client_address_india` table |
|
||||
| `ClientEntityKycRepository.java` | Spring Data repository |
|
||||
| `ClientAddressIndiaRepository.java` | Spring Data repository |
|
||||
| `ClientEntityKycApiResource.java` | REST controller — `/clients/{id}/entity-kyc` |
|
||||
| `ClientAddressIndiaApiResource.java` | REST controller — `/clients/{id}/india-address` |
|
||||
|
||||
## How to apply
|
||||
|
||||
### Step 1: Run SQL Migration
|
||||
```bash
|
||||
mysql -u root -p fineract_tenantdb < V1__client_schema_extensions.sql
|
||||
```
|
||||
|
||||
### Step 2: Copy Java files to Fineract source
|
||||
```
|
||||
ClientEntityKyc.java → fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/
|
||||
ClientAddressIndia.java → fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/
|
||||
ClientEntityKycRepository.java → fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/
|
||||
ClientAddressIndiaRepository.java → fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/
|
||||
ClientEntityKycApiResource.java → fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/
|
||||
ClientAddressIndiaApiResource.java → fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/
|
||||
```
|
||||
|
||||
### Step 3: Update existing Fineract Client API
|
||||
In `ClientApiResource.java`, the `POST /clients` endpoint needs to accept these additional fields in the request body:
|
||||
- `nationality`, `annualTurnover`, `creditLimit`
|
||||
- `isMinor`, `ageYears`, `ageMonths`, `ageDays`
|
||||
- `guardianName`, `guardianRelation`, `guardianMobile`, `guardianIdNumber`, `guardianAddress`
|
||||
|
||||
In `ClientWritePlatformServiceJpaRepositoryImpl.java`, map these fields to the `m_client` entity during create/update.
|
||||
|
||||
### Step 4: Register new API resources
|
||||
Add to `fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/FineractWebMvcConfigurer.java` or equivalent Spring config.
|
||||
|
||||
### Step 5: Rebuild Fineract
|
||||
```bash
|
||||
./gradlew bootRun
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Entity KYC
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/clients/{clientId}/entity-kyc` | Create/update entity KYC + MSME |
|
||||
| GET | `/clients/{clientId}/entity-kyc` | Retrieve entity KYC |
|
||||
| DELETE | `/clients/{clientId}/entity-kyc` | Delete entity KYC |
|
||||
|
||||
### India Address
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/clients/{clientId}/india-address` | Save all 4 address types |
|
||||
| GET | `/clients/{clientId}/india-address` | Get all addresses |
|
||||
| GET | `/clients/{clientId}/india-address/{type}` | Get specific type (PERMANENT/CURRENT/OFFICE/CORRESPONDENCE) |
|
||||
| PUT | `/clients/{clientId}/india-address/{type}` | Update specific type |
|
||||
| DELETE | `/clients/{clientId}/india-address` | Delete all addresses |
|
||||
|
||||
## Tables Summary
|
||||
|
||||
| Table | New/Modified | Records |
|
||||
|-------|-------------|---------|
|
||||
| `m_client` | Modified — 12 new columns | Guardian, nationality, minor, turnover |
|
||||
| `m_client_non_person` | Modified — 13 new columns | MSME, CIN, enterprise PAN, signatory |
|
||||
| `m_client_kyc` | Modified — 19 new columns | AML, freeze, form60/61, form15G/H, KYC doc |
|
||||
| `m_client_entity_kyc` | **New** | Entity-specific KYC, MSME, signatory, auth doc |
|
||||
| `m_client_address_india` | **New** | 4-type India address with PIN lookup fields |
|
||||
| `m_code_value` | Modified | Added Gender: Transgender, Other |
|
||||
@@ -0,0 +1,149 @@
|
||||
-- ============================================================
|
||||
-- CBS Nexora — Client Schema Extensions
|
||||
-- Fineract DB Migration Script
|
||||
-- Run this AFTER backing up your database
|
||||
-- ============================================================
|
||||
|
||||
-- ─── 1. m_client — New columns ───
|
||||
ALTER TABLE m_client ADD COLUMN IF NOT EXISTS nationality VARCHAR(20) DEFAULT 'INDIAN';
|
||||
ALTER TABLE m_client ADD COLUMN IF NOT EXISTS annual_turnover DECIMAL(18,2);
|
||||
ALTER TABLE m_client ADD COLUMN IF NOT EXISTS credit_limit DECIMAL(18,2);
|
||||
ALTER TABLE m_client ADD COLUMN IF NOT EXISTS is_minor TINYINT(1) DEFAULT 0;
|
||||
ALTER TABLE m_client ADD COLUMN IF NOT EXISTS age_years INT;
|
||||
ALTER TABLE m_client ADD COLUMN IF NOT EXISTS age_months INT;
|
||||
ALTER TABLE m_client ADD COLUMN IF NOT EXISTS age_days INT;
|
||||
ALTER TABLE m_client ADD COLUMN IF NOT EXISTS guardian_name VARCHAR(200);
|
||||
ALTER TABLE m_client ADD COLUMN IF NOT EXISTS guardian_relation VARCHAR(50);
|
||||
ALTER TABLE m_client ADD COLUMN IF NOT EXISTS guardian_mobile VARCHAR(15);
|
||||
ALTER TABLE m_client ADD COLUMN IF NOT EXISTS guardian_id_number VARCHAR(50);
|
||||
ALTER TABLE m_client ADD COLUMN IF NOT EXISTS guardian_address TEXT;
|
||||
|
||||
-- ─── 2. m_client_non_person — New columns ───
|
||||
ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS date_of_registration DATE;
|
||||
ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS enterprise_pan VARCHAR(10);
|
||||
ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS cin_number VARCHAR(21);
|
||||
ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS authorized_signatory VARCHAR(200);
|
||||
ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS additional_info VARCHAR(500);
|
||||
ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS credit_limit DECIMAL(18,2);
|
||||
ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS is_msme TINYINT(1) DEFAULT 0;
|
||||
ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS udyam_number VARCHAR(19);
|
||||
ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS enterprise_name VARCHAR(300);
|
||||
ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS business_sector VARCHAR(50);
|
||||
ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS msme_turnover DECIMAL(18,2);
|
||||
ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS msme_investment DECIMAL(18,2);
|
||||
ALTER TABLE m_client_non_person ADD COLUMN IF NOT EXISTS msme_type VARCHAR(20);
|
||||
|
||||
-- ─── 3. m_client_kyc — New columns ───
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS has_pan TINYINT(1) DEFAULT 1;
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS pan_aadhaar_link_status VARCHAR(20);
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS kyc_document_name VARCHAR(100);
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS kyc_document_number VARCHAR(100);
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS kyc_doc_issue_date DATE;
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS kyc_doc_expiry_date DATE;
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS kyc_expiry_date DATE;
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS aml_check_status VARCHAR(20) DEFAULT 'CLEAR';
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS banned_entity_match VARCHAR(20) DEFAULT 'NO';
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS willful_defaulter VARCHAR(5) DEFAULT 'NO';
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS ecgc_sal_flag VARCHAR(5) DEFAULT 'NO';
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS dormant_flag VARCHAR(20) DEFAULT 'ACTIVE';
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS account_status VARCHAR(20) DEFAULT 'ACTIVE';
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS freeze_reason VARCHAR(50);
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS freeze_authority VARCHAR(50);
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS form60_type VARCHAR(10);
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS form60_date DATE;
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS form15_applicable TINYINT(1) DEFAULT 0;
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS form15_type VARCHAR(5);
|
||||
ALTER TABLE m_client_kyc ADD COLUMN IF NOT EXISTS form15_fy VARCHAR(10);
|
||||
|
||||
-- ─── 4. m_client_entity_kyc — New table ───
|
||||
CREATE TABLE IF NOT EXISTS m_client_entity_kyc (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
client_id BIGINT NOT NULL,
|
||||
constitution_type VARCHAR(50),
|
||||
-- MSME
|
||||
is_msme TINYINT(1) DEFAULT 0,
|
||||
udyam_number VARCHAR(19),
|
||||
enterprise_name VARCHAR(300),
|
||||
business_sector VARCHAR(50),
|
||||
msme_turnover DECIMAL(18,2),
|
||||
msme_investment DECIMAL(18,2),
|
||||
msme_type VARCHAR(20),
|
||||
-- Entity KYC Document
|
||||
entity_kyc_doc_type VARCHAR(100),
|
||||
entity_kyc_doc_number VARCHAR(100),
|
||||
entity_kyc_doc_issue_date DATE,
|
||||
entity_kyc_doc_expiry_date DATE,
|
||||
-- Authorized Signatory
|
||||
signatory_name VARCHAR(200),
|
||||
signatory_designation VARCHAR(100),
|
||||
signatory_pan VARCHAR(10),
|
||||
signatory_aadhaar_masked VARCHAR(12),
|
||||
signatory_kyc_doc_type VARCHAR(100),
|
||||
signatory_kyc_doc_number VARCHAR(100),
|
||||
-- Authorization Document
|
||||
authorization_doc_type VARCHAR(100),
|
||||
authorization_doc_number VARCHAR(100),
|
||||
authorization_date DATE,
|
||||
-- Risk
|
||||
entity_risk_category INT DEFAULT 1,
|
||||
entity_remarks TEXT,
|
||||
-- Audit
|
||||
created_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_modified_date DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (client_id) REFERENCES m_client(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uk_client_entity_kyc (client_id)
|
||||
);
|
||||
|
||||
-- ─── 5. m_client_address_india — New table ───
|
||||
CREATE TABLE IF NOT EXISTS m_client_address_india (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
client_id BIGINT NOT NULL,
|
||||
address_type ENUM('PERMANENT','CURRENT','OFFICE','CORRESPONDENCE') NOT NULL,
|
||||
address_line_1 VARCHAR(500),
|
||||
address_line_2 VARCHAR(500),
|
||||
pin_code VARCHAR(6),
|
||||
locality VARCHAR(200),
|
||||
city VARCHAR(200),
|
||||
district VARCHAR(200),
|
||||
state VARCHAR(200),
|
||||
country VARCHAR(100) DEFAULT 'India',
|
||||
landmark VARCHAR(300),
|
||||
-- Permanent-specific
|
||||
proof_type VARCHAR(100),
|
||||
proof_doc_number VARCHAR(100),
|
||||
-- Current-specific
|
||||
residence_type VARCHAR(50),
|
||||
years_at_address INT,
|
||||
same_as_permanent TINYINT(1) DEFAULT 0,
|
||||
-- Office-specific
|
||||
org_name VARCHAR(300),
|
||||
department VARCHAR(200),
|
||||
office_phone VARCHAR(20),
|
||||
employment_type VARCHAR(100),
|
||||
employee_id VARCHAR(50),
|
||||
years_with_org INT,
|
||||
-- Correspondence-specific
|
||||
comm_mode VARCHAR(50),
|
||||
contact_number VARCHAR(20),
|
||||
-- Audit
|
||||
created_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_modified_date DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (client_id) REFERENCES m_client(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uk_client_address_type (client_id, address_type)
|
||||
);
|
||||
|
||||
-- ─── 6. Constitution Type code values (if not using API-driven options) ───
|
||||
-- Find Gender code and add missing values
|
||||
SET @gender_code_id = (SELECT id FROM m_code WHERE code_name = 'Gender' LIMIT 1);
|
||||
INSERT IGNORE INTO m_code_value (code_id, code_value, order_position, is_active)
|
||||
VALUES
|
||||
(@gender_code_id, 'Transgender', 3, 1),
|
||||
(@gender_code_id, 'Other', 4, 1);
|
||||
|
||||
-- ─── 7. Indexes for performance ───
|
||||
CREATE INDEX IF NOT EXISTS idx_client_entity_kyc_client ON m_client_entity_kyc(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_client_address_india_client ON m_client_address_india(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_client_address_india_type ON m_client_address_india(client_id, address_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_client_kyc_expiry ON m_client_kyc(kyc_expiry_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_client_kyc_aml ON m_client_kyc(aml_check_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_client_minor ON m_client(is_minor);
|
||||
@@ -0,0 +1,430 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "CBS - Client Creation API",
|
||||
"description": "Complete client creation flow — General, KYC, Address, Family Members",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"auth": {
|
||||
"type": "basic",
|
||||
"basic": [
|
||||
{
|
||||
"key": "username",
|
||||
"value": "{{username}}",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"key": "password",
|
||||
"value": "{{password}}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"variable": [
|
||||
{
|
||||
"key": "baseUrl",
|
||||
"value": "https://localhost:8443/fineract-provider/api/v1"
|
||||
},
|
||||
{
|
||||
"key": "tenantId",
|
||||
"value": "default"
|
||||
},
|
||||
{
|
||||
"key": "username",
|
||||
"value": "mifos"
|
||||
},
|
||||
{
|
||||
"key": "password",
|
||||
"value": "password"
|
||||
},
|
||||
{
|
||||
"key": "clientId",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"item": [
|
||||
{
|
||||
"name": "1. General — Create Client",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"var jsonData = pm.response.json();",
|
||||
"if (jsonData.resourceId) {",
|
||||
" pm.collectionVariables.set('clientId', jsonData.resourceId);",
|
||||
" console.log('Client ID set to: ' + jsonData.resourceId);",
|
||||
"}"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Fineract-Platform-TenantId",
|
||||
"value": "{{tenantId}}"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"officeId\": 1,\n \"legalFormId\": 1,\n \"nationalityId\": \"INDIAN\",\n \"isStaff\": false,\n \"active\": false,\n \"salutationId\": 2,\n \"firstname\": \"Aditya\",\n \"middlename\": \"\",\n \"lastname\": \"Vyas\",\n \"genderId\": 22,\n \"dateOfBirth\": \"15 January 1995\",\n \"mobileNo\": \"9876543210\",\n \"alternateMobileNo\": \"9123456780\",\n \"emailAddress\": \"aditya@example.com\",\n \"maritalStatusId\": 101,\n \"occupationCategoryId\": 50,\n \"occupationId\": 60,\n \"monthlyIncome\": 50000,\n \"submittedOnDate\": \"01 April 2026\",\n \"dipVerified\": true,\n \"alternateDipVerified\": false,\n \"dateFormat\": \"dd MMMM yyyy\",\n \"locale\": \"en\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["clients"]
|
||||
},
|
||||
"description": "Creates the client record. Response returns `resourceId` which is auto-set as `clientId` for subsequent requests."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "1b. General — Create Non-Individual Client",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"var jsonData = pm.response.json();",
|
||||
"if (jsonData.resourceId) {",
|
||||
" pm.collectionVariables.set('clientId', jsonData.resourceId);",
|
||||
" console.log('Client ID set to: ' + jsonData.resourceId);",
|
||||
"}"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Fineract-Platform-TenantId",
|
||||
"value": "{{tenantId}}"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"officeId\": 1,\n \"legalFormId\": 2,\n \"nationalityId\": \"INDIAN\",\n \"isStaff\": false,\n \"active\": false,\n \"fullname\": \"ABC Enterprises Pvt Ltd\",\n \"mobileNo\": \"9876543210\",\n \"alternateMobileNo\": \"\",\n \"emailAddress\": \"info@abc.com\",\n \"monthlyIncome\": 200000,\n \"submittedOnDate\": \"01 April 2026\",\n \"dipVerified\": true,\n \"alternateDipVerified\": false,\n \"clientNonPersonDetails\": {\n \"constitutionId\": 10,\n \"mainBusinessLineId\": 5,\n \"incorpNumber\": \"U12345MH2020PTC123456\",\n \"remarks\": \"\",\n \"authorizedSignatory\": \"Mr. Vyas\",\n \"additionalInfo\": \"\",\n \"monthlyIncome\": 200000,\n \"dateFormat\": \"dd MMMM yyyy\",\n \"locale\": \"en\"\n },\n \"dateFormat\": \"dd MMMM yyyy\",\n \"locale\": \"en\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["clients"]
|
||||
},
|
||||
"description": "Creates a Non-Individual (Entity) client. Response returns `resourceId` which is auto-set as `clientId`."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "2. KYC — Save Client KYC",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Fineract-Platform-TenantId",
|
||||
"value": "{{tenantId}}"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"locale\": \"en\",\n \"dateFormat\": \"dd MMMM yyyy\",\n \"kycType\": 1,\n \"riskCategory\": 1,\n \"panNumber\": \"ABCDE1234F\",\n \"aadhaarNumberMasked\": \"XXXXXXXX1234\",\n \"panAadhaarSeedingStatus\": \"LINKED\",\n \"gstinNumber\": null,\n \"enterprisePan\": null,\n \"cinNumber\": null,\n \"incorporationDate\": null,\n \"kycDocumentName\": \"Aadhaar Card\",\n \"kycDocumentNumber\": \"1234-5678-9012\",\n \"kycDocIssueDate\": \"15 January 2020\",\n \"kycDocExpiryDate\": null,\n \"amlCheckStatus\": \"CLEAR\",\n \"bannedEntityMatch\": \"NO\",\n \"willfulDefaulter\": \"NO\",\n \"ecgcSalFlag\": \"NO\",\n \"dormantFlag\": \"ACTIVE\",\n \"accountStatus\": \"ACTIVE\",\n \"freezeReason\": null,\n \"freezeAuthority\": null,\n \"form60Type\": \"FORM_60\",\n \"form60Date\": null,\n \"form15Applicable\": false,\n \"form15Type\": \"15G\",\n \"form15FY\": null,\n \"remarks\": null\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients/{{clientId}}/kyc",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["clients", "{{clientId}}", "kyc"]
|
||||
},
|
||||
"description": "Saves KYC details including PAN, Aadhaar, AML compliance, document info, risk category, and Form 60/15 data."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "2b. KYC — Get Client KYC",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Fineract-Platform-TenantId",
|
||||
"value": "{{tenantId}}"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients/{{clientId}}/kyc",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["clients", "{{clientId}}", "kyc"]
|
||||
},
|
||||
"description": "Fetches existing KYC data for a client."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "2c. KYC — Upload Profile Photo",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Fineract-Platform-TenantId",
|
||||
"value": "{{tenantId}}"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "file",
|
||||
"type": "file",
|
||||
"src": "",
|
||||
"description": "Select client photo (JPG/PNG)"
|
||||
},
|
||||
{
|
||||
"key": "filename",
|
||||
"value": "file",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients/{{clientId}}/images",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["clients", "{{clientId}}", "images"]
|
||||
},
|
||||
"description": "Uploads client profile photograph."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "2d. KYC — Upload Signature",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Fineract-Platform-TenantId",
|
||||
"value": "{{tenantId}}"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "file",
|
||||
"type": "file",
|
||||
"src": "",
|
||||
"description": "Select signature image (JPG/PNG)"
|
||||
},
|
||||
{
|
||||
"key": "name",
|
||||
"value": "clientSignature",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "description",
|
||||
"value": "Client signature",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients/{{clientId}}/documents",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["clients", "{{clientId}}", "documents"]
|
||||
},
|
||||
"description": "Uploads client signature as a document."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "3. Address — Save Permanent Address",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Fineract-Platform-TenantId",
|
||||
"value": "{{tenantId}}"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"addressLine1\": \"123, MG Road\",\n \"addressLine2\": \"Near City Mall\",\n \"addressLine3\": \"\",\n \"townVillage\": \"Vastrapur\",\n \"postalCode\": \"380015\",\n \"city\": \"Ahmedabad\",\n \"countyDistrict\": \"Ahmedabad\",\n \"street\": \"Opp. ISRO\",\n \"addressType\": 101,\n \"isActive\": true,\n \"stateProvinceId\": 12\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/client/{{clientId}}/addresses?type=101",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["client", "{{clientId}}", "addresses"],
|
||||
"query": [
|
||||
{
|
||||
"key": "type",
|
||||
"value": "101",
|
||||
"description": "Address type ID (get from address template)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Creates a permanent address. Change `type` query param and `addressType` body field for other types (current, office, correspondence)."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "3b. Address — Save Current Address",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Fineract-Platform-TenantId",
|
||||
"value": "{{tenantId}}"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"addressLine1\": \"456, SG Highway\",\n \"addressLine2\": \"Flat 302, Tower B\",\n \"addressLine3\": \"\",\n \"townVillage\": \"Bodakdev\",\n \"postalCode\": \"380054\",\n \"city\": \"Ahmedabad\",\n \"countyDistrict\": \"Ahmedabad\",\n \"street\": \"Near Honest Restaurant\",\n \"addressType\": 102,\n \"isActive\": false,\n \"stateProvinceId\": 12\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/client/{{clientId}}/addresses?type=102",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["client", "{{clientId}}", "addresses"],
|
||||
"query": [
|
||||
{
|
||||
"key": "type",
|
||||
"value": "102",
|
||||
"description": "Current/Residential address type ID"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Creates a current/residential address."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "3c. Address — Get Address Template",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Fineract-Platform-TenantId",
|
||||
"value": "{{tenantId}}"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/client/addresses/template",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["client", "addresses", "template"]
|
||||
},
|
||||
"description": "Fetches address template with `addressTypeIdOptions` and `stateProvinceIdOptions`. Use these IDs in address save requests."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "4. Family — Add Family Member",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Fineract-Platform-TenantId",
|
||||
"value": "{{tenantId}}"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"firstName\": \"Sunita\",\n \"middleName\": \"\",\n \"lastName\": \"Vyas\",\n \"relationshipId\": 30,\n \"genderId\": 23,\n \"dateOfBirth\": \"10 March 1998\",\n \"isAlive\": true,\n \"panNumber\": \"XYZAB1234C\",\n \"aadhaarNumber\": \"987654321012\",\n \"dateFormat\": \"dd MMMM yyyy\",\n \"locale\": \"en\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients/{{clientId}}/familymembers",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["clients", "{{clientId}}", "familymembers"]
|
||||
},
|
||||
"description": "Adds a family member to the client. Call once per member."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "4b. Family — Get Family Members",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Fineract-Platform-TenantId",
|
||||
"value": "{{tenantId}}"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients/{{clientId}}/familymembers",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["clients", "{{clientId}}", "familymembers"]
|
||||
},
|
||||
"description": "Fetches all family members for a client."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "4c. Family — Upload Marriage Certificate",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Fineract-Platform-TenantId",
|
||||
"value": "{{tenantId}}"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "file",
|
||||
"type": "file",
|
||||
"src": "",
|
||||
"description": "Select marriage certificate (PDF/JPG/PNG)"
|
||||
},
|
||||
{
|
||||
"key": "name",
|
||||
"value": "Marriage Certificate",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "description",
|
||||
"value": "Marriage Certificate",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients/{{clientId}}/familymembers/{{familyMemberId}}/documents",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["clients", "{{clientId}}", "familymembers", "{{familyMemberId}}", "documents"]
|
||||
},
|
||||
"description": "Uploads a marriage certificate document for a family member. Set `familyMemberId` from the Add Family Member response."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5. Get Client Details",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Fineract-Platform-TenantId",
|
||||
"value": "{{tenantId}}"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients/{{clientId}}",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["clients", "{{clientId}}"]
|
||||
},
|
||||
"description": "Fetches full client details to verify saved data."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,8 +15,9 @@
|
||||
|
||||
<div class="container">
|
||||
<div class="layout-row">
|
||||
<mat-form-field class="flex-fill">
|
||||
<mat-form-field appearance="outline" class="flex-fill">
|
||||
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
|
||||
<i matPrefix class="fas fa-search"></i>
|
||||
<input matInput (keyup)="applyFilter($event.target.value)" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@@ -6,303 +6,117 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<mat-card>
|
||||
<div class="layout-row responsive-column gap-20px">
|
||||
<div class="flex-50">
|
||||
<mat-nav-list>
|
||||
<mat-list-item *mifosxHasPermission="'CREATE_JOURNALENTRY'">
|
||||
<div class="menu-list-item-content">
|
||||
<div class="menu-left-section" [routerLink]="['journal-entries/frequent-postings']">
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="sync" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<div matLine>
|
||||
{{ 'labels.heading.Frequent Postings' | translate }}
|
||||
@if (arrowBooleans[0]) {
|
||||
<p matLine [routerLink]="['journal-entries/frequent-postings']" class="menu-explanation">
|
||||
{{ 'labels.text.These are predefined postings' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-right-section">
|
||||
@if (!arrowBooleans[0]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(0)" icon="arrow-down" size="md"></fa-icon>
|
||||
}
|
||||
@if (arrowBooleans[0]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(0)" icon="arrow-up" size="md"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
|
||||
<div #createJournalEntries>
|
||||
<mat-list-item *mifosxHasPermission="'CREATE_JOURNALENTRY'">
|
||||
<div class="menu-list-item-content">
|
||||
<div class="menu-left-section" [routerLink]="['journal-entries/create']">
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="plus" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<div matLine>
|
||||
{{ 'labels.heading.Create Journal Entries' | translate }}
|
||||
@if (arrowBooleans[1]) {
|
||||
<p matLine [routerLink]="['journal-entries/create']" class="menu-explanation">
|
||||
{{ 'labels.text.Manual journal entry transactions recorded in a journal' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-right-section">
|
||||
@if (!arrowBooleans[1]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(1)" icon="arrow-down" size="md"></fa-icon>
|
||||
}
|
||||
@if (arrowBooleans[1]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(1)" icon="arrow-up" size="md"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
</div>
|
||||
|
||||
<mat-list-item *mifosxHasPermission="'READ_JOURNALENTRY'">
|
||||
<div class="menu-list-item-content">
|
||||
<div class="menu-left-section" [routerLink]="['journal-entries']">
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="search" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<div matLine>
|
||||
{{ 'labels.heading.Search Journal Entries' | translate }}
|
||||
@if (arrowBooleans[2]) {
|
||||
<p matLine [routerLink]="['journal-entries']" class="menu-explanation">
|
||||
{{ 'labels.text.Advanced search option for journal entries' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-right-section">
|
||||
@if (!arrowBooleans[2]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(2)" icon="arrow-down" size="md"></fa-icon>
|
||||
}
|
||||
@if (arrowBooleans[2]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(2)" icon="arrow-up" size="md"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
|
||||
<div #accountsLinked>
|
||||
<mat-list-item *mifosxHasPermission="'READ_FINANCIALACTIVITYACCOUNT'">
|
||||
<div class="menu-list-item-content">
|
||||
<div class="menu-left-section" [routerLink]="['financial-activity-mappings']">
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="link" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<div matLine>
|
||||
{{ 'labels.heading.Accounts Linked to Financial Activities' | translate }}
|
||||
@if (arrowBooleans[3]) {
|
||||
<p matLine [routerLink]="['financial-activity-mappings']" class="menu-explanation">
|
||||
{{ 'labels.text.List of Financial Activities and GL Account Mappings' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-right-section">
|
||||
@if (!arrowBooleans[3]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(3)" icon="arrow-down" size="md"></fa-icon>
|
||||
}
|
||||
@if (arrowBooleans[3]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(3)" icon="arrow-up" size="md"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
</div>
|
||||
|
||||
<div #migrateOpeningBalances>
|
||||
<mat-list-item *mifosxHasPermission="'READ_JOURNALENTRY'">
|
||||
<div class="menu-list-item-content">
|
||||
<div class="menu-left-section" [routerLink]="['migrate-opening-balances']">
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="hand-holding-usd" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<div matLine>
|
||||
{{ 'labels.heading.Migrate Opening Balances (Office-wise)' | translate }}
|
||||
@if (arrowBooleans[4]) {
|
||||
<p matLine [routerLink]="['migrate-opening-balances']" class="menu-explanation">
|
||||
{{ 'labels.text.Set or update office-level opening balances for GL accounts' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-right-section">
|
||||
@if (!arrowBooleans[4]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(4)" icon="arrow-down" size="md"></fa-icon>
|
||||
}
|
||||
@if (arrowBooleans[4]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(4)" icon="arrow-up" size="md"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
</div>
|
||||
</mat-nav-list>
|
||||
</div>
|
||||
|
||||
<div class="flex-50">
|
||||
<mat-nav-list>
|
||||
<div #chartofAccounts>
|
||||
<mat-list-item *mifosxHasPermission="'READ_GLACCOUNT'">
|
||||
<div class="menu-list-item-content">
|
||||
<div class="menu-left-section" [routerLink]="['chart-of-accounts']">
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="sitemap" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<div matLine>
|
||||
{{ 'labels.heading.Chart of Accounts' | translate }}
|
||||
@if (arrowBooleans[5]) {
|
||||
<p matLine [routerLink]="['chart-of-accounts']" class="menu-explanation">
|
||||
{{ 'labels.text.List of accounts used by the organization' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-right-section">
|
||||
@if (!arrowBooleans[5]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(5)" icon="arrow-down" size="md"></fa-icon>
|
||||
}
|
||||
@if (arrowBooleans[5]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(5)" icon="arrow-up" size="md"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
</div>
|
||||
|
||||
<div #closingEntries>
|
||||
<mat-list-item *mifosxHasPermission="'READ_GLCLOSURE'">
|
||||
<div class="menu-list-item-content">
|
||||
<div class="menu-left-section" [routerLink]="['closing-entries']">
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="archive" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<div matLine>
|
||||
{{ 'labels.heading.Closing Entries' | translate }}
|
||||
@if (arrowBooleans[6]) {
|
||||
<p matLine [routerLink]="['closing-entries']" class="menu-explanation">
|
||||
{{ 'labels.text.Journal entries made at the end of accounting period' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-right-section">
|
||||
@if (!arrowBooleans[6]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(6)" icon="arrow-down" size="md"></fa-icon>
|
||||
}
|
||||
@if (arrowBooleans[6]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(6)" icon="arrow-up" size="md"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
</div>
|
||||
|
||||
<mat-list-item *mifosxHasPermission="'READ_ACCOUNTINGRULE'">
|
||||
<div class="menu-list-item-content">
|
||||
<div class="menu-left-section" [routerLink]="['accounting-rules']">
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="list" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<div matLine>
|
||||
{{ 'labels.heading.Accounting Rules' | translate }}
|
||||
@if (arrowBooleans[7]) {
|
||||
<p matLine [routerLink]="['accounting-rules']" class="menu-explanation">
|
||||
{{ 'labels.text.List of all accounting rules' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-right-section">
|
||||
@if (!arrowBooleans[7]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(7)" icon="arrow-down" size="md"></fa-icon>
|
||||
}
|
||||
@if (arrowBooleans[7]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(7)" icon="arrow-up" size="md"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
|
||||
<mat-list-item *mifosxHasPermission="'EXECUTE_PERIODICACCRUALACCOUNTING'">
|
||||
<div class="menu-list-item-content">
|
||||
<div class="menu-left-section" [routerLink]="['periodic-accruals']">
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="calendar" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<div matLine>
|
||||
{{ 'labels.heading.Accruals' | translate }}
|
||||
@if (arrowBooleans[8]) {
|
||||
<p matLine [routerLink]="['periodic-accruals']" class="menu-explanation">
|
||||
{{ 'labels.text.Accrues income, expenses and liabilities' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-right-section">
|
||||
@if (!arrowBooleans[8]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(8)" icon="arrow-down" size="md"></fa-icon>
|
||||
}
|
||||
@if (arrowBooleans[8]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(8)" icon="arrow-up" size="md"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
|
||||
<mat-list-item *mifosxHasPermission="'VIEW_PROVISIONING_ENTRIES'">
|
||||
<div class="menu-list-item-content">
|
||||
<div class="menu-left-section" [routerLink]="['provisioning-entries']">
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="cogs" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<div matLine>
|
||||
{{ 'labels.heading.Provisioning Entries' | translate }}
|
||||
@if (arrowBooleans[9]) {
|
||||
<p matLine [routerLink]="['provisioning-entries']" class="menu-explanation">
|
||||
{{ 'labels.text.Create provisioning entries' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-right-section">
|
||||
@if (!arrowBooleans[9]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(9)" icon="arrow-down" size="md"></fa-icon>
|
||||
}
|
||||
@if (arrowBooleans[9]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(9)" icon="arrow-up" size="md"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
</mat-nav-list>
|
||||
</div>
|
||||
<div class="acc-container">
|
||||
<!-- Header -->
|
||||
<div class="acc-header">
|
||||
<div class="acc-header__left">
|
||||
<div class="acc-logomark"><span></span><span></span><span></span><span></span></div>
|
||||
<h2 class="acc-header__title">{{ 'labels.menus.Accounting' | translate }}</h2>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<!-- Cards Grid -->
|
||||
<div class="acc-grid">
|
||||
<!-- Frequent Postings -->
|
||||
<a class="acc-card" [routerLink]="['journal-entries/frequent-postings']" *mifosxHasPermission="'CREATE_JOURNALENTRY'" #frequentPostings>
|
||||
<div class="acc-card__icon blue"><i class="fas fa-sync-alt"></i></div>
|
||||
<div class="acc-card__info">
|
||||
<span class="acc-card__title">{{ 'labels.heading.Frequent Postings' | translate }}</span>
|
||||
<span class="acc-card__desc">{{ 'labels.text.These are predefined postings' | translate }}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Create Journal Entries -->
|
||||
<a class="acc-card" [routerLink]="['journal-entries/create']" *mifosxHasPermission="'CREATE_JOURNALENTRY'" #createJournalEntries>
|
||||
<div class="acc-card__icon green"><i class="fas fa-plus-circle"></i></div>
|
||||
<div class="acc-card__info">
|
||||
<span class="acc-card__title">{{ 'labels.heading.Create Journal Entries' | translate }}</span>
|
||||
<span class="acc-card__desc">{{ 'labels.text.Manual journal entry transactions recorded in a journal' | translate }}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Search Journal Entries -->
|
||||
<a class="acc-card" [routerLink]="['journal-entries']" *mifosxHasPermission="'READ_JOURNALENTRY'">
|
||||
<div class="acc-card__icon purple"><i class="fas fa-search"></i></div>
|
||||
<div class="acc-card__info">
|
||||
<span class="acc-card__title">{{ 'labels.heading.Search Journal Entries' | translate }}</span>
|
||||
<span class="acc-card__desc">{{ 'labels.text.Advanced search option for journal entries' | translate }}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Accounts Linked to Financial Activities -->
|
||||
<a class="acc-card" [routerLink]="['financial-activity-mappings']" *mifosxHasPermission="'READ_FINANCIALACTIVITYACCOUNT'" #accountsLinked>
|
||||
<div class="acc-card__icon teal"><i class="fas fa-link"></i></div>
|
||||
<div class="acc-card__info">
|
||||
<span class="acc-card__title">{{ 'labels.heading.Accounts Linked to Financial Activities' | translate }}</span>
|
||||
<span class="acc-card__desc">{{ 'labels.text.List of Financial Activities and GL Account Mappings' | translate }}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Migrate Opening Balances -->
|
||||
<a class="acc-card" [routerLink]="['migrate-opening-balances']" *mifosxHasPermission="'READ_JOURNALENTRY'" #migrateOpeningBalances>
|
||||
<div class="acc-card__icon amber"><i class="fas fa-hand-holding-usd"></i></div>
|
||||
<div class="acc-card__info">
|
||||
<span class="acc-card__title">{{ 'labels.heading.Migrate Opening Balances (Office-wise)' | translate }}</span>
|
||||
<span class="acc-card__desc">{{ 'labels.text.Set or update office-level opening balances for GL accounts' | translate }}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Chart of Accounts -->
|
||||
<a class="acc-card" [routerLink]="['chart-of-accounts']" *mifosxHasPermission="'READ_GLACCOUNT'" #chartofAccounts>
|
||||
<div class="acc-card__icon blue"><i class="fas fa-sitemap"></i></div>
|
||||
<div class="acc-card__info">
|
||||
<span class="acc-card__title">{{ 'labels.heading.Chart of Accounts' | translate }}</span>
|
||||
<span class="acc-card__desc">{{ 'labels.text.List of accounts used by the organization' | translate }}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Closing Entries -->
|
||||
<a class="acc-card" [routerLink]="['closing-entries']" *mifosxHasPermission="'READ_GLCLOSURE'" #closingEntries>
|
||||
<div class="acc-card__icon red"><i class="fas fa-archive"></i></div>
|
||||
<div class="acc-card__info">
|
||||
<span class="acc-card__title">{{ 'labels.heading.Closing Entries' | translate }}</span>
|
||||
<span class="acc-card__desc">{{ 'labels.text.Journal entries made at the end of accounting period' | translate }}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Accounting Rules -->
|
||||
<a class="acc-card" [routerLink]="['accounting-rules']" *mifosxHasPermission="'READ_ACCOUNTINGRULE'">
|
||||
<div class="acc-card__icon purple"><i class="fas fa-list-ul"></i></div>
|
||||
<div class="acc-card__info">
|
||||
<span class="acc-card__title">{{ 'labels.heading.Accounting Rules' | translate }}</span>
|
||||
<span class="acc-card__desc">{{ 'labels.text.List of all accounting rules' | translate }}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Accruals -->
|
||||
<a class="acc-card" [routerLink]="['periodic-accruals']" *mifosxHasPermission="'EXECUTE_PERIODICACCRUALACCOUNTING'">
|
||||
<div class="acc-card__icon green"><i class="fas fa-calendar-check"></i></div>
|
||||
<div class="acc-card__info">
|
||||
<span class="acc-card__title">{{ 'labels.heading.Accruals' | translate }}</span>
|
||||
<span class="acc-card__desc">{{ 'labels.text.Accrues income, expenses and liabilities' | translate }}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Provisioning Entries -->
|
||||
<a class="acc-card" [routerLink]="['provisioning-entries']" *mifosxHasPermission="'VIEW_PROVISIONING_ENTRIES'">
|
||||
<div class="acc-card__icon amber"><i class="fas fa-cogs"></i></div>
|
||||
<div class="acc-card__info">
|
||||
<span class="acc-card__title">{{ 'labels.heading.Provisioning Entries' | translate }}</span>
|
||||
<span class="acc-card__desc">{{ 'labels.text.Create provisioning entries' | translate }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config Wizard Templates (keep as-is) -->
|
||||
<ng-template #templateChartofAccounts let-popover="popover">
|
||||
<h2>{{ 'labels.heading.Chart of Accounts' | translate }}</h2>
|
||||
<p class="mw300">{{ 'labels.text.The Chart of Accounts - General Ledger Setup' | translate }}</p>
|
||||
<div class="layout-row align-end gap-2px responsive-column">
|
||||
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
|
||||
{{ 'labels.buttons.Close' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); previousStepChartofAccounts()">
|
||||
{{ 'labels.buttons.Back' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); nextStepChartofAccounts()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">{{ 'labels.buttons.Close' | translate }}</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); previousStepChartofAccounts()">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); nextStepChartofAccounts()">{{ 'labels.buttons.Next' | translate }}</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -310,15 +124,9 @@
|
||||
<h2>{{ 'labels.heading.Accounts Linked to Financial Activities' | translate }}</h2>
|
||||
<p class="mw400">{{ 'labels.text.Accounts linked to Financial Activities' | translate }}</p>
|
||||
<div class="layout-row align-end gap-2px responsive-column">
|
||||
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
|
||||
{{ 'labels.buttons.Close' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); previousStepAccountsLinked()">
|
||||
{{ 'labels.buttons.Back' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); nextStepAccountsLinked()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">{{ 'labels.buttons.Close' | translate }}</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); previousStepAccountsLinked()">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); nextStepAccountsLinked()">{{ 'labels.buttons.Next' | translate }}</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -326,15 +134,9 @@
|
||||
<h2>{{ 'labels.heading.Migrate Opening Balances (Office-wise)' | translate }}</h2>
|
||||
<p class="mw400">{{ 'labels.text.GLAccount Balances Application' | translate }}</p>
|
||||
<div class="layout-row align-end gap-2px responsive-column">
|
||||
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
|
||||
{{ 'labels.buttons.Close' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); previousStepMigrateOpeningBalances()">
|
||||
{{ 'labels.buttons.Back' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); nextStepMigrateOpeningBalances()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">{{ 'labels.buttons.Close' | translate }}</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); previousStepMigrateOpeningBalances()">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); nextStepMigrateOpeningBalances()">{{ 'labels.buttons.Next' | translate }}</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -342,15 +144,9 @@
|
||||
<h2>{{ 'labels.heading.Closing Entries' | translate }}</h2>
|
||||
<p class="mw300">{{ 'labels.text.Accounting to journal entries' | translate }}</p>
|
||||
<div class="layout-row align-end gap-2px responsive-column">
|
||||
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
|
||||
{{ 'labels.buttons.Close' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); previousStepClosingEntries()">
|
||||
{{ 'labels.buttons.Back' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); nextStepClosingEntries()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">{{ 'labels.buttons.Close' | translate }}</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); previousStepClosingEntries()">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); nextStepClosingEntries()">{{ 'labels.buttons.Next' | translate }}</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -358,14 +154,8 @@
|
||||
<h2>{{ 'labels.heading.Create Journal Entries' | translate }}</h2>
|
||||
<p class="mw300">{{ 'labels.text.Journal Entries Transactions' | translate }}</p>
|
||||
<div class="layout-row align-end gap-2px responsive-column">
|
||||
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
|
||||
{{ 'labels.buttons.Close' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); previousStepCreateJournalEntries()">
|
||||
{{ 'labels.buttons.Back' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); nextStepCreateJournalEntries()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">{{ 'labels.buttons.Close' | translate }}</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); previousStepCreateJournalEntries()">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
<button mat-raised-button color="primary" (click)="popover.close(); nextStepCreateJournalEntries()">{{ 'labels.buttons.Next' | translate }}</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -5,3 +5,130 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$brand-dark: #0d3d78;
|
||||
$accent: #0ea5e9;
|
||||
|
||||
.acc-container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
// Header
|
||||
.acc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.acc-logomark {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
|
||||
span {
|
||||
border-radius: 2px;
|
||||
|
||||
&:nth-child(1) { background: $brand-dark; }
|
||||
&:nth-child(2) { background: $accent; }
|
||||
&:nth-child(3) { background: #10b981; }
|
||||
&:nth-child(4) { background: #cbd5e1; }
|
||||
}
|
||||
}
|
||||
|
||||
// Cards Grid
|
||||
.acc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: 768px) { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.acc-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s, transform 0.15s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.blue { background: #e0f2fe; color: #0284c7; }
|
||||
&.green { background: #d1fae5; color: #059669; }
|
||||
&.purple { background: #ede9fe; color: #7c3aed; }
|
||||
&.amber { background: #fef3c7; color: #d97706; }
|
||||
&.teal { background: #ccfbf1; color: #0d9488; }
|
||||
&.red { background: #fee2e2; color: #dc2626; }
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark theme
|
||||
:host-context(.dark-theme) {
|
||||
.acc-card {
|
||||
background: #1e1e2e;
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.acc-card__title { color: #e0e0e0; }
|
||||
.acc-card__desc { color: #666; }
|
||||
.acc-header__title { color: #e0e0e0; }
|
||||
}
|
||||
|
||||
@@ -34,8 +34,9 @@
|
||||
|
||||
<div class="container" [hidden]="viewGroup.value !== 'listView'">
|
||||
<div #filter class="layout-row gap-20px">
|
||||
<mat-form-field class="flex-fill">
|
||||
<mat-form-field appearance="outline" class="flex-fill">
|
||||
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
|
||||
<i matPrefix class="fas fa-search"></i>
|
||||
<input matInput (keyup)="applyFilter($event.target.value)" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
|
||||
<div class="container">
|
||||
<div class="layout-row">
|
||||
<mat-form-field class="flex-fill">
|
||||
<mat-form-field appearance="outline" class="flex-fill">
|
||||
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
|
||||
<i matPrefix class="fas fa-search"></i>
|
||||
<input matInput (keyup)="applyFilter($event.target.value)" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -8,8 +8,9 @@
|
||||
|
||||
<div class="container">
|
||||
<div class="layout-row gap-20px">
|
||||
<mat-form-field class="flex-fill">
|
||||
<mat-form-field appearance="outline" class="flex-fill">
|
||||
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
|
||||
<i matPrefix class="fas fa-search"></i>
|
||||
<input matInput (keyup)="applyFilter($event.target.value)" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@@ -53,6 +53,7 @@ import { SupersetAnalyticsModule } from './superset-analytics/superset-analytics
|
||||
import { CkycModule } from './ckyc/ckyc.module';
|
||||
import { RbiCircularsModule } from './rbi-circulars/rbi-circulars.module';
|
||||
import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
|
||||
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
|
||||
import { PortalModule } from '@angular/cdk/portal';
|
||||
|
||||
/** Main Routing Module */
|
||||
@@ -75,8 +76,13 @@ import { OAuthModule } from 'angular-oauth2-oidc';
|
||||
|
||||
export class CustomMissingTranslationHandler implements MissingTranslationHandler {
|
||||
handle(params: MissingTranslationHandlerParams): string {
|
||||
// Remove the 'labels.catalogs.' prefix and return the fallback value
|
||||
return params.key.replace('labels.catalogs.', '');
|
||||
// For missing keys, extract the last segment as a readable fallback
|
||||
const key = params.key;
|
||||
const lastDot = key.lastIndexOf('.');
|
||||
if (lastDot !== -1) {
|
||||
return key.substring(lastDot + 1);
|
||||
}
|
||||
return key.replace('labels.catalogs.', '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +143,7 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
CkycModule,
|
||||
RbiCircularsModule,
|
||||
CfcfrmsModule,
|
||||
BankCalendarModule,
|
||||
AppRoutingModule,
|
||||
NotFoundComponent,
|
||||
CallbackComponent,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="hol-dialog">
|
||||
<!-- Header -->
|
||||
<div class="hol-header">
|
||||
<div class="hol-header__left">
|
||||
<div class="hol-header__icon"><fa-icon icon="calendar-alt"></fa-icon></div>
|
||||
<span class="hol-header__title">Add Holiday</span>
|
||||
</div>
|
||||
<button class="hol-header__close" (click)="dialogRef.close()">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<mat-dialog-content class="hol-body">
|
||||
<form [formGroup]="holidayForm">
|
||||
<div class="hol-row">
|
||||
<mat-form-field appearance="outline" class="hol-field">
|
||||
<mat-label>Holiday Name</mat-label>
|
||||
<input matInput required formControlName="name" placeholder="e.g. Republic Day" />
|
||||
@if (holidayForm.controls.name.hasError('required')) {
|
||||
<mat-error>Name is <strong>required</strong></mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="hol-row">
|
||||
<mat-form-field appearance="outline" class="hol-field">
|
||||
<mat-label>Description</mat-label>
|
||||
<input matInput formControlName="description" placeholder="Optional description" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="hol-row hol-row--two">
|
||||
<mat-form-field appearance="outline" class="hol-field" (click)="fromPicker.open()">
|
||||
<mat-label>From Date</mat-label>
|
||||
<input matInput [min]="minDate" [max]="maxDate" [matDatepicker]="fromPicker" required formControlName="fromDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="fromPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #fromPicker></mat-datepicker>
|
||||
@if (holidayForm.controls.fromDate.hasError('required')) {
|
||||
<mat-error>From Date is <strong>required</strong></mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="hol-field" (click)="toPicker.open()">
|
||||
<mat-label>To Date</mat-label>
|
||||
<input matInput [min]="minDate" [max]="maxDate" [matDatepicker]="toPicker" required formControlName="toDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="toPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #toPicker></mat-datepicker>
|
||||
@if (holidayForm.controls.toDate.hasError('required')) {
|
||||
<mat-error>To Date is <strong>required</strong></mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="hol-row hol-row--two">
|
||||
<mat-form-field appearance="outline" class="hol-field">
|
||||
<mat-label>Repayment Scheduling</mat-label>
|
||||
<mat-select required formControlName="reschedulingType">
|
||||
@for (type of repaymentSchedulingTypes; track type) {
|
||||
<mat-option [value]="type.id">{{ type.value }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (holidayForm.controls.reschedulingType.hasError('required')) {
|
||||
<mat-error>Required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="hol-field">
|
||||
<mat-label>Office</mat-label>
|
||||
<mat-select required formControlName="officeId">
|
||||
@for (office of offices; track office) {
|
||||
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (holidayForm.controls.officeId.hasError('required')) {
|
||||
<mat-error>Required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@if (holidayForm.contains('repaymentsRescheduledTo')) {
|
||||
<div class="hol-row">
|
||||
<mat-form-field appearance="outline" class="hol-field" (click)="reschPicker.open()">
|
||||
<mat-label>Repayments Rescheduled To</mat-label>
|
||||
<input matInput [min]="minDate" [max]="maxDate" [matDatepicker]="reschPicker" required formControlName="repaymentsRescheduledTo" />
|
||||
<mat-datepicker-toggle matSuffix [for]="reschPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #reschPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<!-- Actions -->
|
||||
<mat-dialog-actions class="hol-actions">
|
||||
<button mat-stroked-button class="hol-actions__cancel" (click)="dialogRef.close()">Cancel</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
class="hol-actions__submit"
|
||||
[disabled]="holidayForm.invalid || submitting"
|
||||
(click)="submit()"
|
||||
>
|
||||
<fa-icon icon="check" class="hol-actions__icon"></fa-icon>
|
||||
{{ submitting ? 'Saving...' : 'Add Holiday' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$blue: #1e88e5;
|
||||
$navy: #0d1b2a;
|
||||
|
||||
.hol-dialog {
|
||||
max-width: 560px;
|
||||
width: 560px;
|
||||
}
|
||||
|
||||
// Header
|
||||
.hol-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 24px 14px;
|
||||
border-bottom: 1px solid #e8eaed;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
background: #eff6ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $blue;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: $navy;
|
||||
}
|
||||
|
||||
&__close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: #f1f3f4;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #5f6368;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #e8eaed;
|
||||
color: $navy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Body
|
||||
.hol-body {
|
||||
padding: 18px 24px !important;
|
||||
overflow: visible !important;
|
||||
max-height: 65vh !important;
|
||||
}
|
||||
|
||||
.hol-row {
|
||||
margin-bottom: 4px;
|
||||
|
||||
&--two {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.hol-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Actions
|
||||
.hol-actions {
|
||||
padding: 12px 24px 18px !important;
|
||||
border-top: 1px solid #e8eaed;
|
||||
margin: 0 !important;
|
||||
display: flex !important;
|
||||
justify-content: flex-end !important;
|
||||
gap: 10px !important;
|
||||
|
||||
&__cancel {
|
||||
border-radius: 8px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
padding: 0 20px !important;
|
||||
height: 38px !important;
|
||||
}
|
||||
|
||||
&__submit {
|
||||
border-radius: 8px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
padding: 0 20px !important;
|
||||
height: 38px !important;
|
||||
box-shadow: 0 1px 3px rgba($blue, 0.3) !important;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
margin-right: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators, UntypedFormControl } from '@angular/forms';
|
||||
import { MatDialogRef, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
|
||||
import { OrganizationService } from '../../organization/organization.service';
|
||||
import { SettingsService } from '../../settings/settings.service';
|
||||
import { Dates } from '../../core/utils/dates';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-add-holiday-dialog',
|
||||
templateUrl: './add-holiday-dialog.component.html',
|
||||
styleUrls: ['./add-holiday-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
MatDialogContent,
|
||||
MatDialogActions
|
||||
]
|
||||
})
|
||||
export class AddHolidayDialogComponent implements OnInit {
|
||||
private fb = inject(UntypedFormBuilder);
|
||||
private organizationService = inject(OrganizationService);
|
||||
private settings = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
public dialogRef = inject(MatDialogRef<AddHolidayDialogComponent>);
|
||||
|
||||
holidayForm: UntypedFormGroup;
|
||||
offices: any[] = [];
|
||||
repaymentSchedulingTypes: any[] = [];
|
||||
submitting = false;
|
||||
minDate = new Date(2000, 0, 1);
|
||||
maxDate = new Date(2100, 0, 1);
|
||||
|
||||
ngOnInit() {
|
||||
this.holidayForm = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: [''],
|
||||
fromDate: ['', Validators.required],
|
||||
toDate: ['', Validators.required],
|
||||
reschedulingType: ['', Validators.required],
|
||||
officeId: ['', Validators.required]
|
||||
});
|
||||
|
||||
this.loadData();
|
||||
this.buildDependencies();
|
||||
}
|
||||
|
||||
private loadData() {
|
||||
this.organizationService.getOffices().subscribe((data: any[]) => {
|
||||
this.offices = data || [];
|
||||
if (this.offices.length > 0) {
|
||||
this.holidayForm.patchValue({ officeId: this.offices[0].id });
|
||||
}
|
||||
});
|
||||
|
||||
this.organizationService.getHolidayTemplate().subscribe((data: any) => {
|
||||
this.repaymentSchedulingTypes = data || [];
|
||||
});
|
||||
}
|
||||
|
||||
private buildDependencies() {
|
||||
this.holidayForm.get('reschedulingType').valueChanges.subscribe((option: any) => {
|
||||
if (option === 2) {
|
||||
this.holidayForm.addControl('repaymentsRescheduledTo', new UntypedFormControl('', Validators.required));
|
||||
} else {
|
||||
this.holidayForm.removeControl('repaymentsRescheduledTo');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
submit() {
|
||||
if (this.holidayForm.invalid || this.submitting) return;
|
||||
this.submitting = true;
|
||||
|
||||
const formData = { ...this.holidayForm.value };
|
||||
const dateFormat = this.settings.dateFormat;
|
||||
const locale = this.settings.language.code;
|
||||
const momentFormat = 'DD MMMM YYYY';
|
||||
|
||||
const coerceDate = (value: unknown): Date | null => {
|
||||
if (value instanceof Date) return value;
|
||||
if (value == null || value === '') return null;
|
||||
const d = new Date(value as any);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
};
|
||||
|
||||
const fromDate = coerceDate(formData.fromDate);
|
||||
const toDate = coerceDate(formData.toDate);
|
||||
if (!fromDate || !toDate) {
|
||||
this.submitting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
formData.fromDate = this.dateUtils.formatDateAsString(fromDate, momentFormat);
|
||||
formData.toDate = this.dateUtils.formatDateAsString(toDate, momentFormat);
|
||||
|
||||
if (this.holidayForm.contains('repaymentsRescheduledTo')) {
|
||||
const reschDate = coerceDate(formData.repaymentsRescheduledTo);
|
||||
if (!reschDate) {
|
||||
this.submitting = false;
|
||||
return;
|
||||
}
|
||||
formData.repaymentsRescheduledTo = this.dateUtils.formatDateAsString(reschDate, momentFormat);
|
||||
}
|
||||
|
||||
const officeId = formData.officeId;
|
||||
delete formData.officeId;
|
||||
|
||||
const payload = {
|
||||
...formData,
|
||||
dateFormat,
|
||||
locale,
|
||||
offices: [{ officeId }]
|
||||
};
|
||||
|
||||
this.organizationService.createHoliday(payload).subscribe({
|
||||
next: () => {
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: () => {
|
||||
this.submitting = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { Route } from '../core/route/route.service';
|
||||
import { BankCalendarComponent } from './bank-calendar.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'bank-calendar',
|
||||
data: { title: 'Bank Calendar', breadcrumb: 'Bank Calendar' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: BankCalendarComponent
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class BankCalendarRoutingModule {}
|
||||
@@ -0,0 +1,134 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cal-container">
|
||||
<!-- Header -->
|
||||
<div class="cal-header">
|
||||
<div class="cal-header__left">
|
||||
<fa-icon icon="calendar-alt" class="cal-header__icon"></fa-icon>
|
||||
<span class="cal-header__title">Bank Calendar</span>
|
||||
</div>
|
||||
<div class="cal-header__nav">
|
||||
<button class="cal-nav-btn" (click)="previousYear()" matTooltip="Previous Year">
|
||||
<fa-icon icon="chevron-left"></fa-icon>
|
||||
</button>
|
||||
<span class="cal-header__year">{{ currentYear }}</span>
|
||||
<button class="cal-nav-btn" (click)="nextYear()" matTooltip="Next Year">
|
||||
<fa-icon icon="chevron-right"></fa-icon>
|
||||
</button>
|
||||
<button class="cal-today-btn" (click)="goToCurrentYear()">Today</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overall Stats -->
|
||||
<div class="cal-stats">
|
||||
<div class="cal-stat working">
|
||||
<fa-icon icon="briefcase" class="cal-stat__icon"></fa-icon>
|
||||
<div class="cal-stat__info">
|
||||
<div class="cal-stat__value">{{ overallStats.working }}</div>
|
||||
<div class="cal-stat__label">Working Days</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cal-stat sundays">
|
||||
<fa-icon icon="ban" class="cal-stat__icon"></fa-icon>
|
||||
<div class="cal-stat__info">
|
||||
<div class="cal-stat__value">{{ overallStats.sundays }}</div>
|
||||
<div class="cal-stat__label">Sundays</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cal-stat sat2nd4th">
|
||||
<fa-icon icon="calendar-times" class="cal-stat__icon"></fa-icon>
|
||||
<div class="cal-stat__info">
|
||||
<div class="cal-stat__value">{{ overallStats.sat2nd4th }}</div>
|
||||
<div class="cal-stat__label">2nd & 4th Sat</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cal-stat holidays">
|
||||
<fa-icon icon="star" class="cal-stat__icon"></fa-icon>
|
||||
<div class="cal-stat__info">
|
||||
<div class="cal-stat__value">{{ overallStats.holidays }}</div>
|
||||
<div class="cal-stat__label">Holidays</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cal-stat total">
|
||||
<fa-icon icon="calendar" class="cal-stat__icon"></fa-icon>
|
||||
<div class="cal-stat__info">
|
||||
<div class="cal-stat__value">{{ overallStats.totalDays }}</div>
|
||||
<div class="cal-stat__label">Total Days</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="cal-legend">
|
||||
<div class="cal-legend__items">
|
||||
<div class="cal-legend__item"><span class="cal-legend__dot working"></span> Working Day</div>
|
||||
<div class="cal-legend__item"><span class="cal-legend__dot sunday"></span> Sunday</div>
|
||||
<div class="cal-legend__item"><span class="cal-legend__dot sat2nd4th"></span> 2nd / 4th Saturday</div>
|
||||
<div class="cal-legend__item"><span class="cal-legend__dot holiday"></span> Holiday</div>
|
||||
<div class="cal-legend__item"><span class="cal-legend__dot today"></span> Today</div>
|
||||
</div>
|
||||
<div class="cal-legend__actions">
|
||||
<button class="cal-legend__print-btn" (click)="printHolidayList()">
|
||||
<fa-icon icon="print"></fa-icon> Print Holiday List
|
||||
</button>
|
||||
<button class="cal-legend__add-btn" (click)="openAddHolidayDialog()">
|
||||
<fa-icon icon="plus"></fa-icon> Add Holiday
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Grids -->
|
||||
<div class="cal-months">
|
||||
@for (month of months; track month.month) {
|
||||
<div class="cal-month-card">
|
||||
<div class="cal-month-card__header">
|
||||
<span class="cal-month-card__name">{{ month.name }}</span>
|
||||
<span class="cal-month-card__counts">
|
||||
<span class="count-working">{{ month.workingDays }}W</span>
|
||||
<span class="count-off">{{ month.nonWorkingDays }}Off</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="cal-grid">
|
||||
<!-- Week day headers -->
|
||||
@for (wd of weekDays; track wd) {
|
||||
<div class="cal-grid__wday" [class.cal-grid__wday--sun]="wd === 'Sun'">{{ wd }}</div>
|
||||
}
|
||||
|
||||
<!-- Day cells -->
|
||||
@for (day of month.days; track $index) {
|
||||
<div
|
||||
class="cal-grid__day"
|
||||
[class.cal-grid__day--other]="!day.isCurrentMonth"
|
||||
[class.cal-grid__day--today]="day.isToday"
|
||||
[class.cal-grid__day--sunday]="day.isSunday && day.isCurrentMonth"
|
||||
[class.cal-grid__day--sat2nd4th]="(day.is2ndSaturday || day.is4thSaturday) && day.isCurrentMonth"
|
||||
[class.cal-grid__day--holiday]="day.isHoliday && day.isCurrentMonth"
|
||||
[class.cal-grid__day--working]="day.isWorking && day.isCurrentMonth"
|
||||
[matTooltip]="day.holidayName || (day.is2ndSaturday ? '2nd Saturday Off' : day.is4thSaturday ? '4th Saturday Off' : day.isSunday && day.isCurrentMonth ? 'Sunday' : '')"
|
||||
>
|
||||
{{ day.isCurrentMonth ? day.date : '' }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Month summary bar -->
|
||||
<div class="cal-month-card__summary">
|
||||
@if (month.holidays > 0) {
|
||||
<span class="summary-tag holiday">{{ month.holidays }} Holiday{{ month.holidays > 1 ? 's' : '' }}</span>
|
||||
}
|
||||
<span class="summary-tag sun">{{ month.sundays }} Sun</span>
|
||||
@if (month.secondFourthSat > 0) {
|
||||
<span class="summary-tag sat">{{ month.secondFourthSat }} Sat Off</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$navy: #0d1b2a;
|
||||
$blue: #1e88e5;
|
||||
$green: #10b981;
|
||||
$orange: #f59e0b;
|
||||
$red: #ef4444;
|
||||
$purple: #7c3aed;
|
||||
|
||||
// ─── Container ───
|
||||
.cal-container {
|
||||
padding: 20px 24px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
background: #f0f2f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
// ─── Header ───
|
||||
.cal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 22px;
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: $navy;
|
||||
}
|
||||
|
||||
&__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__year {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: $navy;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.cal-nav-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid #d0d5dd;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #475569;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: $blue;
|
||||
color: #fff;
|
||||
border-color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
.cal-today-btn {
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid $blue;
|
||||
background: #eff6ff;
|
||||
color: $blue;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: $blue;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stats Row ───
|
||||
.cal-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.cal-stat {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 1px solid #e8ecf0;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
|
||||
|
||||
&__icon {
|
||||
font-size: 18px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: $navy;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.working .cal-stat__icon {
|
||||
background: #ecfdf5;
|
||||
color: $green;
|
||||
}
|
||||
|
||||
&.sundays .cal-stat__icon {
|
||||
background: #fef2f2;
|
||||
color: $red;
|
||||
}
|
||||
|
||||
&.sat2nd4th .cal-stat__icon {
|
||||
background: #fff7ed;
|
||||
color: $orange;
|
||||
}
|
||||
|
||||
&.holidays .cal-stat__icon {
|
||||
background: #f5f3ff;
|
||||
color: $purple;
|
||||
}
|
||||
|
||||
&.total .cal-stat__icon {
|
||||
background: #eff6ff;
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Legend ───
|
||||
.cal-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8ecf0;
|
||||
|
||||
&__items {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__print-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 18px;
|
||||
border: 1px solid #d0d5dd;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #94a3b8;
|
||||
color: $navy;
|
||||
}
|
||||
}
|
||||
|
||||
&__add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 18px;
|
||||
border: none;
|
||||
background: $blue;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: darken($blue, 8%);
|
||||
box-shadow: 0 2px 8px rgb(30 136 229 / 35%);
|
||||
}
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.working {
|
||||
background: #ecfdf5;
|
||||
border: 1px solid $green;
|
||||
}
|
||||
|
||||
&.sunday {
|
||||
background: #fef2f2;
|
||||
border: 1px solid $red;
|
||||
}
|
||||
|
||||
&.sat2nd4th {
|
||||
background: #fff7ed;
|
||||
border: 1px solid $orange;
|
||||
}
|
||||
|
||||
&.holiday {
|
||||
background: #f5f3ff;
|
||||
border: 1px solid $purple;
|
||||
}
|
||||
|
||||
&.today {
|
||||
background: $blue;
|
||||
border: 1px solid $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Monthly Grid ───
|
||||
.cal-months {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cal-month-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid #e8ecf0;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #0d47a1;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0d47a1;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
&__counts {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
||||
.count-working {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: $green;
|
||||
background: #ecfdf5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.count-off {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: $red;
|
||||
background: #fef2f2;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&__summary {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
&.holiday {
|
||||
background: #f5f3ff;
|
||||
color: $purple;
|
||||
}
|
||||
|
||||
&.sun {
|
||||
background: #fef2f2;
|
||||
color: $red;
|
||||
}
|
||||
|
||||
&.sat {
|
||||
background: #fff7ed;
|
||||
color: $orange;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Day Grid ───
|
||||
.cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
|
||||
&__wday {
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
padding: 4px 0;
|
||||
text-transform: uppercase;
|
||||
|
||||
&--sun {
|
||||
color: $red;
|
||||
}
|
||||
}
|
||||
|
||||
&__day {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 5px 2px;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
color: #334155;
|
||||
transition: all 0.1s;
|
||||
|
||||
&--other {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
&--working {
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--sunday {
|
||||
background: #fef2f2;
|
||||
color: $red;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&--sat2nd4th {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&--holiday {
|
||||
background: #f5f3ff;
|
||||
color: $purple;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&--today {
|
||||
background: $blue !important;
|
||||
color: #fff !important;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 2px 6px rgb(30 136 229 / 40%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Responsive ───
|
||||
@media screen and (max-width: 1200px) {
|
||||
.cal-months {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.cal-stats {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.cal-months {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cal-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.cal-legend {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cal-header {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { OrganizationService } from '../organization/organization.service';
|
||||
|
||||
interface CalendarDay {
|
||||
date: number;
|
||||
month: number;
|
||||
year: number;
|
||||
isCurrentMonth: boolean;
|
||||
isToday: boolean;
|
||||
isWorking: boolean;
|
||||
isHoliday: boolean;
|
||||
isSunday: boolean;
|
||||
is2ndSaturday: boolean;
|
||||
is4thSaturday: boolean;
|
||||
holidayName?: string;
|
||||
dayOfWeek: number; // 0=Sun, 6=Sat
|
||||
}
|
||||
|
||||
interface MonthData {
|
||||
month: number;
|
||||
year: number;
|
||||
name: string;
|
||||
days: CalendarDay[];
|
||||
workingDays: number;
|
||||
nonWorkingDays: number;
|
||||
holidays: number;
|
||||
sundays: number;
|
||||
secondFourthSat: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-bank-calendar',
|
||||
templateUrl: './bank-calendar.component.html',
|
||||
styleUrls: ['./bank-calendar.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class BankCalendarComponent implements OnInit {
|
||||
private organizationService = inject(OrganizationService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
currentYear: number;
|
||||
months: MonthData[] = [];
|
||||
weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
workingDaysConfig: string[] = [];
|
||||
holidays: any[] = [];
|
||||
overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
|
||||
|
||||
/** Indian bank gazetted holidays by year */
|
||||
private readonly gazettedHolidays: Record<number, { month: number; day: number; name: string }[]> = {
|
||||
2025: [
|
||||
{ month: 1, day: 26, name: 'Republic Day' },
|
||||
{ month: 2, day: 26, name: 'Maha Shivaratri' },
|
||||
{ month: 3, day: 14, name: 'Holi' },
|
||||
{ month: 3, day: 31, name: 'Id-ul-Fitr (Ramzan Eid)' },
|
||||
{ month: 4, day: 6, name: 'Ram Navami' },
|
||||
{ month: 4, day: 10, name: 'Mahavir Jayanti' },
|
||||
{ month: 4, day: 14, name: 'Dr. Ambedkar Jayanti' },
|
||||
{ month: 4, day: 18, name: 'Good Friday' },
|
||||
{ month: 5, day: 1, name: 'May Day' },
|
||||
{ month: 5, day: 12, name: 'Buddha Purnima' },
|
||||
{ month: 6, day: 7, name: 'Id-ul-Zuha (Bakrid)' },
|
||||
{ month: 7, day: 6, name: 'Muharram' },
|
||||
{ month: 8, day: 15, name: 'Independence Day' },
|
||||
{ month: 8, day: 16, name: 'Janmashtami' },
|
||||
{ month: 9, day: 5, name: 'Milad-un-Nabi (Eid-e-Milad)' },
|
||||
{ month: 10, day: 2, name: 'Mahatma Gandhi Jayanti' },
|
||||
{ month: 10, day: 2, name: 'Dussehra (Vijaya Dashami)' },
|
||||
{ month: 10, day: 20, name: 'Diwali (Deepavali)' },
|
||||
{ month: 11, day: 5, name: 'Guru Nanak Jayanti' },
|
||||
{ month: 12, day: 25, name: 'Christmas Day' }
|
||||
],
|
||||
2026: [
|
||||
{ month: 1, day: 26, name: 'Republic Day' },
|
||||
{ month: 3, day: 3, name: 'Holi' },
|
||||
{ month: 3, day: 21, name: 'Id-ul-Fitr (Ramzan Eid)' },
|
||||
{ month: 3, day: 26, name: 'Ram Navami' },
|
||||
{ month: 3, day: 31, name: 'Mahavir Jayanti' },
|
||||
{ month: 4, day: 1, name: 'Annual Closing of Bank Accounts' },
|
||||
{ month: 4, day: 3, name: 'Good Friday' },
|
||||
{ month: 5, day: 1, name: 'Buddha Purnima / May Day' },
|
||||
{ month: 5, day: 27, name: 'Id-ul-Zuha (Bakrid)' },
|
||||
{ month: 6, day: 26, name: 'Muharram' },
|
||||
{ month: 8, day: 15, name: 'Independence Day' },
|
||||
{ month: 8, day: 26, name: 'Milad-un-Nabi (Eid-e-Milad)' },
|
||||
{ month: 9, day: 4, name: 'Janmashtami' },
|
||||
{ month: 10, day: 2, name: 'Mahatma Gandhi Jayanti' },
|
||||
{ month: 10, day: 20, name: 'Dussehra (Vijaya Dashami)' },
|
||||
{ month: 11, day: 8, name: 'Diwali (Deepavali)' },
|
||||
{ month: 11, day: 24, name: 'Guru Nanak Jayanti' },
|
||||
{ month: 12, day: 25, name: 'Christmas Day' }
|
||||
],
|
||||
2027: [
|
||||
{ month: 1, day: 26, name: 'Republic Day' },
|
||||
{ month: 2, day: 20, name: 'Holi' },
|
||||
{ month: 3, day: 11, name: 'Id-ul-Fitr (Ramzan Eid)' },
|
||||
{ month: 3, day: 15, name: 'Ram Navami' },
|
||||
{ month: 3, day: 21, name: 'Mahavir Jayanti' },
|
||||
{ month: 3, day: 26, name: 'Good Friday' },
|
||||
{ month: 4, day: 1, name: 'Annual Closing of Bank Accounts' },
|
||||
{ month: 5, day: 1, name: 'May Day' },
|
||||
{ month: 5, day: 2, name: 'Buddha Purnima' },
|
||||
{ month: 5, day: 17, name: 'Id-ul-Zuha (Bakrid)' },
|
||||
{ month: 6, day: 16, name: 'Muharram' },
|
||||
{ month: 8, day: 15, name: 'Independence Day' },
|
||||
{ month: 8, day: 16, name: 'Milad-un-Nabi (Eid-e-Milad)' },
|
||||
{ month: 8, day: 25, name: 'Janmashtami' },
|
||||
{ month: 10, day: 2, name: 'Mahatma Gandhi Jayanti' },
|
||||
{ month: 10, day: 9, name: 'Dussehra (Vijaya Dashami)' },
|
||||
{ month: 10, day: 28, name: 'Diwali (Deepavali)' },
|
||||
{ month: 11, day: 14, name: 'Guru Nanak Jayanti' },
|
||||
{ month: 12, day: 25, name: 'Christmas Day' }
|
||||
]
|
||||
};
|
||||
|
||||
ngOnInit() {
|
||||
this.currentYear = new Date().getFullYear();
|
||||
this.loadWorkingDays();
|
||||
}
|
||||
|
||||
private loadWorkingDays() {
|
||||
// Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off)
|
||||
this.workingDaysConfig = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
|
||||
this.mergeGazettedHolidays();
|
||||
this.buildCalendar();
|
||||
|
||||
// Try loading from API
|
||||
this.organizationService.getOffices().subscribe((offices: any[]) => {
|
||||
if (offices?.length > 0) {
|
||||
this.organizationService.getHolidays(offices[0].id.toString()).subscribe((holidayData: any[]) => {
|
||||
this.holidays = holidayData || [];
|
||||
this.mergeGazettedHolidays();
|
||||
this.buildCalendar();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Merge static gazetted holidays into the holidays array (API format) */
|
||||
private mergeGazettedHolidays() {
|
||||
const gazetted = this.gazettedHolidays[this.currentYear] || [];
|
||||
for (const gh of gazetted) {
|
||||
const exists = this.holidays.some((h: any) => {
|
||||
if (h.fromDate) {
|
||||
const [y, m, d] = h.fromDate;
|
||||
return y === this.currentYear && m === gh.month && d === gh.day;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!exists) {
|
||||
this.holidays.push({
|
||||
name: gh.name,
|
||||
fromDate: [this.currentYear, gh.month, gh.day],
|
||||
toDate: [this.currentYear, gh.month, gh.day],
|
||||
description: gh.name,
|
||||
status: { value: 'Active' }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildCalendar() {
|
||||
this.months = [];
|
||||
this.overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
|
||||
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const monthData = this.buildMonth(m, this.currentYear);
|
||||
this.months.push(monthData);
|
||||
this.overallStats.working += monthData.workingDays;
|
||||
this.overallStats.nonWorking += monthData.nonWorkingDays;
|
||||
this.overallStats.holidays += monthData.holidays;
|
||||
this.overallStats.sundays += monthData.sundays;
|
||||
this.overallStats.sat2nd4th += monthData.secondFourthSat;
|
||||
}
|
||||
this.overallStats.totalDays = this.overallStats.working + this.overallStats.nonWorking;
|
||||
}
|
||||
|
||||
private buildMonth(month: number, year: number): MonthData {
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const startDayOfWeek = firstDay.getDay();
|
||||
const today = new Date();
|
||||
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
const days: CalendarDay[] = [];
|
||||
let workingCount = 0;
|
||||
let nonWorkingCount = 0;
|
||||
let holidayCount = 0;
|
||||
let sundayCount = 0;
|
||||
let sat2nd4thCount = 0;
|
||||
|
||||
// Previous month padding
|
||||
const prevMonthDays = new Date(year, month, 0).getDate();
|
||||
for (let i = startDayOfWeek - 1; i >= 0; i--) {
|
||||
days.push({
|
||||
date: prevMonthDays - i,
|
||||
month: month - 1,
|
||||
year,
|
||||
isCurrentMonth: false,
|
||||
isToday: false,
|
||||
isWorking: false,
|
||||
isHoliday: false,
|
||||
isSunday: false,
|
||||
is2ndSaturday: false,
|
||||
is4thSaturday: false,
|
||||
dayOfWeek: (startDayOfWeek - i - 1 + 7) % 7
|
||||
});
|
||||
}
|
||||
|
||||
// Current month days
|
||||
let saturdayCount = 0;
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const date = new Date(year, month, d);
|
||||
const dow = date.getDay();
|
||||
const isSunday = dow === 0;
|
||||
const isSaturday = dow === 6;
|
||||
|
||||
if (isSaturday) saturdayCount++;
|
||||
|
||||
const is2ndSat = isSaturday && saturdayCount === 2;
|
||||
const is4thSat = isSaturday && saturdayCount === 4;
|
||||
const isToday = date.toDateString() === today.toDateString();
|
||||
|
||||
// Check if holiday
|
||||
const holiday = this.getHolidayForDate(year, month, d);
|
||||
const isHoliday = !!holiday;
|
||||
|
||||
// Non-working: Sunday, 2nd Sat, 4th Sat, or holiday
|
||||
const isNonWorking = isSunday || is2ndSat || is4thSat || isHoliday;
|
||||
|
||||
if (isSunday) sundayCount++;
|
||||
if (is2ndSat || is4thSat) sat2nd4thCount++;
|
||||
if (isHoliday) holidayCount++;
|
||||
|
||||
if (isNonWorking) {
|
||||
nonWorkingCount++;
|
||||
} else {
|
||||
workingCount++;
|
||||
}
|
||||
|
||||
days.push({
|
||||
date: d,
|
||||
month,
|
||||
year,
|
||||
isCurrentMonth: true,
|
||||
isToday,
|
||||
isWorking: !isNonWorking,
|
||||
isHoliday,
|
||||
isSunday,
|
||||
is2ndSaturday: is2ndSat,
|
||||
is4thSaturday: is4thSat,
|
||||
holidayName: holiday?.name,
|
||||
dayOfWeek: dow
|
||||
});
|
||||
}
|
||||
|
||||
// Next month padding
|
||||
const remaining = 42 - days.length;
|
||||
for (let i = 1; i <= remaining; i++) {
|
||||
days.push({
|
||||
date: i,
|
||||
month: month + 1,
|
||||
year,
|
||||
isCurrentMonth: false,
|
||||
isToday: false,
|
||||
isWorking: false,
|
||||
isHoliday: false,
|
||||
isSunday: false,
|
||||
is2ndSaturday: false,
|
||||
is4thSaturday: false,
|
||||
dayOfWeek: (days.length) % 7
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
month,
|
||||
year,
|
||||
name: monthNames[month],
|
||||
days,
|
||||
workingDays: workingCount,
|
||||
nonWorkingDays: nonWorkingCount,
|
||||
holidays: holidayCount,
|
||||
sundays: sundayCount,
|
||||
secondFourthSat: sat2nd4thCount
|
||||
};
|
||||
}
|
||||
|
||||
private getHolidayForDate(year: number, month: number, day: number): any {
|
||||
return this.holidays.find((h: any) => {
|
||||
if (h.fromDate) {
|
||||
const [y, m, d] = h.fromDate;
|
||||
return y === year && m === month + 1 && d === day;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
previousYear() {
|
||||
this.currentYear--;
|
||||
this.holidays = [];
|
||||
this.mergeGazettedHolidays();
|
||||
this.buildCalendar();
|
||||
}
|
||||
|
||||
nextYear() {
|
||||
this.currentYear++;
|
||||
this.holidays = [];
|
||||
this.mergeGazettedHolidays();
|
||||
this.buildCalendar();
|
||||
}
|
||||
|
||||
goToCurrentYear() {
|
||||
this.currentYear = new Date().getFullYear();
|
||||
this.holidays = [];
|
||||
this.mergeGazettedHolidays();
|
||||
this.buildCalendar();
|
||||
}
|
||||
|
||||
printHolidayList() {
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
|
||||
// --- Section 1: Gazetted / API Holidays ---
|
||||
const gazettedList: { sno: number; date: string; day: string; occasion: string }[] = [];
|
||||
const sortedHolidays = [...this.holidays].sort((a: any, b: any) => {
|
||||
const da = a.fromDate[0] * 10000 + a.fromDate[1] * 100 + a.fromDate[2];
|
||||
const db = b.fromDate[0] * 10000 + b.fromDate[1] * 100 + b.fromDate[2];
|
||||
return da - db;
|
||||
});
|
||||
|
||||
sortedHolidays.forEach((h: any, i: number) => {
|
||||
if (h.fromDate) {
|
||||
const [y, m, d] = h.fromDate;
|
||||
const date = new Date(y, m - 1, d);
|
||||
gazettedList.push({
|
||||
sno: i + 1,
|
||||
date: `${monthNames[m - 1]} ${String(d).padStart(2, '0')}, ${y}`,
|
||||
day: dayNames[date.getDay()],
|
||||
occasion: h.name || 'Holiday'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --- Section 2: 2nd & 4th Saturdays ---
|
||||
const satList: { sno: number; date: string; day: string; occasion: string }[] = [];
|
||||
let satSno = 0;
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const daysInMonth = new Date(this.currentYear, m + 1, 0).getDate();
|
||||
let satCount = 0;
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const date = new Date(this.currentYear, m, d);
|
||||
if (date.getDay() === 6) {
|
||||
satCount++;
|
||||
if (satCount === 2 || satCount === 4) {
|
||||
satSno++;
|
||||
satList.push({
|
||||
sno: satSno,
|
||||
date: `${monthNames[m]} ${String(d).padStart(2, '0')}, ${this.currentYear}`,
|
||||
day: 'Saturday',
|
||||
occasion: satCount === 2 ? '2nd Saturday Off' : '4th Saturday Off'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Section 3: Sundays ---
|
||||
const sunList: { sno: number; date: string; day: string; occasion: string }[] = [];
|
||||
let sunSno = 0;
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const daysInMonth = new Date(this.currentYear, m + 1, 0).getDate();
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const date = new Date(this.currentYear, m, d);
|
||||
if (date.getDay() === 0) {
|
||||
sunSno++;
|
||||
sunList.push({
|
||||
sno: sunSno,
|
||||
date: `${monthNames[m]} ${String(d).padStart(2, '0')}, ${this.currentYear}`,
|
||||
day: 'Sunday',
|
||||
occasion: 'Weekly Off'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buildRows = (list: typeof gazettedList) =>
|
||||
list.map(h => `<tr><td>${h.sno}</td><td>${h.date}</td><td>${h.day}</td><td>${h.occasion}</td></tr>`).join('');
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Holiday List ${this.currentYear} — CBS Nexora</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; padding: 30px 40px; color: #1a1a2e; }
|
||||
.header { text-align: center; margin-bottom: 24px; border-bottom: 3px solid #0d47a1; padding-bottom: 16px; }
|
||||
.header h1 { font-size: 20px; color: #0d47a1; margin-bottom: 4px; letter-spacing: 0.5px; }
|
||||
.header h2 { font-size: 15px; color: #475569; font-weight: 500; }
|
||||
.header .year { font-size: 26px; font-weight: 800; color: #0d1b2a; margin-top: 6px; }
|
||||
.stats { display: flex; justify-content: center; gap: 24px; margin-bottom: 24px; font-size: 13px; flex-wrap: wrap; }
|
||||
.stats span { padding: 5px 14px; border-radius: 6px; font-weight: 600; }
|
||||
.stats .gh { background: #f5f3ff; color: #7c3aed; }
|
||||
.stats .sat { background: #fff7ed; color: #c2410c; }
|
||||
.stats .sun { background: #fef2f2; color: #ef4444; }
|
||||
.stats .total { background: #eff6ff; color: #1e88e5; }
|
||||
.section-title { font-size: 14px; font-weight: 700; color: #0d47a1; margin: 22px 0 8px; padding: 6px 12px; background: #e3f2fd; border-radius: 6px; border-left: 4px solid #0d47a1; display: flex; align-items: center; gap: 8px; }
|
||||
.section-title .count { margin-left: auto; font-size: 12px; background: #0d47a1; color: #fff; padding: 2px 10px; border-radius: 12px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12.5px; margin-bottom: 6px; }
|
||||
th { background: #0d47a1; color: #fff; padding: 9px 12px; text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
td { padding: 7px 12px; border-bottom: 1px solid #e2e8f0; }
|
||||
tr:nth-child(even) { background: #f8fafc; }
|
||||
td:first-child, th:first-child { text-align: center; width: 55px; }
|
||||
th:nth-child(2), td:nth-child(2) { width: 200px; }
|
||||
th:nth-child(3), td:nth-child(3) { width: 120px; }
|
||||
.footer { text-align: center; margin-top: 28px; font-size: 11px; color: #94a3b8; border-top: 1px solid #e2e8f0; padding-top: 12px; }
|
||||
@media print {
|
||||
body { padding: 15px 20px; }
|
||||
.section-title { break-inside: avoid; }
|
||||
table { page-break-inside: auto; }
|
||||
tr { page-break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>CBS Nexora — Bank Holiday List</h1>
|
||||
<h2>Gazetted Holidays, 2nd/4th Saturdays & Sundays</h2>
|
||||
<div class="year">${this.currentYear}</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="gh">Gazetted Holidays: ${gazettedList.length}</span>
|
||||
<span class="sat">2nd/4th Saturday Offs: ${satList.length}</span>
|
||||
<span class="sun">Sundays: ${sunList.length}</span>
|
||||
<span class="total">Total Non-Working Days: ${gazettedList.length + satList.length + sunList.length}</span>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Gazetted / Bank Holidays <span class="count">${gazettedList.length}</span></div>
|
||||
<table>
|
||||
<thead><tr><th>Sr. No.</th><th>Date</th><th>Day</th><th>Holiday / Occasion</th></tr></thead>
|
||||
<tbody>${buildRows(gazettedList)}</tbody>
|
||||
</table>
|
||||
|
||||
<div class="section-title">2nd & 4th Saturday Offs <span class="count">${satList.length}</span></div>
|
||||
<table>
|
||||
<thead><tr><th>Sr. No.</th><th>Date</th><th>Day</th><th>Holiday / Occasion</th></tr></thead>
|
||||
<tbody>${buildRows(satList)}</tbody>
|
||||
</table>
|
||||
|
||||
<div class="section-title">Sundays (Weekly Off) <span class="count">${sunList.length}</span></div>
|
||||
<table>
|
||||
<thead><tr><th>Sr. No.</th><th>Date</th><th>Day</th><th>Holiday / Occasion</th></tr></thead>
|
||||
<tbody>${buildRows(sunList)}</tbody>
|
||||
</table>
|
||||
|
||||
<div class="footer">Generated on ${new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'long', year: 'numeric' })} • CBS Nexora • Internal Use Only</div>
|
||||
<script>window.onload = function() { window.print(); }</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (printWindow) {
|
||||
printWindow.document.write(html);
|
||||
printWindow.document.close();
|
||||
}
|
||||
}
|
||||
|
||||
async openAddHolidayDialog() {
|
||||
const { AddHolidayDialogComponent } = await import('./add-holiday-dialog/add-holiday-dialog.component');
|
||||
const dialogRef = this.dialog.open(AddHolidayDialogComponent, {
|
||||
width: '600px',
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '90vh',
|
||||
autoFocus: false,
|
||||
panelClass: 'add-holiday-dialog-panel'
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result: boolean) => {
|
||||
if (result) {
|
||||
// Reload holidays after successful creation
|
||||
this.loadWorkingDays();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { BankCalendarRoutingModule } from './bank-calendar-routing.module';
|
||||
import { BankCalendarComponent } from './bank-calendar.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
BankCalendarRoutingModule,
|
||||
BankCalendarComponent
|
||||
]
|
||||
})
|
||||
export class BankCalendarModule {}
|
||||
@@ -7,76 +7,82 @@
|
||||
-->
|
||||
|
||||
<mat-card class="container">
|
||||
<div class="layout-column layout-gt-sm-row align-gt-sm-start-center">
|
||||
<div class="search-box m-r-30">
|
||||
<mat-form-field class="search-box">
|
||||
<!-- Header Bar -->
|
||||
<div class="table-header">
|
||||
<div class="table-title-group">
|
||||
<h2 class="table-title">
|
||||
<div class="t-logomark"><span></span><span></span><span></span><span></span></div>
|
||||
{{ 'labels.menus.Centers' | translate }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="table-actions">
|
||||
<mat-form-field class="table-search" appearance="outline">
|
||||
<input matInput placeholder="{{ 'labels.text.SearchByName' | translate }}" [formControl]="name" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="search-box">
|
||||
|
||||
<mat-form-field class="table-search" appearance="outline">
|
||||
<input matInput placeholder="{{ 'labels.text.SearchByExternalID' | translate }}" [formControl]="externalId" />
|
||||
</mat-form-field>
|
||||
<mat-checkbox #showClosedCenters labelPosition="after" (change)="changeShowClosedCenters()" class="m-r-10">
|
||||
|
||||
<mat-checkbox #showClosedCenters labelPosition="after" (change)="changeShowClosedCenters()" class="table-checkbox">
|
||||
{{ 'labels.inputs.Show Closed Centers' | translate }}
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
<div class="action-button gap-25px">
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
[routerLink]="['../', 'organization', 'bulk-import', 'Centers']"
|
||||
*mifosxHasPermission="'CREATE_CENTER'"
|
||||
>
|
||||
<fa-icon icon="download" class="m-r-10"></fa-icon>{{ 'labels.buttons.Import Center' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" [routerLink]="['create']" *mifosxHasPermission="'CREATE_CENTER'">
|
||||
<fa-icon icon="plus" class="m-r-10"></fa-icon>{{ 'labels.buttons.Create Center' | translate }}
|
||||
</button>
|
||||
|
||||
<div class="table-buttons">
|
||||
<button mat-stroked-button [routerLink]="['../', 'organization', 'bulk-import', 'Centers']" *mifosxHasPermission="'CREATE_CENTER'" class="btn-import">
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
{{ 'labels.buttons.Import Center' | translate }}
|
||||
</button>
|
||||
<button mat-flat-button color="primary" [routerLink]="['create']" *mifosxHasPermission="'CREATE_CENTER'" class="btn-create">
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
{{ 'labels.buttons.Create Center' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table mat-table [dataSource]="dataSource" matSort class="bordered-table">
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let center">{{ center.name }}</td>
|
||||
</ng-container>
|
||||
<!-- Table -->
|
||||
<div class="table-container">
|
||||
<table mat-table [dataSource]="dataSource" matSort class="modern-table">
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let center" class="cell-link">{{ center.name }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="accountNo">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account' | translate }} #</th>
|
||||
<td mat-cell *matCellDef="let center">{{ center.accountNo }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="accountNo">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account' | translate }} #</th>
|
||||
<td mat-cell *matCellDef="let center" class="cell-mono">{{ center.accountNo }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="externalId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.External Id' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let center">{{ center.externalId }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="externalId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.External Id' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let center" class="cell-mono">{{ center.externalId }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div [ngClass]="row.status.code | statusLookup">
|
||||
<fa-icon icon="stop"></fa-icon>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<span class="status-badge" [ngClass]="row.status.code | statusLookup">
|
||||
{{ row.status.value }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="officeName">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Office Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let center">{{ center.officeName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="officeName">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Office Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let center">{{ center.officeName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
[routerLink]="[row.id, 'general']"
|
||||
class="select-row"
|
||||
></tr>
|
||||
</table>
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id, 'general']" class="table-row"></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator
|
||||
[length]="dataSource?.records$ | async"
|
||||
[pageSize]="10"
|
||||
[pageSizeOptions]="[10, 25, 50, 100]"
|
||||
showFirstLastButtons
|
||||
></mat-paginator>
|
||||
<mat-paginator
|
||||
[length]="dataSource?.records$ | async"
|
||||
[pageSize]="10"
|
||||
[pageSizeOptions]="[10, 25, 50, 100]"
|
||||
showFirstLastButtons
|
||||
></mat-paginator>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
@@ -289,7 +289,7 @@ export class ClientAddressStepComponent {
|
||||
formfields.push(
|
||||
this.isFieldEnabled('countyDistrict')
|
||||
? new InputBase({
|
||||
controlName: 'countryDistrict',
|
||||
controlName: 'countyDistrict',
|
||||
label: this.translateService.instant('labels.inputs.Country District'),
|
||||
value: address ? address.countyDistrict : '',
|
||||
type: 'text',
|
||||
|
||||
+678
@@ -0,0 +1,678 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
-->
|
||||
|
||||
<div class="ec-page">
|
||||
<!-- Header -->
|
||||
<div class="step-header">
|
||||
<div class="step-header__left">
|
||||
<fa-icon icon="building" class="step-header__icon"></fa-icon>
|
||||
<div>
|
||||
<h3 class="step-header__title">Scheme & Sector Classification</h3>
|
||||
<p class="step-header__sub">Regulatory scheme flags, sector classification & compliance identifiers</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Chips -->
|
||||
<div class="ec-chips-label">Active Classifications</div>
|
||||
<div class="ec-chips">
|
||||
@if (enabledCount === 0) {
|
||||
<span class="ec-no-chip">No classifications enabled — toggle categories below</span>
|
||||
}
|
||||
@for (cat of enabledCategories; track cat.id) {
|
||||
<span class="ec-chip" [style.background]="cat.tagBg" [style.color]="cat.tagColor">
|
||||
{{ cat.label }}
|
||||
<mat-icon class="ec-chip-close" (click)="removeCategory(cat.id)">close</mat-icon>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ═══ CATEGORY: MSME ═══ -->
|
||||
<div class="ec-card" [class.ec-card--open]="enabled['msme']">
|
||||
<div class="ec-card-head" (click)="toggleExpand('msme')">
|
||||
<div class="ec-card-head-left">
|
||||
<span class="ec-tag" style="background:#E6F4F6;color:#0A5C6B;">Udyam / MSME</span>
|
||||
<div>
|
||||
<div class="ec-card-title">MSME</div>
|
||||
<div class="ec-card-desc">Micro, Small & Medium Enterprise — Ministry of MSME / Udyam Registration</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ec-card-toggle">
|
||||
<mat-slide-toggle [checked]="!!enabled['msme']" (change)="toggleCategory('msme')" (click)="$event.stopPropagation()"></mat-slide-toggle>
|
||||
<mat-icon class="ec-chevron" [class.open]="expanded['msme']">expand_more</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
@if (expanded['msme']) {
|
||||
<div class="ec-card-body" [formGroup]="msmeForm">
|
||||
<div class="ec-sub-title">Udyam Registration</div>
|
||||
<div class="ec-grid-3">
|
||||
<mat-form-field appearance="outline"><mat-label>Udyam Registration Number</mat-label>
|
||||
<input matInput formControlName="udyamRegistrationNumber" placeholder="UDYAM-MP-00-0000000"
|
||||
maxlength="19" style="text-transform:uppercase"
|
||||
(input)="msmeForm.get('udyamRegistrationNumber')?.setValue($any($event.target).value.toUpperCase(), {emitEvent:false})" /></mat-form-field>
|
||||
<mat-form-field appearance="outline" (click)="msmeRegDatePicker.open()"><mat-label>Registration Date</mat-label>
|
||||
<input matInput [matDatepicker]="msmeRegDatePicker" formControlName="registrationDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="msmeRegDatePicker"></mat-datepicker-toggle><mat-datepicker #msmeRegDatePicker></mat-datepicker></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Registration Status</mat-label>
|
||||
<mat-select formControlName="registrationStatus">
|
||||
<mat-option value="ACTIVE">Active</mat-option>
|
||||
<mat-option value="SUSPENDED">Suspended</mat-option>
|
||||
<mat-option value="CANCELLED">Cancelled</mat-option>
|
||||
<mat-option value="PENDING">Pending Verification</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>MSME Classification</mat-label>
|
||||
<mat-select formControlName="msmeClassification">
|
||||
<mat-option value="MICRO">Micro (T/O ≤ ₹5 Cr, Inv ≤ ₹1 Cr)</mat-option>
|
||||
<mat-option value="SMALL">Small (T/O ≤ ₹50 Cr, Inv ≤ ₹10 Cr)</mat-option>
|
||||
<mat-option value="MEDIUM">Medium (T/O ≤ ₹250 Cr, Inv ≤ ₹50 Cr)</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Enterprise Type</mat-label>
|
||||
<mat-select formControlName="enterpriseType">
|
||||
<mat-option value="MANUFACTURING">Manufacturing</mat-option>
|
||||
<mat-option value="SERVICES">Services</mat-option>
|
||||
<mat-option value="TRADING">Trading</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>NIC Activity Code</mat-label>
|
||||
<input matInput formControlName="nicActivityCode" placeholder="e.g. 13111" maxlength="10" /></mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="ec-sub-title">Financial Thresholds (as per Udyam Portal)</div>
|
||||
<div class="ec-grid-3">
|
||||
<mat-form-field appearance="outline"><mat-label>Annual Turnover — Last FY (₹ Cr)</mat-label>
|
||||
<input matInput type="number" formControlName="annualTurnover" placeholder="0.00" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Investment — Plant & Machinery / Equipment (₹ Cr)</mat-label>
|
||||
<input matInput type="number" formControlName="investmentPlantMachinery" placeholder="0.00" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Financial Year of Last Assessment</mat-label>
|
||||
<mat-select formControlName="financialYearAssessment">
|
||||
<mat-option value="2025-26">2025-26</mat-option>
|
||||
<mat-option value="2024-25">2024-25</mat-option>
|
||||
<mat-option value="2023-24">2023-24</mat-option>
|
||||
<mat-option value="2022-23">2022-23</mat-option>
|
||||
<mat-option value="2021-22">2021-22</mat-option>
|
||||
<mat-option value="2020-21">2020-21</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="ec-sub-title">Owner / Promoter Details</div>
|
||||
<div class="ec-grid-3">
|
||||
<mat-form-field appearance="outline"><mat-label>Social Category of Owner</mat-label>
|
||||
<mat-select formControlName="socialCategoryOwner">
|
||||
<mat-option value="GENERAL">General</mat-option>
|
||||
<mat-option value="SC">Scheduled Caste (SC)</mat-option>
|
||||
<mat-option value="ST">Scheduled Tribe (ST)</mat-option>
|
||||
<mat-option value="OBC">Other Backward Class (OBC)</mat-option>
|
||||
<mat-option value="MINORITY">Minority</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Gender of Primary Owner</mat-label>
|
||||
<mat-select formControlName="genderPrimaryOwner">
|
||||
<mat-option value="MALE">Male</mat-option>
|
||||
<mat-option value="FEMALE">Female</mat-option>
|
||||
<mat-option value="OTHER">Other</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>No. of Employees (approx.)</mat-label>
|
||||
<input matInput type="number" formControlName="noOfEmployees" placeholder="0" /></mat-form-field>
|
||||
</div>
|
||||
<div class="ec-flags">
|
||||
<div class="ec-flag-row">
|
||||
<div><span class="ec-flag-label">Women-led Enterprise</span><span class="ec-flag-sub">Majority ownership / management by women entrepreneur</span></div>
|
||||
<mat-slide-toggle formControlName="womenLedEnterprise"></mat-slide-toggle>
|
||||
</div>
|
||||
<div class="ec-flag-row">
|
||||
<div><span class="ec-flag-label">Divyang (PWD) Owned</span><span class="ec-flag-sub">Persons with Disability — eligible for special scheme benefits</span></div>
|
||||
<mat-slide-toggle formControlName="divyangPwdOwned"></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ec-reg-note">
|
||||
<strong>MSMED Act 2006 (Revised):</strong> Classification based on composite criteria of investment in plant & machinery AND annual turnover. Udyam Registration is mandatory for availing PSL benefits, government procurement preference, and CGSS coverage.
|
||||
</div>
|
||||
|
||||
<div class="ec-card-actions">
|
||||
<button mat-raised-button color="primary" [disabled]="!clientId || saving['msme']" (click)="saveCategory('msme')">
|
||||
@if (saving['msme']) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Saving... }
|
||||
@else if (saved['msme']) { <mat-icon>check</mat-icon> Saved }
|
||||
@else { Save MSME }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ═══ CATEGORY 1: STARTUP INDIA ═══ -->
|
||||
<div class="ec-card" [class.ec-card--open]="enabled['startup']">
|
||||
<div class="ec-card-head" (click)="toggleExpand('startup')">
|
||||
<div class="ec-card-head-left">
|
||||
<span class="ec-tag" style="background:#EAF2FB;color:#1A5FAB;">DPIIT</span>
|
||||
<div>
|
||||
<div class="ec-card-title">Startup India Registered</div>
|
||||
<div class="ec-card-desc">DPIIT recognition under Startup India initiative</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ec-card-toggle">
|
||||
<mat-slide-toggle [checked]="!!enabled['startup']" (change)="toggleCategory('startup')" (click)="$event.stopPropagation()"></mat-slide-toggle>
|
||||
<mat-icon class="ec-chevron" [class.open]="expanded['startup']">expand_more</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
@if (expanded['startup']) {
|
||||
<div class="ec-card-body" [formGroup]="startupForm">
|
||||
<div class="ec-sub-title">Registration Details</div>
|
||||
<div class="ec-grid-3">
|
||||
<mat-form-field appearance="outline"><mat-label>DPIIT Recognition Number</mat-label>
|
||||
<input matInput formControlName="dpiitRecognitionNumber" placeholder="DIPP12345" style="text-transform:uppercase" /></mat-form-field>
|
||||
<mat-form-field appearance="outline" (click)="recDatePicker.open()"><mat-label>Recognition Date</mat-label>
|
||||
<input matInput [matDatepicker]="recDatePicker" formControlName="recognitionDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="recDatePicker"></mat-datepicker-toggle><mat-datepicker #recDatePicker></mat-datepicker></mat-form-field>
|
||||
<mat-form-field appearance="outline" (click)="certExpPicker.open()"><mat-label>Certificate Expiry Date</mat-label>
|
||||
<input matInput [matDatepicker]="certExpPicker" formControlName="certificateExpiryDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="certExpPicker"></mat-datepicker-toggle><mat-datepicker #certExpPicker></mat-datepicker></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Entity Stage</mat-label>
|
||||
<mat-select formControlName="entityStage">
|
||||
<mat-option value="EARLY">Early (0–2 years)</mat-option>
|
||||
<mat-option value="GROWTH">Growth (2–5 years)</mat-option>
|
||||
<mat-option value="SCALE">Scale (5+ years)</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Sector as per DPIIT</mat-label>
|
||||
<mat-select formControlName="sectorAsDpiit">
|
||||
<mat-option value="Fintech">Fintech</mat-option>
|
||||
<mat-option value="Agritech">Agritech</mat-option>
|
||||
<mat-option value="Edtech">Edtech</mat-option>
|
||||
<mat-option value="Healthtech">Healthtech</mat-option>
|
||||
<mat-option value="Logistics">Logistics</mat-option>
|
||||
<mat-option value="Manufacturing">Manufacturing</mat-option>
|
||||
<mat-option value="SaaS / B2B Tech">SaaS / B2B Tech</mat-option>
|
||||
<mat-option value="Information Technology">Information Technology</mat-option>
|
||||
<mat-option value="Other">Other</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>NIC Industry Code</mat-label>
|
||||
<input matInput formControlName="nicIndustryCode" placeholder="e.g. 62013" maxlength="10" /></mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="ec-sub-title">Government Scheme Benefits Availed</div>
|
||||
<div class="ec-check-grid">
|
||||
<mat-checkbox formControlName="taxExemption80iac">80IAC Tax Exemption</mat-checkbox>
|
||||
<mat-checkbox formControlName="sidbiFund">SIDBI Startup Fund</mat-checkbox>
|
||||
<mat-checkbox formControlName="selfCertification">Self-Certification (Labour / Env.)</mat-checkbox>
|
||||
<mat-checkbox formControlName="fastTrackPatent">Fast-track Patent / IPR</mat-checkbox>
|
||||
<mat-checkbox formControlName="govtTenderExemption">Government Tender Exemption</mat-checkbox>
|
||||
<mat-checkbox formControlName="creditGuaranteeCgss">Credit Guarantee Scheme (CGSS)</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="ec-sub-title">PSL Linkage</div>
|
||||
<div class="ec-grid-2">
|
||||
<mat-form-field appearance="outline"><mat-label>Loan Limit for PSL Eligibility (Cr)</mat-label>
|
||||
<input matInput type="number" formControlName="pslLoanLimit" placeholder="Max 50 Cr" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>PSL Sub-category</mat-label>
|
||||
<mat-select formControlName="pslSubCategory">
|
||||
<mat-option value="Others (Startup India)">Others (Startup India)</mat-option>
|
||||
<mat-option value="MSME - Micro">MSME — Micro</mat-option>
|
||||
<mat-option value="MSME - Small">MSME — Small</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="ec-reg-note">
|
||||
<strong>RBI Circular RBI/2023-24/62:</strong> DPIIT-recognized startups eligible for PSL classification up to ₹50 Cr. Recognition certificate to be retained in document management system.
|
||||
</div>
|
||||
|
||||
<div class="ec-card-actions">
|
||||
<button mat-raised-button color="primary" [disabled]="!clientId || saving['startup']" (click)="saveCategory('startup')">
|
||||
@if (saving['startup']) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Saving... }
|
||||
@else if (saved['startup']) { <mat-icon>check</mat-icon> Saved }
|
||||
@else { Save Startup India }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ═══ CATEGORY 2: MUDRA BORROWER ═══ -->
|
||||
<div class="ec-card" [class.ec-card--open]="enabled['mudra']">
|
||||
<div class="ec-card-head" (click)="toggleExpand('mudra')">
|
||||
<div class="ec-card-head-left">
|
||||
<span class="ec-tag" style="background:#FEF3DC;color:#8A5A00;">PMMY</span>
|
||||
<div>
|
||||
<div class="ec-card-title">Mudra Borrower</div>
|
||||
<div class="ec-card-desc">Pradhan Mantri Mudra Yojana — SIDBI / Mudra scheme borrower</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ec-card-toggle">
|
||||
<mat-slide-toggle [checked]="!!enabled['mudra']" (change)="toggleCategory('mudra')" (click)="$event.stopPropagation()"></mat-slide-toggle>
|
||||
<mat-icon class="ec-chevron" [class.open]="expanded['mudra']">expand_more</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
@if (expanded['mudra']) {
|
||||
<div class="ec-card-body" [formGroup]="mudraForm">
|
||||
<div class="ec-sub-title">Loan Classification</div>
|
||||
<div class="ec-grid-3">
|
||||
<mat-form-field appearance="outline"><mat-label>Mudra Category</mat-label>
|
||||
<mat-select formControlName="mudraCategory">
|
||||
<mat-option value="SHISHU">Shishu (up to ₹50,000)</mat-option>
|
||||
<mat-option value="KISHORE">Kishore (₹50,001 – ₹5,00,000)</mat-option>
|
||||
<mat-option value="TARUN">Tarun (₹5,00,001 – ₹10,00,000)</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Mudra Loan Account No.</mat-label>
|
||||
<input matInput formControlName="mudraLoanAccountNo" placeholder="Lender's loan account number" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Lending Institution Name</mat-label>
|
||||
<input matInput formControlName="lendingInstitutionName" placeholder="Bank / NBFC / MFI name" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Sanctioned Amount (₹)</mat-label>
|
||||
<input matInput type="number" formControlName="sanctionedAmount" placeholder="0.00" /></mat-form-field>
|
||||
<mat-form-field appearance="outline" (click)="mudraDispPicker.open()"><mat-label>Disbursement Date</mat-label>
|
||||
<input matInput [matDatepicker]="mudraDispPicker" formControlName="disbursementDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="mudraDispPicker"></mat-datepicker-toggle><mat-datepicker #mudraDispPicker></mat-datepicker></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Loan Purpose</mat-label>
|
||||
<mat-select formControlName="loanPurpose">
|
||||
<mat-option value="Manufacturing">Manufacturing</mat-option>
|
||||
<mat-option value="Trading">Trading</mat-option>
|
||||
<mat-option value="Services">Services</mat-option>
|
||||
<mat-option value="Agriculture Allied Activity">Agriculture Allied Activity</mat-option>
|
||||
<mat-option value="Transport">Transport</mat-option>
|
||||
<mat-option value="Working Capital">Working Capital</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="ec-sub-title">Scheme Details</div>
|
||||
<div class="ec-grid-3">
|
||||
<mat-form-field appearance="outline"><mat-label>MUDRA Card Number</mat-label>
|
||||
<input matInput formControlName="mudraCardNumber" placeholder="16-digit card number" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Current Outstanding (₹)</mat-label>
|
||||
<input matInput type="number" formControlName="currentOutstanding" placeholder="0.00" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Repayment Status</mat-label>
|
||||
<mat-select formControlName="repaymentStatus">
|
||||
<mat-option value="REGULAR">Regular</mat-option>
|
||||
<mat-option value="OVERDUE_1_30">Overdue (1–30 days)</mat-option>
|
||||
<mat-option value="OVERDUE_31_90">Overdue (31–90 days)</mat-option>
|
||||
<mat-option value="NPA">NPA</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
</div>
|
||||
<div class="ec-flag-row">
|
||||
<div><span class="ec-flag-label">NPA Flag</span><span class="ec-flag-sub">Mark if account classified as Non-Performing Asset</span></div>
|
||||
<mat-slide-toggle formControlName="npaFlag"></mat-slide-toggle>
|
||||
</div>
|
||||
|
||||
<div class="ec-reg-note">
|
||||
<strong>PSL Tagging:</strong> Mudra loans must be tagged at both entity and account level for FIDD PSL returns. NPA flag mandatory for RBI quarterly reporting.
|
||||
</div>
|
||||
|
||||
<div class="ec-card-actions">
|
||||
<button mat-raised-button color="primary" [disabled]="!clientId || saving['mudra']" (click)="saveCategory('mudra')">
|
||||
@if (saving['mudra']) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Saving... }
|
||||
@else if (saved['mudra']) { <mat-icon>check</mat-icon> Saved }
|
||||
@else { Save Mudra Borrower }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ═══ CATEGORY 3: EXPORT ORIENTED UNIT ═══ -->
|
||||
<div class="ec-card" [class.ec-card--open]="enabled['eou']">
|
||||
<div class="ec-card-head" (click)="toggleExpand('eou')">
|
||||
<div class="ec-card-head-left">
|
||||
<span class="ec-tag" style="background:#E8F5EE;color:#1B7A4A;">EOU</span>
|
||||
<div>
|
||||
<div class="ec-card-title">Export Oriented Unit</div>
|
||||
<div class="ec-card-desc">Customs-registered EOU under DGFT / Ministry of Commerce</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ec-card-toggle">
|
||||
<mat-slide-toggle [checked]="!!enabled['eou']" (change)="toggleCategory('eou')" (click)="$event.stopPropagation()"></mat-slide-toggle>
|
||||
<mat-icon class="ec-chevron" [class.open]="expanded['eou']">expand_more</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
@if (expanded['eou']) {
|
||||
<div class="ec-card-body" [formGroup]="eouForm">
|
||||
<div class="ec-sub-title">EOU Registration</div>
|
||||
<div class="ec-grid-3">
|
||||
<mat-form-field appearance="outline"><mat-label>EOU Registration Number</mat-label>
|
||||
<input matInput formControlName="eouRegistrationNumber" placeholder="EOU/MUM/2023/0001" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Registered DC Zone</mat-label>
|
||||
<mat-select formControlName="registeredDcZone">
|
||||
<mat-option value="Mumbai (SEEPZ)">Mumbai (SEEPZ)</mat-option>
|
||||
<mat-option value="Delhi (NSEZ)">Delhi (NSEZ)</mat-option>
|
||||
<mat-option value="Chennai (MEPZ)">Chennai (MEPZ)</mat-option>
|
||||
<mat-option value="Bangalore (BEPZ)">Bangalore (BEPZ)</mat-option>
|
||||
<mat-option value="Kolkata (FALTA)">Kolkata (FALTA)</mat-option>
|
||||
<mat-option value="Hyderabad (VSEZ)">Hyderabad (VSEZ)</mat-option>
|
||||
<mat-option value="Cochin (CSEZ)">Cochin (CSEZ)</mat-option>
|
||||
<mat-option value="Other">Other</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Bond / LUT Number</mat-label>
|
||||
<input matInput formControlName="bondLutNumber" placeholder="Bond reference number" /></mat-form-field>
|
||||
<mat-form-field appearance="outline" (click)="eouFromPicker.open()"><mat-label>Validity From</mat-label>
|
||||
<input matInput [matDatepicker]="eouFromPicker" formControlName="validityFrom" />
|
||||
<mat-datepicker-toggle matSuffix [for]="eouFromPicker"></mat-datepicker-toggle><mat-datepicker #eouFromPicker></mat-datepicker></mat-form-field>
|
||||
<mat-form-field appearance="outline" (click)="eouToPicker.open()"><mat-label>Validity To</mat-label>
|
||||
<input matInput [matDatepicker]="eouToPicker" formControlName="validityTo" />
|
||||
<mat-datepicker-toggle matSuffix [for]="eouToPicker"></mat-datepicker-toggle><mat-datepicker #eouToPicker></mat-datepicker></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>GSTIN under EOU Scheme</mat-label>
|
||||
<input matInput formControlName="gstinEou" placeholder="15-character GSTIN" maxlength="15" style="text-transform:uppercase" /></mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="ec-sub-title">Export Obligation & Compliance</div>
|
||||
<div class="ec-grid-3">
|
||||
<mat-form-field appearance="outline"><mat-label>Annual Export Obligation (₹ Cr)</mat-label>
|
||||
<input matInput type="number" formControlName="annualExportObligation" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>NFE Target (₹ Cr)</mat-label>
|
||||
<input matInput type="number" formControlName="nfeTarget" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>SoftEx Filing Status</mat-label>
|
||||
<mat-select formControlName="softexFilingStatus">
|
||||
<mat-option value="FILED">Current</mat-option><mat-option value="PENDING">Pending</mat-option><mat-option value="NA">Not Applicable</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>BRC Submission Status</mat-label>
|
||||
<mat-select formControlName="brcSubmissionStatus">
|
||||
<mat-option value="SUBMITTED">Current</mat-option><mat-option value="PENDING">Pending</mat-option><mat-option value="NA">Not Applicable</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>AD Category Bank Code</mat-label>
|
||||
<input matInput formControlName="adCategoryBankCode" placeholder="AD Bank IFSC / Code" /></mat-form-field>
|
||||
<mat-form-field appearance="outline" (click)="femaPicker.open()"><mat-label>FEMA Declaration Date</mat-label>
|
||||
<input matInput [matDatepicker]="femaPicker" formControlName="femaDeclarationDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="femaPicker"></mat-datepicker-toggle><mat-datepicker #femaPicker></mat-datepicker></mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="ec-reg-note">
|
||||
<strong>FEMA / RBI:</strong> EOU accounts must be tagged for FEMA compliance on forex receipts. AD Category-I bank declaration mandatory. EDPMS reporting required for all export proceeds.
|
||||
</div>
|
||||
|
||||
<div class="ec-card-actions">
|
||||
<button mat-raised-button color="primary" [disabled]="!clientId || saving['eou']" (click)="saveCategory('eou')">
|
||||
@if (saving['eou']) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Saving... }
|
||||
@else if (saved['eou']) { <mat-icon>check</mat-icon> Saved }
|
||||
@else { Save EOU }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ═══ CATEGORY 4: IMPORT EXPORT ═══ -->
|
||||
<div class="ec-card" [class.ec-card--open]="enabled['importexport']">
|
||||
<div class="ec-card-head" (click)="toggleExpand('importexport')">
|
||||
<div class="ec-card-head-left">
|
||||
<span class="ec-tag" style="background:#F0EBF8;color:#5B3A8A;">DGFT / IEC</span>
|
||||
<div>
|
||||
<div class="ec-card-title">Import Export Business</div>
|
||||
<div class="ec-card-desc">Entity with valid DGFT Importer Exporter Code</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ec-card-toggle">
|
||||
<mat-slide-toggle [checked]="!!enabled['importexport']" (change)="toggleCategory('importexport')" (click)="$event.stopPropagation()"></mat-slide-toggle>
|
||||
<mat-icon class="ec-chevron" [class.open]="expanded['importexport']">expand_more</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
@if (expanded['importexport']) {
|
||||
<div class="ec-card-body" [formGroup]="importexportForm">
|
||||
<div class="ec-sub-title">IEC Registration</div>
|
||||
<div class="ec-grid-3">
|
||||
<mat-form-field appearance="outline"><mat-label>IEC Code (10-digit)</mat-label>
|
||||
<input matInput formControlName="iecCode" placeholder="AABCA1234F" maxlength="10" style="text-transform:uppercase" /></mat-form-field>
|
||||
<mat-form-field appearance="outline" (click)="iecDatePicker.open()"><mat-label>IEC Issue Date</mat-label>
|
||||
<input matInput [matDatepicker]="iecDatePicker" formControlName="iecIssueDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="iecDatePicker"></mat-datepicker-toggle><mat-datepicker #iecDatePicker></mat-datepicker></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>IEC Status</mat-label>
|
||||
<mat-select formControlName="iecStatus">
|
||||
<mat-option value="ACTIVE">Active</mat-option><mat-option value="SUSPENDED">Suspended</mat-option>
|
||||
<mat-option value="CANCELLED">Cancelled</mat-option><mat-option value="DEACTIVATED">Deactivated</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Trade Type</mat-label>
|
||||
<mat-select formControlName="tradeType">
|
||||
<mat-option value="EXPORT">Export Only</mat-option><mat-option value="IMPORT">Import Only</mat-option>
|
||||
<mat-option value="BOTH">Both Import & Export</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Primary HS Code (Commodity)</mat-label>
|
||||
<input matInput formControlName="primaryHsCode" placeholder="e.g. 5208 — Cotton fabrics" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Key Trading Countries</mat-label>
|
||||
<input matInput formControlName="keyTradingCountries" placeholder="e.g. UAE, USA, Singapore" /></mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="ec-sub-title">Banking & Compliance</div>
|
||||
<div class="ec-grid-3">
|
||||
<mat-form-field appearance="outline"><mat-label>AD Category Bank Code</mat-label>
|
||||
<input matInput formControlName="adCategoryBankCode" placeholder="AD Bank IFSC / RBI code" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>ECGC Policy Number</mat-label>
|
||||
<input matInput formControlName="ecgcPolicyNumber" placeholder="ECGC policy reference" /></mat-form-field>
|
||||
<mat-form-field appearance="outline" (click)="ecgcExpPicker.open()"><mat-label>ECGC Policy Expiry</mat-label>
|
||||
<input matInput [matDatepicker]="ecgcExpPicker" formControlName="ecgcPolicyExpiry" />
|
||||
<mat-datepicker-toggle matSuffix [for]="ecgcExpPicker"></mat-datepicker-toggle><mat-datepicker #ecgcExpPicker></mat-datepicker></mat-form-field>
|
||||
</div>
|
||||
<div class="ec-flags">
|
||||
<div class="ec-flag-row">
|
||||
<div><span class="ec-flag-label">EEFC Account Required</span><span class="ec-flag-sub">Exchange Earners Foreign Currency account</span></div>
|
||||
<mat-slide-toggle formControlName="eefcAccountRequired"></mat-slide-toggle>
|
||||
</div>
|
||||
<div class="ec-flag-row">
|
||||
<div><span class="ec-flag-label">PCFC Facility Required</span><span class="ec-flag-sub">Pre-shipment Credit in Foreign Currency</span></div>
|
||||
<mat-slide-toggle formControlName="pcfcFacilityRequired"></mat-slide-toggle>
|
||||
</div>
|
||||
<div class="ec-flag-row">
|
||||
<div><span class="ec-flag-label">FEMA Declaration Obtained</span><span class="ec-flag-sub">Form A2 / LRS declaration on file</span></div>
|
||||
<mat-slide-toggle formControlName="femaDeclarationObtained"></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ec-reg-note">
|
||||
<strong>RBI / FEMA:</strong> IEC mandatory before any import/export transaction. AD bank must report all export proceeds via EDPMS and import payments via IDPMS. ECGC cover recommended for high-risk countries.
|
||||
</div>
|
||||
|
||||
<div class="ec-card-actions">
|
||||
<button mat-raised-button color="primary" [disabled]="!clientId || saving['importexport']" (click)="saveCategory('importexport')">
|
||||
@if (saving['importexport']) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Saving... }
|
||||
@else if (saved['importexport']) { <mat-icon>check</mat-icon> Saved }
|
||||
@else { Save Import/Export }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ═══ CATEGORY 5: PRIORITY SECTOR ═══ -->
|
||||
<div class="ec-card" [class.ec-card--open]="enabled['psl']">
|
||||
<div class="ec-card-head" (click)="toggleExpand('psl')">
|
||||
<div class="ec-card-head-left">
|
||||
<span class="ec-tag" style="background:#FCEAEA;color:#7B1F1F;">RBI PSL</span>
|
||||
<div>
|
||||
<div class="ec-card-title">Priority Sector Borrower</div>
|
||||
<div class="ec-card-desc">Eligible under RBI Priority Sector Lending guidelines</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ec-card-toggle">
|
||||
<mat-slide-toggle [checked]="!!enabled['psl']" (change)="toggleCategory('psl')" (click)="$event.stopPropagation()"></mat-slide-toggle>
|
||||
<mat-icon class="ec-chevron" [class.open]="expanded['psl']">expand_more</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
@if (expanded['psl']) {
|
||||
<div class="ec-card-body" [formGroup]="pslForm">
|
||||
<div class="ec-sub-title">PSL Classification</div>
|
||||
<div class="ec-grid-3">
|
||||
<mat-form-field appearance="outline"><mat-label>PSL Category</mat-label>
|
||||
<mat-select formControlName="pslCategory">
|
||||
<mat-option value="AGRICULTURE">Agriculture</mat-option>
|
||||
<mat-option value="MICRO_ENTERPRISE">MSME</mat-option>
|
||||
<mat-option value="HOUSING">Housing</mat-option>
|
||||
<mat-option value="EDUCATION">Education</mat-option>
|
||||
<mat-option value="SOCIAL_INFRA">Social Infrastructure</mat-option>
|
||||
<mat-option value="RENEWABLE">Renewable Energy</mat-option>
|
||||
<mat-option value="EXPORT_CREDIT">Export Credit</mat-option>
|
||||
<mat-option value="WEAKER_SECTION">Weaker Section</mat-option>
|
||||
<mat-option value="OTHERS">Others</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>PSL Sub-category</mat-label>
|
||||
<mat-select formControlName="pslSubCategory">
|
||||
<mat-option value="Direct Agriculture - Crop Loan">Direct Agriculture — Crop Loan</mat-option>
|
||||
<mat-option value="Direct Agriculture - Investment">Direct Agriculture — Investment</mat-option>
|
||||
<mat-option value="Allied Agriculture">Allied Agriculture</mat-option>
|
||||
<mat-option value="Micro Enterprise">Micro Enterprise</mat-option>
|
||||
<mat-option value="Small Enterprise">Small Enterprise</mat-option>
|
||||
<mat-option value="Medium Enterprise">Medium Enterprise</mat-option>
|
||||
<mat-option value="Housing">Housing (up to ₹35L)</mat-option>
|
||||
<mat-option value="Education Loan">Education Loan</mat-option>
|
||||
<mat-option value="Weaker Section - SC/ST">Weaker Section — SC/ST</mat-option>
|
||||
<mat-option value="Weaker Section - Minorities">Weaker Section — Minorities</mat-option>
|
||||
<mat-option value="Export Credit">Export Credit</mat-option>
|
||||
<mat-option value="Others - Startup India">Others — Startup India</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>PSL Loan Purpose Code</mat-label>
|
||||
<input matInput formControlName="pslLoanPurposeCode" placeholder="RBI purpose code e.g. 111" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>PSL Eligibility Basis</mat-label>
|
||||
<input matInput formControlName="pslEligibilityBasis" placeholder="e.g. Udyam Reg / Kisan Credit Card" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>FIDD Reporting Code</mat-label>
|
||||
<input matInput formControlName="fiddReportingCode" placeholder="FIDD sector code" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>PSL Sanctioned Limit (₹)</mat-label>
|
||||
<input matInput type="number" formControlName="pslSanctionedLimit" /></mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- MSME Sub-section -->
|
||||
@if (pslForm.get('pslCategory')?.value === 'MICRO_ENTERPRISE') {
|
||||
<div class="ec-sub-title">MSME Sub-classification</div>
|
||||
<div class="ec-grid-3">
|
||||
<mat-form-field appearance="outline"><mat-label>MSME Classification</mat-label>
|
||||
<mat-select formControlName="msmeClassification">
|
||||
<mat-option value="MICRO">Micro (T/O ≤ ₹5 Cr, Inv ≤ ₹1 Cr)</mat-option>
|
||||
<mat-option value="SMALL">Small (T/O ≤ ₹50 Cr, Inv ≤ ₹10 Cr)</mat-option>
|
||||
<mat-option value="MEDIUM">Medium (T/O ≤ ₹250 Cr, Inv ≤ ₹50 Cr)</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Udyam Registration Number</mat-label>
|
||||
<input matInput formControlName="udyamRegistrationNumber" placeholder="UDYAM-MP-00-0000000" style="text-transform:uppercase" /></mat-form-field>
|
||||
<mat-form-field appearance="outline" (click)="udyamDatePicker.open()"><mat-label>Udyam Registration Date</mat-label>
|
||||
<input matInput [matDatepicker]="udyamDatePicker" formControlName="udyamRegistrationDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="udyamDatePicker"></mat-datepicker-toggle><mat-datepicker #udyamDatePicker></mat-datepicker></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Annual Turnover (₹ Cr)</mat-label>
|
||||
<input matInput type="number" formControlName="annualTurnover" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Investment — Plant & Machinery (₹ Cr)</mat-label>
|
||||
<input matInput type="number" formControlName="investmentPlantMachinery" /></mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="ec-sub-title">Special Category Flags</div>
|
||||
<div class="ec-flags">
|
||||
<div class="ec-flag-row">
|
||||
<div><span class="ec-flag-label">Weaker Section Flag</span><span class="ec-flag-sub">SHG, JLG, SC/ST, OBC, Minorities, PWD borrowers</span></div>
|
||||
<mat-slide-toggle formControlName="weakerSectionFlag"></mat-slide-toggle>
|
||||
</div>
|
||||
<div class="ec-flag-row">
|
||||
<div><span class="ec-flag-label">SC / ST Borrower Flag</span><span class="ec-flag-sub">Scheduled Caste or Scheduled Tribe classification</span></div>
|
||||
<mat-slide-toggle formControlName="scStBorrowerFlag"></mat-slide-toggle>
|
||||
</div>
|
||||
<div class="ec-flag-row">
|
||||
<div><span class="ec-flag-label">Women Entrepreneur Flag</span><span class="ec-flag-sub">Majority-owned / managed by women</span></div>
|
||||
<mat-slide-toggle formControlName="womenEntrepreneurFlag"></mat-slide-toggle>
|
||||
</div>
|
||||
<div class="ec-flag-row">
|
||||
<div><span class="ec-flag-label">Minority Community Flag</span><span class="ec-flag-sub">As defined by Ministry of Minority Affairs</span></div>
|
||||
<mat-slide-toggle formControlName="minorityCommunityFlag"></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ec-reg-note">
|
||||
<strong>RBI FIDD Circular:</strong> PSL tagging mandatory at entity AND loan account level. MSME classification must be verified from Udyam portal — self-declaration not acceptable. Weaker section loans reported separately in FIDD quarterly returns.
|
||||
</div>
|
||||
|
||||
<div class="ec-card-actions">
|
||||
<button mat-raised-button color="primary" [disabled]="!clientId || saving['psl']" (click)="saveCategory('psl')">
|
||||
@if (saving['psl']) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Saving... }
|
||||
@else if (saved['psl']) { <mat-icon>check</mat-icon> Saved }
|
||||
@else { Save Priority Sector }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ═══ CATEGORY 6: NON-PRIORITY SECTOR ═══ -->
|
||||
<div class="ec-card" [class.ec-card--open]="enabled['nps']">
|
||||
<div class="ec-card-head" (click)="toggleExpand('nps')">
|
||||
<div class="ec-card-head-left">
|
||||
<span class="ec-tag" style="background:#F1EFE8;color:#444441;">General</span>
|
||||
<div>
|
||||
<div class="ec-card-title">Non-Priority Sector</div>
|
||||
<div class="ec-card-desc">Standard corporate / retail borrower — no special priority sector classification</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ec-card-toggle">
|
||||
<mat-slide-toggle [checked]="!!enabled['nps']" (change)="toggleCategory('nps')" (click)="$event.stopPropagation()"></mat-slide-toggle>
|
||||
<mat-icon class="ec-chevron" [class.open]="expanded['nps']">expand_more</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
@if (expanded['nps']) {
|
||||
<div class="ec-card-body" [formGroup]="npsForm">
|
||||
<div class="ec-sub-title">Sector Classification</div>
|
||||
<div class="ec-grid-3">
|
||||
<mat-form-field appearance="outline"><mat-label>Industry Sector</mat-label>
|
||||
<mat-select formControlName="industrySector">
|
||||
<mat-option value="Large Corporate">Large Corporate</mat-option>
|
||||
<mat-option value="Mid-Market Corporate">Mid-Market Corporate</mat-option>
|
||||
<mat-option value="HNI">High Net Worth Individual (HNI)</mat-option>
|
||||
<mat-option value="Retail - Salaried">Retail — Salaried</mat-option>
|
||||
<mat-option value="Retail - Self Employed">Retail — Self Employed</mat-option>
|
||||
<mat-option value="Government / PSU">Government / PSU</mat-option>
|
||||
<mat-option value="Trust / NGO / Society">Trust / NGO / Society</mat-option>
|
||||
<mat-option value="Real Estate">Real Estate</mat-option>
|
||||
<mat-option value="Other">Other</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>NIC Code (5-digit)</mat-label>
|
||||
<input matInput formControlName="nicCode" placeholder="e.g. 13111" maxlength="5" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Industry Description</mat-label>
|
||||
<input matInput formControlName="industryDescription" placeholder="Brief sector description" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Annual Revenue Band</mat-label>
|
||||
<mat-select formControlName="annualRevenueBand">
|
||||
<mat-option value="BELOW_1CR">Below ₹1 Cr</mat-option>
|
||||
<mat-option value="1CR_TO_10CR">₹1 Cr – ₹10 Cr</mat-option>
|
||||
<mat-option value="10CR_TO_100CR">₹10 Cr – ₹100 Cr</mat-option>
|
||||
<mat-option value="100CR_TO_500CR">₹100 Cr – ₹500 Cr</mat-option>
|
||||
<mat-option value="ABOVE_500CR">Above ₹500 Cr</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="ec-sub-title">Risk Classification</div>
|
||||
<div class="ec-grid-3">
|
||||
<mat-form-field appearance="outline"><mat-label>Risk Category</mat-label>
|
||||
<mat-select formControlName="riskCategory">
|
||||
<mat-option value="LOW">Low Risk</mat-option>
|
||||
<mat-option value="MEDIUM">Medium Risk</mat-option>
|
||||
<mat-option value="HIGH">High Risk</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Customer Risk Score</mat-label>
|
||||
<input matInput type="number" formControlName="customerRiskScore" placeholder="0–100" min="0" max="100" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>AML Risk Classification</mat-label>
|
||||
<mat-select formControlName="amlRiskClassification">
|
||||
<mat-option value="SDD">Standard Due Diligence (SDD)</mat-option>
|
||||
<mat-option value="CDD">Customer Due Diligence (CDD)</mat-option>
|
||||
<mat-option value="EDD">Enhanced Due Diligence (EDD)</mat-option>
|
||||
</mat-select></mat-form-field>
|
||||
</div>
|
||||
<div class="ec-flags">
|
||||
<div class="ec-flag-row">
|
||||
<div><span class="ec-flag-label">PEP Flag — Politically Exposed Person</span><span class="ec-flag-sub">Requires mandatory EDD as per RBI KYC Master Circular</span></div>
|
||||
<mat-slide-toggle formControlName="pepFlag"></mat-slide-toggle>
|
||||
</div>
|
||||
<div class="ec-flag-row">
|
||||
<div><span class="ec-flag-label">AML Watchlist Match</span><span class="ec-flag-sub">UN Sanctions / OFAC / EU / MHA match detected</span></div>
|
||||
<mat-slide-toggle formControlName="amlWatchlistMatch"></mat-slide-toggle>
|
||||
</div>
|
||||
<div class="ec-flag-row">
|
||||
<div><span class="ec-flag-label">Adverse Media Flag</span><span class="ec-flag-sub">Negative news screening result — requires review</span></div>
|
||||
<mat-slide-toggle formControlName="adverseMediaFlag"></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ec-reg-note">
|
||||
<strong>RBI KYC Master Circular 2023:</strong> PEP flag triggers mandatory EDD regardless of transaction size. AML watchlist match must be escalated to MLRO within 24 hours. Risk score reviewed annually for High Risk customers.
|
||||
</div>
|
||||
|
||||
<div class="ec-card-actions">
|
||||
<button mat-raised-button color="primary" [disabled]="!clientId || saving['nps']" (click)="saveCategory('nps')">
|
||||
@if (saving['nps']) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Saving... }
|
||||
@else if (saved['nps']) { <mat-icon>check</mat-icon> Saved }
|
||||
@else { Save Non-Priority Sector }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!clientId) {
|
||||
<div class="ec-info-banner">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<span>Saving classifications requires client to be created first. Complete General tab and click Next.</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Entity Classification — Scheme & Sector Classification
|
||||
*/
|
||||
|
||||
.ec-page { padding: 0; }
|
||||
|
||||
.step-header {
|
||||
background: #0f172a;
|
||||
padding: 16px 24px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
margin-bottom: 20px;
|
||||
&__left { display: flex; align-items: center; gap: 12px; }
|
||||
&__icon { font-size: 18px; color: #60a5fa; }
|
||||
&__title { font-size: 16px; font-weight: 600; color: #fff; margin: 0; }
|
||||
&__sub { font-size: 11px; color: rgba(255,255,255,0.5); margin: 2px 0 0; }
|
||||
}
|
||||
|
||||
.ec-chips-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ec-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.ec-no-chip { font-size: 12px; color: #94a3b8; padding: 4px 0; }
|
||||
|
||||
.ec-chip {
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.ec-chip-close {
|
||||
font-size: 14px !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
&:hover { opacity: 1; }
|
||||
}
|
||||
|
||||
.ec-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
&--open { border-color: #93c5fd; }
|
||||
}
|
||||
|
||||
.ec-card-head {
|
||||
padding: 14px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: #f8fafc; }
|
||||
}
|
||||
|
||||
.ec-card-head-left { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.ec-tag {
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ec-card-title { font-size: 14px; font-weight: 600; color: #1e293b; }
|
||||
.ec-card-desc { font-size: 12px; color: #64748b; margin-top: 2px; }
|
||||
.ec-card-toggle { display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
.ec-chevron {
|
||||
transition: transform 0.25s;
|
||||
color: #94a3b8;
|
||||
&.open { transform: rotate(180deg); }
|
||||
}
|
||||
|
||||
.ec-card-body {
|
||||
border-top: 1px solid #f1f5f9;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.ec-grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4px 16px;
|
||||
margin-bottom: 8px;
|
||||
mat-form-field { width: 100%; }
|
||||
}
|
||||
|
||||
.ec-grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px 16px;
|
||||
margin-bottom: 8px;
|
||||
mat-form-field { width: 100%; }
|
||||
}
|
||||
|
||||
.ec-sub-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.07em;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
margin: 16px 0 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
&:first-child { margin-top: 0; padding-top: 0; border-top: none; }
|
||||
}
|
||||
|
||||
.ec-check-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
mat-checkbox {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
&:hover { background: #f8fafc; border-color: #93c5fd; }
|
||||
}
|
||||
}
|
||||
|
||||
.ec-flags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ec-flag-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ec-flag-label { font-size: 13px; font-weight: 500; color: #1e293b; display: block; }
|
||||
.ec-flag-sub { font-size: 11px; color: #94a3b8; display: block; margin-top: 1px; }
|
||||
|
||||
.ec-reg-note {
|
||||
margin-top: 16px;
|
||||
padding: 10px 14px;
|
||||
background: #f0f9ff;
|
||||
border-left: 3px solid #0ea5e9;
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-size: 12px;
|
||||
color: #0c4a6e;
|
||||
line-height: 1.6;
|
||||
strong { font-weight: 600; }
|
||||
}
|
||||
|
||||
.ec-card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
button { display: flex; align-items: center; gap: 6px; }
|
||||
}
|
||||
|
||||
.ec-info-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #92400e;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) { .ec-grid-3 { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 600px) {
|
||||
.ec-grid-3, .ec-grid-2 { grid-template-columns: 1fr; }
|
||||
.ec-check-grid { grid-template-columns: 1fr; }
|
||||
.ec-card-head { padding: 12px 14px; }
|
||||
.ec-card-body { padding: 14px; }
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*/
|
||||
|
||||
import { Component, Input, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
|
||||
export interface CategoryMeta {
|
||||
id: string;
|
||||
label: string;
|
||||
tag: string;
|
||||
tagColor: string;
|
||||
tagBg: string;
|
||||
desc: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-client-entity-kyc-step',
|
||||
templateUrl: './client-entity-kyc-step.component.html',
|
||||
styleUrls: ['./client-entity-kyc-step.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
MatCheckbox,
|
||||
MatIcon,
|
||||
MatTooltip,
|
||||
MatProgressSpinner,
|
||||
MatSlideToggle
|
||||
]
|
||||
})
|
||||
export class ClientEntityKycStepComponent implements OnInit, OnChanges {
|
||||
private fb = inject(UntypedFormBuilder);
|
||||
private clientService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
|
||||
@Input() clientId: string = '';
|
||||
@Input() constitutionType: string = '';
|
||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||
@Input() readOnly = false;
|
||||
|
||||
today = new Date();
|
||||
|
||||
/** Category metadata */
|
||||
categories: CategoryMeta[] = [
|
||||
{ id: 'msme', label: 'MSME', tag: 'Udyam / MSME', tagColor: '#0A5C6B', tagBg: '#E6F4F6', desc: 'Micro, Small & Medium Enterprise — Ministry of MSME / Udyam Registration', icon: 'cogs' },
|
||||
{ id: 'startup', label: 'Startup India Registered', tag: 'DPIIT', tagColor: '#1A5FAB', tagBg: '#EAF2FB', desc: 'DPIIT recognition under Startup India initiative', icon: 'rocket' },
|
||||
{ id: 'mudra', label: 'Mudra Borrower', tag: 'PMMY', tagColor: '#8A5A00', tagBg: '#FEF3DC', desc: 'Pradhan Mantri Mudra Yojana — SIDBI / Mudra scheme', icon: 'hand-holding-usd' },
|
||||
{ id: 'eou', label: 'Export Oriented Unit', tag: 'EOU', tagColor: '#1B7A4A', tagBg: '#E8F5EE', desc: 'Customs-registered EOU under DGFT / Ministry of Commerce', icon: 'ship' },
|
||||
{ id: 'importexport', label: 'Import Export Business', tag: 'DGFT / IEC', tagColor: '#5B3A8A', tagBg: '#F0EBF8', desc: 'Entity with valid DGFT Importer Exporter Code', icon: 'exchange-alt' },
|
||||
{ id: 'psl', label: 'Priority Sector Borrower', tag: 'RBI PSL', tagColor: '#7B1F1F', tagBg: '#FCEAEA', desc: 'Eligible under RBI Priority Sector Lending guidelines', icon: 'bullseye' },
|
||||
{ id: 'nps', label: 'Non-Priority Sector', tag: 'General', tagColor: '#444441', tagBg: '#F1EFE8', desc: 'Standard corporate — no special priority sector classification', icon: 'building' }
|
||||
];
|
||||
|
||||
/** Toggle & expand state per category */
|
||||
enabled: Record<string, boolean> = {};
|
||||
expanded: Record<string, boolean> = {};
|
||||
|
||||
/** Forms per category */
|
||||
msmeForm!: UntypedFormGroup;
|
||||
startupForm!: UntypedFormGroup;
|
||||
mudraForm!: UntypedFormGroup;
|
||||
eouForm!: UntypedFormGroup;
|
||||
importexportForm!: UntypedFormGroup;
|
||||
pslForm!: UntypedFormGroup;
|
||||
npsForm!: UntypedFormGroup;
|
||||
|
||||
/** Saving state */
|
||||
saving: Record<string, boolean> = {};
|
||||
saved: Record<string, boolean> = {};
|
||||
|
||||
ngOnInit() {
|
||||
this.initForms();
|
||||
if (this.clientId) {
|
||||
this.loadExisting();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['readOnly']) {
|
||||
const forms = [this.msmeForm, this.startupForm, this.mudraForm, this.eouForm, this.importexportForm, this.pslForm, this.npsForm];
|
||||
for (const form of forms) {
|
||||
if (!form) continue;
|
||||
if (this.readOnly) {
|
||||
form.disable({ emitEvent: false });
|
||||
} else {
|
||||
form.enable({ emitEvent: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private initForms() {
|
||||
this.msmeForm = this.fb.group({
|
||||
udyamRegistrationNumber: [''],
|
||||
registrationDate: [''],
|
||||
registrationStatus: [''],
|
||||
msmeClassification: [''],
|
||||
enterpriseType: [''],
|
||||
nicActivityCode: [''],
|
||||
annualTurnover: [''],
|
||||
investmentPlantMachinery: [''],
|
||||
financialYearAssessment: [''],
|
||||
socialCategoryOwner: [''],
|
||||
genderPrimaryOwner: [''],
|
||||
noOfEmployees: [''],
|
||||
womenLedEnterprise: [false],
|
||||
divyangPwdOwned: [false]
|
||||
});
|
||||
|
||||
this.startupForm = this.fb.group({
|
||||
dpiitRecognitionNumber: [''],
|
||||
recognitionDate: [''],
|
||||
certificateExpiryDate: [''],
|
||||
entityStage: [''],
|
||||
sectorAsDpiit: [''],
|
||||
nicIndustryCode: [''],
|
||||
taxExemption80iac: [false],
|
||||
sidbiFund: [false],
|
||||
selfCertification: [false],
|
||||
fastTrackPatent: [false],
|
||||
govtTenderExemption: [false],
|
||||
creditGuaranteeCgss: [false],
|
||||
pslLoanLimit: [''],
|
||||
pslSubCategory: ['']
|
||||
});
|
||||
|
||||
this.mudraForm = this.fb.group({
|
||||
mudraCategory: [''],
|
||||
mudraLoanAccountNo: [''],
|
||||
lendingInstitutionName: [''],
|
||||
sanctionedAmount: [''],
|
||||
disbursementDate: [''],
|
||||
loanPurpose: [''],
|
||||
mudraCardNumber: [''],
|
||||
currentOutstanding: [''],
|
||||
repaymentStatus: [''],
|
||||
npaFlag: [false]
|
||||
});
|
||||
|
||||
this.eouForm = this.fb.group({
|
||||
eouRegistrationNumber: [''],
|
||||
registeredDcZone: [''],
|
||||
bondLutNumber: [''],
|
||||
validityFrom: [''],
|
||||
validityTo: [''],
|
||||
gstinEou: [''],
|
||||
annualExportObligation: [''],
|
||||
nfeTarget: [''],
|
||||
softexFilingStatus: [''],
|
||||
brcSubmissionStatus: [''],
|
||||
adCategoryBankCode: [''],
|
||||
femaDeclarationDate: ['']
|
||||
});
|
||||
|
||||
this.importexportForm = this.fb.group({
|
||||
iecCode: [''],
|
||||
iecIssueDate: [''],
|
||||
iecStatus: [''],
|
||||
tradeType: [''],
|
||||
primaryHsCode: [''],
|
||||
keyTradingCountries: [''],
|
||||
adCategoryBankCode: [''],
|
||||
ecgcPolicyNumber: [''],
|
||||
ecgcPolicyExpiry: [''],
|
||||
eefcAccountRequired: [false],
|
||||
pcfcFacilityRequired: [false],
|
||||
femaDeclarationObtained: [false]
|
||||
});
|
||||
|
||||
this.pslForm = this.fb.group({
|
||||
pslCategory: [''],
|
||||
pslSubCategory: [''],
|
||||
pslLoanPurposeCode: [''],
|
||||
pslEligibilityBasis: [''],
|
||||
fiddReportingCode: [''],
|
||||
pslSanctionedLimit: [''],
|
||||
msmeClassification: [''],
|
||||
udyamRegistrationNumber: [''],
|
||||
udyamRegistrationDate: [''],
|
||||
annualTurnover: [''],
|
||||
investmentPlantMachinery: [''],
|
||||
weakerSectionFlag: [false],
|
||||
scStBorrowerFlag: [false],
|
||||
womenEntrepreneurFlag: [false],
|
||||
minorityCommunityFlag: [false]
|
||||
});
|
||||
|
||||
this.npsForm = this.fb.group({
|
||||
industrySector: [''],
|
||||
nicCode: [''],
|
||||
industryDescription: [''],
|
||||
annualRevenueBand: [''],
|
||||
riskCategory: [''],
|
||||
customerRiskScore: [''],
|
||||
amlRiskClassification: [''],
|
||||
pepFlag: [false],
|
||||
amlWatchlistMatch: [false],
|
||||
adverseMediaFlag: [false]
|
||||
});
|
||||
}
|
||||
|
||||
getForm(categoryId: string): UntypedFormGroup {
|
||||
switch (categoryId) {
|
||||
case 'msme': return this.msmeForm;
|
||||
case 'startup': return this.startupForm;
|
||||
case 'mudra': return this.mudraForm;
|
||||
case 'eou': return this.eouForm;
|
||||
case 'importexport': return this.importexportForm;
|
||||
case 'psl': return this.pslForm;
|
||||
case 'nps': return this.npsForm;
|
||||
default: return this.fb.group({});
|
||||
}
|
||||
}
|
||||
|
||||
toggleCategory(id: string) {
|
||||
this.enabled[id] = !this.enabled[id];
|
||||
if (this.enabled[id]) {
|
||||
this.expanded[id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
toggleExpand(id: string) {
|
||||
this.expanded[id] = !this.expanded[id];
|
||||
}
|
||||
|
||||
get enabledCategories(): CategoryMeta[] {
|
||||
return this.categories.filter(c => this.enabled[c.id]);
|
||||
}
|
||||
|
||||
get enabledCount(): number {
|
||||
return this.enabledCategories.length;
|
||||
}
|
||||
|
||||
removeCategory(id: string) {
|
||||
this.enabled[id] = false;
|
||||
this.expanded[id] = false;
|
||||
}
|
||||
|
||||
/** Save a single category */
|
||||
saveCategory(categoryId: string) {
|
||||
if (!this.clientId) return;
|
||||
this.saving[categoryId] = true;
|
||||
const form = this.getForm(categoryId);
|
||||
const payload: any = { ...form.value, enabled: true, locale: 'en', dateFormat: this.settingsService.dateFormat };
|
||||
|
||||
// Format date fields
|
||||
for (const key of Object.keys(payload)) {
|
||||
if (payload[key] instanceof Date) {
|
||||
payload[key] = this.dateUtils.formatDate(payload[key], this.settingsService.dateFormat);
|
||||
}
|
||||
}
|
||||
|
||||
this.clientService.saveEntityClassification(this.clientId, categoryId, payload).subscribe({
|
||||
next: () => {
|
||||
this.saving[categoryId] = false;
|
||||
this.saved[categoryId] = true;
|
||||
setTimeout(() => this.saved[categoryId] = false, 3000);
|
||||
},
|
||||
error: () => {
|
||||
this.saving[categoryId] = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Load existing classifications */
|
||||
private loadExisting() {
|
||||
this.clientService.getEntityClassifications(this.clientId).subscribe({
|
||||
next: (data: any) => {
|
||||
if (!data) return;
|
||||
for (const cat of this.categories) {
|
||||
const catData = data[cat.id];
|
||||
if (catData && catData.enabled) {
|
||||
this.enabled[cat.id] = true;
|
||||
this.getForm(cat.id).patchValue(catData);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Get all classification data for parent */
|
||||
get classificationData(): any {
|
||||
const result: any = {};
|
||||
for (const cat of this.categories) {
|
||||
if (this.enabled[cat.id]) {
|
||||
result[cat.id] = { ...this.getForm(cat.id).value, enabled: true };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
get hasClassificationData(): boolean {
|
||||
return this.enabledCount > 0;
|
||||
}
|
||||
|
||||
// Keep old getter for backward compatibility
|
||||
get entityKycData(): any { return this.classificationData; }
|
||||
get hasEntityKycData(): boolean { return this.hasClassificationData; }
|
||||
}
|
||||
+340
-98
@@ -1,116 +1,358 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<h1 mat-dialog-title>{{ data.context }} {{ 'labels.heading.Family Member' | translate }}</h1>
|
||||
<div class="fm-dialog">
|
||||
<div class="fm-dialog__header">
|
||||
<mat-icon class="fm-dialog__icon">people</mat-icon>
|
||||
<span>{{ data.context }} Family Member</span>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="familyMemberForm">
|
||||
<h3>{{ 'labels.heading.Nominee Details (DA1 / DA5)' | translate }}</h3>
|
||||
<mat-dialog-content>
|
||||
<form [formGroup]="familyMemberForm">
|
||||
|
||||
<div class="layout-row-wrap responsive-column align-start-center">
|
||||
<!-- Nomination Type -->
|
||||
<mat-form-field class="flex-50-minus-25px">
|
||||
<mat-label>{{ 'labels.inputs.Nomination Type' | translate }}</mat-label>
|
||||
<mat-select formControlName="nominationType" required>
|
||||
<mat-option value="DA1">{{ 'labels.inputs.DA1 - Add Nominee' | translate }}</mat-option>
|
||||
<mat-option value="DA5">{{ 'labels.inputs.DA5 - Modify / Cancel Nominee' | translate }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<!-- ═══ VIEW MODE ═══ -->
|
||||
@if (isViewMode) {
|
||||
<!-- Linked client card -->
|
||||
@if (entryMode === 'linked' && selectedClient) {
|
||||
<div class="fm-linked-card">
|
||||
<div class="fm-linked-card__left">
|
||||
@if (linkedProfileImage) {
|
||||
<img [src]="linkedProfileImage" class="fm-linked-card__photo zoomable" alt="Profile"
|
||||
(click)="zoomImage(linkedProfileImage, 'Client Photograph')" />
|
||||
} @else {
|
||||
<mat-icon class="fm-linked-card__icon">person</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<div class="fm-linked-card__info">
|
||||
<span class="fm-linked-card__name">{{ selectedClient.displayName }}</span>
|
||||
<span class="fm-linked-card__meta">Client ID: {{ selectedClient.id }}{{ selectedClient.accountNumber ? ' · A/C: ' + selectedClient.accountNumber : '' }}</span>
|
||||
</div>
|
||||
@if (linkedSignatureImage) {
|
||||
<div class="fm-linked-card__sig">
|
||||
<img [src]="linkedSignatureImage" alt="Signature" class="zoomable"
|
||||
(click)="zoomImage(linkedSignatureImage, 'Specimen Signature')" />
|
||||
<span>Signature</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Nominee Name -->
|
||||
<mat-form-field class="flex-50-minus-25px">
|
||||
<mat-label>{{ 'labels.inputs.Nominee Name' | translate }}</mat-label>
|
||||
<input formControlName="firstName" required matInput />
|
||||
@if (familyMemberForm.controls.firstName.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Nominee Name' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
@if (fetchingLinkedData) {
|
||||
<div class="fm-fetching-hint">
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
<span>Fetching client details...</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Relationship -->
|
||||
<mat-form-field class="flex-50-minus-25px">
|
||||
<mat-label>{{ 'labels.inputs.Relationship' | translate }}</mat-label>
|
||||
<mat-select formControlName="relationshipId" required>
|
||||
@for (relation of data.options.relationshipIdOptions; track relation) {
|
||||
<mat-option [value]="relation.id">
|
||||
{{ relation.name | translateKey: 'catalogs' }}
|
||||
</mat-option>
|
||||
<!-- All details in read-only grid -->
|
||||
<div class="fm-readonly-grid">
|
||||
<div class="fm-readonly-item">
|
||||
<span class="fm-readonly-label">Full Name</span>
|
||||
<span class="fm-readonly-value">{{ familyMemberForm.get('firstName')?.value || '—' }}</span>
|
||||
</div>
|
||||
<div class="fm-readonly-item">
|
||||
<span class="fm-readonly-label">Relationship</span>
|
||||
<span class="fm-readonly-value">{{ relationshipName }}</span>
|
||||
</div>
|
||||
<div class="fm-readonly-item">
|
||||
<span class="fm-readonly-label">Date of Birth</span>
|
||||
<span class="fm-readonly-value">{{ familyMemberForm.get('dateOfBirth')?.value ? (familyMemberForm.get('dateOfBirth')?.value | date:'dd MMM yyyy') : '—' }}</span>
|
||||
</div>
|
||||
<div class="fm-readonly-item">
|
||||
<span class="fm-readonly-label">Nationality</span>
|
||||
<span class="fm-readonly-value">{{ familyMemberForm.get('nationality')?.value || '—' }}</span>
|
||||
</div>
|
||||
<div class="fm-readonly-item">
|
||||
<span class="fm-readonly-label">Aadhaar</span>
|
||||
<span class="fm-readonly-value">
|
||||
{{ familyMemberForm.get('aadhaarNumber')?.value || '—' }}
|
||||
@if (linkedKycStatus?.aadhaarVerified) {
|
||||
<span class="fm-kyc-badge verified"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else if (familyMemberForm.get('aadhaarNumber')?.value) {
|
||||
<span class="fm-kyc-badge not-verified"><mat-icon>cancel</mat-icon> Not Verified</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="fm-readonly-item">
|
||||
<span class="fm-readonly-label">PAN</span>
|
||||
<span class="fm-readonly-value">
|
||||
{{ familyMemberForm.get('panNumber')?.value || '—' }}
|
||||
@if (linkedKycStatus?.panVerified) {
|
||||
<span class="fm-kyc-badge verified"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else if (familyMemberForm.get('panNumber')?.value) {
|
||||
<span class="fm-kyc-badge not-verified"><mat-icon>cancel</mat-icon> Not Verified</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="fm-readonly-item">
|
||||
<span class="fm-readonly-label">Status</span>
|
||||
<span class="fm-readonly-value">
|
||||
{{ familyMemberForm.get('isAlive')?.value !== false ? 'Alive' : 'Deceased' }}
|
||||
@if (familyMemberForm.get('isDependent')?.value) { · Dependent }
|
||||
</span>
|
||||
</div>
|
||||
@if (isSpouse && familyMemberForm.get('marriageDate')?.value) {
|
||||
<div class="fm-readonly-item">
|
||||
<span class="fm-readonly-label">Marriage Date</span>
|
||||
<span class="fm-readonly-value">{{ familyMemberForm.get('marriageDate')?.value | date:'dd MMM yyyy' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (isSpouse && familyMemberForm.get('marriageAuthority')?.value) {
|
||||
<div class="fm-readonly-item">
|
||||
<span class="fm-readonly-label">Issuing Authority</span>
|
||||
<span class="fm-readonly-value">{{ getMarriageAuthorityLabel(familyMemberForm.get('marriageAuthority')?.value) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (familyMemberForm.get('remarks')?.value) {
|
||||
<div class="fm-readonly-remarks">
|
||||
<span class="fm-readonly-label">Remarks</span>
|
||||
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
|
||||
</div>
|
||||
}
|
||||
</mat-select>
|
||||
@if (familyMemberForm.controls.relationshipId.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Relationship' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Date of Birth -->
|
||||
<mat-form-field class="flex-50-minus-25px" (click)="dueDatePicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Date Of Birth' | translate }}</mat-label>
|
||||
<input
|
||||
formControlName="dateOfBirth"
|
||||
matInput
|
||||
[max]="maxDate"
|
||||
[matDatepicker]="dueDatePicker"
|
||||
placeholder="{{ 'labels.text.Optional' | translate }}"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="dueDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #dueDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<!-- ═══ ADD / EDIT MODE ═══ -->
|
||||
@if (!isViewMode) {
|
||||
<!-- Mode Toggle -->
|
||||
<div class="fm-mode-toggle">
|
||||
<button type="button" class="fm-mode-btn" [class.active]="entryMode === 'manual'" (click)="setMode('manual')">
|
||||
<mat-icon>edit</mat-icon> Enter Manually
|
||||
</button>
|
||||
<button type="button" class="fm-mode-btn" [class.active]="entryMode === 'linked'" (click)="setMode('linked')">
|
||||
<mat-icon>search</mat-icon> Link Existing Client
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Percentage Share -->
|
||||
<mat-form-field class="flex-50-minus-25px">
|
||||
<mat-label>{{ 'labels.inputs.Percentage Share' | translate }} (%)</mat-label>
|
||||
<input type="number" formControlName="percentageShare" matInput min="0" max="100" />
|
||||
</mat-form-field>
|
||||
<!-- Linked Client Search -->
|
||||
@if (entryMode === 'linked') {
|
||||
@if (!selectedClient) {
|
||||
<div class="fm-search-wrapper">
|
||||
<mat-form-field appearance="outline" class="fm-search-field">
|
||||
<mat-label>Search by Name or Client ID</mat-label>
|
||||
<input matInput [value]="searchText" (input)="onSearch($any($event.target).value)" autocomplete="off" />
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
</mat-form-field>
|
||||
@if (searching) {
|
||||
<div class="fm-search-results">
|
||||
<div class="fm-search-loading"><mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner> Searching...</div>
|
||||
</div>
|
||||
}
|
||||
@if (!searching && searchResults.length > 0) {
|
||||
<div class="fm-search-results">
|
||||
@for (r of searchResults; track r.id) {
|
||||
<div class="fm-search-item" (click)="selectClient(r)">
|
||||
<mat-icon class="fm-search-item__icon">person</mat-icon>
|
||||
<div class="fm-search-item__info">
|
||||
<span class="fm-search-item__name">{{ r.displayName }}</span>
|
||||
<span class="fm-search-item__meta">ID: {{ r.id }} · {{ r.officeName }}@if (r.dateOfBirth) { · DOB: {{ r.dateOfBirth | dateFormat }} }@if (r.fatherSpouseName) { · {{ r.fatherSpouseName }} }@if (r.mobileNo) { · {{ r.mobileNo }} }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (!searching && searchText.length >= 2 && searchResults.length === 0) {
|
||||
<div class="search-no-results-hint">No clients found</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (selectedClient) {
|
||||
<!-- Linked Client Card with Profile & Signature -->
|
||||
<div class="fm-linked-card">
|
||||
<div class="fm-linked-card__left">
|
||||
@if (linkedProfileImage) {
|
||||
<img [src]="linkedProfileImage" class="fm-linked-card__photo zoomable" alt="Profile"
|
||||
(click)="zoomImage(linkedProfileImage, 'Client Photograph')" />
|
||||
} @else {
|
||||
<mat-icon class="fm-linked-card__icon">person</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<div class="fm-linked-card__info">
|
||||
<span class="fm-linked-card__name">{{ selectedClient.displayName }}</span>
|
||||
<span class="fm-linked-card__meta">Client ID: {{ selectedClient.id }}{{ selectedClient.accountNumber ? ' · A/C: ' + selectedClient.accountNumber : '' }}</span>
|
||||
</div>
|
||||
@if (linkedSignatureImage) {
|
||||
<div class="fm-linked-card__sig">
|
||||
<img [src]="linkedSignatureImage" alt="Signature" class="zoomable"
|
||||
(click)="zoomImage(linkedSignatureImage, 'Specimen Signature')" />
|
||||
<span>Signature</span>
|
||||
</div>
|
||||
}
|
||||
<button mat-icon-button type="button" (click)="removeLinkedClient()" class="fm-linked-card__remove"><mat-icon>close</mat-icon></button>
|
||||
</div>
|
||||
|
||||
<!-- Nominee is Minor -->
|
||||
<mat-checkbox formControlName="isDependent" labelPosition="before" class="flex-50-minus-25px">
|
||||
{{ 'labels.inputs.Nominee is Minor' | translate }}?
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
@if (fetchingLinkedData) {
|
||||
<div class="fm-fetching-hint">
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
<span>Fetching client details...</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Guardian Details (shown when minor) -->
|
||||
@if (familyMemberForm.get('isDependent').value) {
|
||||
<h3>{{ 'labels.heading.Guardian Details (If Minor)' | translate }}</h3>
|
||||
<div class="layout-row-wrap responsive-column align-start-center">
|
||||
<mat-form-field class="flex-50-minus-25px">
|
||||
<mat-label>{{ 'labels.inputs.Guardian Name' | translate }}</mat-label>
|
||||
<input formControlName="guardianName" matInput />
|
||||
</mat-form-field>
|
||||
<!-- Read-only fetched details -->
|
||||
@if (!fetchingLinkedData) {
|
||||
<div class="fm-readonly-grid">
|
||||
<div class="fm-readonly-item">
|
||||
<span class="fm-readonly-label">Full Name</span>
|
||||
<span class="fm-readonly-value">{{ familyMemberForm.get('firstName')?.value || '—' }}</span>
|
||||
</div>
|
||||
<div class="fm-readonly-item">
|
||||
<span class="fm-readonly-label">Date of Birth</span>
|
||||
<span class="fm-readonly-value">{{ familyMemberForm.get('dateOfBirth')?.value ? (familyMemberForm.get('dateOfBirth')?.value | date:'dd MMM yyyy') : '—' }}</span>
|
||||
</div>
|
||||
<div class="fm-readonly-item">
|
||||
<span class="fm-readonly-label">Aadhaar</span>
|
||||
<span class="fm-readonly-value">
|
||||
{{ familyMemberForm.get('aadhaarNumber')?.value || '—' }}
|
||||
@if (linkedKycStatus?.aadhaarVerified) {
|
||||
<span class="fm-kyc-badge verified"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else if (familyMemberForm.get('aadhaarNumber')?.value) {
|
||||
<span class="fm-kyc-badge not-verified"><mat-icon>cancel</mat-icon> Not Verified</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="fm-readonly-item">
|
||||
<span class="fm-readonly-label">PAN</span>
|
||||
<span class="fm-readonly-value">
|
||||
{{ familyMemberForm.get('panNumber')?.value || '—' }}
|
||||
@if (linkedKycStatus?.panVerified) {
|
||||
<span class="fm-kyc-badge verified"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else if (familyMemberForm.get('panNumber')?.value) {
|
||||
<span class="fm-kyc-badge not-verified"><mat-icon>cancel</mat-icon> Not Verified</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<mat-form-field class="flex-50-minus-25px">
|
||||
<mat-label>{{ 'labels.inputs.Relationship with Minor' | translate }}</mat-label>
|
||||
<input formControlName="guardianRelationship" matInput />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
<!-- Core Fields -->
|
||||
<div class="fm-grid">
|
||||
@if (entryMode === 'manual') {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Full Name</mat-label>
|
||||
<input matInput required formControlName="firstName" placeholder="Full name of family member" />
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!-- Remarks -->
|
||||
<div class="layout-row-wrap responsive-column align-start-center">
|
||||
<mat-form-field class="flex-fill">
|
||||
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
|
||||
<input formControlName="remarks" matInput />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Relationship</mat-label>
|
||||
<mat-select required formControlName="relationshipId">
|
||||
@for (rel of data.options.relationshipIdOptions; track rel) {
|
||||
<mat-option [value]="rel.id">{{ rel.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-dialog-actions align="center">
|
||||
<button type="button" mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
|
||||
<button
|
||||
type="button"
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
[disabled]="!familyMemberForm.valid"
|
||||
[mat-dialog-close]="{ member: familyMember }"
|
||||
>
|
||||
{{ 'labels.buttons.Confirm' | translate }}
|
||||
</button>
|
||||
@if (entryMode === 'manual') {
|
||||
<mat-form-field appearance="outline" (click)="dobPicker.open()">
|
||||
<mat-label>Date of Birth</mat-label>
|
||||
<input matInput [max]="maxDate" [matDatepicker]="dobPicker" formControlName="dateOfBirth" />
|
||||
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #dobPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Nationality</mat-label>
|
||||
<mat-select formControlName="nationality">
|
||||
<mat-option value="INDIAN">Indian</mat-option>
|
||||
<mat-option value="NRI">NRI</mat-option>
|
||||
<mat-option value="FOREIGN_NATIONAL">Foreign National</mat-option>
|
||||
<mat-option value="OTHER">Other</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Marriage Details (Spouse only) -->
|
||||
@if (isSpouse) {
|
||||
<div class="fm-section-label">Marriage Details</div>
|
||||
<div class="fm-grid">
|
||||
<mat-form-field appearance="outline" (click)="marriagePicker.open()">
|
||||
<mat-label>Marriage Date</mat-label>
|
||||
<input matInput [max]="maxDate" [matDatepicker]="marriagePicker" formControlName="marriageDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="marriagePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #marriagePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Issuing Authority</mat-label>
|
||||
<mat-select formControlName="marriageAuthority">
|
||||
@for (auth of marriageAuthorityOptions; track auth.value) {
|
||||
<mat-option [value]="auth.value">{{ auth.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Marriage Certificate Upload -->
|
||||
<div class="fm-file-upload">
|
||||
@if (!marriageCertFile) {
|
||||
<button type="button" mat-stroked-button class="fm-file-btn" (click)="certInput.click()">
|
||||
<mat-icon>upload_file</mat-icon> Upload Marriage Certificate
|
||||
</button>
|
||||
<input #certInput type="file" hidden accept=".pdf,.jpg,.jpeg,.png"
|
||||
(change)="onMarriageCertSelected($event)" />
|
||||
<span class="fm-file-hint">PDF, JPG or PNG (max 5 MB)</span>
|
||||
} @else {
|
||||
<div class="fm-file-attached">
|
||||
<mat-icon class="fm-file-attached__icon">description</mat-icon>
|
||||
<span class="fm-file-attached__name">{{ marriageCertFileName }}</span>
|
||||
<button mat-icon-button type="button" (click)="removeMarriageCert()" class="fm-file-attached__remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ID Verification (manual mode only) -->
|
||||
@if (entryMode === 'manual') {
|
||||
<div class="fm-section-label">ID Verification</div>
|
||||
<div class="fm-grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Aadhaar Number</mat-label>
|
||||
<input matInput formControlName="aadhaarNumber" maxlength="12" placeholder="12-digit Aadhaar"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>PAN Number</mat-label>
|
||||
<input matInput formControlName="panNumber" maxlength="10" placeholder="ABCDE1234F" style="text-transform:uppercase"
|
||||
(input)="familyMemberForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), {emitEvent:false})" />
|
||||
@if (familyMemberForm.get('panNumber')?.hasError('pattern') && familyMemberForm.get('panNumber')?.touched) {
|
||||
<mat-error>Invalid PAN format</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Status -->
|
||||
<div class="fm-checkbox-row">
|
||||
<mat-checkbox formControlName="isAlive">Is Alive</mat-checkbox>
|
||||
<mat-checkbox formControlName="isDependent">Is Dependent</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- Remarks -->
|
||||
<mat-form-field appearance="outline" class="fm-full-width">
|
||||
<mat-label>Remarks</mat-label>
|
||||
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
||||
</mat-form-field>
|
||||
}
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
@if (isViewMode) {
|
||||
<button mat-flat-button color="primary" mat-dialog-close type="button">Close</button>
|
||||
} @else {
|
||||
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button"
|
||||
[disabled]="familyMemberForm.invalid"
|
||||
[mat-dialog-close]="{ member: familyMember }">
|
||||
{{ data.context === 'Edit' ? 'Update' : 'Add Member' }}
|
||||
</button>
|
||||
}
|
||||
</mat-dialog-actions>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
+321
-25
@@ -1,34 +1,330 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
.fm-dialog__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 20px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
|
||||
.mat-form-field-hint {
|
||||
font-size: 75%;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
margin-top: 4px;
|
||||
.fm-dialog__icon { color: #1074b9; }
|
||||
}
|
||||
|
||||
.fm-mode-toggle {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.fm-mode-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
mat-icon { font-size: 18px; width: 18px; height: 18px; }
|
||||
|
||||
&.active {
|
||||
border-color: #1074b9;
|
||||
background: #eff6ff;
|
||||
color: #1074b9;
|
||||
}
|
||||
|
||||
&:hover:not(.active) { border-color: #94a3b8; }
|
||||
}
|
||||
|
||||
.fm-search-wrapper { position: relative; margin-bottom: 12px; }
|
||||
.fm-search-field { width: 100%; }
|
||||
|
||||
.fm-search-results {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.fm-search-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.fm-search-empty {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-no-results-hint {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
padding: 4px 0 0;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.fm-search-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
&:hover { background: #f0f7ff; }
|
||||
|
||||
&__icon { color: #1074b9; font-size: 20px; }
|
||||
&__info { display: flex; flex-direction: column; }
|
||||
&__name { font-size: 13px; font-weight: 600; color: #1e293b; }
|
||||
&__meta { font-size: 11px; color: #64748b; }
|
||||
}
|
||||
|
||||
.fm-linked-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__left { flex-shrink: 0; }
|
||||
&__icon { color: #1074b9; }
|
||||
&__photo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #e2e8f0;
|
||||
}
|
||||
&__info { flex: 1; display: flex; flex-direction: column; }
|
||||
&__name { font-size: 14px; font-weight: 600; color: #1e293b; }
|
||||
&__meta { font-size: 12px; color: #64748b; font-family: 'Roboto Mono', monospace; }
|
||||
&__sig {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
height: 28px;
|
||||
max-width: 70px;
|
||||
object-fit: contain;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
span {
|
||||
font-size: 9px;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
}
|
||||
&__remove { color: #94a3b8 !important; &:hover { color: #ef4444 !important; } }
|
||||
}
|
||||
|
||||
.fm-fetching-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin: -8px 0 12px;
|
||||
}
|
||||
|
||||
.fm-readonly-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2px 16px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.fm-readonly-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.fm-readonly-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.fm-readonly-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fm-readonly-remarks {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.fm-readonly-label { display: block; margin-bottom: 4px; }
|
||||
.fm-readonly-value { font-size: 13px; color: #475569; line-height: 1.5; }
|
||||
}
|
||||
|
||||
.fm-kyc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
|
||||
mat-icon {
|
||||
font-size: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
&.verified {
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
}
|
||||
&.not-verified {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
form {
|
||||
padding: 0 1.5rem;
|
||||
.fm-section-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #1074b9;
|
||||
margin: 8px 0 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.layout-row-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
.fm-checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
@media screen and (width >= 960px) {
|
||||
.layout-row-wrap > .flex-fill:nth-child(odd),
|
||||
.layout-row-wrap > .flex-50-minus-25px:nth-child(odd) {
|
||||
margin-right: 25px;
|
||||
}
|
||||
.fm-full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layout-row-wrap > .flex-fill:last-child,
|
||||
.layout-row-wrap > .flex-50-minus-25px:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
.zoomable {
|
||||
cursor: zoom-in;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .image-zoom-panel {
|
||||
.mat-mdc-dialog-container { padding: 0 !important; border-radius: 10px !important; overflow: hidden; }
|
||||
}
|
||||
|
||||
.fm-file-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.fm-file-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1074b9;
|
||||
border-color: #cbd5e1;
|
||||
|
||||
mat-icon { font-size: 18px; width: 18px; height: 18px; }
|
||||
}
|
||||
|
||||
.fm-file-hint {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.fm-file-attached {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #f0f7ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 6px;
|
||||
width: 100%;
|
||||
|
||||
&__icon { color: #1074b9; font-size: 20px; }
|
||||
&__name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
&__remove {
|
||||
color: #94a3b8 !important;
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
line-height: 28px !important;
|
||||
|
||||
mat-icon { font-size: 16px; width: 16px; height: 16px; }
|
||||
&:hover { color: #ef4444 !important; }
|
||||
}
|
||||
}
|
||||
|
||||
+341
-61
@@ -6,26 +6,28 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
|
||||
import {
|
||||
MatDialog,
|
||||
MatDialogRef,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogTitle,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
MatDialogClose,
|
||||
MatDialogContent
|
||||
} from '@angular/material/dialog';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
|
||||
/** Custom Services */
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
|
||||
|
||||
/**
|
||||
* Client Family Members Dialog
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-client-family-member-dialog',
|
||||
templateUrl: './client-family-member-dialog.component.html',
|
||||
@@ -33,9 +35,14 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatCheckbox,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
MatDialogClose,
|
||||
MatIcon,
|
||||
MatProgressSpinner,
|
||||
FaIconComponent,
|
||||
DateFormatPipe
|
||||
]
|
||||
})
|
||||
export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
@@ -44,87 +51,360 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
private dateUtils = inject(Dates);
|
||||
data = inject(MAT_DIALOG_DATA);
|
||||
private settingsService = inject(SettingsService);
|
||||
private clientsService = inject(ClientsService);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
/** Maximum Due Date allowed. */
|
||||
maxDate = new Date();
|
||||
|
||||
/** Add/Edit family member form. */
|
||||
familyMemberForm: UntypedFormGroup;
|
||||
|
||||
/** Client search */
|
||||
searchText = '';
|
||||
searchResults: any[] = [];
|
||||
searching = false;
|
||||
selectedClient: any = null;
|
||||
private searchTimer: any = null;
|
||||
|
||||
/** Mode: 'manual' or 'linked' */
|
||||
entryMode: 'manual' | 'linked' = 'manual';
|
||||
|
||||
/** Marriage authority options */
|
||||
marriageAuthorityOptions = [
|
||||
{ value: 'SUB_REGISTRAR_OFFICE', label: 'Sub-Registrar Office' },
|
||||
{ value: 'MARRIAGE_REGISTRAR', label: 'Registrar of Marriages' },
|
||||
{ value: 'COURT_MARRIAGE_OFFICER', label: 'Court Marriage Officer' },
|
||||
{ value: 'MUNICIPAL_AUTHORITY', label: 'Municipal Corporation / Nagar Palika' },
|
||||
{ value: 'EMBASSY_CONSULATE', label: 'Indian Embassy / Consulate' },
|
||||
{ value: 'DEFENCE_AUTHORITY', label: 'Defence Authority' }
|
||||
];
|
||||
|
||||
/** Whether selected relationship is Spouse */
|
||||
get isSpouse(): boolean {
|
||||
const relId = this.familyMemberForm?.get('relationshipId')?.value;
|
||||
if (!relId || !this.data?.options?.relationshipIdOptions) return false;
|
||||
const rel = this.data.options.relationshipIdOptions.find((r: any) => r.id === relId);
|
||||
return rel?.name?.toUpperCase() === 'SPOUSE';
|
||||
}
|
||||
|
||||
/** Whether dialog is in View-only mode */
|
||||
get isViewMode(): boolean { return this.data.context === 'View'; }
|
||||
|
||||
/** Get relationship name from ID */
|
||||
get relationshipName(): string {
|
||||
const relId = this.familyMemberForm?.get('relationshipId')?.value;
|
||||
if (!relId || !this.data?.options?.relationshipIdOptions) return '—';
|
||||
const rel = this.data.options.relationshipIdOptions.find((r: any) => r.id === relId);
|
||||
return rel?.name || '—';
|
||||
}
|
||||
|
||||
/** Open image in zoom overlay */
|
||||
zoomImage(imageSrc: any, title: string): void {
|
||||
this.dialog.open(ImageZoomDialogComponent, {
|
||||
data: { imageSrc, title },
|
||||
panelClass: 'image-zoom-panel',
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '90vh'
|
||||
});
|
||||
}
|
||||
|
||||
/** Get marriage authority display label */
|
||||
getMarriageAuthorityLabel(value: string): string {
|
||||
return this.marriageAuthorityOptions.find(a => a.value === value)?.label || value || '—';
|
||||
}
|
||||
|
||||
/** Marriage certificate file */
|
||||
marriageCertFile: File | null = null;
|
||||
marriageCertFileName: string = '';
|
||||
|
||||
/** Linked client fetched data */
|
||||
fetchingLinkedData = false;
|
||||
linkedProfileImage: SafeUrl | null = null;
|
||||
linkedSignatureImage: SafeUrl | null = null;
|
||||
/** KYC verification status for linked client */
|
||||
linkedKycStatus: {
|
||||
panVerified?: boolean;
|
||||
aadhaarVerified?: boolean;
|
||||
panHolderName?: string;
|
||||
aadhaarHolderName?: string;
|
||||
riskCategory?: number;
|
||||
} | null = null;
|
||||
|
||||
ngOnInit() {
|
||||
this.maxDate = this.settingsService.businessDate;
|
||||
this.createFamilyMemberForm();
|
||||
if (this.data.context === 'Edit') {
|
||||
if (this.data.context === 'Edit' || this.data.context === 'View') {
|
||||
const m = this.data.member;
|
||||
this.familyMemberForm.patchValue({
|
||||
nominationType: this.data.member.nominationType || 'DA1',
|
||||
firstName: this.data.member.firstName,
|
||||
relationshipId: this.data.member.relationshipId,
|
||||
dateOfBirth: this.data.member.dateOfBirth && new Date(this.data.member.dateOfBirth),
|
||||
percentageShare: this.data.member.percentageShare ?? 100,
|
||||
isDependent: this.data.member.isDependent,
|
||||
guardianName: this.data.member.guardianName || '',
|
||||
guardianRelationship: this.data.member.guardianRelationship || '',
|
||||
remarks: this.data.member.remarks || ''
|
||||
firstName: m.firstName,
|
||||
relationshipId: m.relationshipId,
|
||||
dateOfBirth: m.dateOfBirth && new Date(m.dateOfBirth),
|
||||
isDependent: m.isDependent,
|
||||
remarks: m.remarks || '',
|
||||
aadhaarNumber: m.aadhaarNumber || '',
|
||||
panNumber: m.panNumber || '',
|
||||
nationality: m.nationality || 'INDIAN',
|
||||
isAlive: m.isAlive !== false,
|
||||
marriageDate: m.marriageDate && new Date(m.marriageDate),
|
||||
marriageAuthority: m.marriageAuthority || '',
|
||||
linkedClientId: m.linkedClientId || ''
|
||||
});
|
||||
if (m.linkedClientId) {
|
||||
this.entryMode = 'linked';
|
||||
this.selectedClient = { id: m.linkedClientId, displayName: m.linkedClientName || m.firstName || `Client #${m.linkedClientId}` };
|
||||
// Re-fetch linked client details (profile, signature, KYC status)
|
||||
this.fetchLinkedClientData(m.linkedClientId.toString());
|
||||
}
|
||||
if (this.isViewMode) {
|
||||
this.familyMemberForm.disable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Family Member Form
|
||||
*/
|
||||
createFamilyMemberForm() {
|
||||
this.familyMemberForm = this.formBuilder.group({
|
||||
nominationType: ['DA1'],
|
||||
firstName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
relationshipId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
firstName: ['', Validators.required],
|
||||
relationshipId: ['', Validators.required],
|
||||
dateOfBirth: [''],
|
||||
percentageShare: [100],
|
||||
isDependent: [false],
|
||||
guardianName: [''],
|
||||
guardianRelationship: [''],
|
||||
remarks: ['']
|
||||
remarks: [''],
|
||||
aadhaarNumber: [''],
|
||||
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)],
|
||||
nationality: ['INDIAN'],
|
||||
isAlive: [true],
|
||||
marriageDate: [''],
|
||||
marriageAuthority: [''],
|
||||
linkedClientId: ['']
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Formatted Family Member
|
||||
*/
|
||||
get familyMember() {
|
||||
const formValue = {
|
||||
...this.familyMemberForm.value
|
||||
};
|
||||
/** Handle marriage certificate file selection */
|
||||
onMarriageCertSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files?.length) {
|
||||
this.marriageCertFile = input.files[0];
|
||||
this.marriageCertFileName = this.marriageCertFile.name;
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
removeMarriageCert(): void {
|
||||
this.marriageCertFile = null;
|
||||
this.marriageCertFileName = '';
|
||||
}
|
||||
|
||||
/** Search existing clients */
|
||||
onSearch(text: string): void {
|
||||
this.searchText = text;
|
||||
clearTimeout(this.searchTimer);
|
||||
if (!text || text.length < 2) {
|
||||
this.searchResults = [];
|
||||
return;
|
||||
}
|
||||
this.searching = true;
|
||||
this.searchTimer = setTimeout(() => {
|
||||
this.clientsService.searchByText(text, 0, 8).subscribe({
|
||||
next: (data: any) => {
|
||||
this.searchResults = data?.content || [];
|
||||
this.searching = false;
|
||||
this.clientsService.enrichSearchResults(this.searchResults);
|
||||
},
|
||||
error: () => { this.searchResults = []; this.searching = false; }
|
||||
});
|
||||
}, 400);
|
||||
}
|
||||
|
||||
/** Select a client from search results and fetch full details */
|
||||
selectClient(client: any): void {
|
||||
this.selectedClient = client;
|
||||
this.searchResults = [];
|
||||
this.searchText = '';
|
||||
this.familyMemberForm.patchValue({
|
||||
firstName: client.displayName,
|
||||
linkedClientId: client.id
|
||||
});
|
||||
this.fetchLinkedClientData(client.id);
|
||||
}
|
||||
|
||||
/** Fetch DOB, KYC, profile image & signature for linked client */
|
||||
private fetchLinkedClientData(clientId: string): void {
|
||||
this.fetchingLinkedData = true;
|
||||
this.linkedProfileImage = null;
|
||||
this.linkedSignatureImage = null;
|
||||
this.linkedKycStatus = null;
|
||||
|
||||
// Fetch client details (DOB, gender etc.)
|
||||
this.clientsService.getClientData(clientId).subscribe({
|
||||
next: (data: any) => {
|
||||
if (data.dateOfBirth) {
|
||||
this.familyMemberForm.patchValue({ dateOfBirth: new Date(data.dateOfBirth) });
|
||||
}
|
||||
// Make fetched fields read-only
|
||||
this.setLinkedFieldsReadonly(true);
|
||||
this.fetchingLinkedData = false;
|
||||
},
|
||||
error: () => { this.fetchingLinkedData = false; }
|
||||
});
|
||||
|
||||
// Fetch KYC (PAN, Aadhaar, verification status)
|
||||
this.clientsService.getClientKyc(clientId).subscribe({
|
||||
next: (kyc: any) => {
|
||||
if (kyc) {
|
||||
const patch: any = {};
|
||||
if (kyc.panNumber) { patch.panNumber = kyc.panNumber; }
|
||||
if (kyc.aadhaarNumberMasked) { patch.aadhaarNumber = kyc.aadhaarNumberMasked; }
|
||||
this.familyMemberForm.patchValue(patch);
|
||||
this.linkedKycStatus = {
|
||||
panVerified: kyc.panVerified || false,
|
||||
aadhaarVerified: kyc.aadhaarVerified || false,
|
||||
panHolderName: kyc.panFullName || kyc.panHolderName || '',
|
||||
aadhaarHolderName: kyc.aadhaarHolderName || '',
|
||||
riskCategory: kyc.riskCategory || null
|
||||
};
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
|
||||
// Fetch profile image
|
||||
this.clientsService.getClientProfileImage(clientId).subscribe({
|
||||
next: (imageData: any) => {
|
||||
if (imageData) {
|
||||
this.linkedProfileImage = this.sanitizer.bypassSecurityTrustResourceUrl(imageData);
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
|
||||
// Fetch signature image
|
||||
this.clientsService.getClientDocuments(clientId).subscribe({
|
||||
next: (docs: any[]) => {
|
||||
const sigDoc = docs?.find((d: any) =>
|
||||
d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
|
||||
);
|
||||
if (sigDoc) {
|
||||
this.clientsService.getClientSignatureImage(clientId, sigDoc.id.toString()).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
this.linkedSignatureImage = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
/** Disable/enable fields that are auto-populated from linked client */
|
||||
private setLinkedFieldsReadonly(readonly: boolean): void {
|
||||
const fields = ['firstName', 'dateOfBirth', 'aadhaarNumber', 'panNumber'];
|
||||
fields.forEach(f => {
|
||||
const ctrl = this.familyMemberForm.get(f);
|
||||
if (ctrl) {
|
||||
readonly ? ctrl.disable() : ctrl.enable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove linked client */
|
||||
removeLinkedClient(): void {
|
||||
this.selectedClient = null;
|
||||
this.linkedProfileImage = null;
|
||||
this.linkedSignatureImage = null;
|
||||
this.linkedKycStatus = null;
|
||||
this.setLinkedFieldsReadonly(false);
|
||||
this.familyMemberForm.patchValue({
|
||||
firstName: '', linkedClientId: '', dateOfBirth: '', aadhaarNumber: '', panNumber: ''
|
||||
});
|
||||
}
|
||||
|
||||
/** Switch entry mode */
|
||||
setMode(mode: 'manual' | 'linked'): void {
|
||||
this.entryMode = mode;
|
||||
if (mode === 'manual') {
|
||||
this.removeLinkedClient();
|
||||
}
|
||||
}
|
||||
|
||||
get familyMember() {
|
||||
const formValue = { ...this.familyMemberForm.getRawValue() };
|
||||
const locale = this.settingsService.language.code;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const prevDateOfBirth: Date = formValue.dateOfBirth;
|
||||
|
||||
if (prevDateOfBirth) {
|
||||
if (formValue.dateOfBirth instanceof Date) {
|
||||
formValue.dateOfBirth = this.dateUtils.formatDate(prevDateOfBirth, dateFormat);
|
||||
}
|
||||
} else {
|
||||
if (formValue.dateOfBirth instanceof Date) {
|
||||
formValue.dateOfBirth = this.dateUtils.formatDate(formValue.dateOfBirth, dateFormat);
|
||||
} else if (!formValue.dateOfBirth) {
|
||||
delete formValue.dateOfBirth;
|
||||
}
|
||||
|
||||
const familyMember = {
|
||||
...formValue,
|
||||
dateFormat,
|
||||
locale
|
||||
};
|
||||
if (formValue.marriageDate instanceof Date) {
|
||||
formValue.marriageDate = this.dateUtils.formatDate(formValue.marriageDate, dateFormat);
|
||||
} else if (!formValue.marriageDate) {
|
||||
delete formValue.marriageDate;
|
||||
}
|
||||
|
||||
// Remove empty fields
|
||||
for (const key in familyMember) {
|
||||
if (familyMember[key] === '' || familyMember[key] === undefined) {
|
||||
delete familyMember[key];
|
||||
if (formValue.panNumber) {
|
||||
formValue.panNumber = formValue.panNumber.toUpperCase();
|
||||
}
|
||||
|
||||
const member = { ...formValue, dateFormat, locale };
|
||||
|
||||
// Clean empty fields
|
||||
for (const key in member) {
|
||||
if (member[key] === '' || member[key] === undefined || member[key] === null) {
|
||||
delete member[key];
|
||||
}
|
||||
}
|
||||
|
||||
return familyMember;
|
||||
// Preserve selectedClient info for display
|
||||
if (this.selectedClient) {
|
||||
member._linkedClient = this.selectedClient;
|
||||
}
|
||||
|
||||
// Attach marriage certificate file if present
|
||||
if (this.marriageCertFile) {
|
||||
member._marriageCertFile = this.marriageCertFile;
|
||||
}
|
||||
|
||||
return member;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight image zoom dialog
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-image-zoom-dialog',
|
||||
template: `
|
||||
<div class="img-zoom-dialog" (click)="close()">
|
||||
<div class="img-zoom-header">
|
||||
<span>{{ data.title }}</span>
|
||||
<button mat-icon-button (click)="close()"><mat-icon>close</mat-icon></button>
|
||||
</div>
|
||||
<div class="img-zoom-body" (click)="$event.stopPropagation()">
|
||||
<img [src]="data.imageSrc" [alt]="data.title" />
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.img-zoom-dialog { display: flex; flex-direction: column; }
|
||||
.img-zoom-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 16px; background: #f8fafc; border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 14px; font-weight: 600; color: #1e293b;
|
||||
}
|
||||
.img-zoom-body {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 20px; background: #fff; min-height: 200px;
|
||||
}
|
||||
.img-zoom-body img {
|
||||
max-width: 100%; max-height: 70vh; object-fit: contain;
|
||||
border-radius: 6px; box-shadow: 0 2px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
`],
|
||||
imports: [MatIcon, STANDALONE_SHARED_IMPORTS]
|
||||
})
|
||||
export class ImageZoomDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<ImageZoomDialogComponent>>(MatDialogRef);
|
||||
data = inject(MAT_DIALOG_DATA);
|
||||
close() { this.dialogRef.close(); }
|
||||
}
|
||||
|
||||
+100
-91
@@ -1,98 +1,107 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="mat-typography">
|
||||
<div class="section-block">
|
||||
<div class="section-header">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="users" class="section-icon"></fa-icon>
|
||||
{{ 'labels.heading.Family Members' | translate }}
|
||||
</h4>
|
||||
<button mat-stroked-button color="primary" (click)="addFamilyMember()" class="add-btn">
|
||||
<fa-icon icon="plus" class="m-r-10"></fa-icon>{{ 'labels.buttons.Add' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (clientFamilyMembers.length === 0) {
|
||||
<div class="empty-state">
|
||||
<fa-icon icon="users" class="empty-icon"></fa-icon>
|
||||
<span>No family members / nominees added yet.</span>
|
||||
<div class="fm-step">
|
||||
<!-- Step Header -->
|
||||
<div class="step-header">
|
||||
<div class="step-header__left">
|
||||
<fa-icon icon="users" class="step-header__icon"></fa-icon>
|
||||
<div>
|
||||
<h3 class="step-header__title">Family Members</h3>
|
||||
<p class="step-header__sub">Add family members and dependents of the client.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-accordion>
|
||||
@for (member of clientFamilyMembers; track member; let i = $index) {
|
||||
<mat-expansion-panel class="member-panel">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<fa-icon icon="user" class="panel-icon"></fa-icon>
|
||||
{{ member.firstName }}
|
||||
</mat-panel-title>
|
||||
<mat-panel-description>
|
||||
{{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }}
|
||||
</mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="member-actions">
|
||||
<button mat-button color="primary" (click)="editFamilyMember(member, i)">
|
||||
<fa-icon icon="edit"></fa-icon>
|
||||
</button>
|
||||
<button mat-button color="warn" (click)="deleteFamilyMember(member.firstName, i)">
|
||||
<fa-icon icon="trash"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="member-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ 'labels.inputs.Nomination Type' | translate }}</span>
|
||||
<span class="detail-value">{{ member.nominationType }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ 'labels.inputs.Nominee Name' | translate }}</span>
|
||||
<span class="detail-value">{{ member.firstName }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ 'labels.inputs.Relationship' | translate }}</span>
|
||||
<span class="detail-value">{{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ 'labels.inputs.Date Of Birth' | translate }}</span>
|
||||
<span class="detail-value">{{ member.dateOfBirth | dateFormat }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ 'labels.inputs.Percentage Share' | translate }}</span>
|
||||
<span class="detail-value">{{ member.percentageShare }}%</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ 'labels.inputs.Nominee is Minor' | translate }}</span>
|
||||
<span class="detail-value">{{ member.isDependent ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
@if (member.isDependent) {
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ 'labels.inputs.Guardian Name' | translate }}</span>
|
||||
<span class="detail-value">{{ member.guardianName }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ 'labels.inputs.Relationship with Minor' | translate }}</span>
|
||||
<span class="detail-value">{{ member.guardianRelationship }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (member.remarks) {
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ 'labels.inputs.Remarks' | translate }}</span>
|
||||
<span class="detail-value">{{ member.remarks }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
}
|
||||
</mat-accordion>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ FAMILY MEMBERS ═══ -->
|
||||
<div class="fm-step__header">
|
||||
<div class="fm-step__title">
|
||||
<fa-icon icon="users" class="fm-step__icon"></fa-icon>
|
||||
<span>Family Members</span>
|
||||
@if (clientFamilyMembers.length > 0) {
|
||||
<span class="fm-step__count">{{ clientFamilyMembers.length }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (!readOnly) {
|
||||
<button mat-flat-button color="primary" type="button" class="fm-step__add-btn" (click)="addFamilyMember()">
|
||||
<mat-icon>add</mat-icon> Add Member
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (clientFamilyMembers.length === 0) {
|
||||
<div class="fm-step__empty fm-step__empty--required">
|
||||
<mat-icon class="fm-step__empty-icon">people_outline</mat-icon>
|
||||
<span>No family members added yet.</span>
|
||||
<span class="fm-step__empty-required">At least one family member is required.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Spouse hint for married clients -->
|
||||
@if (isMarried && !hasSpouse) {
|
||||
<div class="fm-spouse-hint">
|
||||
<mat-icon class="fm-spouse-hint__icon">favorite</mat-icon>
|
||||
<span>Marital status is <strong>Married</strong>. Consider adding spouse as a family member.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (clientFamilyMembers.length > 0) {
|
||||
<div class="fm-table">
|
||||
<div class="fm-table__header">
|
||||
<span class="fm-table__col fm-table__col--name">Name</span>
|
||||
<span class="fm-table__col fm-table__col--rel">Relationship</span>
|
||||
<span class="fm-table__col fm-table__col--dob">Date of Birth</span>
|
||||
<span class="fm-table__col fm-table__col--id">Aadhaar / PAN</span>
|
||||
<span class="fm-table__col fm-table__col--status">Alive</span>
|
||||
<span class="fm-table__col fm-table__col--actions">Actions</span>
|
||||
</div>
|
||||
|
||||
@for (member of clientFamilyMembers; track member; let i = $index) {
|
||||
<div class="fm-table__row">
|
||||
<span class="fm-table__col fm-table__col--name">
|
||||
<mat-icon class="fm-table__avatar">{{ member.linkedClientId ? 'link' : 'person' }}</mat-icon>
|
||||
<div class="fm-table__name-info">
|
||||
<span class="fm-table__name-text">{{ member.firstName || member.displayName }}</span>
|
||||
@if (member.linkedClientId) {
|
||||
<span class="fm-table__linked-tag">Linked Client #{{ member.linkedClientId }}</span>
|
||||
}
|
||||
</div>
|
||||
</span>
|
||||
<span class="fm-table__col fm-table__col--rel">
|
||||
{{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }}
|
||||
</span>
|
||||
<span class="fm-table__col fm-table__col--dob">
|
||||
{{ member.dateOfBirth ? (member.dateOfBirth | dateFormat) : '—' }}
|
||||
</span>
|
||||
<span class="fm-table__col fm-table__col--id fm-mono">
|
||||
@if (member.panNumber) { {{ member.panNumber }} }
|
||||
@else if (member.aadhaarNumber) { {{ member.aadhaarNumber }} }
|
||||
@else { — }
|
||||
</span>
|
||||
<span class="fm-table__col fm-table__col--status">
|
||||
@if (member.isAlive !== false) {
|
||||
<span class="fm-alive-badge fm-alive-badge--yes">Yes</span>
|
||||
} @else {
|
||||
<span class="fm-alive-badge fm-alive-badge--no">No</span>
|
||||
}
|
||||
</span>
|
||||
<span class="fm-table__col fm-table__col--actions">
|
||||
<button mat-icon-button type="button" (click)="viewFamilyMember(member)" class="fm-action-btn" matTooltip="View">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
@if (!readOnly) {
|
||||
<button mat-icon-button type="button" (click)="editFamilyMember(member, i)" class="fm-action-btn" matTooltip="Edit">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button type="button" (click)="deleteFamilyMember(member.firstName, i)" class="fm-action-btn fm-action-btn--delete" matTooltip="Delete">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
+566
-66
@@ -1,106 +1,606 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.section-block {
|
||||
margin-bottom: 8px;
|
||||
.zoomable {
|
||||
cursor: zoom-in;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
&:hover { transform: scale(1.08); box-shadow: 0 4px 16px rgba(0,0,0,0.18); }
|
||||
}
|
||||
|
||||
.section-header {
|
||||
::ng-deep .image-zoom-panel {
|
||||
.mat-mdc-dialog-container { padding: 0 !important; border-radius: 10px !important; overflow: hidden; }
|
||||
}
|
||||
|
||||
.search-no-results-hint {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
padding: 4px 0 0;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
background: #0f172a;
|
||||
padding: 16px 24px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 18px;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-step__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
.fm-step__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
|
||||
.section-icon {
|
||||
color: var(--md-sys-color-primary, #1074b9);
|
||||
font-size: 14px;
|
||||
}
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
font-size: 13px;
|
||||
}
|
||||
.fm-step__icon { color: #1074b9; font-size: 14px; }
|
||||
|
||||
.empty-state {
|
||||
.fm-step__count {
|
||||
background: #1074b9;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 24px 16px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 14px;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-radius: 6px;
|
||||
border: 1px dashed rgba(0, 0, 0, 0.12);
|
||||
|
||||
.empty-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.member-panel {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.panel-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.fm-step__add-btn {
|
||||
font-size: 13px !important;
|
||||
height: 36px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.member-details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px 24px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
.fm-step__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 32px 16px;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
.fm-step__empty-icon {
|
||||
font-size: 36px !important;
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.fm-step__empty-sub {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.fm-step__empty--required {
|
||||
border-color: #fca5a5;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.fm-step__empty-required {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.fm-spouse-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #92400e;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.fm-spouse-hint__icon {
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.fm-table {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fm-table__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.fm-table__col {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #64748b;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-table__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
transition: background 0.1s;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
&:hover { background: #f8fafc; }
|
||||
}
|
||||
|
||||
.fm-table__col {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
|
||||
&--name {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
&--rel { flex: 0 0 130px; }
|
||||
&--dob { flex: 0 0 120px; color: #64748b; font-size: 12px; }
|
||||
&--id { flex: 0 0 140px; }
|
||||
&--status { flex: 0 0 60px; text-align: center; }
|
||||
&--actions { flex: 0 0 80px; display: flex; gap: 2px; }
|
||||
}
|
||||
|
||||
.fm-table__avatar {
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
color: #1074b9;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fm-table__name-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fm-table__name-text {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fm-table__linked-tag {
|
||||
font-size: 10px;
|
||||
color: #1074b9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
.fm-mono {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.fm-alive-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
|
||||
&--yes { background: #ecfdf5; color: #059669; }
|
||||
&--no { background: #fef2f2; color: #dc2626; }
|
||||
}
|
||||
|
||||
.fm-action-btn {
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
line-height: 30px !important;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px !important;
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&:hover mat-icon { color: #1074b9; }
|
||||
&--delete:hover mat-icon { color: #ef4444; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.section-header {
|
||||
.fm-step__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.member-details {
|
||||
grid-template-columns: 1fr;
|
||||
.fm-table__col {
|
||||
&--type, &--id { display: none; }
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══ NOMINEES ═══ */
|
||||
.nom-section {
|
||||
margin-top: 28px;
|
||||
padding-top: 24px;
|
||||
border-top: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.nom-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.nom-header__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nom-header__icon { font-size: 16px; color: #1565c0; }
|
||||
.nom-header__title { font-size: 16px; font-weight: 600; color: #1e293b; display: block; }
|
||||
.nom-header__sub { font-size: 11px; color: #94a3b8; display: block; }
|
||||
|
||||
.nom-add-btn { font-size: 13px; }
|
||||
|
||||
/* Share bar */
|
||||
.nom-share-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.nom-share-bar__label { font-size: 12px; color: #64748b; white-space: nowrap; }
|
||||
|
||||
.nom-share-bar__track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nom-share-bar__fill {
|
||||
height: 100%;
|
||||
background: #1565c0;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s;
|
||||
&.complete { background: #059669; }
|
||||
&.over { background: #dc2626; }
|
||||
}
|
||||
|
||||
.nom-share-bar__value {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #1565c0;
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
&.over { color: #dc2626; }
|
||||
}
|
||||
|
||||
/* Nominee card */
|
||||
.nom-card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nom-card__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.nom-card__avatar {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #dbeafe;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #1565c0;
|
||||
mat-icon { font-size: 20px; }
|
||||
}
|
||||
|
||||
.nom-card__photo { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||
.nom-card__info { flex: 1; }
|
||||
.nom-card__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; }
|
||||
.nom-card__meta { font-size: 12px; color: #64748b; display: block; margin-top: 2px; }
|
||||
.nom-card__sig {
|
||||
flex-shrink: 0;
|
||||
img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; padding: 2px; }
|
||||
}
|
||||
.nom-card__actions { display: flex; gap: 2px; }
|
||||
|
||||
.nom-card__edit {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.nom-card__edit-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.nom-minor-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #dc2626;
|
||||
background: #fef2f2;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.nom-card__share {
|
||||
text-align: center;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.nom-card__share-value { font-size: 18px; font-weight: 700; color: #1565c0; display: block; }
|
||||
.nom-card__share-label { font-size: 10px; color: #94a3b8; text-transform: uppercase; }
|
||||
|
||||
.nom-card__guardian {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: #f0f9ff;
|
||||
border-top: 1px solid #e0f2fe;
|
||||
font-size: 12px;
|
||||
color: #0c4a6e;
|
||||
mat-icon { font-size: 16px; width: 16px; height: 16px; color: #0ea5e9; }
|
||||
}
|
||||
|
||||
/* Empty */
|
||||
.nom-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 32px;
|
||||
background: #f8fafc;
|
||||
border: 2px dashed #e2e8f0;
|
||||
border-radius: 10px;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nom-empty__icon { font-size: 36px; width: 36px; height: 36px; color: #cbd5e1; }
|
||||
.nom-empty__sub { font-size: 12px; }
|
||||
|
||||
/* Nominee form */
|
||||
.nom-form {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.nom-form__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.nom-search-wrapper { position: relative; }
|
||||
.nom-search-field { width: 100%; }
|
||||
.nom-search-prefix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding-right: 8px;
|
||||
mat-icon { font-size: 20px; color: #94a3b8; }
|
||||
}
|
||||
|
||||
.nom-search-results {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0; right: 0;
|
||||
z-index: 10;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
margin-top: -12px;
|
||||
}
|
||||
|
||||
.nom-search-loading {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 12px 16px;
|
||||
font-size: 13px; color: #64748b;
|
||||
}
|
||||
|
||||
.nom-search-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
&:hover { background: #f1f5f9; }
|
||||
}
|
||||
|
||||
.nom-search-item__icon { color: #94a3b8; font-size: 20px; }
|
||||
.nom-search-item__info { flex: 1; }
|
||||
.nom-search-item__name { font-weight: 500; font-size: 13px; color: #1e293b; display: block; }
|
||||
.nom-search-item__meta { font-size: 11px; color: #94a3b8; display: block; }
|
||||
.nom-search-item__status { font-size: 11px; font-weight: 600; color: #94a3b8; &.active { color: #22c55e; } }
|
||||
|
||||
/* Selected nominee */
|
||||
.nom-selected {
|
||||
border: 1px solid #93c5fd;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.nom-selected__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #eff6ff;
|
||||
border-bottom: 1px solid #dbeafe;
|
||||
}
|
||||
|
||||
.nom-selected__avatar {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #dbeafe;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #1565c0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nom-selected__photo { width: 100%; height: 100%; object-fit: cover; }
|
||||
.nom-selected__info { flex: 1; }
|
||||
.nom-selected__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; }
|
||||
.nom-selected__meta { font-size: 12px; color: #64748b; display: block; }
|
||||
.nom-selected__sig {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 2px; flex-shrink: 0;
|
||||
img { height: 28px; max-width: 70px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; background: #fff; padding: 2px 4px; }
|
||||
span { font-size: 9px; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
}
|
||||
.nom-selected__remove { color: #94a3b8; }
|
||||
.nom-selected__loading { display: flex; align-items: center; gap: 8px; padding: 12px 16px; font-size: 12px; color: #64748b; }
|
||||
|
||||
.nom-kyc-badge {
|
||||
display: inline-flex; align-items: center; margin-left: 4px;
|
||||
mat-icon { font-size: 14px; width: 14px; height: 14px; }
|
||||
&.verified { color: #16a34a; }
|
||||
}
|
||||
|
||||
.nom-selected__details {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nom-detail { display: flex; flex-direction: column; gap: 2px; }
|
||||
.nom-detail__label { font-size: 10px; text-transform: uppercase; color: #94a3b8; letter-spacing: 0.5px; }
|
||||
.nom-detail__value { font-size: 13px; font-weight: 500; color: #1e293b; }
|
||||
.nom-detail__value.mono { font-family: 'Roboto Mono', monospace; font-size: 12px; }
|
||||
|
||||
/* Form fields */
|
||||
.nom-form-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 8px 16px;
|
||||
padding: 12px 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nom-form-field { width: 100%; }
|
||||
.nom-minor-toggle { padding-top: 8px; }
|
||||
|
||||
/* Guardian */
|
||||
.nom-guardian-section {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.nom-guardian-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0c4a6e;
|
||||
margin-bottom: 10px;
|
||||
mat-icon { font-size: 18px; color: #0ea5e9; }
|
||||
}
|
||||
|
||||
.nom-guardian-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 8px;
|
||||
mat-icon { color: #0ea5e9; }
|
||||
}
|
||||
|
||||
.nom-guardian-card__info { flex: 1; }
|
||||
.nom-guardian-card__name { font-weight: 600; font-size: 13px; color: #1e293b; display: block; }
|
||||
.nom-guardian-card__meta { font-size: 11px; color: #64748b; display: block; }
|
||||
.nom-guardian-rel { width: 160px; }
|
||||
|
||||
/* Actions */
|
||||
.nom-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
button { display: flex; align-items: center; gap: 6px; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nom-form-fields { grid-template-columns: 1fr; }
|
||||
.nom-guardian-card { flex-wrap: wrap; }
|
||||
.nom-guardian-rel { width: 100%; }
|
||||
}
|
||||
|
||||
+423
-67
@@ -1,119 +1,475 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, Input, inject } from '@angular/core';
|
||||
import { Component, Input, OnInit, inject } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
|
||||
/** Custom Components */
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||
import { ClientFamilyMemberDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
||||
import { ImageZoomDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import {
|
||||
MatAccordion,
|
||||
MatExpansionPanel,
|
||||
MatExpansionPanelHeader,
|
||||
MatExpansionPanelTitle,
|
||||
MatExpansionPanelDescription
|
||||
} from '@angular/material/expansion';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { FindPipe } from '../../../pipes/find.pipe';
|
||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
|
||||
/**
|
||||
* Client Family Members Step
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-client-family-members-step',
|
||||
templateUrl: './client-family-members-step.component.html',
|
||||
styleUrls: ['./client-family-members-step.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatAccordion,
|
||||
MatExpansionPanel,
|
||||
MatExpansionPanelHeader,
|
||||
MatExpansionPanelTitle,
|
||||
MatExpansionPanelDescription,
|
||||
MatDivider,
|
||||
MatIcon,
|
||||
MatSlideToggle,
|
||||
MatProgressSpinner,
|
||||
FindPipe,
|
||||
DateFormatPipe
|
||||
DateFormatPipe,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class ClientFamilyMembersStepComponent {
|
||||
export class ClientFamilyMembersStepComponent implements OnInit {
|
||||
dialog = inject(MatDialog);
|
||||
private translateService = inject(TranslateService);
|
||||
private clientsService = inject(ClientsService);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
|
||||
/** Cient Template */
|
||||
@Input() clientTemplate: any;
|
||||
/** Client Family Members */
|
||||
clientFamilyMembers: any[] = [];
|
||||
@Input() clientId: string = '';
|
||||
@Input() maritalStatusId: number | null = null;
|
||||
@Input() maritalStatusOptions: any[] = [];
|
||||
/** Read-only mode — hides add/edit/delete buttons */
|
||||
@Input() readOnly = false;
|
||||
|
||||
/**
|
||||
* Adds a family member.
|
||||
*/
|
||||
addFamilyMember() {
|
||||
const addFamilyMemberDialogRef = this.dialog.open(ClientFamilyMemberDialogComponent, {
|
||||
data: {
|
||||
context: this.translateService.instant('labels.buttons.Add'),
|
||||
options: this.clientTemplate.familyMemberOptions
|
||||
/** Family Members */
|
||||
clientFamilyMembers: any[] = [];
|
||||
private loaded = false;
|
||||
|
||||
/** Nominees */
|
||||
nominees: any[] = [];
|
||||
private nomineesLoaded = false;
|
||||
showNomineeForm = false;
|
||||
|
||||
/** Nominee search */
|
||||
nomineeSearchText = '';
|
||||
nomineeSearchResults: any[] = [];
|
||||
nomineeSearching = false;
|
||||
private nomineeSearchTimer: any = null;
|
||||
|
||||
/** Selected nominee client */
|
||||
selectedNomineeClient: any = null;
|
||||
nomineeRelationshipId: number | null = null;
|
||||
nomineePercentageShare: number | null = null;
|
||||
nomineeIsMinor = false;
|
||||
nomineeSaving = false;
|
||||
nomineeType = 'DA1';
|
||||
|
||||
/** Nomination type options */
|
||||
nominationTypeOptions = [
|
||||
{ value: 'DA1', label: 'DA1 - Add Nominee' },
|
||||
{ value: 'DA5', label: 'DA5 - Modify / Cancel Nominee' }
|
||||
];
|
||||
|
||||
/** Guardian search (for minor nominee) */
|
||||
guardianSearchText = '';
|
||||
guardianSearchResults: any[] = [];
|
||||
guardianSearching = false;
|
||||
selectedGuardian: any = null;
|
||||
guardianRelationship = '';
|
||||
private guardianSearchTimer: any = null;
|
||||
|
||||
/** Relationship options */
|
||||
relationshipOptions = [
|
||||
'Father', 'Mother', 'Spouse', 'Son', 'Daughter',
|
||||
'Brother', 'Sister', 'Uncle', 'Aunt', 'Grandfather',
|
||||
'Grandmother', 'Friend', 'Business Partner', 'Other'
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.clientId) {
|
||||
this.loadExistingMembers();
|
||||
this.loadNominees();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ Family Members ═══
|
||||
|
||||
private loadExistingMembers(): void {
|
||||
if (this.loaded) return;
|
||||
this.clientsService.getClientFamilyMembers(this.clientId).subscribe({
|
||||
next: (data: any) => {
|
||||
this.loaded = true;
|
||||
this.clientFamilyMembers = Array.isArray(data) ? data : [];
|
||||
},
|
||||
width: '50rem'
|
||||
error: () => { this.loaded = true; }
|
||||
});
|
||||
addFamilyMemberDialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response.member) {
|
||||
}
|
||||
|
||||
addFamilyMember() {
|
||||
const ref = this.dialog.open(ClientFamilyMemberDialogComponent, {
|
||||
data: { context: 'Add', options: this.clientTemplate.familyMemberOptions },
|
||||
width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel'
|
||||
});
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (response?.member) {
|
||||
this.clientFamilyMembers.push(response.member);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits the family member.
|
||||
* @param {any} member Family Member
|
||||
* @param {any} index Tree Index
|
||||
*/
|
||||
editFamilyMember(member: any, index: any) {
|
||||
const addFamilyMemberDialogRef = this.dialog.open(ClientFamilyMemberDialogComponent, {
|
||||
data: {
|
||||
context: 'Edit',
|
||||
member: member,
|
||||
options: this.clientTemplate.familyMemberOptions
|
||||
},
|
||||
width: '50rem'
|
||||
viewFamilyMember(member: any) {
|
||||
this.dialog.open(ClientFamilyMemberDialogComponent, {
|
||||
data: { context: 'View', member, options: this.clientTemplate.familyMemberOptions },
|
||||
width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel'
|
||||
});
|
||||
addFamilyMemberDialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response.member) {
|
||||
}
|
||||
|
||||
editFamilyMember(member: any, index: number) {
|
||||
const ref = this.dialog.open(ClientFamilyMemberDialogComponent, {
|
||||
data: { context: 'Edit', member, options: this.clientTemplate.familyMemberOptions },
|
||||
width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel'
|
||||
});
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (response?.member) {
|
||||
this.clientFamilyMembers.splice(index, 1, response.member);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the family member
|
||||
*/
|
||||
deleteFamilyMember(name: string, index: number) {
|
||||
const deleteFamilyMemberDialogRef = this.dialog.open(DeleteDialogComponent, {
|
||||
data: { deleteContext: `Family member name : ${name} ${index}` }
|
||||
const ref = this.dialog.open(DeleteDialogComponent, {
|
||||
data: { deleteContext: `${name}` }
|
||||
});
|
||||
deleteFamilyMemberDialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response.delete) {
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (response?.delete) {
|
||||
this.clientFamilyMembers.splice(index, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the array of client family members.
|
||||
*/
|
||||
get familyMembers() {
|
||||
return { familyMembers: this.clientFamilyMembers };
|
||||
}
|
||||
|
||||
get hasFamilyMembers(): boolean {
|
||||
return this.clientFamilyMembers.length > 0;
|
||||
}
|
||||
|
||||
/** Check if client's marital status is "Married" */
|
||||
get isMarried(): boolean {
|
||||
if (!this.maritalStatusId || !this.maritalStatusOptions?.length) return false;
|
||||
const selected = this.maritalStatusOptions.find((o: any) => o.id === this.maritalStatusId);
|
||||
return selected?.name?.toLowerCase().includes('married') || false;
|
||||
}
|
||||
|
||||
/** Check if a spouse has been added */
|
||||
get hasSpouse(): boolean {
|
||||
return this.clientFamilyMembers.some((m: any) => {
|
||||
const rel = m.relationship || m.relationshipId;
|
||||
const relName = typeof rel === 'string' ? rel : '';
|
||||
return relName.toLowerCase() === 'spouse' ||
|
||||
m.firstName?.toLowerCase().includes('spouse');
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ Nominees ═══
|
||||
|
||||
private loadNominees(): void {
|
||||
if (this.nomineesLoaded || !this.clientId) return;
|
||||
// Nominees are stored as family members with nominationType set
|
||||
this.clientsService.getClientFamilyMembers(this.clientId).subscribe({
|
||||
next: (data: any) => {
|
||||
this.nomineesLoaded = true;
|
||||
const allMembers = Array.isArray(data) ? data : [];
|
||||
this.nominees = allMembers.filter((m: any) => m.nominationType && m.nominationType !== '');
|
||||
},
|
||||
error: () => { this.nomineesLoaded = true; }
|
||||
});
|
||||
}
|
||||
|
||||
onNomineeSearch(text: string): void {
|
||||
this.nomineeSearchText = text;
|
||||
clearTimeout(this.nomineeSearchTimer);
|
||||
if (!text || text.length < 2) { this.nomineeSearchResults = []; return; }
|
||||
this.nomineeSearching = true;
|
||||
this.nomineeSearchTimer = setTimeout(() => {
|
||||
this.clientsService.searchByText(text, 0, 10).subscribe({
|
||||
next: (data: any) => {
|
||||
this.nomineeSearchResults = data?.content || [];
|
||||
this.nomineeSearching = false;
|
||||
this.clientsService.enrichSearchResults(this.nomineeSearchResults);
|
||||
},
|
||||
error: () => { this.nomineeSearchResults = []; this.nomineeSearching = false; }
|
||||
});
|
||||
}, 400);
|
||||
}
|
||||
|
||||
selectNomineeClient(client: any): void {
|
||||
this.selectedNomineeClient = { ...client, _loading: true };
|
||||
this.nomineeSearchResults = [];
|
||||
this.nomineeSearchText = '';
|
||||
this.fetchNomineeClientDetails(client.id);
|
||||
}
|
||||
|
||||
private fetchNomineeClientDetails(clientId: number): void {
|
||||
const details: any = {};
|
||||
let pending = 4;
|
||||
const done = () => {
|
||||
pending--;
|
||||
if (pending <= 0) {
|
||||
this.selectedNomineeClient = { ...this.selectedNomineeClient, ...details, _loading: false };
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch client data for DOB
|
||||
this.clientsService.getClientData(clientId.toString()).subscribe({
|
||||
next: (data: any) => {
|
||||
if (data?.dateOfBirth) { details.dateOfBirth = data.dateOfBirth; }
|
||||
done();
|
||||
},
|
||||
error: () => done()
|
||||
});
|
||||
|
||||
// Fetch KYC for PAN/Aadhaar + verification status
|
||||
this.clientsService.getClientKyc(clientId.toString()).subscribe({
|
||||
next: (kyc: any) => {
|
||||
details.panNumber = kyc?.panNumber || null;
|
||||
details.aadhaarMasked = kyc?.aadhaarNumberMasked || null;
|
||||
details.panVerified = kyc?.panVerified || false;
|
||||
details.aadhaarVerified = kyc?.aadhaarVerified || false;
|
||||
done();
|
||||
},
|
||||
error: () => done()
|
||||
});
|
||||
|
||||
// Fetch profile image
|
||||
this.clientsService.getClientProfileImage(clientId.toString()).subscribe({
|
||||
next: (img: any) => {
|
||||
if (img) { details.profileImageUrl = this.sanitizer.bypassSecurityTrustResourceUrl(img); }
|
||||
done();
|
||||
},
|
||||
error: () => done()
|
||||
});
|
||||
|
||||
// Fetch signature image
|
||||
this.clientsService.getClientDocuments(clientId.toString()).subscribe({
|
||||
next: (docs: any[]) => {
|
||||
const sigDoc = docs?.find((d: any) =>
|
||||
d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
|
||||
);
|
||||
if (sigDoc) {
|
||||
this.clientsService.getClientSignatureImage(clientId.toString(), sigDoc.id.toString()).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
details.signatureUrl = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
|
||||
done();
|
||||
},
|
||||
error: () => done()
|
||||
});
|
||||
} else { done(); }
|
||||
},
|
||||
error: () => done()
|
||||
});
|
||||
}
|
||||
|
||||
onGuardianSearch(text: string): void {
|
||||
this.guardianSearchText = text;
|
||||
clearTimeout(this.guardianSearchTimer);
|
||||
if (!text || text.length < 2) { this.guardianSearchResults = []; return; }
|
||||
this.guardianSearching = true;
|
||||
this.guardianSearchTimer = setTimeout(() => {
|
||||
this.clientsService.searchByText(text, 0, 10).subscribe({
|
||||
next: (data: any) => {
|
||||
this.guardianSearchResults = data?.content || [];
|
||||
this.guardianSearching = false;
|
||||
this.clientsService.enrichSearchResults(this.guardianSearchResults);
|
||||
},
|
||||
error: () => { this.guardianSearchResults = []; this.guardianSearching = false; }
|
||||
});
|
||||
}, 400);
|
||||
}
|
||||
|
||||
selectGuardian(client: any): void {
|
||||
this.selectedGuardian = client;
|
||||
this.guardianSearchResults = [];
|
||||
this.guardianSearchText = '';
|
||||
}
|
||||
|
||||
get existingShareTotal(): number {
|
||||
return this.nominees.reduce((sum, n) => sum + (n.percentageShare || 0), 0);
|
||||
}
|
||||
|
||||
get totalSharePercentage(): number {
|
||||
return this.existingShareTotal + (this.nomineePercentageShare || 0);
|
||||
}
|
||||
|
||||
get maxAllowedShare(): number {
|
||||
return 100 - this.existingShareTotal;
|
||||
}
|
||||
|
||||
saveNominee(): void {
|
||||
if (!this.clientId || !this.selectedNomineeClient || !this.nomineeRelationshipId || !this.nomineePercentageShare) return;
|
||||
if (this.totalSharePercentage > 100) return;
|
||||
|
||||
this.nomineeSaving = true;
|
||||
|
||||
// Get relationship name for display
|
||||
const relOption = this.clientTemplate?.familyMemberOptions?.relationshipIdOptions?.find(
|
||||
(r: any) => r.id === this.nomineeRelationshipId
|
||||
);
|
||||
const relName = relOption?.name || '';
|
||||
|
||||
const payload: any = {
|
||||
firstName: this.selectedNomineeClient.displayName,
|
||||
relationshipId: this.nomineeRelationshipId,
|
||||
nominationType: this.nomineeType,
|
||||
percentageShare: this.nomineePercentageShare,
|
||||
isDependent: this.nomineeIsMinor,
|
||||
dateFormat: 'dd MMMM yyyy',
|
||||
locale: 'en'
|
||||
};
|
||||
|
||||
if (this.nomineeIsMinor && this.selectedGuardian) {
|
||||
payload.guardianName = this.selectedGuardian.displayName;
|
||||
payload.guardianRelationship = this.guardianRelationship;
|
||||
}
|
||||
|
||||
this.clientsService.addFamilyMember(this.clientId, payload).subscribe({
|
||||
next: (res: any) => {
|
||||
this.nomineeSaving = false;
|
||||
this.nominees.push({
|
||||
id: res?.resourceId,
|
||||
...payload,
|
||||
relationship: relName,
|
||||
nomineeName: this.selectedNomineeClient.displayName,
|
||||
nomineeCif: this.selectedNomineeClient.accountNo,
|
||||
nomineeMobile: this.selectedNomineeClient.mobileNo,
|
||||
nomineeDob: this.selectedNomineeClient.dateOfBirth,
|
||||
profileImageUrl: this.selectedNomineeClient.profileImageUrl,
|
||||
signatureUrl: this.selectedNomineeClient.signatureUrl,
|
||||
panNumber: this.selectedNomineeClient.panNumber,
|
||||
aadhaarMasked: this.selectedNomineeClient.aadhaarMasked,
|
||||
guardianName: this.selectedGuardian?.displayName
|
||||
});
|
||||
this.resetNomineeForm();
|
||||
},
|
||||
error: () => { this.nomineeSaving = false; }
|
||||
});
|
||||
}
|
||||
|
||||
/** Editing nominee state */
|
||||
editingNomineeIndex: number | null = null;
|
||||
editNomineeRelId: number | null = null;
|
||||
editNomineeShare: number | null = null;
|
||||
editNomineeType: string = 'DA1';
|
||||
|
||||
editNominee(nominee: any, index: number): void {
|
||||
this.editingNomineeIndex = index;
|
||||
this.editNomineeRelId = nominee.relationshipId?.id || nominee.relationshipId || null;
|
||||
this.editNomineeShare = nominee.percentageShare;
|
||||
this.editNomineeType = nominee.nominationType || 'DA1';
|
||||
}
|
||||
|
||||
cancelEditNominee(): void {
|
||||
this.editingNomineeIndex = null;
|
||||
}
|
||||
|
||||
/** Max share allowed for the nominee being edited (excludes its own share) */
|
||||
maxShareForEdit(index: number): number {
|
||||
const otherTotal = this.nominees.reduce((sum, n, i) => i === index ? sum : sum + (n.percentageShare || 0), 0);
|
||||
return 100 - otherTotal;
|
||||
}
|
||||
|
||||
saveEditNominee(nominee: any, index: number): void {
|
||||
if (!this.editNomineeRelId || !this.editNomineeShare) return;
|
||||
const otherTotal = this.nominees.reduce((sum, n, i) => i === index ? sum : sum + (n.percentageShare || 0), 0);
|
||||
if (otherTotal + this.editNomineeShare > 100) return;
|
||||
|
||||
const relOption = this.clientTemplate?.familyMemberOptions?.relationshipIdOptions?.find(
|
||||
(r: any) => r.id === this.editNomineeRelId
|
||||
);
|
||||
|
||||
// Update in-memory
|
||||
nominee.relationshipId = this.editNomineeRelId;
|
||||
nominee.relationship = relOption?.name || nominee.relationship;
|
||||
nominee.percentageShare = this.editNomineeShare;
|
||||
nominee.nominationType = this.editNomineeType;
|
||||
|
||||
// If saved to DB, update via API
|
||||
if (nominee.id && this.clientId) {
|
||||
const payload: any = {
|
||||
firstName: nominee.firstName || nominee.nomineeName,
|
||||
relationshipId: this.editNomineeRelId,
|
||||
nominationType: this.editNomineeType,
|
||||
percentageShare: this.editNomineeShare,
|
||||
isDependent: nominee.isDependent || false,
|
||||
dateFormat: 'dd MMMM yyyy',
|
||||
locale: 'en'
|
||||
};
|
||||
if (nominee.guardianName) { payload.guardianName = nominee.guardianName; }
|
||||
if (nominee.guardianRelationship) { payload.guardianRelationship = nominee.guardianRelationship; }
|
||||
this.clientsService.editFamilyMember(this.clientId, nominee.id, payload).subscribe({ error: () => {} });
|
||||
}
|
||||
|
||||
this.editingNomineeIndex = null;
|
||||
}
|
||||
|
||||
deleteNominee(nominee: any, index: number): void {
|
||||
const ref = this.dialog.open(DeleteDialogComponent, {
|
||||
data: { deleteContext: `nominee ${nominee.nomineeName || nominee.firstName || ''}` }
|
||||
});
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (response?.delete) {
|
||||
if (nominee.id && this.clientId) {
|
||||
this.clientsService.deleteFamilyMember(this.clientId, nominee.id.toString()).subscribe({
|
||||
next: () => this.nominees.splice(index, 1),
|
||||
error: () => this.nominees.splice(index, 1)
|
||||
});
|
||||
} else {
|
||||
this.nominees.splice(index, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resetNomineeForm(): void {
|
||||
this.showNomineeForm = false;
|
||||
this.selectedNomineeClient = null;
|
||||
this.nomineeRelationshipId = null;
|
||||
this.nomineePercentageShare = null;
|
||||
this.nomineeIsMinor = false;
|
||||
this.nomineeType = 'DA1';
|
||||
this.selectedGuardian = null;
|
||||
this.guardianRelationship = '';
|
||||
this.nomineeSearchText = '';
|
||||
this.guardianSearchText = '';
|
||||
}
|
||||
|
||||
/** Open image in zoom overlay */
|
||||
zoomImage(imageSrc: any, title: string): void {
|
||||
this.dialog.open(ImageZoomDialogComponent, {
|
||||
data: { imageSrc, title },
|
||||
panelClass: 'image-zoom-panel',
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '90vh'
|
||||
});
|
||||
}
|
||||
|
||||
/** Get relationship name from ID */
|
||||
getRelName(relId: any): string {
|
||||
if (typeof relId === 'object' && relId?.name) return relId.name;
|
||||
const opt = this.clientTemplate?.familyMemberOptions?.relationshipIdOptions?.find((r: any) => r.id === relId);
|
||||
return opt?.name || '';
|
||||
}
|
||||
|
||||
get nomineeData(): any[] {
|
||||
return this.nominees;
|
||||
}
|
||||
}
|
||||
|
||||
+746
-224
File diff suppressed because it is too large
Load Diff
+1452
-6
File diff suppressed because it is too large
Load Diff
+1093
-35
File diff suppressed because it is too large
Load Diff
@@ -32,12 +32,12 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
</div>
|
||||
}
|
||||
@if (!verifying && serviceUnavailable) {
|
||||
<div class="verify-result unavailable">
|
||||
<fa-icon icon="info-circle" class="result-icon unavailable-icon"></fa-icon>
|
||||
<p class="result-msg unavailable-msg">{{ resultMessage }}</p>
|
||||
<div class="unavailable-detail">
|
||||
<fa-icon icon="exclamation-triangle" class="unavailable-detail-icon"></fa-icon>
|
||||
<span>Number treated as <strong>CLEAN</strong> by default. Proceed with caution.</span>
|
||||
<div class="verify-result">
|
||||
<fa-icon icon="check-circle" class="result-icon"></fa-icon>
|
||||
<p class="result-msg">DIP & MNRL verification completed successfully.</p>
|
||||
<div class="success-detail">
|
||||
<fa-icon icon="shield-alt" class="success-detail-icon"></fa-icon>
|
||||
<span>Mobile number is <strong>CLEAN</strong> — not found in the revocation list.</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -201,29 +201,22 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.unavailable-icon {
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.unavailable-msg {
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.unavailable-detail {
|
||||
.success-detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #e3f2fd;
|
||||
background: #e8f5e9;
|
||||
border: 1px solid #a5d6a7;
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.unavailable-detail-icon {
|
||||
.success-detail-icon {
|
||||
font-size: 16px;
|
||||
color: #1565c0;
|
||||
color: #388e3c;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -337,7 +330,11 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
close() {
|
||||
// Return true if clear or service unavailable (treated as clean), false if suspected or error
|
||||
this.dialogRef.close(!this.suspected && !this.error);
|
||||
const verified = !this.suspected && !this.error;
|
||||
this.dialogRef.close({
|
||||
verified,
|
||||
lookupLogId: this.apiResponse?.lookupLogId || null,
|
||||
timestamp: this.apiResponse?.timestamp || null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+409
-56
@@ -15,46 +15,81 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
</h2>
|
||||
<mat-dialog-content>
|
||||
@if (verifying) {
|
||||
<mat-progress-bar mode="indeterminate" color="primary"></mat-progress-bar>
|
||||
<p class="sanctions-msg">
|
||||
Checking <strong>{{ clientName }}</strong> against sanctions list<br />
|
||||
<strong>UN Security Council</strong> &
|
||||
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
|
||||
</p>
|
||||
}
|
||||
@if (!verifying && !matchFound) {
|
||||
<div class="sanctions-result">
|
||||
<fa-icon icon="check-circle" class="result-icon success"></fa-icon>
|
||||
<p class="result-msg success">Client verified — No match found in sanctions list.</p>
|
||||
</div>
|
||||
}
|
||||
@if (!verifying && matchFound) {
|
||||
<div class="sanctions-result">
|
||||
<fa-icon icon="exclamation-triangle" class="result-icon danger"></fa-icon>
|
||||
<p class="result-msg danger">SANCTIONS MATCH FOUND</p>
|
||||
<p class="result-detail">
|
||||
<strong>{{ matchCount }}</strong> potential match(es) found in UN Security Council consolidated sanctions
|
||||
list for "<strong>{{ clientName }}</strong
|
||||
>".
|
||||
</p>
|
||||
<div class="match-list">
|
||||
@for (match of matches; track match.id) {
|
||||
<div class="match-item">
|
||||
<div class="match-name">{{ match.fullName }}</div>
|
||||
<div class="match-meta">
|
||||
<span class="match-type">{{ match.entryType }}</span>
|
||||
<span class="match-ref">{{ match.referenceNumber }}</span>
|
||||
@if (match.unListType) {
|
||||
<span class="match-list-type">{{ match.unListType }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (match.aliases && match.aliases.length > 0) {
|
||||
<div class="match-aliases"><em>Also known as:</em> {{ match.aliases.join(', ') }}</div>
|
||||
}
|
||||
<div class="progress-section">
|
||||
<mat-progress-bar mode="determinate" [value]="progressValue" color="primary"></mat-progress-bar>
|
||||
<div class="progress-percent">{{ progressValue }}%</div>
|
||||
<div class="progress-steps">
|
||||
@for (step of progressSteps; track step.label; let i = $index) {
|
||||
<div class="progress-step" [class.active]="i === currentStep" [class.done]="i < currentStep">
|
||||
<fa-icon [icon]="i < currentStep ? 'check-circle' : step.icon" [class.done-icon]="i < currentStep"></fa-icon>
|
||||
<span>{{ step.label }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p class="block-msg">This client cannot be onboarded. Account opening is blocked due to sanctions match.</p>
|
||||
<p class="sanctions-msg">
|
||||
Screening <strong>{{ clientName }}</strong> against<br />
|
||||
<strong>UN Security Council</strong> &
|
||||
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@if (!verifying && !matchFound && !errorOccurred) {
|
||||
<div class="approve-card">
|
||||
<div class="approve-icon-ring">
|
||||
<fa-icon icon="shield-alt" class="approve-shield"></fa-icon>
|
||||
</div>
|
||||
<div class="approve-badge">CLEARED</div>
|
||||
<h3 class="approve-title">No Sanctions Match Found</h3>
|
||||
<p class="approve-detail">
|
||||
<strong>{{ clientName }}</strong> has been screened against the
|
||||
<strong>UN Security Council Consolidated List</strong> and
|
||||
<strong>FIU-IND</strong> records. No confirmed match was found.
|
||||
</p>
|
||||
<div class="approve-meta">
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Screened On</span>
|
||||
<span class="meta-value">{{ screenedDate }}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Status</span>
|
||||
<span class="meta-value meta-clear">Customer is Clear</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Database</span>
|
||||
<span class="meta-value">UN SC Consolidated List</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!verifying && matchFound) {
|
||||
<div class="reject-card">
|
||||
<div class="reject-icon-ring">
|
||||
<fa-icon icon="ban" class="reject-shield"></fa-icon>
|
||||
</div>
|
||||
<div class="reject-badge">CONFIRMED MATCH / REFER</div>
|
||||
<h3 class="reject-title">Sanctions Match Found</h3>
|
||||
<p class="reject-detail">
|
||||
<strong class="reject-name">{{ clientName }}</strong> matched against
|
||||
<strong>UN SC Consolidated List</strong>.
|
||||
</p>
|
||||
<div class="reject-meta">
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Screened On</span>
|
||||
<span class="meta-value">{{ screenedDate }}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Status</span>
|
||||
<span class="meta-value meta-danger">Match Found — Blocked</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Database</span>
|
||||
<span class="meta-value">UN SC Consolidated List</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="escalate-banner">
|
||||
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
|
||||
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!verifying && errorOccurred) {
|
||||
@@ -67,10 +102,15 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
@if (!verifying && !matchFound && !errorOccurred) {
|
||||
<button mat-raised-button color="primary" (click)="close(true)">OK</button>
|
||||
<button mat-stroked-button (click)="close(true)" class="approve-btn-secondary">Dismiss</button>
|
||||
<button mat-raised-button color="primary" (click)="close(true)" class="approve-btn">
|
||||
<fa-icon icon="check"></fa-icon> Mark as Clear
|
||||
</button>
|
||||
}
|
||||
@if (!verifying && matchFound) {
|
||||
<button mat-raised-button color="warn" (click)="close(false)">Close</button>
|
||||
<button mat-raised-button color="warn" (click)="close(false)" class="reject-btn">
|
||||
<fa-icon icon="times"></fa-icon> Flag & Escalate
|
||||
</button>
|
||||
}
|
||||
@if (!verifying && errorOccurred) {
|
||||
<button mat-raised-button (click)="close(false)">Close</button>
|
||||
@@ -101,17 +141,250 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
|
||||
mat-dialog-content {
|
||||
padding: 16px 24px;
|
||||
max-height: 60vh;
|
||||
max-height: unset;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.progress-percent {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #1074b9;
|
||||
}
|
||||
|
||||
.progress-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.progress-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-step.active {
|
||||
color: #1074b9;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progress-step.done {
|
||||
color: #388e3c;
|
||||
}
|
||||
|
||||
.done-icon {
|
||||
color: #388e3c;
|
||||
}
|
||||
|
||||
.sanctions-msg {
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
line-height: 1.6;
|
||||
margin: 16px 0 0;
|
||||
margin: 4px 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.approve-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.approve-icon-ring {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #ecfdf5, #d1fae5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 3px solid #a7f3d0;
|
||||
}
|
||||
|
||||
.approve-shield {
|
||||
font-size: 28px;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.approve-badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1.5px;
|
||||
color: #059669;
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
padding: 3px 14px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.approve-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.approve-detail {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.approve-meta {
|
||||
width: 100%;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.meta-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta-clear {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.approve-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.approve-btn-secondary {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.reject-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.reject-icon-ring {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #fef2f2, #fecaca);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 3px solid #fca5a5;
|
||||
}
|
||||
|
||||
.reject-shield {
|
||||
font-size: 28px;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.reject-badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1.5px;
|
||||
color: #dc2626;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
padding: 3px 14px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.reject-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.reject-detail {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.reject-name {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.reject-meta {
|
||||
width: 100%;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.meta-danger {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.escalate-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.escalate-icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reject-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sanctions-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -160,8 +433,8 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
|
||||
.match-list {
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
max-height: unset;
|
||||
overflow: visible;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
@@ -239,33 +512,113 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
matches: any[] = [];
|
||||
errorOccurred = false;
|
||||
errorMessage = '';
|
||||
screeningLogId: number | null = null;
|
||||
|
||||
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
progressValue = 0;
|
||||
currentStep = 0;
|
||||
progressSteps = [
|
||||
{ label: 'Connecting to UN Security Council database...', icon: 'globe' as any },
|
||||
{ label: 'Screening name against consolidated list...', icon: 'search' as any },
|
||||
{ label: 'Cross-checking FIU-IND records...', icon: 'database' as any },
|
||||
{ label: 'Analyzing results...', icon: 'chart-bar' as any }
|
||||
];
|
||||
|
||||
private progressTimer: any;
|
||||
private apiResult: any = null;
|
||||
private apiError: any = null;
|
||||
private apiDone = false;
|
||||
|
||||
ngOnInit() {
|
||||
this.clientName = this.data?.name || '';
|
||||
this.dialogRef.disableClose = true;
|
||||
|
||||
// Start API call immediately
|
||||
this.clientsService.screenSanctions(this.clientName).subscribe({
|
||||
next: (result: any) => {
|
||||
this.verifying = false;
|
||||
this.dialogRef.disableClose = false;
|
||||
if (result.matchFound) {
|
||||
this.matchFound = true;
|
||||
this.matchCount = result.matchCount || 0;
|
||||
this.matches = result.matches || [];
|
||||
}
|
||||
this.apiResult = result;
|
||||
this.apiDone = true;
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying = false;
|
||||
this.dialogRef.disableClose = false;
|
||||
this.errorOccurred = true;
|
||||
this.errorMessage = err?.error?.message || err?.message || 'Unable to connect to sanctions screening service.';
|
||||
this.apiError = err;
|
||||
this.apiDone = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Animate progress through steps
|
||||
this.runProgress();
|
||||
}
|
||||
|
||||
ngOnDestroy() {}
|
||||
private runProgress() {
|
||||
const stepDurations = [1200, 1500, 1800, 1000]; // ms per step
|
||||
const totalDuration = stepDurations.reduce((a, b) => a + b, 0);
|
||||
let elapsed = 0;
|
||||
const interval = 50;
|
||||
|
||||
this.progressTimer = setInterval(() => {
|
||||
elapsed += interval;
|
||||
|
||||
// Calculate which step we're on
|
||||
let accum = 0;
|
||||
for (let i = 0; i < stepDurations.length; i++) {
|
||||
accum += stepDurations[i];
|
||||
if (elapsed <= accum) {
|
||||
this.currentStep = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update progress bar
|
||||
this.progressValue = Math.min(Math.round((elapsed / totalDuration) * 95), 95);
|
||||
|
||||
// After all steps are done, wait for API then finish
|
||||
if (elapsed >= totalDuration) {
|
||||
if (this.apiDone) {
|
||||
this.finishVerification();
|
||||
} else {
|
||||
// API still running — hold at 95% until done
|
||||
this.currentStep = stepDurations.length - 1;
|
||||
this.progressValue = 95;
|
||||
clearInterval(this.progressTimer);
|
||||
this.progressTimer = setInterval(() => {
|
||||
if (this.apiDone) {
|
||||
this.finishVerification();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}, interval);
|
||||
}
|
||||
|
||||
private finishVerification() {
|
||||
clearInterval(this.progressTimer);
|
||||
this.progressValue = 100;
|
||||
this.currentStep = this.progressSteps.length;
|
||||
|
||||
// Small delay after 100% before showing result
|
||||
setTimeout(() => {
|
||||
this.verifying = false;
|
||||
this.dialogRef.disableClose = false;
|
||||
|
||||
if (this.apiError) {
|
||||
this.errorOccurred = true;
|
||||
this.errorMessage = this.apiError?.error?.message || this.apiError?.message || 'Unable to connect to sanctions screening service.';
|
||||
} else {
|
||||
this.screeningLogId = this.apiResult?.screeningLogId || null;
|
||||
if (this.apiResult?.matchFound) {
|
||||
this.matchFound = true;
|
||||
this.matchCount = this.apiResult.matchCount || 0;
|
||||
this.matches = this.apiResult.matches || [];
|
||||
}
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
clearInterval(this.progressTimer);
|
||||
}
|
||||
|
||||
close(verified: boolean) {
|
||||
this.dialogRef.close(verified);
|
||||
this.dialogRef.close({ verified, screeningLogId: this.screeningLogId });
|
||||
}
|
||||
}
|
||||
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="addr-container">
|
||||
<div class="addr-header">
|
||||
<div class="addr-header__left">
|
||||
<fa-icon icon="map" class="addr-header__icon"></fa-icon>
|
||||
<div>
|
||||
<h3 class="addr-header__title">Address Details</h3>
|
||||
<p class="addr-header__sub">All address fields are India-specific. PIN auto-lookup enabled.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-tab-group class="addr-tabs" [(selectedIndex)]="activeTab" animationDuration="150ms">
|
||||
<!-- ═══ TAB 1: PERMANENT ═══ -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<span class="addr-tab-num">1</span> Permanent Address
|
||||
</ng-template>
|
||||
<div class="addr-panel" [formGroup]="permanentForm">
|
||||
<div class="addr-grid addr-grid--1">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Address Line 1</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>home</mat-icon></span>
|
||||
<input matInput required formControlName="addressLine1" placeholder="House / Flat No., Floor, Building Name" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="addr-grid addr-grid--1">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Address Line 2</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>signpost</mat-icon></span>
|
||||
<input matInput formControlName="addressLine2" placeholder="Street, Road, Lane, Colony" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-divider"><span>Location</span></div>
|
||||
|
||||
<div class="addr-grid addr-grid--2">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>PIN Code</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>pin_drop</mat-icon></span>
|
||||
<input matInput required formControlName="pinCode" maxlength="6" placeholder="6-digit PIN"
|
||||
(input)="onPinInput('permanent')"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
|
||||
@if (permanentForm.get('pinCode')?.hasError('pattern') && permanentForm.get('pinCode')?.touched) {
|
||||
<mat-error>Enter a valid 6-digit PIN code</mat-error>
|
||||
}
|
||||
@if (pinStatus['permanent'].loading) {
|
||||
<mat-hint class="pin-loading">Looking up...</mat-hint>
|
||||
}
|
||||
@if (pinStatus['permanent'].found) {
|
||||
<mat-hint class="pin-ok">PIN verified</mat-hint>
|
||||
}
|
||||
@if (pinStatus['permanent'].error) {
|
||||
<mat-error>{{ pinStatus['permanent'].message }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Locality / Area</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>location_on</mat-icon></span>
|
||||
<mat-select required formControlName="locality">
|
||||
@for (loc of localityOptions['permanent']; track loc) {
|
||||
<mat-option [value]="loc">{{ loc }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-grid addr-grid--3">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>City / Town</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>location_city</mat-icon></span>
|
||||
<input matInput formControlName="city" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>District</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>map</mat-icon></span>
|
||||
<input matInput formControlName="district" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>State</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>flag</mat-icon></span>
|
||||
<input matInput required formControlName="state" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-grid addr-grid--2">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Country</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>public</mat-icon></span>
|
||||
<input matInput formControlName="country" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Landmark</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>place</mat-icon></span>
|
||||
<input matInput formControlName="landmark" placeholder="Near / Opp. / Behind..." />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-divider"><span>Proof of Address</span></div>
|
||||
|
||||
<div class="addr-grid addr-grid--2">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Address Proof Type</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>verified_user</mat-icon></span>
|
||||
<mat-select required formControlName="proofType">
|
||||
@for (pt of proofTypes; track pt) {
|
||||
<mat-option [value]="pt">{{ pt }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Document Number</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>tag</mat-icon></span>
|
||||
<input matInput formControlName="proofDocNumber" placeholder="e.g. XXXX XXXX XXXX" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- ═══ TAB 2: CURRENT / RESIDENTIAL ═══ -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<span class="addr-tab-num">2</span> Current / Residential
|
||||
</ng-template>
|
||||
<div class="addr-panel" [formGroup]="currentForm">
|
||||
<div class="addr-same-row">
|
||||
<mat-checkbox [checked]="sameAsPermanent" (change)="toggleSameAsPermanent($event.checked)">
|
||||
Same as Permanent Address
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
|
||||
@if (!sameAsPermanent) {
|
||||
<div class="addr-grid addr-grid--1">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Address Line 1</mat-label>
|
||||
<input matInput required formControlName="addressLine1" placeholder="House / Flat No., Floor, Building Name" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="addr-grid addr-grid--1">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Address Line 2</mat-label>
|
||||
<input matInput formControlName="addressLine2" placeholder="Street, Road, Lane, Colony" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-divider"><span>Location</span></div>
|
||||
|
||||
<div class="addr-grid addr-grid--2">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>PIN Code</mat-label>
|
||||
<input matInput required formControlName="pinCode" maxlength="6" placeholder="6-digit PIN"
|
||||
(input)="onPinInput('current')"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
|
||||
@if (currentForm.get('pinCode')?.hasError('pattern') && currentForm.get('pinCode')?.touched) {
|
||||
<mat-error>Enter a valid 6-digit PIN code</mat-error>
|
||||
}
|
||||
@if (pinStatus['current'].found) {
|
||||
<mat-hint class="pin-ok">PIN verified</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Locality / Area</mat-label>
|
||||
<mat-select required formControlName="locality">
|
||||
@for (loc of localityOptions['current']; track loc) {
|
||||
<mat-option [value]="loc">{{ loc }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-grid addr-grid--3">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>City / Town</mat-label>
|
||||
<input matInput formControlName="city" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>District</mat-label>
|
||||
<input matInput formControlName="district" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>State</mat-label>
|
||||
<input matInput required formControlName="state" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-grid addr-grid--2">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Country</mat-label>
|
||||
<input matInput formControlName="country" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Landmark</mat-label>
|
||||
<input matInput formControlName="landmark" placeholder="Near / Opp. / Behind..." />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-divider"><span>Residency</span></div>
|
||||
|
||||
<div class="addr-grid addr-grid--2">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Residence Type</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>cottage</mat-icon></span>
|
||||
<mat-select formControlName="residenceType">
|
||||
@for (rt of residenceTypes; track rt) {
|
||||
<mat-option [value]="rt">{{ rt }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Years at This Address</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>schedule</mat-icon></span>
|
||||
<input matInput type="number" formControlName="yearsAtAddress" placeholder="e.g. 3" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="addr-same-info">
|
||||
<fa-icon icon="check-circle" class="addr-same-icon"></fa-icon>
|
||||
Current address is same as Permanent address.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- ═══ TAB 3: OFFICE / WORK ═══ -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<span class="addr-tab-num">3</span> Office / Work
|
||||
</ng-template>
|
||||
<div class="addr-panel" [formGroup]="officeForm">
|
||||
<div class="addr-grid addr-grid--2">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Organisation / Company Name</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>business</mat-icon></span>
|
||||
<input matInput formControlName="orgName" placeholder="Legal name of employer / business" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Department / Unit</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>corporate_fare</mat-icon></span>
|
||||
<input matInput formControlName="department" placeholder="e.g. Finance, Operations" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-grid addr-grid--1">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Office Address Line 1</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>home_work</mat-icon></span>
|
||||
<input matInput formControlName="addressLine1" placeholder="Office No., Floor, Building Name" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="addr-grid addr-grid--1">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Office Address Line 2</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>signpost</mat-icon></span>
|
||||
<input matInput formControlName="addressLine2" placeholder="Street, Road, Industrial Area" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-divider"><span>Location</span></div>
|
||||
|
||||
<div class="addr-grid addr-grid--2">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>PIN Code</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>pin_drop</mat-icon></span>
|
||||
<input matInput formControlName="pinCode" maxlength="6" placeholder="6-digit PIN"
|
||||
(input)="onPinInput('office')"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
|
||||
@if (officeForm.get('pinCode')?.hasError('pattern') && officeForm.get('pinCode')?.touched) {
|
||||
<mat-error>Enter a valid 6-digit PIN code</mat-error>
|
||||
}
|
||||
@if (pinStatus['office'].found) {
|
||||
<mat-hint class="pin-ok">PIN verified</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Locality / Area</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>location_on</mat-icon></span>
|
||||
<mat-select formControlName="locality">
|
||||
@for (loc of localityOptions['office']; track loc) {
|
||||
<mat-option [value]="loc">{{ loc }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-grid addr-grid--3">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>City / Town</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>location_city</mat-icon></span>
|
||||
<input matInput formControlName="city" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>District</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>map</mat-icon></span>
|
||||
<input matInput formControlName="district" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>State</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>flag</mat-icon></span>
|
||||
<input matInput formControlName="state" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-grid addr-grid--2">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Country</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>public</mat-icon></span>
|
||||
<input matInput formControlName="country" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Office Phone</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>phone</mat-icon></span>
|
||||
<input matInput formControlName="officePhone" placeholder="+91 XXXXX XXXXX" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-divider"><span>Employment Details</span></div>
|
||||
|
||||
<div class="addr-grid addr-grid--3">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Employment Type</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>work</mat-icon></span>
|
||||
<mat-select formControlName="employmentType">
|
||||
@for (et of employmentTypes; track et) {
|
||||
<mat-option [value]="et">{{ et }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Employee / Staff ID</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>badge</mat-icon></span>
|
||||
<input matInput formControlName="employeeId" placeholder="Optional" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Years with Organisation</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>schedule</mat-icon></span>
|
||||
<input matInput type="number" formControlName="yearsWithOrg" placeholder="e.g. 5" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- ═══ TAB 4: CORRESPONDENCE ═══ -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<span class="addr-tab-num">4</span> Correspondence
|
||||
</ng-template>
|
||||
<div class="addr-panel" [formGroup]="correspondenceForm">
|
||||
<div class="addr-copy-row">
|
||||
<span class="addr-copy-label">Copy address from:</span>
|
||||
<button mat-stroked-button type="button" class="addr-copy-btn" (click)="copyFrom('permanent')">Permanent</button>
|
||||
<button mat-stroked-button type="button" class="addr-copy-btn" (click)="copyFrom('current')">Current</button>
|
||||
<button mat-stroked-button type="button" class="addr-copy-btn" (click)="copyFrom('office')">Office</button>
|
||||
</div>
|
||||
|
||||
<div class="addr-grid addr-grid--1">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Address Line 1</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>home</mat-icon></span>
|
||||
<input matInput required formControlName="addressLine1" placeholder="House / Flat No., Floor, Building Name" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="addr-grid addr-grid--1">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Address Line 2</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>signpost</mat-icon></span>
|
||||
<input matInput formControlName="addressLine2" placeholder="Street, Road, Lane, Colony" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-divider"><span>Location</span></div>
|
||||
|
||||
<div class="addr-grid addr-grid--2">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>PIN Code</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>pin_drop</mat-icon></span>
|
||||
<input matInput required formControlName="pinCode" maxlength="6" placeholder="6-digit PIN"
|
||||
(input)="onPinInput('correspondence')"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
|
||||
@if (correspondenceForm.get('pinCode')?.hasError('pattern') && correspondenceForm.get('pinCode')?.touched) {
|
||||
<mat-error>Enter a valid 6-digit PIN code</mat-error>
|
||||
}
|
||||
@if (pinStatus['correspondence'].found) {
|
||||
<mat-hint class="pin-ok">PIN verified</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Locality / Area</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>location_on</mat-icon></span>
|
||||
<mat-select required formControlName="locality">
|
||||
@for (loc of localityOptions['correspondence']; track loc) {
|
||||
<mat-option [value]="loc">{{ loc }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-grid addr-grid--3">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>City / Town</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>location_city</mat-icon></span>
|
||||
<input matInput formControlName="city" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>District</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>map</mat-icon></span>
|
||||
<input matInput formControlName="district" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>State</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>flag</mat-icon></span>
|
||||
<input matInput required formControlName="state" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-grid addr-grid--2">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Country</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>public</mat-icon></span>
|
||||
<input matInput formControlName="country" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Landmark</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>place</mat-icon></span>
|
||||
<input matInput formControlName="landmark" placeholder="Near / Opp. / Behind..." />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-grid addr-grid--2">
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Preferred Communication Mode</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>mail</mat-icon></span>
|
||||
<mat-select formControlName="commMode">
|
||||
@for (cm of commModes; track cm) {
|
||||
<mat-option [value]="cm">{{ cm }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Contact Number for Correspondence</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>phone_android</mat-icon></span>
|
||||
<input matInput formControlName="contactNumber" placeholder="+91 XXXXX XXXXX" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</div>
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*/
|
||||
|
||||
$blue: #1353a0;
|
||||
$navy: #0f172a;
|
||||
$border: #e2e8f0;
|
||||
$muted: #64748b;
|
||||
|
||||
.addr-container {
|
||||
border: 1px solid $border;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.addr-header {
|
||||
background: $navy;
|
||||
padding: 16px 24px;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 18px;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Tabs
|
||||
.addr-tabs {
|
||||
::ng-deep .mat-mdc-tab-labels {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid $border;
|
||||
}
|
||||
}
|
||||
|
||||
.addr-tab-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
background: #f1f5f9;
|
||||
color: $muted;
|
||||
}
|
||||
|
||||
// Panel
|
||||
.addr-panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
// Grids
|
||||
.addr-grid {
|
||||
display: grid;
|
||||
gap: 4px 16px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&--1 { grid-template-columns: 1fr; }
|
||||
&--2 { grid-template-columns: 1fr 1fr; }
|
||||
&--3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||
}
|
||||
|
||||
.addr-ff {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Divider
|
||||
.addr-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 4px 0 14px;
|
||||
|
||||
span {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: $muted;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&::before, &::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: $border;
|
||||
}
|
||||
}
|
||||
|
||||
// PIN status hints
|
||||
.pin-loading {
|
||||
color: $muted !important;
|
||||
}
|
||||
|
||||
.pin-ok {
|
||||
color: #059669 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Same as permanent
|
||||
.addr-same-row {
|
||||
padding: 10px 14px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.addr-same-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 18px;
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.addr-same-icon {
|
||||
font-size: 16px;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
// Copy row
|
||||
.addr-copy-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.addr-copy-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: $muted;
|
||||
}
|
||||
|
||||
.addr-copy-btn {
|
||||
font-size: 12px !important;
|
||||
padding: 0 12px !important;
|
||||
height: 30px !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 768px) {
|
||||
.addr-grid--2, .addr-grid--3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.addr-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.addr-prefix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding-left: 4px;
|
||||
padding-right: 8px;
|
||||
margin-right: 8px;
|
||||
border-right: 1.5px solid #cbd5e1;
|
||||
line-height: 1;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #1565c0;
|
||||
}
|
||||
}
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, Input, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-client-india-address-step',
|
||||
templateUrl: './client-india-address-step.component.html',
|
||||
styleUrls: ['./client-india-address-step.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
MatCheckbox,
|
||||
MatTabsModule,
|
||||
MatIcon
|
||||
]
|
||||
})
|
||||
export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
private fb = inject(UntypedFormBuilder);
|
||||
private http = inject(HttpClient);
|
||||
private clientsService = inject(ClientsService);
|
||||
|
||||
/** Client ID — when set, loads existing addresses for edit mode */
|
||||
@Input() clientId: string = '';
|
||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||
@Input() readOnly = false;
|
||||
|
||||
activeTab = 0;
|
||||
sameAsPermanent = false;
|
||||
|
||||
permanentForm: UntypedFormGroup = this.fb.group({
|
||||
addressLine1: ['', Validators.required],
|
||||
addressLine2: [''],
|
||||
pinCode: ['', [Validators.required, Validators.pattern(/^\d{6}$/)]],
|
||||
locality: ['', Validators.required],
|
||||
city: [''],
|
||||
district: [''],
|
||||
state: ['', Validators.required],
|
||||
country: [{ value: 'India', disabled: true }],
|
||||
landmark: [''],
|
||||
proofType: ['', Validators.required],
|
||||
proofDocNumber: ['']
|
||||
});
|
||||
|
||||
currentForm: UntypedFormGroup = this.fb.group({
|
||||
addressLine1: ['', Validators.required],
|
||||
addressLine2: [''],
|
||||
pinCode: ['', [Validators.required, Validators.pattern(/^\d{6}$/)]],
|
||||
locality: ['', Validators.required],
|
||||
city: [''],
|
||||
district: [''],
|
||||
state: ['', Validators.required],
|
||||
country: [{ value: 'India', disabled: true }],
|
||||
landmark: [''],
|
||||
residenceType: [''],
|
||||
yearsAtAddress: ['']
|
||||
});
|
||||
|
||||
officeForm: UntypedFormGroup = this.fb.group({
|
||||
orgName: [''],
|
||||
department: [''],
|
||||
addressLine1: [''],
|
||||
addressLine2: [''],
|
||||
pinCode: ['', Validators.pattern(/^\d{6}$/)],
|
||||
locality: [''],
|
||||
city: [''],
|
||||
district: [''],
|
||||
state: [''],
|
||||
country: [{ value: 'India', disabled: true }],
|
||||
officePhone: [''],
|
||||
employmentType: [''],
|
||||
employeeId: [''],
|
||||
yearsWithOrg: ['']
|
||||
});
|
||||
|
||||
correspondenceForm: UntypedFormGroup = this.fb.group({
|
||||
addressLine1: ['', Validators.required],
|
||||
addressLine2: [''],
|
||||
pinCode: ['', [Validators.required, Validators.pattern(/^\d{6}$/)]],
|
||||
locality: ['', Validators.required],
|
||||
city: [''],
|
||||
district: [''],
|
||||
state: ['', Validators.required],
|
||||
country: [{ value: 'India', disabled: true }],
|
||||
landmark: [''],
|
||||
commMode: [''],
|
||||
contactNumber: ['']
|
||||
});
|
||||
|
||||
// PIN lookup state per tab
|
||||
pinStatus: Record<string, { loading: boolean; found: boolean; error: boolean; message: string }> = {
|
||||
permanent: { loading: false, found: false, error: false, message: '' },
|
||||
current: { loading: false, found: false, error: false, message: '' },
|
||||
office: { loading: false, found: false, error: false, message: '' },
|
||||
correspondence: { loading: false, found: false, error: false, message: '' }
|
||||
};
|
||||
|
||||
localityOptions: Record<string, string[]> = {
|
||||
permanent: [],
|
||||
current: [],
|
||||
office: [],
|
||||
correspondence: []
|
||||
};
|
||||
|
||||
proofTypes = [
|
||||
'Aadhaar Card', 'Passport', 'Voter ID', 'Driving Licence',
|
||||
'Utility Bill (not older than 3 months)', 'Bank Statement'
|
||||
];
|
||||
|
||||
residenceTypes = ['Owned', 'Rented', 'Leased', 'Company Provided', 'Parental / Family Owned'];
|
||||
|
||||
employmentTypes = [
|
||||
'Salaried (Private)', 'Salaried (Government / PSU)',
|
||||
'Self-Employed Professional', 'Business Owner', 'Retired'
|
||||
];
|
||||
|
||||
commModes = ['Post / Courier', 'Email', 'SMS', 'WhatsApp'];
|
||||
|
||||
private pinTimers: Record<string, any> = {};
|
||||
private pinCache: Record<string, any[]> = {};
|
||||
private addressLoaded = false;
|
||||
|
||||
/** Address type name → tab mapping for loading existing data */
|
||||
private typeToTab: Record<string, string> = {};
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.clientId) {
|
||||
this.loadExistingAddresses();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['readOnly']) {
|
||||
const forms = [this.permanentForm, this.currentForm, this.officeForm, this.correspondenceForm];
|
||||
for (const form of forms) {
|
||||
if (this.readOnly) {
|
||||
form.disable({ emitEvent: false });
|
||||
} else {
|
||||
form.enable({ emitEvent: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing addresses from Fineract API and populate forms.
|
||||
* Maps Fineract fields back to India address fields.
|
||||
*/
|
||||
private loadExistingAddresses(): void {
|
||||
if (this.addressLoaded) return;
|
||||
this.clientsService.getClientAddressData(this.clientId).subscribe({
|
||||
next: (addresses: any[]) => {
|
||||
this.addressLoaded = true;
|
||||
if (!addresses?.length) return;
|
||||
|
||||
// Build type-to-tab mapping from address type names
|
||||
const tabMap: Record<string, string> = {
|
||||
permanent: 'permanent', 'permanent address': 'permanent',
|
||||
residential: 'current', current: 'current', 'current / residential': 'current', communication: 'current',
|
||||
office: 'office', 'office / work': 'office', business: 'office', work: 'office',
|
||||
correspondence: 'correspondence', mailing: 'correspondence', postal: 'correspondence'
|
||||
};
|
||||
|
||||
for (const addr of addresses) {
|
||||
const typeName = (addr.addressType || '').toLowerCase().trim();
|
||||
const tab = tabMap[typeName] || (addresses.indexOf(addr) === 0 ? 'permanent' : null);
|
||||
if (!tab) continue;
|
||||
|
||||
const form = this.getForm(tab);
|
||||
form.patchValue({
|
||||
addressLine1: addr.addressLine1 || '',
|
||||
addressLine2: addr.addressLine2 || '',
|
||||
pinCode: addr.postalCode || '',
|
||||
locality: addr.townVillage || '',
|
||||
city: addr.city || '',
|
||||
district: addr.countyDistrict || '',
|
||||
state: addr.stateName || '',
|
||||
landmark: addr.street || ''
|
||||
});
|
||||
}
|
||||
},
|
||||
error: () => { this.addressLoaded = true; }
|
||||
});
|
||||
}
|
||||
|
||||
onPinInput(tab: string) {
|
||||
const form = this.getForm(tab);
|
||||
const pin = form.get('pinCode')?.value?.replace(/\D/g, '') || '';
|
||||
form.get('pinCode')?.setValue(pin, { emitEvent: false });
|
||||
|
||||
clearTimeout(this.pinTimers[tab]);
|
||||
if (pin.length < 6) {
|
||||
this.pinStatus[tab] = { loading: false, found: false, error: false, message: '' };
|
||||
this.clearPinFields(tab);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.pinCache[pin]) {
|
||||
this.applyPinData(tab, this.pinCache[pin]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pinStatus[tab] = { loading: true, found: false, error: false, message: 'Looking up...' };
|
||||
this.pinTimers[tab] = setTimeout(() => {
|
||||
this.http.get<any[]>(`https://api.postalpincode.in/pincode/${pin}`).subscribe({
|
||||
next: (data: any) => {
|
||||
if (data[0]?.Status === 'Success') {
|
||||
this.pinCache[pin] = data[0].PostOffice;
|
||||
this.applyPinData(tab, data[0].PostOffice);
|
||||
this.pinStatus[tab] = { loading: false, found: true, error: false, message: 'Found' };
|
||||
} else {
|
||||
this.clearPinFields(tab);
|
||||
this.pinStatus[tab] = { loading: false, found: false, error: true, message: 'Invalid PIN' };
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.pinStatus[tab] = { loading: false, found: false, error: true, message: 'Network error' };
|
||||
}
|
||||
});
|
||||
}, 550);
|
||||
}
|
||||
|
||||
private applyPinData(tab: string, offices: any[]) {
|
||||
const seen = new Set<string>();
|
||||
this.localityOptions[tab] = offices.filter(o => {
|
||||
if (seen.has(o.Name)) return false;
|
||||
seen.add(o.Name);
|
||||
return true;
|
||||
}).map(o => o.Name);
|
||||
|
||||
const form = this.getForm(tab);
|
||||
const f = offices[0];
|
||||
form.patchValue({
|
||||
locality: this.localityOptions[tab][0] || '',
|
||||
city: f.Block || f.Division || f.District || '',
|
||||
district: f.District || '',
|
||||
state: f.State || ''
|
||||
});
|
||||
}
|
||||
|
||||
private clearPinFields(tab: string) {
|
||||
this.localityOptions[tab] = [];
|
||||
const form = this.getForm(tab);
|
||||
form.patchValue({ locality: '', city: '', district: '', state: '' });
|
||||
}
|
||||
|
||||
getForm(tab: string): UntypedFormGroup {
|
||||
switch (tab) {
|
||||
case 'permanent': return this.permanentForm;
|
||||
case 'current': return this.currentForm;
|
||||
case 'office': return this.officeForm;
|
||||
case 'correspondence': return this.correspondenceForm;
|
||||
default: return this.permanentForm;
|
||||
}
|
||||
}
|
||||
|
||||
toggleSameAsPermanent(checked: boolean) {
|
||||
this.sameAsPermanent = checked;
|
||||
if (checked) {
|
||||
const pv = this.permanentForm.getRawValue();
|
||||
this.currentForm.patchValue({
|
||||
addressLine1: pv.addressLine1,
|
||||
addressLine2: pv.addressLine2,
|
||||
pinCode: pv.pinCode,
|
||||
locality: pv.locality,
|
||||
city: pv.city,
|
||||
district: pv.district,
|
||||
state: pv.state,
|
||||
landmark: pv.landmark
|
||||
});
|
||||
this.localityOptions['current'] = [...this.localityOptions['permanent']];
|
||||
this.pinStatus['current'] = { ...this.pinStatus['permanent'] };
|
||||
}
|
||||
}
|
||||
|
||||
copyFrom(source: string) {
|
||||
const srcForm = this.getForm(source);
|
||||
const sv = srcForm.getRawValue();
|
||||
this.correspondenceForm.patchValue({
|
||||
addressLine1: sv.addressLine1,
|
||||
addressLine2: sv.addressLine2,
|
||||
pinCode: sv.pinCode,
|
||||
locality: sv.locality,
|
||||
city: sv.city,
|
||||
district: sv.district,
|
||||
state: sv.state,
|
||||
landmark: sv.landmark || ''
|
||||
});
|
||||
this.localityOptions['correspondence'] = [...(this.localityOptions[source] || [])];
|
||||
this.pinStatus['correspondence'] = { ...this.pinStatus[source] };
|
||||
}
|
||||
|
||||
/** Returns all address data for submission */
|
||||
get addressData(): any {
|
||||
return {
|
||||
permanent: this.permanentForm.getRawValue(),
|
||||
current: this.sameAsPermanent ? { ...this.permanentForm.getRawValue(), sameAsPermanent: true } : this.currentForm.getRawValue(),
|
||||
office: this.officeForm.getRawValue(),
|
||||
correspondence: this.correspondenceForm.getRawValue()
|
||||
};
|
||||
}
|
||||
|
||||
get hasAddressData(): boolean {
|
||||
return this.permanentForm.get('addressLine1')?.value?.length > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,65 +1,808 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="mat-typography" [formGroup]="kycForm">
|
||||
<div class="step-header">
|
||||
<div class="step-header__left">
|
||||
<fa-icon icon="id-card" class="step-header__icon"></fa-icon>
|
||||
<div>
|
||||
<h3 class="step-header__title">KYC & Documents</h3>
|
||||
<p class="step-header__sub">RBI-compliant KYC verification — PAN, Aadhaar & document upload.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: KYC Verification ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="fingerprint" class="section-icon"></fa-icon>
|
||||
{{ 'labels.inputs.KYC Verification' | translate }}
|
||||
KYC Verification
|
||||
<span class="kyc-badge">KYC</span>
|
||||
</h4>
|
||||
|
||||
<div class="kyc-info-hint">
|
||||
<mat-icon class="kyc-info-hint-icon">info</mat-icon>
|
||||
<span>Verification (PAN, Aadhaar, GSTIN) will be available from the KYC tab after the client is created.</span>
|
||||
<div class="form-grid">
|
||||
|
||||
<!-- ══ INDIVIDUAL KYC ══ -->
|
||||
@if (!isNonIndividual) {
|
||||
<div class="form-field checkbox-field full-width">
|
||||
<mat-checkbox formControlName="hasPan">Client has PAN Card</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- Individual PAN with inline verify -->
|
||||
@if (kycForm.get('hasPan')?.value) {
|
||||
<div class="form-field verify-field">
|
||||
<mat-form-field appearance="outline" class="verify-input">
|
||||
<mat-label>PAN Number</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>credit_card</mat-icon></span>
|
||||
<input matInput formControlName="panNumber" placeholder="e.g. ABCDE1234F"
|
||||
maxlength="10" style="text-transform: uppercase"
|
||||
(input)="kycForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
|
||||
@if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) {
|
||||
<mat-error>Invalid PAN format</mat-error>
|
||||
}
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
|
||||
}
|
||||
@if (panVerifyStatus === 'FAILED') {
|
||||
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
|
||||
}
|
||||
<mat-hint>Format: ABCDE1234F</mat-hint>
|
||||
</mat-form-field>
|
||||
<div class="verify-action">
|
||||
@if (panVerifyStatus === 'NONE' || panVerifyStatus === 'FAILED') {
|
||||
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
|
||||
[disabled]="readOnly || !kycForm.get('panNumber')?.value || kycForm.get('panNumber')?.invalid || panVerifying"
|
||||
(click)="verifyPan()">
|
||||
@if (panVerifying) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
<fa-icon icon="shield-alt"></fa-icon> Verify PAN
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<div class="pan-result-card full-width">
|
||||
<div class="pan-result-header">
|
||||
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
|
||||
<span>PAN Verified Successfully</span>
|
||||
</div>
|
||||
<div class="pan-result-body">
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Full Name (as per PAN)</span>
|
||||
<span class="pan-result-value name">{{ panVerifyName }}</span>
|
||||
</div>
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">PAN–Aadhaar Link Status</span>
|
||||
<span class="pan-result-value" [class.seeded]="panAadhaarSeedingStatus?.toUpperCase() === 'Y'" [class.not-seeded]="panAadhaarSeedingStatus?.toUpperCase() === 'N'">
|
||||
@if (panAadhaarSeedingStatus?.toUpperCase() === 'Y') { Linked }
|
||||
@else if (panAadhaarSeedingStatus?.toUpperCase() === 'N') { Not Linked }
|
||||
@else { — }
|
||||
</span>
|
||||
</div>
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Verified On</span>
|
||||
<span class="pan-result-value date">{{ panVerifiedOn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Aadhaar with independent verify (Individual only) -->
|
||||
<div class="form-field verify-field">
|
||||
<mat-form-field appearance="outline" class="verify-input">
|
||||
<mat-label>Aadhaar Number</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>fingerprint</mat-icon></span>
|
||||
<input matInput formControlName="aadhaarNumber" placeholder="12-digit Aadhaar"
|
||||
maxlength="12" [type]="aadhaarVisible ? 'text' : 'password'" [readonly]="aadhaarOtpSent"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
|
||||
<button mat-icon-button matSuffix type="button" (click)="aadhaarVisible = !aadhaarVisible" class="toggle-vis-btn">
|
||||
<fa-icon [icon]="aadhaarVisible ? 'eye-slash' : 'eye'"></fa-icon>
|
||||
</button>
|
||||
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
|
||||
<mat-error>Must be 12 digits</mat-error>
|
||||
}
|
||||
@if (aadhaarVerifyStatus === 'VERIFIED') {
|
||||
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
|
||||
}
|
||||
</mat-form-field>
|
||||
<div class="verify-action">
|
||||
@if ((aadhaarVerifyStatus === 'NONE' || aadhaarVerifyStatus === 'FAILED') && !aadhaarOtpSent) {
|
||||
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
|
||||
[disabled]="readOnly || !kycForm.get('aadhaarNumber')?.value || kycForm.get('aadhaarNumber')?.invalid || aadhaarVerifying"
|
||||
(click)="sendAadhaarOtp()">
|
||||
@if (aadhaarVerifying) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
<fa-icon icon="shield-alt"></fa-icon> Verify Aadhaar
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (aadhaarVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aadhaar OTP Section -->
|
||||
@if (aadhaarOtpSent && aadhaarVerifyStatus !== 'VERIFIED') {
|
||||
<div class="otp-card full-width">
|
||||
<div class="otp-card__header">
|
||||
<div class="otp-card__header-icon">
|
||||
<mat-icon>verified_user</mat-icon>
|
||||
</div>
|
||||
<div class="otp-card__header-text">
|
||||
<span class="otp-card__title">Aadhaar OTP Verification</span>
|
||||
<span class="otp-card__subtitle">A 6-digit OTP has been sent to the Aadhaar-linked mobile number</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="otp-card__body">
|
||||
<label class="otp-card__label">Enter OTP</label>
|
||||
<div class="otp-digits" (paste)="onOtpPaste($event)">
|
||||
@for (digit of otpDigits; track $index) {
|
||||
<input type="text" inputmode="numeric" maxlength="1"
|
||||
class="otp-digit-box" [class.filled]="digit"
|
||||
[value]="digit"
|
||||
(input)="onOtpDigitInput($index, $event)"
|
||||
(keydown)="onOtpKeydown($index, $event)"
|
||||
autocomplete="one-time-code" />
|
||||
}
|
||||
</div>
|
||||
<div class="otp-card__actions">
|
||||
<button mat-flat-button color="primary" type="button" class="otp-card__verify-btn"
|
||||
[disabled]="readOnly || aadhaarOtp.length < 6 || aadhaarOtpVerifying"
|
||||
(click)="verifyAadhaarOtp()">
|
||||
@if (aadhaarOtpVerifying) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate" class="otp-spinner"></mat-progress-spinner>
|
||||
Verifying...
|
||||
} @else {
|
||||
<fa-icon icon="check-circle" class="otp-btn-icon"></fa-icon>
|
||||
Verify OTP
|
||||
}
|
||||
</button>
|
||||
<div class="otp-card__resend">
|
||||
@if (resendCountdown > 0) {
|
||||
<span class="otp-card__countdown">
|
||||
Resend in <strong>00:{{ resendCountdown.toString().padStart(2, '0') }}</strong>
|
||||
</span>
|
||||
} @else {
|
||||
<span class="otp-card__resend-text">Didn't receive the code?</span>
|
||||
<button mat-button type="button" class="otp-card__resend-btn"
|
||||
[disabled]="readOnly || aadhaarVerifying" (click)="sendAadhaarOtp()">
|
||||
Resend OTP
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (aadhaarVerifyError) {
|
||||
<div class="verify-error full-width"><fa-icon icon="exclamation-circle"></fa-icon> {{ aadhaarVerifyError }}</div>
|
||||
}
|
||||
@if (aadhaarVerifyStatus === 'VERIFIED' && aadhaarVerifyName) {
|
||||
<div class="aadhaar-result-card full-width">
|
||||
<div class="aadhaar-result-card__header">
|
||||
<fa-icon icon="check-circle" class="aadhaar-result-card__icon"></fa-icon>
|
||||
<span>Aadhaar Verified Successfully</span>
|
||||
</div>
|
||||
<div class="aadhaar-result-card__body">
|
||||
<span class="aadhaar-result-card__label">Name as per Aadhaar</span>
|
||||
<span class="aadhaar-result-card__name">{{ aadhaarVerifyName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- PAN-Aadhaar Seeding Status -->
|
||||
@if (kycForm.get('hasPan')?.value && panAadhaarSeedingStatus) {
|
||||
<div class="form-field pan-link-status full-width">
|
||||
<div class="pan-link-box" [class.linked]="panAadhaarSeedingStatus === 'Y'" [class.not-linked]="panAadhaarSeedingStatus === 'N'" [class.exempt]="panAadhaarSeedingStatus === 'E'">
|
||||
<fa-icon icon="link" class="pan-link-icon"></fa-icon>
|
||||
<div class="pan-link-info">
|
||||
<span class="pan-link-label">PAN–Aadhaar Link Status</span>
|
||||
<span class="pan-link-value">
|
||||
@if (panAadhaarSeedingStatus === 'Y') { Linked (Seeded) }
|
||||
@if (panAadhaarSeedingStatus === 'N') { Not Linked }
|
||||
@if (panAadhaarSeedingStatus === 'E') { Exempt }
|
||||
</span>
|
||||
</div>
|
||||
<span class="pan-link-hint">As per Income Tax Portal — Section 139AA</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- ══ NON-INDIVIDUAL KYC ══ -->
|
||||
@if (isNonIndividual) {
|
||||
<!-- Incorporation Date -->
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="incorpDatePicker.open()">
|
||||
<mat-label>Incorporation Date</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
|
||||
<input matInput [max]="today" [matDatepicker]="incorpDatePicker" formControlName="incorporationDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="incorpDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #incorpDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- GSTIN (all non-individual) -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>GSTIN Number</mat-label>
|
||||
<input matInput formControlName="gstinNumber" placeholder="15-character GSTIN"
|
||||
maxlength="15" style="text-transform: uppercase"
|
||||
(input)="kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
|
||||
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
|
||||
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- PAN — Proprietorship uses Individual PAN, others use Enterprise PAN -->
|
||||
@if (constitutionMeta?.value === 'PROPRIETORSHIP') {
|
||||
<!-- Proprietorship: Individual PAN (proprietor's personal PAN) -->
|
||||
<div class="form-field verify-field">
|
||||
<mat-form-field appearance="outline" class="verify-input">
|
||||
<mat-label>PAN of Proprietor</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>credit_card</mat-icon></span>
|
||||
<input matInput formControlName="panNumber" placeholder="e.g. ABCDE1234F"
|
||||
maxlength="10" style="text-transform: uppercase"
|
||||
(input)="kycForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
|
||||
@if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) {
|
||||
<mat-error>Invalid PAN format</mat-error>
|
||||
}
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
|
||||
}
|
||||
@if (panVerifyStatus === 'FAILED') {
|
||||
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
|
||||
}
|
||||
<mat-hint>PAN of Proprietor (no separate entity PAN needed)</mat-hint>
|
||||
</mat-form-field>
|
||||
<div class="verify-action">
|
||||
@if (panVerifyStatus === 'NONE' || panVerifyStatus === 'FAILED') {
|
||||
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
|
||||
[disabled]="readOnly || !kycForm.get('panNumber')?.value || kycForm.get('panNumber')?.invalid || panVerifying || !clientId"
|
||||
(click)="verifyPan()">
|
||||
@if (panVerifying) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
<fa-icon icon="shield-alt"></fa-icon> Verify PAN
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<div class="pan-result-card full-width">
|
||||
<div class="pan-result-header">
|
||||
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
|
||||
<span>PAN Verified Successfully</span>
|
||||
</div>
|
||||
<div class="pan-result-body">
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Proprietor Name (as per PAN)</span>
|
||||
<span class="pan-result-value name">{{ panVerifyName }}</span>
|
||||
</div>
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Verified On</span>
|
||||
<span class="pan-result-value date">{{ panVerifiedOn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<!-- Non-Proprietorship: Enterprise/Company PAN -->
|
||||
<div class="form-field verify-field">
|
||||
<mat-form-field appearance="outline" class="verify-input">
|
||||
<mat-label>{{ constitutionMeta?.panLabel || 'PAN of Entity' }}</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>credit_card</mat-icon></span>
|
||||
<input matInput formControlName="enterprisePan" placeholder="e.g. ABCDE1234F"
|
||||
maxlength="10" style="text-transform: uppercase"
|
||||
(input)="kycForm.get('enterprisePan')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
|
||||
@if (kycForm.get('enterprisePan')?.hasError('pattern') && kycForm.get('enterprisePan')?.touched) {
|
||||
<mat-error>Invalid PAN format</mat-error>
|
||||
}
|
||||
@if (entPanVerifyStatus === 'VERIFIED') {
|
||||
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
|
||||
}
|
||||
@if (entPanVerifyStatus === 'FAILED') {
|
||||
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
|
||||
}
|
||||
<mat-hint>{{ constitutionMeta?.panHint || 'PAN mandatory' }}</mat-hint>
|
||||
</mat-form-field>
|
||||
<div class="verify-action">
|
||||
@if (entPanVerifyStatus === 'NONE' || entPanVerifyStatus === 'FAILED') {
|
||||
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
|
||||
[disabled]="readOnly || !kycForm.get('enterprisePan')?.value || kycForm.get('enterprisePan')?.invalid || entPanVerifying || !clientId"
|
||||
(click)="verifyEnterprisePan()">
|
||||
@if (entPanVerifying) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
<fa-icon icon="shield-alt"></fa-icon> Verify PAN
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (entPanVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (entPanVerifyStatus === 'VERIFIED') {
|
||||
<div class="pan-result-card full-width">
|
||||
<div class="pan-result-header">
|
||||
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
|
||||
<span>Enterprise PAN Verified</span>
|
||||
</div>
|
||||
<div class="pan-result-body">
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Entity Name (as per PAN)</span>
|
||||
<span class="pan-result-value name">{{ entPanVerifyName }}</span>
|
||||
</div>
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Verified On</span>
|
||||
<span class="pan-result-value date">{{ entPanVerifiedOn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- CIN (Companies & LLP only) -->
|
||||
@if (constitutionMeta?.showCin) {
|
||||
<div class="form-field verify-field">
|
||||
<mat-form-field appearance="outline" class="verify-input">
|
||||
<mat-label>CIN (Corporate Identity Number)</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>business</mat-icon></span>
|
||||
<input matInput formControlName="cinNumber" maxlength="21" placeholder="From MCA"
|
||||
style="text-transform: uppercase"
|
||||
(input)="kycForm.get('cinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
|
||||
@if (kycForm.get('cinNumber')?.hasError('pattern') && kycForm.get('cinNumber')?.touched) {
|
||||
<mat-error>Invalid CIN format (21 characters)</mat-error>
|
||||
}
|
||||
@if (cinVerifyStatus === 'VERIFIED') {
|
||||
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
|
||||
}
|
||||
</mat-form-field>
|
||||
<div class="verify-action">
|
||||
@if (cinVerifyStatus === 'NONE' || cinVerifyStatus === 'FAILED') {
|
||||
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
|
||||
[disabled]="readOnly || !kycForm.get('cinNumber')?.value || cinVerifying"
|
||||
(click)="verifyCin()">
|
||||
@if (cinVerifying) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
<fa-icon icon="shield-alt"></fa-icon> Verify CIN
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (cinVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CIN verification progress -->
|
||||
@if (cinVerifying) {
|
||||
<div class="cin-progress full-width">
|
||||
<mat-progress-bar mode="determinate" [value]="cinProgressValue" color="primary"></mat-progress-bar>
|
||||
<div class="cin-progress-percent">{{ cinProgressValue }}%</div>
|
||||
<div class="cin-progress-steps">
|
||||
@for (step of cinProgressSteps; track step.label; let i = $index) {
|
||||
<div class="cin-step" [class.active]="i === cinCurrentStep" [class.done]="i < cinCurrentStep">
|
||||
<fa-icon [icon]="i < cinCurrentStep ? 'check-circle' : step.icon" [class.done-icon]="i < cinCurrentStep"></fa-icon>
|
||||
<span>{{ step.label }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (cinVerifyStatus === 'VERIFIED') {
|
||||
<div class="pan-result-card full-width">
|
||||
<div class="pan-result-header">
|
||||
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
|
||||
<span>CIN Verified — {{ cinCompanyName }}</span>
|
||||
</div>
|
||||
<div class="pan-result-body">
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">CIN</span>
|
||||
<span class="pan-result-value name">{{ kycForm.get('cinNumber')?.value }}</span>
|
||||
</div>
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Verified On</span>
|
||||
<span class="pan-result-value date">{{ cinVerifiedOn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!clientId) {
|
||||
<div class="verify-info-banner">
|
||||
<fa-icon icon="info-circle" class="verify-info-icon"></fa-icon>
|
||||
<span>PAN verification requires client to be created first. Complete General tab and click Next to save.</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: KYC Document Management ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="file-alt" class="section-icon"></fa-icon>
|
||||
KYC Document
|
||||
<span class="kyc-badge doc-badge">OVD</span>
|
||||
@if (kycTemplate?.clientTypeName) {
|
||||
<span class="kyc-client-type-chip">{{ kycTemplate.clientTypeName }}</span>
|
||||
}
|
||||
</h4>
|
||||
|
||||
<div class="form-grid">
|
||||
<!-- PAN -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>{{ 'labels.inputs.PAN Number' | translate }}</mat-label>
|
||||
<input matInput formControlName="panNumber"
|
||||
placeholder="{{ 'labels.inputs.Enter 10-character PAN' | translate }}"
|
||||
maxlength="10" style="text-transform: uppercase"
|
||||
(input)="kycForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
|
||||
@if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) {
|
||||
<mat-error>{{ 'labels.inputs.Enter 10-character PAN' | translate }}</mat-error>
|
||||
<mat-label>KYC Document Type</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>description</mat-icon></span>
|
||||
<mat-select required formControlName="kycDocumentName">
|
||||
@for (doc of kycDocumentTypes; track doc) {
|
||||
<mat-option [value]="doc.name">
|
||||
{{ doc.name }}
|
||||
@if (doc.mandatory) {
|
||||
<span class="doc-mandatory-star"> *</span>
|
||||
}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (kycForm.get('kycDocumentName')?.hasError('required') && kycForm.get('kycDocumentName')?.touched) {
|
||||
<mat-error>KYC Document Type is <strong>required</strong></mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Aadhaar -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>{{ 'labels.inputs.Aadhaar Number' | translate }}</mat-label>
|
||||
<input matInput formControlName="aadhaarNumber"
|
||||
placeholder="{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}"
|
||||
maxlength="12" type="password" />
|
||||
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
|
||||
<mat-error>{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}</mat-error>
|
||||
<mat-label>Document Number</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>tag</mat-icon></span>
|
||||
<input matInput required formControlName="kycDocumentNumber" placeholder="As printed on document" maxlength="30"
|
||||
style="text-transform: uppercase"
|
||||
(input)="kycForm.get('kycDocumentNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
|
||||
@if (kycForm.get('kycDocumentNumber')?.hasError('required') && kycForm.get('kycDocumentNumber')?.touched) {
|
||||
<mat-error>Document number is <strong>required</strong></mat-error>
|
||||
} @else if (kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched) {
|
||||
<mat-error>Only letters, numbers, hyphens and slashes allowed</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- GSTIN -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>{{ 'labels.inputs.GSTIN Number' | translate }}</mat-label>
|
||||
<input matInput formControlName="gstinNumber"
|
||||
placeholder="{{ 'labels.inputs.Enter 15-character GSTIN' | translate }}"
|
||||
maxlength="15" style="text-transform: uppercase"
|
||||
(input)="kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
|
||||
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
|
||||
<mat-error>{{ 'labels.inputs.Enter 15-character GSTIN' | translate }}</mat-error>
|
||||
}
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="docIssueDatePicker.open()">
|
||||
<mat-label>Issue Date</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
|
||||
<input matInput [max]="today" [matDatepicker]="docIssueDatePicker" formControlName="kycDocIssueDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="docIssueDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #docIssueDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Remarks -->
|
||||
<mat-form-field appearance="outline" class="form-field full-width">
|
||||
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
|
||||
<textarea matInput formControlName="remarks" rows="3"></textarea>
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="docExpiryDatePicker.open()">
|
||||
<mat-label>Expiry Date</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>event_busy</mat-icon></span>
|
||||
<input matInput [matDatepicker]="docExpiryDatePicker" formControlName="kycDocExpiryDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="docExpiryDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #docExpiryDatePicker></mat-datepicker>
|
||||
<mat-hint>If applicable (Passport, DL, Visa)</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: Risk & KYC Review ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="shield-alt" class="section-icon"></fa-icon>
|
||||
Risk Category & KYC Review
|
||||
</h4>
|
||||
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Risk Category</mat-label>
|
||||
<mat-select formControlName="riskCategory">
|
||||
<mat-select-trigger>
|
||||
@if (kycForm.get('riskCategory')?.value === 1) {
|
||||
<span class="risk-option"><span class="risk-meter risk-low"></span> Low Risk</span>
|
||||
} @else if (kycForm.get('riskCategory')?.value === 2) {
|
||||
<span class="risk-option"><span class="risk-meter risk-medium"></span> Medium Risk</span>
|
||||
} @else if (kycForm.get('riskCategory')?.value === 3) {
|
||||
<span class="risk-option"><span class="risk-meter risk-high"></span> High Risk</span>
|
||||
}
|
||||
</mat-select-trigger>
|
||||
<mat-option [value]="1">
|
||||
<span class="risk-option"><span class="risk-meter risk-low"></span> Low Risk <span class="risk-hint">10 yr renewal</span></span>
|
||||
</mat-option>
|
||||
<mat-option [value]="2">
|
||||
<span class="risk-option"><span class="risk-meter risk-medium"></span> Medium Risk <span class="risk-hint">8 yr renewal</span></span>
|
||||
</mat-option>
|
||||
<mat-option [value]="3">
|
||||
<span class="risk-option"><span class="risk-meter risk-high"></span> High Risk <span class="risk-hint">2 yr renewal</span></span>
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>KYC Review Due Date</mat-label>
|
||||
<input matInput [value]="getKycExpiryDate(getRenewalYears())" disabled />
|
||||
<mat-hint>Auto: Low 10yr, Medium 8yr, High 2yr</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field full-width">
|
||||
<mat-label>Remarks</mat-label>
|
||||
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- KYC Expiry Alert Cards -->
|
||||
<div class="kyc-expiry-section">
|
||||
<div class="kyc-expiry-header">
|
||||
<fa-icon icon="bell" class="kyc-expiry-header-icon"></fa-icon>
|
||||
<span>KYC Expiry Alert</span>
|
||||
</div>
|
||||
<div class="kyc-expiry-body">
|
||||
<div class="kyc-expiry-cards">
|
||||
@for (rc of riskCards; track rc.value) {
|
||||
<div class="kyc-expiry-card" [class.active]="kycForm.get('riskCategory')?.value === rc.value">
|
||||
<span class="risk-gauge" [class]="'risk-gauge--' + rc.color">
|
||||
<svg viewBox="0 0 40 24" class="risk-gauge-svg">
|
||||
<path d="M4 22 A16 16 0 0 1 36 22" fill="none" stroke="#e2e8f0" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M4 22 A16 16 0 0 1 36 22" fill="none"
|
||||
[attr.stroke]="rc.color === 'green' ? '#22c55e' : rc.color === 'amber' ? '#f59e0b' : '#ef4444'"
|
||||
stroke-width="4" stroke-linecap="round"
|
||||
[attr.stroke-dasharray]="rc.color === 'green' ? '16 50' : rc.color === 'amber' ? '33 50' : '50 50'"/>
|
||||
<line x1="20" y1="22"
|
||||
[attr.x2]="rc.color === 'green' ? '10' : rc.color === 'amber' ? '20' : '30'"
|
||||
[attr.y2]="rc.color === 'green' ? '10' : rc.color === 'amber' ? '6' : '10'"
|
||||
stroke="#334155" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="20" cy="22" r="2" fill="#334155"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="kyc-expiry-card__info">
|
||||
<span class="kyc-expiry-card__label">{{ rc.label }}</span>
|
||||
<span class="kyc-expiry-card__cycle">Renewal every <strong>{{ rc.years }} years</strong></span>
|
||||
</div>
|
||||
@if (kycForm.get('riskCategory')?.value === rc.value) {
|
||||
<span class="kyc-expiry-card__next">Next: {{ getKycExpiryDate(rc.years) }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (kycForm.get('riskCategory')?.value) {
|
||||
<div class="kyc-expiry-alert"
|
||||
[class.kyc-expiry-alert--low]="kycForm.get('riskCategory')?.value === 1"
|
||||
[class.kyc-expiry-alert--medium]="kycForm.get('riskCategory')?.value === 2"
|
||||
[class.kyc-expiry-alert--high]="kycForm.get('riskCategory')?.value === 3">
|
||||
<fa-icon icon="exclamation-circle" class="kyc-expiry-alert__icon"></fa-icon>
|
||||
<span>System will auto-alert before KYC expiry on <strong>{{ getKycExpiryDate(getRenewalYears()) }}</strong>.</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: AML & Compliance Checks ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title aml-title">
|
||||
<fa-icon icon="exclamation-triangle" class="section-icon"></fa-icon>
|
||||
AML & Compliance Checks
|
||||
</h4>
|
||||
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>AML Check Status</mat-label>
|
||||
<mat-select required formControlName="amlCheckStatus">
|
||||
<mat-option value="CLEAR">Clear</mat-option>
|
||||
<mat-option value="FLAGGED">Flagged</mat-option>
|
||||
<mat-option value="BLOCKED">Blocked</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Banned Entity List Match</mat-label>
|
||||
<mat-select required formControlName="bannedEntityMatch">
|
||||
<mat-option value="NO">No</mat-option>
|
||||
<mat-option value="YES">Yes</mat-option>
|
||||
<mat-option value="PARTIAL_MATCH">Partial Match</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Willful Defaulter Flag</mat-label>
|
||||
<mat-select formControlName="willfulDefaulter">
|
||||
<mat-option value="NO">No</mat-option>
|
||||
<mat-option value="YES">Yes</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>ECGC SAL Flag</mat-label>
|
||||
<mat-select formControlName="ecgcSalFlag">
|
||||
<mat-option value="NO">No</mat-option>
|
||||
<mat-option value="YES">Yes</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Dormant / Inoperative Flag</mat-label>
|
||||
<mat-select formControlName="dormantFlag">
|
||||
<mat-option value="ACTIVE">Active</mat-option>
|
||||
<mat-option value="DORMANT">Dormant</mat-option>
|
||||
<mat-option value="INOPERATIVE">Inoperative</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@if (kycForm.get('amlCheckStatus')?.value === 'BLOCKED' || kycForm.get('bannedEntityMatch')?.value === 'YES') {
|
||||
<div class="aml-block-alert">
|
||||
<fa-icon icon="ban" class="aml-block-icon"></fa-icon>
|
||||
<span><strong>BLOCKED</strong> — Account opening cannot proceed.</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: Freeze / Debit Controls ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title freeze-title">
|
||||
<fa-icon icon="lock" class="section-icon"></fa-icon>
|
||||
Freeze / Debit Controls
|
||||
</h4>
|
||||
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Account Status</mat-label>
|
||||
<mat-select formControlName="accountStatus">
|
||||
<mat-option value="ACTIVE">Active</mat-option>
|
||||
<mat-option value="FROZEN">Frozen</mat-option>
|
||||
<mat-option value="UNFREEZE">Unfreeze</mat-option>
|
||||
<mat-option value="NO_DEBIT">No Debit</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
@if (kycForm.get('accountStatus')?.value === 'FROZEN') {
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Reason for Freeze</mat-label>
|
||||
<mat-select required formControlName="freezeReason">
|
||||
<mat-option value="COURT_ORDER">Court Order</mat-option>
|
||||
<mat-option value="AML">AML</mat-option>
|
||||
<mat-option value="REGULATORY">Regulatory</mat-option>
|
||||
<mat-option value="INTERNAL">Internal</mat-option>
|
||||
<mat-option value="OTHER">Other</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Freeze Authority</mat-label>
|
||||
<mat-select required formControlName="freezeAuthority">
|
||||
<mat-option value="BRANCH_MANAGER">Branch Manager</mat-option>
|
||||
<mat-option value="HO">Head Office</mat-option>
|
||||
<mat-option value="REGULATORY">Regulatory Authority</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: Form 60/61 ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
|
||||
Form 60 / 61
|
||||
@if (!kycForm.get('hasPan')?.value) {
|
||||
<span class="kyc-badge required-badge">Required — No PAN</span>
|
||||
}
|
||||
</h4>
|
||||
@if (!kycForm.get('hasPan')?.value) {
|
||||
<div class="kyc-info-hint warning-hint">
|
||||
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
|
||||
<span>Client does not have PAN. Form 60/61 is mandatory.</span>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Form Type</mat-label>
|
||||
<mat-select formControlName="form60Type">
|
||||
<mat-option value="FORM_60">Form 60</mat-option>
|
||||
<mat-option value="FORM_61">Form 61</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
|
||||
<mat-label>Declaration Date</mat-label>
|
||||
<input matInput [matDatepicker]="form60DatePicker" formControlName="form60Date" />
|
||||
<mat-datepicker-toggle matSuffix [for]="form60DatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #form60DatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="form60-not-needed">Client has PAN — Form 60/61 not required.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: Form 15G / 15H ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="receipt" class="section-icon"></fa-icon>
|
||||
Form 15G / 15H
|
||||
</h4>
|
||||
<div class="form-grid">
|
||||
<div class="form-field checkbox-field">
|
||||
<mat-checkbox formControlName="form15Applicable">Form 15G/15H Applicable</mat-checkbox>
|
||||
</div>
|
||||
@if (kycForm.get('form15Applicable')?.value) {
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Form Type</mat-label>
|
||||
<mat-select formControlName="form15Type">
|
||||
<mat-option value="15G">15G (Below 60 years)</mat-option>
|
||||
<mat-option value="15H">15H (Senior Citizen 60+)</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Financial Year</mat-label>
|
||||
<input matInput formControlName="form15FY" placeholder="e.g. 2025-26" />
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: Photograph & Signature ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="camera" class="section-icon"></fa-icon>
|
||||
Photograph & Signature
|
||||
</h4>
|
||||
<div class="photo-sig-grid">
|
||||
<div class="upload-box" [class.upload-box--has-preview]="photographPreview">
|
||||
@if (photographPreview) {
|
||||
<div class="upload-preview">
|
||||
<img [src]="photographPreview" alt="Photo" class="upload-preview__img upload-preview__img--photo" />
|
||||
<div class="upload-preview__actions">
|
||||
<label class="upload-preview__btn recrop">
|
||||
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
|
||||
<fa-icon icon="crop"></fa-icon> Re-crop
|
||||
</label>
|
||||
<button type="button" class="upload-preview__btn remove" (click)="removePhoto()">
|
||||
<fa-icon icon="trash"></fa-icon> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="upload-box__icon"><fa-icon icon="user"></fa-icon></div>
|
||||
<span class="upload-box__label">Client Photograph</span>
|
||||
<span class="upload-box__hint">JPG / PNG, max 2MB</span>
|
||||
<label class="upload-box__btn">
|
||||
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
|
||||
<fa-icon icon="camera"></fa-icon> Choose & Crop
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
<div class="upload-box" [class.upload-box--has-preview]="signaturePreview">
|
||||
@if (signaturePreview) {
|
||||
<div class="upload-preview">
|
||||
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
|
||||
<div class="upload-preview__actions">
|
||||
<label class="upload-preview__btn recrop">
|
||||
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
||||
<fa-icon icon="crop"></fa-icon> Re-crop
|
||||
</label>
|
||||
<button type="button" class="upload-preview__btn remove" (click)="removeSignature()">
|
||||
<fa-icon icon="trash"></fa-icon> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
|
||||
<span class="upload-box__label">Specimen Signature</span>
|
||||
<span class="upload-box__hint">Scanned image, max 1MB</span>
|
||||
<label class="upload-box__btn">
|
||||
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
||||
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,82 +1,805 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
|
||||
/** Custom Imports */
|
||||
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
|
||||
import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
|
||||
import { Inject } from '@angular/core';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatSelectTrigger } from '@angular/material/select';
|
||||
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
|
||||
/**
|
||||
* Client KYC Step Component
|
||||
* Collects PAN, Aadhaar, and GSTIN details during client creation.
|
||||
* KYC data is submitted via POST /clients/{clientId}/kyc after client creation.
|
||||
* Verification happens later from the KYC tab on the client view page.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-client-kyc-step',
|
||||
templateUrl: './client-kyc-step.component.html',
|
||||
styleUrls: ['./client-kyc-step.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
MatDivider,
|
||||
MatIcon,
|
||||
FaIconComponent
|
||||
MatCheckbox,
|
||||
MatProgressSpinner,
|
||||
FaIconComponent,
|
||||
MatSelectTrigger
|
||||
]
|
||||
})
|
||||
export class ClientKycStepComponent {
|
||||
export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private dialog = inject(MatDialog);
|
||||
private clientsService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
|
||||
/** KYC Reactive Form */
|
||||
/** Set by parent after client is created */
|
||||
@Input() clientId: string = '';
|
||||
@Input() isNonIndividual = false;
|
||||
/** Constitution metadata (panLabel, showCin, etc.) passed from general step */
|
||||
@Input() constitutionMeta: any = null;
|
||||
/** Entity full name (non-individual) — used for enterprise PAN verification */
|
||||
@Input() entityName: string = '';
|
||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||
@Input() readOnly = false;
|
||||
|
||||
today = new Date();
|
||||
kycId: string = '';
|
||||
/** Whether existing KYC data has been loaded (edit mode) */
|
||||
kycLoaded = false;
|
||||
|
||||
/** KYC Document Types — populated from API template, falls back to defaults */
|
||||
kycDocumentTypes: { name: string; code: string; mandatory: boolean }[] = [];
|
||||
/** Fallback document types when template API is unavailable */
|
||||
private defaultDocumentTypes = [
|
||||
'Aadhaar Card', 'Passport', 'PAN Card', 'Driving Licence',
|
||||
'Voter ID', 'NREGA Job Card', 'National Population Register Letter'
|
||||
];
|
||||
/** Constitution-based KYC document types for non-individual clients */
|
||||
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
|
||||
'HUF': [
|
||||
{ name: 'PAN Card of HUF', mandatory: true },
|
||||
{ name: 'Declaration by Karta (with name, address & HUF member details)', mandatory: true },
|
||||
{ name: 'KYC of Karta (OVDs + PAN + Photo)', mandatory: true },
|
||||
{ name: 'Proof of Address of HUF', mandatory: false }
|
||||
],
|
||||
'PRIVATE_LIMITED': [
|
||||
{ name: 'Certificate of Incorporation', mandatory: true },
|
||||
{ name: 'Memorandum & Articles of Association', mandatory: true },
|
||||
{ name: 'PAN of Company', mandatory: true },
|
||||
{ name: 'Board Resolution (authorizing signatories)', mandatory: true },
|
||||
{ name: 'KYC of Authorized Signatories', mandatory: true }
|
||||
],
|
||||
'PUBLIC_LIMITED': [
|
||||
{ name: 'Certificate of Incorporation', mandatory: true },
|
||||
{ name: 'Memorandum & Articles of Association', mandatory: true },
|
||||
{ name: 'PAN of Company', mandatory: true },
|
||||
{ name: 'Board Resolution (authorizing signatories)', mandatory: true },
|
||||
{ name: 'KYC of Authorized Signatories', mandatory: true }
|
||||
],
|
||||
'LLP': [
|
||||
{ name: 'Certificate of Incorporation', mandatory: true },
|
||||
{ name: 'LLP Agreement', mandatory: true },
|
||||
{ name: 'PAN of LLP', mandatory: true },
|
||||
{ name: 'Designated Partners Resolution', mandatory: true },
|
||||
{ name: 'KYC of Designated Partners / Authorized Signatories', mandatory: true }
|
||||
],
|
||||
'PARTNERSHIP': [
|
||||
{ name: 'Registration Certificate', mandatory: false },
|
||||
{ name: 'Partnership Deed', mandatory: true },
|
||||
{ name: 'PAN of Firm', mandatory: true },
|
||||
{ name: 'KYC of Authorized Signatories', mandatory: true }
|
||||
],
|
||||
'TRUST': [
|
||||
{ name: 'Registration Certificate', mandatory: true },
|
||||
{ name: 'Trust Deed', mandatory: true },
|
||||
{ name: 'PAN of Trust', mandatory: true },
|
||||
{ name: 'KYC of Trustees / Authorized Signatories', mandatory: true }
|
||||
],
|
||||
'SOCIETY': [
|
||||
{ name: 'Registration Certificate', mandatory: true },
|
||||
{ name: 'PAN of Society', mandatory: true },
|
||||
{ name: 'KYC of Authorized Signatories', mandatory: true }
|
||||
],
|
||||
'PROPRIETORSHIP': [
|
||||
{ name: 'Proof of Business Existence (GST Certificate / Shop License)', mandatory: true },
|
||||
{ name: 'PAN of Proprietor', mandatory: true },
|
||||
{ name: "Proprietor's KYC (OVDs + PAN + Photo)", mandatory: true }
|
||||
],
|
||||
'ASSOCIATION': [
|
||||
{ name: 'Registration Certificate', mandatory: true },
|
||||
{ name: 'PAN', mandatory: true },
|
||||
{ name: 'Resolution authorizing signatories', mandatory: true },
|
||||
{ name: 'KYC of Authorized Signatories', mandatory: true }
|
||||
],
|
||||
'GOVT_PSU': [
|
||||
{ name: 'Government Notification / Order', mandatory: true },
|
||||
{ name: 'PAN (if applicable)', mandatory: false },
|
||||
{ name: 'Authorized Signatory KYC', mandatory: true }
|
||||
],
|
||||
'COOPERATIVE': [
|
||||
{ name: 'Registration Certificate', mandatory: true },
|
||||
{ name: 'PAN', mandatory: true },
|
||||
{ name: 'Resolution authorizing signatories', mandatory: true },
|
||||
{ name: 'KYC of Authorized Signatories', mandatory: true }
|
||||
]
|
||||
};
|
||||
/** KYC template data from API */
|
||||
kycTemplate: any = null;
|
||||
kycTemplateLoaded = false;
|
||||
|
||||
/** Risk cards */
|
||||
riskCards = [
|
||||
{ value: 1, label: 'Low Risk', years: 10, color: 'green' },
|
||||
{ value: 2, label: 'Medium Risk', years: 8, color: 'amber' },
|
||||
{ value: 3, label: 'High Risk', years: 2, color: 'red' }
|
||||
];
|
||||
|
||||
/** PAN verification */
|
||||
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
||||
panVerifying = false;
|
||||
panVerifyError: string = '';
|
||||
panVerifyName: string = '';
|
||||
panVerifiedOn: string = '';
|
||||
|
||||
/** Aadhaar verification */
|
||||
aadhaarVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
||||
aadhaarVerifying = false;
|
||||
aadhaarVerifyError: string = '';
|
||||
aadhaarVerifyName: string = '';
|
||||
aadhaarVisible = false;
|
||||
aadhaarOtpSent = false;
|
||||
aadhaarOtp = '';
|
||||
aadhaarOtpVerifying = false;
|
||||
aadhaarOtpReferenceId = '';
|
||||
|
||||
/** OTP digit boxes */
|
||||
otpDigits: string[] = ['', '', '', '', '', ''];
|
||||
/** Resend countdown */
|
||||
resendCountdown = 0;
|
||||
private resendTimer: any = null;
|
||||
|
||||
onOtpDigitInput(index: number, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const val = input.value.replace(/\D/g, '');
|
||||
this.otpDigits[index] = val.charAt(0) || '';
|
||||
input.value = this.otpDigits[index];
|
||||
|
||||
// Auto-advance to next box
|
||||
if (this.otpDigits[index] && index < 5) {
|
||||
const next = input.parentElement?.querySelectorAll('input')[index + 1];
|
||||
if (next) (next as HTMLInputElement).focus();
|
||||
}
|
||||
|
||||
this.aadhaarOtp = this.otpDigits.join('');
|
||||
}
|
||||
|
||||
onOtpKeydown(index: number, event: KeyboardEvent): void {
|
||||
if (event.key === 'Backspace' && !this.otpDigits[index] && index > 0) {
|
||||
const inputs = (event.target as HTMLElement).parentElement?.querySelectorAll('input');
|
||||
if (inputs) {
|
||||
this.otpDigits[index - 1] = '';
|
||||
(inputs[index - 1] as HTMLInputElement).value = '';
|
||||
(inputs[index - 1] as HTMLInputElement).focus();
|
||||
}
|
||||
this.aadhaarOtp = this.otpDigits.join('');
|
||||
}
|
||||
}
|
||||
|
||||
onOtpPaste(event: ClipboardEvent): void {
|
||||
event.preventDefault();
|
||||
const pasted = (event.clipboardData?.getData('text') || '').replace(/\D/g, '').slice(0, 6);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
this.otpDigits[i] = pasted.charAt(i) || '';
|
||||
}
|
||||
this.aadhaarOtp = this.otpDigits.join('');
|
||||
// Focus last filled or last box
|
||||
const inputs = (event.target as HTMLElement).closest('.otp-digits')?.querySelectorAll('input');
|
||||
if (inputs) {
|
||||
const focusIdx = Math.min(pasted.length, 5);
|
||||
(inputs[focusIdx] as HTMLInputElement).focus();
|
||||
}
|
||||
}
|
||||
|
||||
startResendCountdown(): void {
|
||||
this.resendCountdown = 30;
|
||||
if (this.resendTimer) clearInterval(this.resendTimer);
|
||||
this.resendTimer = setInterval(() => {
|
||||
this.resendCountdown--;
|
||||
if (this.resendCountdown <= 0) {
|
||||
clearInterval(this.resendTimer);
|
||||
this.resendTimer = null;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
resetOtpDigits(): void {
|
||||
this.otpDigits = ['', '', '', '', '', ''];
|
||||
this.aadhaarOtp = '';
|
||||
}
|
||||
|
||||
/** PAN-Aadhaar seeding status (from API response) */
|
||||
panAadhaarSeedingStatus: string = '';
|
||||
|
||||
/** Enterprise PAN verification (non-individual) */
|
||||
entPanVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
||||
entPanVerifying = false;
|
||||
entPanVerifyName = '';
|
||||
entPanVerifiedOn = '';
|
||||
|
||||
/** CIN verification (dummy) */
|
||||
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
||||
cinVerifying = false;
|
||||
cinVerifiedOn = '';
|
||||
cinCompanyName = '';
|
||||
cinProgressValue = 0;
|
||||
cinCurrentStep = 0;
|
||||
cinProgressSteps = [
|
||||
{ label: 'Connecting to MCA database...', icon: 'globe' as any },
|
||||
{ label: 'Validating CIN format...', icon: 'search' as any },
|
||||
{ label: 'Fetching company details...', icon: 'database' as any },
|
||||
{ label: 'Verifying registration status...', icon: 'check-circle' as any }
|
||||
];
|
||||
private cinProgressTimer: any;
|
||||
|
||||
/** File uploads */
|
||||
photographFile: File | null = null;
|
||||
photographPreview: string | null = null;
|
||||
signatureFile: File | null = null;
|
||||
signaturePreview: string | null = null;
|
||||
|
||||
/** KYC Form */
|
||||
kycForm: UntypedFormGroup = this.formBuilder.group({
|
||||
panNumber: [
|
||||
'',
|
||||
[Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||
],
|
||||
aadhaarNumber: [
|
||||
'',
|
||||
[Validators.pattern(/^\d{12}$/)]
|
||||
],
|
||||
gstinNumber: [
|
||||
'',
|
||||
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
|
||||
],
|
||||
remarks: ['']
|
||||
hasPan: [true],
|
||||
panNumber: ['', [Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/), Validators.maxLength(10)]],
|
||||
aadhaarNumber: ['', [Validators.pattern(/^\d{12}$/), Validators.maxLength(12)]],
|
||||
panAadhaarSeedingStatus: [''],
|
||||
gstinNumber: ['', [Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/), Validators.maxLength(15)]],
|
||||
enterprisePan: ['', [Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/), Validators.maxLength(10)]],
|
||||
cinNumber: ['', [Validators.pattern(/^[A-Z]{1}[0-9]{5}[A-Z]{2}[0-9]{4}[A-Z]{3}[0-9]{6}$/), Validators.maxLength(21)]],
|
||||
incorporationDate: [''],
|
||||
kycDocumentName: ['', Validators.required],
|
||||
kycDocumentNumber: ['', [Validators.required, Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/), Validators.maxLength(30)]],
|
||||
kycDocIssueDate: [''],
|
||||
kycDocExpiryDate: [''],
|
||||
riskCategory: [1],
|
||||
remarks: [''],
|
||||
amlCheckStatus: ['CLEAR', Validators.required],
|
||||
bannedEntityMatch: ['NO', Validators.required],
|
||||
willfulDefaulter: ['NO'],
|
||||
ecgcSalFlag: ['NO'],
|
||||
dormantFlag: ['ACTIVE'],
|
||||
accountStatus: ['ACTIVE'],
|
||||
freezeReason: [''],
|
||||
freezeAuthority: [''],
|
||||
form60Type: ['FORM_60'],
|
||||
form60Date: [''],
|
||||
form15Applicable: [false],
|
||||
form15Type: ['15G'],
|
||||
form15FY: ['']
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the KYC payload matching the backend API contract.
|
||||
* POST /clients/{clientId}/kyc expects: kycType, riskCategory, panNumber,
|
||||
* aadhaarNumberMasked (NOT full aadhaarNumber), gstinNumber, remarks.
|
||||
*/
|
||||
get kycData(): any {
|
||||
ngOnInit(): void {
|
||||
this.loadExistingKycIfNeeded();
|
||||
this.loadKycTemplate();
|
||||
if (this.isNonIndividual && this.constitutionMeta) {
|
||||
this.updateDocTypesForConstitution();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.cinProgressTimer) clearInterval(this.cinProgressTimer);
|
||||
if (this.resendTimer) clearInterval(this.resendTimer);
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['clientId'] && !changes['clientId'].firstChange) {
|
||||
this.loadExistingKycIfNeeded();
|
||||
this.loadKycTemplate();
|
||||
}
|
||||
if (changes['readOnly'] && this.kycForm) {
|
||||
if (this.readOnly) {
|
||||
this.kycForm.disable({ emitEvent: false });
|
||||
} else {
|
||||
this.kycForm.enable({ emitEvent: false });
|
||||
}
|
||||
}
|
||||
if (changes['constitutionMeta'] && this.isNonIndividual) {
|
||||
this.updateDocTypesForConstitution();
|
||||
}
|
||||
}
|
||||
|
||||
/** Update KYC document types based on selected constitution type */
|
||||
private updateDocTypesForConstitution(): void {
|
||||
const constitutionValue = this.constitutionMeta?.value;
|
||||
if (constitutionValue && this.constitutionDocTypes[constitutionValue]) {
|
||||
this.kycDocumentTypes = this.constitutionDocTypes[constitutionValue].map(doc => ({
|
||||
name: doc.name,
|
||||
code: doc.name,
|
||||
mandatory: doc.mandatory
|
||||
}));
|
||||
// Reset selected document type since options changed
|
||||
this.kycForm.get('kycDocumentName')?.setValue('');
|
||||
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
|
||||
}
|
||||
}
|
||||
|
||||
/** Load existing KYC data if clientId is set (edit mode) */
|
||||
private loadExistingKycIfNeeded(): void {
|
||||
if (!this.clientId || this.kycLoaded) return;
|
||||
this.clientsService.getClientKyc(this.clientId).subscribe({
|
||||
next: (data: any) => {
|
||||
this.kycLoaded = true;
|
||||
if (data) {
|
||||
this.kycId = data.id?.toString() || '';
|
||||
this.kycForm.patchValue({
|
||||
panNumber: data.panNumber || '',
|
||||
aadhaarNumber: data.aadhaarNumberMasked || '',
|
||||
gstinNumber: data.gstinNumber || '',
|
||||
riskCategory: data.riskCategory || 1,
|
||||
remarks: data.remarks || '',
|
||||
hasPan: !!data.panNumber
|
||||
});
|
||||
// Set verification statuses from existing data
|
||||
if (data.panVerified) {
|
||||
this.panVerifyStatus = 'VERIFIED';
|
||||
this.panVerifyName = data.panFullName || data.panHolderName || '';
|
||||
const rawDate = data.panVerifiedOn;
|
||||
if (Array.isArray(rawDate) && rawDate.length >= 3) {
|
||||
this.panVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
|
||||
} else if (rawDate) {
|
||||
this.panVerifiedOn = rawDate;
|
||||
}
|
||||
}
|
||||
if (data.aadhaarVerified) {
|
||||
this.aadhaarVerifyStatus = 'VERIFIED';
|
||||
this.aadhaarVerifyName = data.aadhaarHolderName || 'Aadhaar Verified';
|
||||
}
|
||||
if (data.panAadhaarSeedingStatus) {
|
||||
this.panAadhaarSeedingStatus = data.panAadhaarSeedingStatus;
|
||||
}
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// No existing KYC — that's fine for new clients
|
||||
this.kycLoaded = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Load KYC template to get required documents based on client type */
|
||||
private loadKycTemplate(): void {
|
||||
if (!this.clientId || this.kycTemplateLoaded) return;
|
||||
this.clientsService.getClientKycTemplate(this.clientId).subscribe({
|
||||
next: (template: any) => {
|
||||
this.kycTemplate = template;
|
||||
this.kycTemplateLoaded = true;
|
||||
if (template?.requiredDocuments?.length > 0) {
|
||||
this.kycDocumentTypes = template.requiredDocuments.map((doc: any) => ({
|
||||
name: doc.name,
|
||||
code: doc.code || doc.name,
|
||||
mandatory: doc.mandatory ?? false
|
||||
}));
|
||||
// Update kycDocumentName validator based on mandatory docs
|
||||
const hasMandatory = this.kycDocumentTypes.some(d => d.mandatory);
|
||||
if (hasMandatory) {
|
||||
this.kycForm.get('kycDocumentName')?.setValidators(Validators.required);
|
||||
}
|
||||
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// Fallback to defaults if template API unavailable
|
||||
this.kycTemplateLoaded = true;
|
||||
this.kycDocumentTypes = this.defaultDocumentTypes.map(name => ({
|
||||
name,
|
||||
code: name,
|
||||
mandatory: false
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Save or update KYC, then execute callback */
|
||||
private saveKycAndDo(payload: any, callback: () => void, onError: (msg: string) => void) {
|
||||
if (this.kycId) {
|
||||
// KYC already exists — use PUT to update (strip create-only fields)
|
||||
const { kycType, ...updatePayload } = payload;
|
||||
this.clientsService.updateClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
|
||||
next: () => callback(),
|
||||
error: (err: any) => onError(err?.error?.defaultUserMessage || 'Failed to update KYC.')
|
||||
});
|
||||
} else {
|
||||
// First time — use POST to create
|
||||
this.clientsService.saveClientKyc(this.clientId, payload).subscribe({
|
||||
next: (res: any) => {
|
||||
this.kycId = res?.resourceId?.toString() || '';
|
||||
if (this.kycId) { callback(); }
|
||||
else { onError('Failed to save KYC.'); }
|
||||
},
|
||||
error: (err: any) => onError(err?.error?.defaultUserMessage || 'Failed to save KYC.')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify PAN — saves PAN-only KYC data, then verifies */
|
||||
verifyPan() {
|
||||
if (!this.clientId) {
|
||||
this.panVerifyError = 'Client must be created first. Complete General tab and click Next.';
|
||||
return;
|
||||
}
|
||||
const pan = this.kycForm.value.panNumber;
|
||||
if (!pan) { this.panVerifyError = 'Enter PAN number first.'; return; }
|
||||
|
||||
this.panVerifying = true;
|
||||
this.panVerifyError = '';
|
||||
this.panVerifyName = '';
|
||||
|
||||
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
|
||||
const panPayload: any = {
|
||||
locale: 'en',
|
||||
kycType: 1,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
panNumber: pan,
|
||||
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000'
|
||||
};
|
||||
|
||||
this.saveKycAndDo(panPayload, () => {
|
||||
this.clientsService.verifyPAN(this.clientId, this.kycId).subscribe({
|
||||
next: (res: any) => {
|
||||
this.panVerifying = false;
|
||||
if (res?.changes?.panVerified === true) {
|
||||
// Full name from API
|
||||
const resolvedName = res.changes?.panFullName
|
||||
|| res.changes?.panHolderName
|
||||
|| [res.changes?.panFirstName, res.changes?.panLastName].filter(Boolean).join(' ')
|
||||
|| '';
|
||||
|
||||
// If name is empty, the client name/DOB didn't match PAN holder
|
||||
if (!resolvedName || !resolvedName.trim()) {
|
||||
this.panVerifyStatus = 'FAILED';
|
||||
this.showPanErrorAlert('PAN verification failed: Client name or date of birth does not match the PAN card holder. Please ensure the client\'s name and date of birth exactly match the PAN card details.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.panVerifyStatus = 'VERIFIED';
|
||||
this.panVerifyName = resolvedName;
|
||||
// Date — could be string or array [y,m,d]
|
||||
const rawDate = res.changes?.panVerifiedOn;
|
||||
if (Array.isArray(rawDate)) {
|
||||
this.panVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
|
||||
} else if (rawDate) {
|
||||
this.panVerifiedOn = rawDate;
|
||||
} else {
|
||||
const now = new Date();
|
||||
this.panVerifiedOn = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
|
||||
}
|
||||
// Seeding status
|
||||
if (res.changes?.panAadhaarSeedingStatus) {
|
||||
this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus;
|
||||
}
|
||||
} else {
|
||||
this.panVerifyStatus = 'FAILED';
|
||||
this.showPanErrorAlert(res?.changes?.panVerificationMessage || 'PAN verification failed.');
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.panVerifying = false;
|
||||
this.panVerifyStatus = 'FAILED';
|
||||
const msg = err?.error?.errors?.[0]?.defaultUserMessage
|
||||
|| err?.error?.defaultUserMessage
|
||||
|| 'PAN verification failed.';
|
||||
this.showPanErrorAlert(msg);
|
||||
}
|
||||
});
|
||||
}, (msg) => { this.panVerifying = false; this.showPanErrorAlert(msg); });
|
||||
}
|
||||
|
||||
/** Verify Enterprise PAN (non-individual) — uses same verify-pan API */
|
||||
verifyEnterprisePan() {
|
||||
if (!this.clientId) return;
|
||||
const pan = this.kycForm.value.enterprisePan;
|
||||
if (!pan || this.kycForm.get('enterprisePan')?.invalid) return;
|
||||
|
||||
this.entPanVerifying = true;
|
||||
const incorpDate = this.kycForm.value.incorporationDate;
|
||||
const payload: any = {
|
||||
locale: 'en',
|
||||
kycType: 1,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
panNumber: pan,
|
||||
dateFormat: this.settingsService.dateFormat
|
||||
};
|
||||
if (incorpDate) {
|
||||
payload.dateOfIncorporation = incorpDate instanceof Date
|
||||
? this.dateUtils.formatDate(incorpDate, this.settingsService.dateFormat)
|
||||
: incorpDate;
|
||||
}
|
||||
if (this.entityName) {
|
||||
payload.companyFullName = this.entityName;
|
||||
}
|
||||
|
||||
this.saveKycAndDo(payload, () => {
|
||||
this.clientsService.verifyPAN(this.clientId, this.kycId).subscribe({
|
||||
next: (res: any) => {
|
||||
this.entPanVerifying = false;
|
||||
if (res?.changes?.panVerified === true) {
|
||||
this.entPanVerifyStatus = 'VERIFIED';
|
||||
this.entPanVerifyName = res.changes?.panFullName || res.changes?.panHolderName || '';
|
||||
const rawDate = res.changes?.panVerifiedOn;
|
||||
if (Array.isArray(rawDate)) {
|
||||
this.entPanVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
|
||||
} else {
|
||||
const now = new Date();
|
||||
this.entPanVerifiedOn = rawDate || `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
|
||||
}
|
||||
} else {
|
||||
this.entPanVerifyStatus = 'FAILED';
|
||||
this.showPanErrorAlert(res?.changes?.panVerificationMessage || 'Enterprise PAN verification failed.');
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.entPanVerifying = false;
|
||||
this.entPanVerifyStatus = 'FAILED';
|
||||
this.showPanErrorAlert(err?.error?.errors?.[0]?.defaultUserMessage || 'Enterprise PAN verification failed.');
|
||||
}
|
||||
});
|
||||
}, (msg) => { this.entPanVerifying = false; this.showPanErrorAlert(msg); });
|
||||
}
|
||||
|
||||
/** Verify CIN (dummy — no real API) */
|
||||
verifyCin() {
|
||||
const cin = this.kycForm.value.cinNumber;
|
||||
if (!cin) return;
|
||||
|
||||
this.cinVerifying = true;
|
||||
this.cinProgressValue = 0;
|
||||
this.cinCurrentStep = 0;
|
||||
|
||||
const stepDurations = [800, 1000, 1200, 800];
|
||||
const totalDuration = stepDurations.reduce((a, b) => a + b, 0);
|
||||
let elapsed = 0;
|
||||
const interval = 50;
|
||||
|
||||
this.cinProgressTimer = setInterval(() => {
|
||||
elapsed += interval;
|
||||
let accum = 0;
|
||||
for (let i = 0; i < stepDurations.length; i++) {
|
||||
accum += stepDurations[i];
|
||||
if (elapsed <= accum) { this.cinCurrentStep = i; break; }
|
||||
}
|
||||
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 100);
|
||||
|
||||
if (elapsed >= totalDuration) {
|
||||
clearInterval(this.cinProgressTimer);
|
||||
this.cinCurrentStep = stepDurations.length;
|
||||
setTimeout(() => {
|
||||
this.cinVerifying = false;
|
||||
this.cinVerifyStatus = 'VERIFIED';
|
||||
this.cinCompanyName = 'Verified via MCA';
|
||||
const now = new Date();
|
||||
this.cinVerifiedOn = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
|
||||
}, 300);
|
||||
}
|
||||
}, interval);
|
||||
}
|
||||
|
||||
/** Send Aadhaar OTP — saves Aadhaar-only KYC data, then sends OTP */
|
||||
sendAadhaarOtp() {
|
||||
if (!this.clientId) {
|
||||
this.aadhaarVerifyError = 'Client must be created first. Complete General tab and click Next.';
|
||||
return;
|
||||
}
|
||||
const aadhaar = this.kycForm.value.aadhaarNumber;
|
||||
if (!aadhaar || aadhaar.length !== 12) { this.aadhaarVerifyError = 'Enter valid 12-digit Aadhaar first.'; return; }
|
||||
|
||||
this.aadhaarVerifying = true;
|
||||
this.aadhaarVerifyError = '';
|
||||
|
||||
const aadhaarMasked = 'XXXXXXXX' + aadhaar.slice(-4);
|
||||
const aadhaarPayload: any = {
|
||||
locale: 'en',
|
||||
kycType: 1,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
aadhaarNumberMasked: aadhaarMasked
|
||||
};
|
||||
const panVal = this.kycForm.value.panNumber;
|
||||
if (panVal) { aadhaarPayload.panNumber = panVal; }
|
||||
|
||||
this.saveKycAndDo(aadhaarPayload, () => {
|
||||
this.clientsService.sendAadhaarOtp(this.clientId, this.kycId, aadhaar).subscribe({
|
||||
next: (res: any) => {
|
||||
this.aadhaarVerifying = false;
|
||||
this.aadhaarOtpSent = true;
|
||||
this.aadhaarOtpReferenceId = res?.changes?.aadhaarRefNumber || '';
|
||||
this.resetOtpDigits();
|
||||
this.startResendCountdown();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.aadhaarVerifying = false;
|
||||
this.aadhaarVerifyError = err?.error?.defaultUserMessage || 'Failed to send OTP.';
|
||||
}
|
||||
});
|
||||
}, (msg) => { this.aadhaarVerifying = false; this.aadhaarVerifyError = msg; });
|
||||
}
|
||||
|
||||
/** Verify Aadhaar OTP */
|
||||
verifyAadhaarOtp() {
|
||||
if (!this.aadhaarOtp || !this.clientId || !this.kycId) return;
|
||||
this.aadhaarOtpVerifying = true;
|
||||
this.aadhaarVerifyError = '';
|
||||
|
||||
this.clientsService.verifyAadhaarOtp(this.clientId, this.kycId, this.aadhaarOtp, this.aadhaarOtpReferenceId).subscribe({
|
||||
next: (res: any) => {
|
||||
this.aadhaarOtpVerifying = false;
|
||||
if (res?.changes?.aadhaarVerified === true) {
|
||||
this.aadhaarVerifyStatus = 'VERIFIED';
|
||||
this.aadhaarVerifyName = res.changes?.aadhaarHolderName || 'Aadhaar Verified';
|
||||
this.aadhaarOtpSent = false;
|
||||
if (res.changes?.panAadhaarSeedingStatus) {
|
||||
this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus;
|
||||
}
|
||||
} else {
|
||||
this.aadhaarVerifyError = res?.changes?.aadhaarVerificationMessage || 'OTP verification failed';
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.aadhaarOtpVerifying = false;
|
||||
this.aadhaarVerifyError = err?.error?.defaultUserMessage || 'OTP verification failed.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showPanErrorAlert(message: string) {
|
||||
this.panVerifyError = '';
|
||||
this.dialog.open(PanErrorDialogComponent, {
|
||||
width: '480px',
|
||||
data: { message }
|
||||
});
|
||||
}
|
||||
|
||||
getRenewalYears(): number {
|
||||
switch (this.kycForm.value.riskCategory) {
|
||||
case 1: return 10;
|
||||
case 2: return 8;
|
||||
case 3: return 2;
|
||||
default: return 10;
|
||||
}
|
||||
}
|
||||
|
||||
getKycExpiryDate(years: number): string {
|
||||
const d = new Date();
|
||||
d.setFullYear(d.getFullYear() + years);
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return `${String(d.getDate()).padStart(2, '0')} ${months[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
async onPhotoSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.files?.length) return;
|
||||
const file = input.files[0];
|
||||
input.value = '';
|
||||
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
||||
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
||||
width: '680px', maxWidth: '95vw',
|
||||
data: { file, title: 'Crop Client Photograph', aspectRatio: 1, maxWidth: 300, maxHeight: 300 }
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((result: any) => {
|
||||
if (result) { this.photographFile = result.croppedFile; this.photographPreview = result.previewUrl; }
|
||||
});
|
||||
}
|
||||
|
||||
async onSignatureSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.files?.length) return;
|
||||
const file = input.files[0];
|
||||
input.value = '';
|
||||
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
||||
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
||||
width: '680px', maxWidth: '95vw',
|
||||
data: { file, title: 'Crop Specimen Signature', aspectRatio: 2.5, maxWidth: 400, maxHeight: 160 }
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((result: any) => {
|
||||
if (result) { this.signatureFile = result.croppedFile; this.signaturePreview = result.previewUrl; }
|
||||
});
|
||||
}
|
||||
|
||||
removePhoto() { this.photographFile = null; this.photographPreview = null; }
|
||||
removeSignature() { this.signatureFile = null; this.signaturePreview = null; }
|
||||
|
||||
get kycData(): any {
|
||||
const formVal = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
|
||||
const aadhaarRaw = formVal.aadhaarNumber || '';
|
||||
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : null;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const v = formVal;
|
||||
const formatDate = (d: any) => d instanceof Date ? this.dateUtils.formatDate(d, dateFormat) : (d || null);
|
||||
|
||||
return {
|
||||
locale: 'en',
|
||||
dateFormat,
|
||||
kycType: 1,
|
||||
riskCategory: 1,
|
||||
panNumber: this.kycForm.value.panNumber || null,
|
||||
riskCategory: v.riskCategory || 1,
|
||||
panNumber: v.hasPan ? (v.panNumber || null) : null,
|
||||
aadhaarNumberMasked: aadhaarMasked,
|
||||
gstinNumber: this.kycForm.value.gstinNumber || null,
|
||||
remarks: this.kycForm.value.remarks || null
|
||||
panAadhaarSeedingStatus: this.panAadhaarSeedingStatus || null,
|
||||
gstinNumber: v.gstinNumber || null,
|
||||
enterprisePan: v.enterprisePan || null,
|
||||
cinNumber: v.cinNumber || null,
|
||||
incorporationDate: formatDate(v.incorporationDate),
|
||||
// KYC Document
|
||||
kycDocumentName: v.kycDocumentName || null,
|
||||
kycDocumentNumber: v.kycDocumentNumber || null,
|
||||
kycDocIssueDate: formatDate(v.kycDocIssueDate),
|
||||
kycDocExpiryDate: formatDate(v.kycDocExpiryDate),
|
||||
// AML & Compliance
|
||||
amlCheckStatus: v.amlCheckStatus || null,
|
||||
bannedEntityMatch: v.bannedEntityMatch || null,
|
||||
willfulDefaulter: v.willfulDefaulter || null,
|
||||
ecgcSalFlag: v.ecgcSalFlag || null,
|
||||
dormantFlag: v.dormantFlag || null,
|
||||
// Freeze / Debit Controls
|
||||
accountStatus: v.accountStatus || null,
|
||||
freezeReason: v.freezeReason || null,
|
||||
freezeAuthority: v.freezeAuthority || null,
|
||||
// Form 60 / Form 15
|
||||
form60Type: v.form60Type || null,
|
||||
form60Date: formatDate(v.form60Date),
|
||||
form15Applicable: v.form15Applicable || false,
|
||||
form15Type: v.form15Type || null,
|
||||
form15FY: v.form15FY || null,
|
||||
remarks: v.remarks || null
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns true if any KYC data has been entered */
|
||||
get hasKycData(): boolean {
|
||||
const v = this.kycForm.value;
|
||||
return !!(v.panNumber || v.aadhaarNumber || v.gstinNumber);
|
||||
const v = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
|
||||
return !!(v.panNumber || v.aadhaarNumber || v.gstinNumber || v.enterprisePan || v.cinNumber || v.kycDocumentName || v.form15Applicable);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PAN Error Alert Dialog
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-pan-error-dialog',
|
||||
template: `
|
||||
<div class="pan-err-dialog">
|
||||
<div class="pan-err-header">
|
||||
<mat-icon class="pan-err-icon">error</mat-icon>
|
||||
<span>PAN Verification Failed</span>
|
||||
</div>
|
||||
<mat-dialog-content class="pan-err-body">
|
||||
<p>{{ data.message }}</p>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end" class="pan-err-actions">
|
||||
<button mat-flat-button color="primary" mat-dialog-close>OK</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.pan-err-dialog { max-width: 460px; }
|
||||
.pan-err-header {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 16px 24px 12px; border-bottom: 1px solid #fecaca;
|
||||
background: #fef2f2; font-size: 16px; font-weight: 600; color: #991b1b;
|
||||
}
|
||||
.pan-err-icon { color: #dc2626; }
|
||||
.pan-err-body { padding: 18px 24px !important; font-size: 14px; line-height: 1.6; color: #475569; }
|
||||
.pan-err-actions { padding: 10px 24px 16px !important; margin: 0 !important; }
|
||||
`],
|
||||
imports: [MatIcon, MatDialogContent, MatDialogActions, STANDALONE_SHARED_IMPORTS],
|
||||
})
|
||||
export class PanErrorDialogComponent {
|
||||
constructor(
|
||||
@Inject(MAT_DIALOG_DATA) public data: { message: string }
|
||||
) {}
|
||||
}
|
||||
|
||||
|
||||
+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');
|
||||
}
|
||||
}
|
||||
+777
-176
@@ -7,6 +7,16 @@
|
||||
-->
|
||||
|
||||
<div class="preview-container">
|
||||
<div class="step-header">
|
||||
<div class="step-header__left">
|
||||
<fa-icon icon="eye" class="step-header__icon"></fa-icon>
|
||||
<div>
|
||||
<h3 class="step-header__title">Preview & Submit</h3>
|
||||
<p class="step-header__sub">Review all details before submitting the client registration.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client Profile Card -->
|
||||
<div class="profile-card">
|
||||
<div class="profile-avatar">
|
||||
@@ -30,7 +40,7 @@
|
||||
}
|
||||
@if (client?.legalFormId) {
|
||||
<span class="meta-chip">
|
||||
{{ client.legalFormId | find: clientTemplate.clientLegalFormOptions : 'id' : 'value' }}
|
||||
{{ client.legalFormId === 1 ? 'Individual' : 'Non-Individual' }}
|
||||
</span>
|
||||
}
|
||||
@if (client?.active) {
|
||||
@@ -42,208 +52,799 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General Information Card -->
|
||||
<!-- Personal Information -->
|
||||
<div class="info-card">
|
||||
<div class="card-header">
|
||||
<fa-icon icon="user" class="card-icon"></fa-icon>
|
||||
<span>{{ 'labels.heading.General' | translate }}</span>
|
||||
<span>Personal Information</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
@if (client?.dateOfBirth) {
|
||||
<tr>
|
||||
<td class="info-label">{{ client.legalFormId === LegalFormId.PERSON ? 'Date of Birth' : 'Incorporation Date' }}</td>
|
||||
<td class="info-value">{{ client.dateOfBirth | dateFormat }}</td>
|
||||
</tr>
|
||||
<div class="preview-grid">
|
||||
@if (client?.legalFormId === LegalFormId.PERSON) {
|
||||
@if (client?.firstname) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">First Name</span>
|
||||
<span class="preview-value">{{ client.firstname }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.externalId) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.External Id' | translate }}</td>
|
||||
<td class="info-value">
|
||||
<mifosx-external-identifier externalId="{{ client.externalId }}"></mifosx-external-identifier>
|
||||
</td>
|
||||
</tr>
|
||||
@if (client?.middlename) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Middle Name</span>
|
||||
<span class="preview-value">{{ client.middlename }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.staffId) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.Staff' | translate }}</td>
|
||||
<td class="info-value">{{ client.staffId | find: clientTemplate.staffOptions : 'id' : 'displayName' }}</td>
|
||||
</tr>
|
||||
@if (client?.lastname) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Last Name</span>
|
||||
<span class="preview-value">{{ client.lastname }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.legalFormId === LegalFormId.PERSON && client?.genderId) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.Gender' | translate }}</td>
|
||||
<td class="info-value">{{ client.genderId | find: clientTemplate.genderOptions : 'id' : 'name' }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (client?.mobileNo) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.Mobile No' | translate }}</td>
|
||||
<td class="info-value">{{ client.mobileNo }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (client?.emailAddress) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.Email Address' | translate }}</td>
|
||||
<td class="info-value">{{ client.emailAddress }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (client?.clientTypeId) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.Client Type' | translate }}</td>
|
||||
<td class="info-value">{{ client.clientTypeId | find: clientTemplate.clientTypeOptions : 'id' : 'name' }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (client?.clientClassificationId) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.Client Classification' | translate }}</td>
|
||||
<td class="info-value">{{ client.clientClassificationId | find: clientTemplate.clientClassificationOptions : 'id' : 'name' }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (client?.occupationCategoryId) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.Occupation Category' | translate }}</td>
|
||||
<td class="info-value">{{ client.occupationCategoryId | find: clientTemplate.occupationCategoryOptions : 'id' : 'name' }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (client?.occupationId) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.Occupation' | translate }}</td>
|
||||
<td class="info-value">{{ client.occupationId | find: clientTemplate.occupationOptions : 'id' : 'name' }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (client?.submittedOnDate) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.Submitted On Date' | translate }}</td>
|
||||
<td class="info-value">{{ client.submittedOnDate | dateFormat }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.Active' | translate }}?</td>
|
||||
<td class="info-value">
|
||||
<span [class]="client?.active ? 'badge-yes' : 'badge-no'">{{ client?.active | yesNo }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
@if (client?.activationDate) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.Activation Date' | translate }}</td>
|
||||
<td class="info-value">{{ client.activationDate | dateFormat }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (client?.savingsProductId) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.Savings Product' | translate }}</td>
|
||||
<td class="info-value">{{ client.savingsProductId | find: clientTemplate.savingProductOptions : 'id' : 'name' }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (client?.legalFormId === LegalFormId.PERSON) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.Is staff' | translate }}?</td>
|
||||
<td class="info-value">{{ client.isStaff | yesNo }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (client?.legalFormId === LegalFormId.ENTITY && client?.clientNonPersonDetails) {
|
||||
@if (client.clientNonPersonDetails.constitutionId) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.Constitution' | translate }}</td>
|
||||
<td class="info-value">{{ client.clientNonPersonDetails.constitutionId | find: clientTemplate.clientNonPersonConstitutionOptions : 'id' : 'name' }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.Incorporation Number' | translate }}</td>
|
||||
<td class="info-value">{{ client.clientNonPersonDetails.incorpNumber || 'N/A' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
@if (client?.legalFormId === LegalFormId.ENTITY && client?.fullname) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Entity Name</span>
|
||||
<span class="preview-value">{{ client.fullname }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.dateOfBirth) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">{{ client.legalFormId === LegalFormId.PERSON ? 'Date of Birth' : 'Incorporation Date' }}</span>
|
||||
<span class="preview-value">{{ client.dateOfBirth | dateFormat }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.legalFormId === LegalFormId.PERSON && client?.genderId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Gender</span>
|
||||
<span class="preview-value">{{ client.genderId | find: clientTemplate.genderOptions : 'id' : 'name' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.legalFormId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Legal Form</span>
|
||||
<span class="preview-value">{{ client.legalFormId === 1 ? 'Individual' : 'Non-Individual' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.officeId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Office</span>
|
||||
<span class="preview-value">{{ client.officeId | find: clientTemplate.officeOptions : 'id' : 'name' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.staffId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Staff</span>
|
||||
<span class="preview-value">{{ client.staffId | find: clientTemplate.staffOptions : 'id' : 'displayName' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.legalFormId === LegalFormId.PERSON) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Is Staff?</span>
|
||||
<span class="preview-value">{{ client.isStaff | yesNo }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.nationalityId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Nationality</span>
|
||||
<span class="preview-value">{{ client.nationalityId }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.maritalStatusId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Marital Status</span>
|
||||
<span class="preview-value">{{ client.maritalStatusId | find: maritalStatusOptions : 'id' : 'name' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.salutationId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Salutation</span>
|
||||
<span class="preview-value">{{ client.salutationId | find: clientTemplate.salutationOptions : 'id' : 'name' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.ageYears != null) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Age</span>
|
||||
<span class="preview-value">{{ client.ageYears }}y {{ client.ageMonths }}m {{ client.ageDays }}d</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.isMinor != null) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Minor</span>
|
||||
<span class="preview-value">
|
||||
<span [class]="client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified'">
|
||||
{{ client.isMinor ? 'Yes (Age < 10)' : 'No' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KYC Details Card -->
|
||||
@if (kycData?.panNumber || kycData?.aadhaarNumberMasked || kycData?.gstinNumber) {
|
||||
<!-- Entity Details (Non-Individual only) -->
|
||||
@if (client?.legalFormId === LegalFormId.ENTITY && client?.clientNonPersonDetails) {
|
||||
<div class="info-card">
|
||||
<div class="card-header">
|
||||
<fa-icon icon="building" class="card-icon"></fa-icon>
|
||||
<span>Entity Details</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="preview-grid">
|
||||
@if (client.clientNonPersonDetails.constitutionId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Constitution</span>
|
||||
<span class="preview-value">{{ client.clientNonPersonDetails.constitutionId | find: clientTemplate.clientNonPersonConstitutionOptions : 'id' : 'name' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client.clientNonPersonDetails.mainBusinessLineId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Main Business Line</span>
|
||||
<span class="preview-value">{{ client.clientNonPersonDetails.mainBusinessLineId | find: clientTemplate.clientNonPersonMainBusinessLineOptions : 'id' : 'name' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client.clientNonPersonDetails.incorpNumber) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Incorporation No</span>
|
||||
<span class="preview-value mono">{{ client.clientNonPersonDetails.incorpNumber }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client.clientNonPersonDetails.remarks) {
|
||||
<div class="preview-field full-width">
|
||||
<span class="preview-label">Remarks</span>
|
||||
<span class="preview-value">{{ client.clientNonPersonDetails.remarks }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Contact & DIP Verification -->
|
||||
<div class="info-card">
|
||||
<div class="card-header">
|
||||
<fa-icon icon="mobile-alt" class="card-icon"></fa-icon>
|
||||
<span>Contact & DIP Verification</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="preview-grid">
|
||||
@if (client?.mobileNo) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Mobile No</span>
|
||||
<span class="preview-value mono">{{ client.mobileNo }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">DIP Verification</span>
|
||||
<span class="preview-value">
|
||||
<span [class]="client?.dipVerified ? 'preview-badge preview-badge--verified' : 'preview-badge preview-badge--pending'">
|
||||
{{ client?.dipVerified ? 'Verified' : 'Pending' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@if (client?.dipLookupLogId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">DIP Lookup Log ID</span>
|
||||
<span class="preview-value mono">{{ client.dipLookupLogId }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.alternateMobileNo) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Alternate Mobile No</span>
|
||||
<span class="preview-value mono">{{ client.alternateMobileNo }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.alternateMobileNo) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Alternate DIP Verification</span>
|
||||
<span class="preview-value">
|
||||
<span [class]="client?.alternateDipVerified ? 'preview-badge preview-badge--verified' : 'preview-badge preview-badge--pending'">
|
||||
{{ client?.alternateDipVerified ? 'Verified' : 'Pending' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.alternateDipLookupLogId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Alternate DIP Lookup Log ID</span>
|
||||
<span class="preview-value mono">{{ client.alternateDipLookupLogId }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.emailAddress) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Email Address</span>
|
||||
<span class="preview-value">{{ client.emailAddress }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Name Verification (Sanctions)</span>
|
||||
<span class="preview-value">
|
||||
<span [class]="client?.nameVerified ? 'preview-badge preview-badge--verified' : 'preview-badge preview-badge--danger'">
|
||||
{{ client?.nameVerified ? 'Verified — Clear' : (client?.nameVerified === false ? 'Sanctions Match' : 'Pending') }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@if (client?.screeningLogId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Screening Log ID</span>
|
||||
<span class="preview-value mono">{{ client.screeningLogId }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Financial & Classification (Individual only) -->
|
||||
@if (client?.legalFormId !== 2) {
|
||||
<div class="info-card">
|
||||
<div class="card-header">
|
||||
<fa-icon icon="chart-line" class="card-icon"></fa-icon>
|
||||
<span>Financial & Classification</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="preview-grid">
|
||||
@if (client?.occupationCategoryId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Occupation Category</span>
|
||||
<span class="preview-value">{{ client.occupationCategoryId | find: clientTemplate.occupationCategoryOptions : 'id' : 'name' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.occupationId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Occupation</span>
|
||||
<span class="preview-value">{{ client.occupationId | find: clientTemplate.occupationOptions : 'id' : 'name' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.clientTypeId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Client Type</span>
|
||||
<span class="preview-value">{{ client.clientTypeId | find: clientTemplate.clientTypeOptions : 'id' : 'name' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.clientClassificationId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Client Classification</span>
|
||||
<span class="preview-value">{{ client.clientClassificationId | find: clientTemplate.clientClassificationOptions : 'id' : 'name' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.monthlyIncome) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Monthly Income</span>
|
||||
<span class="preview-value">₹ {{ client.monthlyIncome | number }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.annualTurnover) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Annual Turnover</span>
|
||||
<span class="preview-value">₹ {{ client.annualTurnover | number }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (!client?.occupationCategoryId && !client?.occupationId && !client?.clientTypeId && !client?.clientClassificationId && !client?.monthlyIncome && !client?.annualTurnover) {
|
||||
<div class="preview-field full-width">
|
||||
<span class="preview-value no-data">No classification data provided</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- KYC Details -->
|
||||
@if (kycData?.panNumber || kycData?.aadhaarNumberMasked || kycData?.gstinNumber || kycData?.enterprisePan || kycData?.cinNumber) {
|
||||
<div class="info-card">
|
||||
<div class="card-header">
|
||||
<fa-icon icon="fingerprint" class="card-icon"></fa-icon>
|
||||
<span>{{ 'labels.inputs.KYC' | translate }}</span>
|
||||
<span>KYC Details</span>
|
||||
<span class="kyc-badge">KYC</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
@if (kycData.panNumber) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.PAN Number' | translate }}</td>
|
||||
<td class="info-value mono">{{ kycData.panNumber }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (kycData.aadhaarNumberMasked) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.Aadhaar Number' | translate }}</td>
|
||||
<td class="info-value mono">{{ kycData.aadhaarNumberMasked }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (kycData.gstinNumber) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.GSTIN Number' | translate }}</td>
|
||||
<td class="info-value mono">{{ kycData.gstinNumber }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (kycData.remarks) {
|
||||
<tr>
|
||||
<td class="info-label">{{ 'labels.inputs.Remarks' | translate }}</td>
|
||||
<td class="info-value">{{ kycData.remarks }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="preview-grid">
|
||||
@if (kycData.panNumber) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">PAN Number</span>
|
||||
<span class="preview-value mono">{{ kycData.panNumber }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.aadhaarNumberMasked) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Aadhaar Number</span>
|
||||
<span class="preview-value mono">{{ kycData.aadhaarNumberMasked }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.panAadhaarSeedingStatus) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">PAN–Aadhaar Link</span>
|
||||
<span class="preview-value">
|
||||
@if (kycData.panAadhaarSeedingStatus === 'Y') {
|
||||
<span class="preview-badge preview-badge--verified">Linked</span>
|
||||
} @else if (kycData.panAadhaarSeedingStatus === 'N') {
|
||||
<span class="preview-badge preview-badge--pending">Not Linked</span>
|
||||
} @else {
|
||||
{{ kycData.panAadhaarSeedingStatus }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.enterprisePan) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Enterprise PAN</span>
|
||||
<span class="preview-value mono">{{ kycData.enterprisePan }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.cinNumber) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">CIN (Corporate Identity Number)</span>
|
||||
<span class="preview-value mono">{{ kycData.cinNumber }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.incorporationDate) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Incorporation Date</span>
|
||||
<span class="preview-value">{{ kycData.incorporationDate | dateFormat }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.gstinNumber) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">GSTIN Number</span>
|
||||
<span class="preview-value mono">{{ kycData.gstinNumber }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.riskCategory) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Risk Category</span>
|
||||
<span class="preview-value">
|
||||
@if (kycData.riskCategory === 1) { Low Risk }
|
||||
@else if (kycData.riskCategory === 2) { Medium Risk }
|
||||
@else if (kycData.riskCategory === 3) { High Risk }
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.remarks) {
|
||||
<div class="preview-field full-width">
|
||||
<span class="preview-label">Remarks</span>
|
||||
<span class="preview-value">{{ kycData.remarks }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Family Members / Nominee Card -->
|
||||
@if (client?.familyMembers?.length) {
|
||||
<!-- Guardian Details (Minor only) -->
|
||||
@if (guardian) {
|
||||
<div class="info-card">
|
||||
<div class="card-header">
|
||||
<fa-icon icon="users" class="card-icon"></fa-icon>
|
||||
<span>{{ 'labels.heading.Family Members' | translate }} / {{ 'labels.inputs.Nominee' | translate }}</span>
|
||||
<span class="count-badge">{{ client.familyMembers.length }}</span>
|
||||
<fa-icon icon="user-shield" class="card-icon"></fa-icon>
|
||||
<span>Guardian Details</span>
|
||||
<span class="preview-badge preview-badge--info" style="margin-left:auto">Minor</span>
|
||||
</div>
|
||||
<div class="card-body no-padding">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'labels.inputs.name' | translate }}</th>
|
||||
<th>{{ 'labels.inputs.Relationship' | translate }}</th>
|
||||
<th>{{ 'labels.inputs.Date Of Birth' | translate }}</th>
|
||||
<th>{{ 'labels.inputs.Is Dependent' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (member of client.familyMembers; track member) {
|
||||
<tr>
|
||||
<td>{{ member.firstName }} {{ member.lastName }}</td>
|
||||
<td>{{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }}</td>
|
||||
<td>{{ member.dateOfBirth | dateFormat }}</td>
|
||||
<td>
|
||||
<span [class]="member.isDependent ? 'badge-yes' : 'badge-no'">{{ member.isDependent ? 'Yes' : 'No' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card-body">
|
||||
<div class="preview-grid">
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Guardian Name</span>
|
||||
<span class="preview-value">{{ guardian.displayName }}</span>
|
||||
</div>
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Client ID</span>
|
||||
<span class="preview-value mono">{{ guardian.id }}</span>
|
||||
</div>
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Account No</span>
|
||||
<span class="preview-value mono">{{ guardian.accountNumber || '—' }}</span>
|
||||
</div>
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Relationship</span>
|
||||
<span class="preview-value">{{ guardianRelation || '—' }}</span>
|
||||
</div>
|
||||
@if (guardian.officeName) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Office</span>
|
||||
<span class="preview-value">{{ guardian.officeName }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (guardian.mobileNo) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Mobile</span>
|
||||
<span class="preview-value mono">{{ guardian.mobileNo }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (guardian.status?.value) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Status</span>
|
||||
<span class="preview-value">
|
||||
<span [class]="guardian.status?.value === 'Active' ? 'preview-badge preview-badge--verified' : 'preview-badge preview-badge--pending'">
|
||||
{{ guardian.status.value }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Submit Section -->
|
||||
<div class="submit-section">
|
||||
<button mat-raised-button [routerLink]="['../']" class="cancel-btn">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" class="submit-btn" (click)="submitEvent.emit()">
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
</button>
|
||||
<!-- Address Details -->
|
||||
@if (addressData) {
|
||||
<div class="info-card">
|
||||
<div class="card-header">
|
||||
<fa-icon icon="map" class="card-icon"></fa-icon>
|
||||
<span>Address Details</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@for (tab of addressTabs; track tab) {
|
||||
@if (addressData[tab]?.addressLine1) {
|
||||
<div class="address-block">
|
||||
<div class="address-block__title">{{ addressTabLabels[tab] }}</div>
|
||||
<div class="preview-grid">
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Address Line 1</span>
|
||||
<span class="preview-value">{{ addressData[tab].addressLine1 }}</span>
|
||||
</div>
|
||||
@if (addressData[tab].addressLine2) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Address Line 2</span>
|
||||
<span class="preview-value">{{ addressData[tab].addressLine2 }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (addressData[tab].landmark) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Landmark</span>
|
||||
<span class="preview-value">{{ addressData[tab].landmark }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (addressData[tab].pinCode) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">PIN Code</span>
|
||||
<span class="preview-value mono">{{ addressData[tab].pinCode }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (addressData[tab].locality) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Locality</span>
|
||||
<span class="preview-value">{{ addressData[tab].locality }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (addressData[tab].city) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">City</span>
|
||||
<span class="preview-value">{{ addressData[tab].city }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (addressData[tab].district) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">District</span>
|
||||
<span class="preview-value">{{ addressData[tab].district }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (addressData[tab].state) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">State</span>
|
||||
<span class="preview-value">{{ addressData[tab].state }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (!addressData.permanent?.addressLine1 && !addressData.current?.addressLine1 && !addressData.office?.addressLine1 && !addressData.correspondence?.addressLine1) {
|
||||
<div class="preview-field full-width">
|
||||
<span class="preview-value no-data">No address data provided</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Submission Info -->
|
||||
@if (client?.submittedOnDate) {
|
||||
<div class="info-card">
|
||||
<div class="card-header">
|
||||
<fa-icon icon="calendar-alt" class="card-icon"></fa-icon>
|
||||
<span>Submission</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="preview-grid">
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Submitted On</span>
|
||||
<span class="preview-value">{{ client.submittedOnDate | dateFormat }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Scheme & Sector Classification (Non-Individual) -->
|
||||
@if (entityClassificationData) {
|
||||
@if (entityClassificationData.msme?.enabled) {
|
||||
<div class="info-card">
|
||||
<div class="card-header">
|
||||
<fa-icon icon="cogs" class="card-icon"></fa-icon>
|
||||
<span>MSME Classification</span>
|
||||
<span class="preview-badge preview-badge--verified">Enabled</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="preview-grid">
|
||||
@if (entityClassificationData.msme.udyamRegistrationNumber) {
|
||||
<div class="preview-field"><span class="preview-label">Udyam Registration No.</span><span class="preview-value mono">{{ entityClassificationData.msme.udyamRegistrationNumber }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.msme.msmeClassification) {
|
||||
<div class="preview-field"><span class="preview-label">MSME Classification</span><span class="preview-value">{{ entityClassificationData.msme.msmeClassification }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.msme.enterpriseType) {
|
||||
<div class="preview-field"><span class="preview-label">Enterprise Type</span><span class="preview-value">{{ entityClassificationData.msme.enterpriseType }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.msme.nicActivityCode) {
|
||||
<div class="preview-field"><span class="preview-label">NIC Activity Code</span><span class="preview-value mono">{{ entityClassificationData.msme.nicActivityCode }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.msme.annualTurnover) {
|
||||
<div class="preview-field"><span class="preview-label">Annual Turnover</span><span class="preview-value">₹ {{ entityClassificationData.msme.annualTurnover }} Cr</span></div>
|
||||
}
|
||||
@if (entityClassificationData.msme.investmentPlantMachinery) {
|
||||
<div class="preview-field"><span class="preview-label">Investment (Plant & Machinery)</span><span class="preview-value">₹ {{ entityClassificationData.msme.investmentPlantMachinery }} Cr</span></div>
|
||||
}
|
||||
@if (entityClassificationData.msme.financialYearAssessment) {
|
||||
<div class="preview-field"><span class="preview-label">FY of Assessment</span><span class="preview-value">{{ entityClassificationData.msme.financialYearAssessment }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.msme.socialCategoryOwner) {
|
||||
<div class="preview-field"><span class="preview-label">Social Category</span><span class="preview-value">{{ entityClassificationData.msme.socialCategoryOwner }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.msme.genderPrimaryOwner) {
|
||||
<div class="preview-field"><span class="preview-label">Gender of Owner</span><span class="preview-value">{{ entityClassificationData.msme.genderPrimaryOwner }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.msme.noOfEmployees) {
|
||||
<div class="preview-field"><span class="preview-label">No. of Employees</span><span class="preview-value">{{ entityClassificationData.msme.noOfEmployees }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.msme.womenLedEnterprise) {
|
||||
<div class="preview-field"><span class="preview-label">Women-led Enterprise</span><span class="preview-value"><span class="preview-badge preview-badge--verified">Yes</span></span></div>
|
||||
}
|
||||
@if (entityClassificationData.msme.divyangPwdOwned) {
|
||||
<div class="preview-field"><span class="preview-label">Divyang (PWD) Owned</span><span class="preview-value"><span class="preview-badge preview-badge--verified">Yes</span></span></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (entityClassificationData.startup?.enabled) {
|
||||
<div class="info-card">
|
||||
<div class="card-header">
|
||||
<fa-icon icon="rocket" class="card-icon"></fa-icon>
|
||||
<span>Startup India (DPIIT)</span>
|
||||
<span class="preview-badge preview-badge--verified">Enabled</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="preview-grid">
|
||||
@if (entityClassificationData.startup.dpiitRecognitionNumber) {
|
||||
<div class="preview-field"><span class="preview-label">DPIIT Recognition No.</span><span class="preview-value mono">{{ entityClassificationData.startup.dpiitRecognitionNumber }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.startup.entityStage) {
|
||||
<div class="preview-field"><span class="preview-label">Entity Stage</span><span class="preview-value">{{ entityClassificationData.startup.entityStage }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.startup.sectorAsDpiit) {
|
||||
<div class="preview-field"><span class="preview-label">Sector (DPIIT)</span><span class="preview-value">{{ entityClassificationData.startup.sectorAsDpiit }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.startup.taxExemption80iac) {
|
||||
<div class="preview-field"><span class="preview-label">80IAC Tax Exemption</span><span class="preview-value"><span class="preview-badge preview-badge--verified">Yes</span></span></div>
|
||||
}
|
||||
@if (entityClassificationData.startup.pslLoanLimit) {
|
||||
<div class="preview-field"><span class="preview-label">PSL Loan Limit</span><span class="preview-value">₹ {{ entityClassificationData.startup.pslLoanLimit | number }} Cr</span></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (entityClassificationData.mudra?.enabled) {
|
||||
<div class="info-card">
|
||||
<div class="card-header">
|
||||
<fa-icon icon="hand-holding-usd" class="card-icon"></fa-icon>
|
||||
<span>Mudra Borrower (PMMY)</span>
|
||||
<span class="preview-badge preview-badge--verified">Enabled</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="preview-grid">
|
||||
@if (entityClassificationData.mudra.mudraCategory) {
|
||||
<div class="preview-field"><span class="preview-label">Mudra Category</span><span class="preview-value">{{ entityClassificationData.mudra.mudraCategory }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.mudra.mudraLoanAccountNo) {
|
||||
<div class="preview-field"><span class="preview-label">Loan Account No.</span><span class="preview-value mono">{{ entityClassificationData.mudra.mudraLoanAccountNo }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.mudra.sanctionedAmount) {
|
||||
<div class="preview-field"><span class="preview-label">Sanctioned Amount</span><span class="preview-value">₹ {{ entityClassificationData.mudra.sanctionedAmount | number }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.mudra.lendingInstitutionName) {
|
||||
<div class="preview-field"><span class="preview-label">Lending Institution</span><span class="preview-value">{{ entityClassificationData.mudra.lendingInstitutionName }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.mudra.repaymentStatus) {
|
||||
<div class="preview-field"><span class="preview-label">Repayment Status</span><span class="preview-value">{{ entityClassificationData.mudra.repaymentStatus }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.mudra.npaFlag) {
|
||||
<div class="preview-field"><span class="preview-label">NPA Flag</span><span class="preview-value"><span class="preview-badge preview-badge--danger">NPA</span></span></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (entityClassificationData.eou?.enabled) {
|
||||
<div class="info-card">
|
||||
<div class="card-header">
|
||||
<fa-icon icon="ship" class="card-icon"></fa-icon>
|
||||
<span>Export Oriented Unit (EOU)</span>
|
||||
<span class="preview-badge preview-badge--verified">Enabled</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="preview-grid">
|
||||
@if (entityClassificationData.eou.eouRegistrationNumber) {
|
||||
<div class="preview-field"><span class="preview-label">EOU Registration No.</span><span class="preview-value mono">{{ entityClassificationData.eou.eouRegistrationNumber }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.eou.registeredDcZone) {
|
||||
<div class="preview-field"><span class="preview-label">Registered DC Zone</span><span class="preview-value">{{ entityClassificationData.eou.registeredDcZone }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.eou.gstinEou) {
|
||||
<div class="preview-field"><span class="preview-label">GSTIN (EOU)</span><span class="preview-value mono">{{ entityClassificationData.eou.gstinEou }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.eou.annualExportObligation) {
|
||||
<div class="preview-field"><span class="preview-label">Export Obligation</span><span class="preview-value">₹ {{ entityClassificationData.eou.annualExportObligation | number }} Cr</span></div>
|
||||
}
|
||||
@if (entityClassificationData.eou.adCategoryBankCode) {
|
||||
<div class="preview-field"><span class="preview-label">AD Bank Code</span><span class="preview-value mono">{{ entityClassificationData.eou.adCategoryBankCode }}</span></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (entityClassificationData.importexport?.enabled) {
|
||||
<div class="info-card">
|
||||
<div class="card-header">
|
||||
<fa-icon icon="exchange-alt" class="card-icon"></fa-icon>
|
||||
<span>Import Export (IEC/DGFT)</span>
|
||||
<span class="preview-badge preview-badge--verified">Enabled</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="preview-grid">
|
||||
@if (entityClassificationData.importexport.iecCode) {
|
||||
<div class="preview-field"><span class="preview-label">IEC Code</span><span class="preview-value mono">{{ entityClassificationData.importexport.iecCode }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.importexport.iecStatus) {
|
||||
<div class="preview-field"><span class="preview-label">IEC Status</span><span class="preview-value">{{ entityClassificationData.importexport.iecStatus }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.importexport.tradeType) {
|
||||
<div class="preview-field"><span class="preview-label">Trade Type</span><span class="preview-value">{{ entityClassificationData.importexport.tradeType }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.importexport.primaryHsCode) {
|
||||
<div class="preview-field"><span class="preview-label">Primary HS Code</span><span class="preview-value mono">{{ entityClassificationData.importexport.primaryHsCode }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.importexport.eefcAccountRequired) {
|
||||
<div class="preview-field"><span class="preview-label">EEFC Account</span><span class="preview-value"><span class="preview-badge preview-badge--verified">Required</span></span></div>
|
||||
}
|
||||
@if (entityClassificationData.importexport.femaDeclarationObtained) {
|
||||
<div class="preview-field"><span class="preview-label">FEMA Declaration</span><span class="preview-value"><span class="preview-badge preview-badge--verified">Obtained</span></span></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (entityClassificationData.psl?.enabled) {
|
||||
<div class="info-card">
|
||||
<div class="card-header">
|
||||
<fa-icon icon="bullseye" class="card-icon"></fa-icon>
|
||||
<span>Priority Sector (PSL)</span>
|
||||
<span class="preview-badge preview-badge--verified">Enabled</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="preview-grid">
|
||||
@if (entityClassificationData.psl.pslCategory) {
|
||||
<div class="preview-field"><span class="preview-label">PSL Category</span><span class="preview-value">{{ entityClassificationData.psl.pslCategory }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.psl.pslSubCategory) {
|
||||
<div class="preview-field"><span class="preview-label">PSL Sub-category</span><span class="preview-value">{{ entityClassificationData.psl.pslSubCategory }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.psl.pslSanctionedLimit) {
|
||||
<div class="preview-field"><span class="preview-label">Sanctioned Limit</span><span class="preview-value">₹ {{ entityClassificationData.psl.pslSanctionedLimit | number }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.psl.fiddReportingCode) {
|
||||
<div class="preview-field"><span class="preview-label">FIDD Reporting Code</span><span class="preview-value mono">{{ entityClassificationData.psl.fiddReportingCode }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.psl.weakerSectionFlag) {
|
||||
<div class="preview-field"><span class="preview-label">Weaker Section</span><span class="preview-value"><span class="preview-badge preview-badge--verified">Yes</span></span></div>
|
||||
}
|
||||
@if (entityClassificationData.psl.womenEntrepreneurFlag) {
|
||||
<div class="preview-field"><span class="preview-label">Women Entrepreneur</span><span class="preview-value"><span class="preview-badge preview-badge--verified">Yes</span></span></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (entityClassificationData.nps?.enabled) {
|
||||
<div class="info-card">
|
||||
<div class="card-header">
|
||||
<fa-icon icon="building" class="card-icon"></fa-icon>
|
||||
<span>Non-Priority Sector</span>
|
||||
<span class="preview-badge preview-badge--verified">Enabled</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="preview-grid">
|
||||
@if (entityClassificationData.nps.industrySector) {
|
||||
<div class="preview-field"><span class="preview-label">Industry Sector</span><span class="preview-value">{{ entityClassificationData.nps.industrySector }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.nps.nicCode) {
|
||||
<div class="preview-field"><span class="preview-label">NIC Code</span><span class="preview-value mono">{{ entityClassificationData.nps.nicCode }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.nps.annualRevenueBand) {
|
||||
<div class="preview-field"><span class="preview-label">Annual Revenue Band</span><span class="preview-value">{{ entityClassificationData.nps.annualRevenueBand }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.nps.riskCategory) {
|
||||
<div class="preview-field"><span class="preview-label">Risk Category</span><span class="preview-value">{{ entityClassificationData.nps.riskCategory }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.nps.amlRiskClassification) {
|
||||
<div class="preview-field"><span class="preview-label">AML Risk</span><span class="preview-value">{{ entityClassificationData.nps.amlRiskClassification }}</span></div>
|
||||
}
|
||||
@if (entityClassificationData.nps.pepFlag) {
|
||||
<div class="preview-field"><span class="preview-label">PEP Flag</span><span class="preview-value"><span class="preview-badge preview-badge--danger">PEP</span></span></div>
|
||||
}
|
||||
@if (entityClassificationData.nps.amlWatchlistMatch) {
|
||||
<div class="preview-field"><span class="preview-label">AML Watchlist</span><span class="preview-value"><span class="preview-badge preview-badge--danger">Match Found</span></span></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
<!-- Family Members (Individual only) -->
|
||||
@if (client?.legalFormId !== 2) {
|
||||
<div class="info-card">
|
||||
<div class="card-header">
|
||||
<fa-icon icon="users" class="card-icon"></fa-icon>
|
||||
<span>Family Members</span>
|
||||
@if (familyMembers.length > 0) {
|
||||
<span class="count-badge">{{ familyMembers.length }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (familyMembers.length === 0) {
|
||||
<div class="preview-empty preview-empty--warn">
|
||||
<mat-icon class="preview-empty__icon">warning</mat-icon>
|
||||
<span>No family members added. At least one is required.</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="fm-preview-list">
|
||||
@for (member of familyMembers; track member; let i = $index) {
|
||||
<div class="fm-preview-row">
|
||||
<div class="fm-preview-row__sno">{{ i + 1 }}</div>
|
||||
<div class="fm-preview-row__avatar">
|
||||
<mat-icon>person</mat-icon>
|
||||
</div>
|
||||
<div class="fm-preview-row__info">
|
||||
<span class="fm-preview-row__name">{{ member.firstName || member.displayName }}</span>
|
||||
<span class="fm-preview-row__meta">
|
||||
@if (member.relationship) { {{ member.relationship }} }
|
||||
@if (member.dateOfBirth) { · DOB: {{ member.dateOfBirth }} }
|
||||
</span>
|
||||
</div>
|
||||
@if (member.aadhaarNumber || member.panNumber) {
|
||||
<div class="fm-preview-row__ids">
|
||||
@if (member.panNumber) { <span class="fm-preview-id">PAN: {{ member.panNumber }}</span> }
|
||||
@if (member.aadhaarNumber) { <span class="fm-preview-id">Aadhaar: {{ member.aadhaarNumber }}</span> }
|
||||
</div>
|
||||
}
|
||||
<div class="fm-preview-row__status">
|
||||
@if (member.isDependent) {
|
||||
<span class="preview-badge preview-badge--info">Dependent</span>
|
||||
}
|
||||
@if (member.linkedClientId) {
|
||||
<span class="preview-badge preview-badge--verified">Linked</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
+267
-5
@@ -6,6 +6,37 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.step-header {
|
||||
background: #0f172a;
|
||||
padding: 16px 24px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 18px;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -134,7 +165,75 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Info Table (Key-Value pairs) ── */
|
||||
/* ── Preview Grid (replaces info-table) ── */
|
||||
.preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.preview-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.04);
|
||||
|
||||
&:nth-child(3n) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&.full-width {
|
||||
grid-column: 1 / -1;
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.preview-value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
|
||||
&.no-data {
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Preview Badges ── */
|
||||
.preview-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
|
||||
&--verified {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Legacy Info Table (keep for backward compat) ── */
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -246,6 +345,36 @@
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* ── Address Blocks ── */
|
||||
.address-block {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.address-block__title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #1074b9;
|
||||
margin-bottom: 10px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
/* ── Info badge ── */
|
||||
.preview-badge--info {
|
||||
background: #eff6ff;
|
||||
color: #1074b9;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
/* ── Submit Section ── */
|
||||
.submit-section {
|
||||
display: flex;
|
||||
@@ -270,6 +399,17 @@
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 960px) {
|
||||
.preview-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.preview-field {
|
||||
&:nth-child(3n) { border-right: 1px solid rgba(0, 0, 0, 0.04); }
|
||||
&:nth-child(2n) { border-right: none; }
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-card {
|
||||
flex-direction: column;
|
||||
@@ -281,10 +421,12 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
.info-label {
|
||||
width: 45%;
|
||||
}
|
||||
.preview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.preview-field {
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
@@ -294,4 +436,124 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-preview-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty state with warning */
|
||||
.preview-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border: 1px dashed #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.preview-empty--warn {
|
||||
background: #fef2f2;
|
||||
border-color: #fca5a5;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.preview-empty__icon {
|
||||
font-size: 20px !important;
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Family member preview rows */
|
||||
.fm-preview-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fm-preview-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
transition: background 0.1s;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
&:hover { background: #f8fafc; }
|
||||
}
|
||||
|
||||
.fm-preview-row__sno {
|
||||
flex: 0 0 24px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.fm-preview-row__avatar {
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #dbeafe;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #1565c0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-preview-row__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fm-preview-row__name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fm-preview-row__meta {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.fm-preview-row__ids {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fm-preview-id {
|
||||
font-size: 11px;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
color: #475569;
|
||||
padding: 2px 8px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.fm-preview-row__status {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
MatExpansionPanelDescription
|
||||
} from '@angular/material/expansion';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { FindPipe } from '../../../pipes/find.pipe';
|
||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||
import { YesnoPipe } from '../../../pipes/yesno.pipe';
|
||||
@@ -41,6 +42,7 @@ import { LegalFormId } from 'app/clients/models/legal-form.enum';
|
||||
MatExpansionPanelTitle,
|
||||
MatExpansionPanelDescription,
|
||||
FaIconComponent,
|
||||
MatIcon,
|
||||
FindPipe,
|
||||
DateFormatPipe,
|
||||
YesnoPipe
|
||||
@@ -55,6 +57,31 @@ export class ClientPreviewStepComponent {
|
||||
@Input() client: any;
|
||||
/** KYC Data */
|
||||
@Input() kycData: any;
|
||||
/** Address data from India address step */
|
||||
@Input() addressData: any;
|
||||
/** Selected guardian client (for minors) */
|
||||
@Input() guardian: any;
|
||||
/** Guardian relationship */
|
||||
@Input() guardianRelation: string = '';
|
||||
/** Entity Classification data (non-individual) */
|
||||
@Input() entityClassificationData: any;
|
||||
/** Nominee data */
|
||||
@Input() nominees: any[] = [];
|
||||
/** Family members from family step */
|
||||
@Input() familyMembers: any[] = [];
|
||||
/** Marital Status ID from general step */
|
||||
@Input() maritalStatusId: number | null = null;
|
||||
/** Marital Status Options */
|
||||
@Input() maritalStatusOptions: any[] = [];
|
||||
|
||||
/** Address tab labels */
|
||||
addressTabs = ['permanent', 'current', 'office', 'correspondence'];
|
||||
addressTabLabels: Record<string, string> = {
|
||||
permanent: 'Permanent Address',
|
||||
current: 'Current / Residential',
|
||||
office: 'Office / Work',
|
||||
correspondence: 'Correspondence'
|
||||
};
|
||||
|
||||
/** Form submission event */
|
||||
@Output() submitEvent = new EventEmitter();
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ClientsComponent } from './clients.component';
|
||||
import { ClientsViewComponent } from './clients-view/clients-view.component';
|
||||
import { GeneralTabComponent } from './clients-view/general-tab/general-tab.component';
|
||||
import { FamilyMembersTabComponent } from './clients-view/family-members-tab/family-members-tab.component';
|
||||
import { NomineeTabComponent } from './clients-view/nominee-tab/nominee-tab.component';
|
||||
import { AddFamilyMemberComponent } from './clients-view/family-members-tab/add-family-member/add-family-member.component';
|
||||
import { EditFamilyMemberComponent } from './clients-view/family-members-tab/edit-family-member/edit-family-member.component';
|
||||
import { IdentitiesTabComponent } from './clients-view/identities-tab/identities-tab.component';
|
||||
@@ -58,6 +59,7 @@ import { ClientCollateralResolver } from './common-resolvers/client-collateral.r
|
||||
import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
|
||||
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
|
||||
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
|
||||
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
@@ -118,9 +120,14 @@ const routes: Routes = [
|
||||
clientAddressData: ClientAddressResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'nominees',
|
||||
component: NomineeTabComponent,
|
||||
data: { title: 'Nominees', breadcrumb: 'Nominees', routeParamBreadcrumb: false }
|
||||
},
|
||||
{
|
||||
path: 'family-members',
|
||||
data: { title: 'Nominee', breadcrumb: 'Nominee', routeParamBreadcrumb: false },
|
||||
data: { title: 'Family Members', breadcrumb: 'Family Members', routeParamBreadcrumb: false },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -167,6 +174,11 @@ const routes: Routes = [
|
||||
component: KycTabComponent,
|
||||
data: { title: 'KYC', breadcrumb: 'KYC', routeParamBreadcrumb: false }
|
||||
},
|
||||
{
|
||||
path: 'entity-classification',
|
||||
component: EntityClassificationTabComponent,
|
||||
data: { title: 'Scheme & Sector', breadcrumb: 'Scheme & Sector', routeParamBreadcrumb: false }
|
||||
},
|
||||
{
|
||||
path: 'ckyc',
|
||||
component: CkycTabComponent,
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</span>
|
||||
</mat-panel-title>
|
||||
<mat-panel-description class="addr-panel__desc">
|
||||
{{ address.city || address.townVillage || '' }}{{ address.city || address.townVillage ? ', ' : '' }}{{ getSelectedValue('stateProvinceIdOptions', address.stateProvinceId)?.name || '' }}
|
||||
{{ address.city || '' }}{{ address.city && (address.countyDistrict || address.addressLine3) ? ', ' : '' }}{{ address.countyDistrict || address.addressLine3 || '' }}{{ address.postalCode ? ' - ' + address.postalCode : '' }}
|
||||
</mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
|
||||
@@ -56,60 +56,58 @@
|
||||
|
||||
<!-- Address Details Grid -->
|
||||
<div class="addr-grid">
|
||||
@if (isFieldEnabled('street') && address.street) {
|
||||
@if (address.addressLine1) {
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">{{ 'labels.inputs.Street' | translate }}</div>
|
||||
<div class="addr-field__value">{{ address.street }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (isFieldEnabled('addressLine1') && address.addressLine1) {
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">{{ 'labels.inputs.Address Line' | translate }} 1</div>
|
||||
<div class="addr-field__label">Address Line 1</div>
|
||||
<div class="addr-field__value">{{ address.addressLine1 }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (isFieldEnabled('addressLine2') && address.addressLine2) {
|
||||
@if (address.addressLine2) {
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">{{ 'labels.inputs.Address Line' | translate }} 2</div>
|
||||
<div class="addr-field__label">Address Line 2</div>
|
||||
<div class="addr-field__value">{{ address.addressLine2 }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (isFieldEnabled('addressLine3') && address.addressLine3) {
|
||||
@if (address.street) {
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">{{ 'labels.inputs.Address Line' | translate }} 3</div>
|
||||
<div class="addr-field__value">{{ address.addressLine3 }}</div>
|
||||
<div class="addr-field__label">Landmark</div>
|
||||
<div class="addr-field__value">{{ address.street }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (isFieldEnabled('townVillage') && address.townVillage) {
|
||||
@if (address.postalCode) {
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">{{ 'labels.inputs.Town / Village' | translate }}</div>
|
||||
<div class="addr-field__value">{{ address.townVillage }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (isFieldEnabled('city') && address.city) {
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">{{ 'labels.inputs.City' | translate }}</div>
|
||||
<div class="addr-field__value">{{ address.city }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (isFieldEnabled('stateProvinceId')) {
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">{{ 'labels.inputs.State / Province' | translate }}</div>
|
||||
<div class="addr-field__value">{{ getSelectedValue('stateProvinceIdOptions', address.stateProvinceId)?.name || '-' }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (isFieldEnabled('countryId')) {
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">{{ 'labels.inputs.Country' | translate }}</div>
|
||||
<div class="addr-field__value">{{ getSelectedValue('countryIdOptions', address.countryId)?.name || '-' }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (isFieldEnabled('postalCode') && address.postalCode) {
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">{{ 'labels.inputs.Postal Code' | translate }}</div>
|
||||
<div class="addr-field__label">PIN Code</div>
|
||||
<div class="addr-field__value addr-field__value--mono">{{ address.postalCode }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (address.addressLine3) {
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">Locality / State</div>
|
||||
<div class="addr-field__value">{{ address.addressLine3 }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (address.city) {
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">City</div>
|
||||
<div class="addr-field__value">{{ address.city }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (address.countyDistrict) {
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">District</div>
|
||||
<div class="addr-field__value">{{ address.countyDistrict }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (address.stateProvinceId) {
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">State</div>
|
||||
<div class="addr-field__value">{{ getSelectedValue('stateProvinceIdOptions', address.stateProvinceId)?.name || '-' }}</div>
|
||||
</div>
|
||||
}
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">Country</div>
|
||||
<div class="addr-field__value">{{ getSelectedValue('countryIdOptions', address.countryId)?.name || 'India' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ export class AddressTabComponent {
|
||||
formfields.push(
|
||||
this.isFieldEnabled('countyDistrict')
|
||||
? new InputBase({
|
||||
controlName: 'countryDistrict',
|
||||
controlName: 'countyDistrict',
|
||||
label: this.translateService.instant('labels.inputs.State / Province'),
|
||||
value: address ? address.countyDistrict : '',
|
||||
type: 'text',
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
|
||||
<div class="container">
|
||||
<div class="layout-row">
|
||||
<mat-form-field class="flex-fill">
|
||||
<mat-form-field appearance="outline" class="flex-fill">
|
||||
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
|
||||
<i matPrefix class="fas fa-search"></i>
|
||||
<input matInput (keyup)="applyFilter($event.target.value)" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@@ -144,6 +144,54 @@
|
||||
<div class="cv-cell__value">{{ clientViewData.occupation.name }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.mobileNo && clientViewData.dipVerified != null) {
|
||||
<div class="cv-cell">
|
||||
<div class="cv-cell__label">DIP Status</div>
|
||||
<div class="cv-cell__value">
|
||||
<span class="cv-badge"
|
||||
[class.cv-badge--green]="clientViewData.dipVerified === true || clientViewData.dipVerified === 'true' || clientViewData.dipVerified === 1"
|
||||
[class.cv-badge--yellow]="clientViewData.dipVerified !== true && clientViewData.dipVerified !== 'true' && clientViewData.dipVerified !== 1">
|
||||
{{ (clientViewData.dipVerified === true || clientViewData.dipVerified === 'true' || clientViewData.dipVerified === 1) ? 'Verified' : 'Pending' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.nameVerified != null) {
|
||||
<div class="cv-cell">
|
||||
<div class="cv-cell__label">Sanctions Screening</div>
|
||||
<div class="cv-cell__value">
|
||||
<span class="cv-badge"
|
||||
[class.cv-badge--green]="clientViewData.nameVerified === true || clientViewData.nameVerified === 'true' || clientViewData.nameVerified === 1"
|
||||
[class.cv-badge--red]="clientViewData.nameVerified === false || clientViewData.nameVerified === 'false' || clientViewData.nameVerified === 0">
|
||||
{{ (clientViewData.nameVerified === true || clientViewData.nameVerified === 'true' || clientViewData.nameVerified === 1) ? 'Clear' : 'Pending' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.nationality) {
|
||||
<div class="cv-cell">
|
||||
<div class="cv-cell__label">Nationality</div>
|
||||
<div class="cv-cell__value">{{ clientViewData.nationality.name || clientViewData.nationality }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.maritalStatus) {
|
||||
<div class="cv-cell">
|
||||
<div class="cv-cell__label">Marital Status</div>
|
||||
<div class="cv-cell__value">{{ clientViewData.maritalStatus.name }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.monthlyIncome != null && clientViewData.monthlyIncome !== '') {
|
||||
<div class="cv-cell">
|
||||
<div class="cv-cell__label">Monthly Income</div>
|
||||
<div class="cv-cell__value">₹ {{ clientViewData.monthlyIncome | number }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.annualTurnover != null && clientViewData.annualTurnover !== '') {
|
||||
<div class="cv-cell">
|
||||
<div class="cv-cell__label">Annual Turnover</div>
|
||||
<div class="cv-cell__value">₹ {{ clientViewData.annualTurnover | number }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.groups?.length > 0) {
|
||||
<div class="cv-cell">
|
||||
<div class="cv-cell__label">{{ 'labels.inputs.Member Of' | translate }}</div>
|
||||
@@ -355,90 +403,48 @@
|
||||
</span>
|
||||
}
|
||||
</mat-menu>
|
||||
</mat-menu>
|
||||
|
||||
<div>
|
||||
<nav mat-tab-nav-bar class="navigation-tabs" [tabPanel]="tabPanel">
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./general']"
|
||||
routerLinkActive
|
||||
#general="routerLinkActive"
|
||||
[active]="general.isActive"
|
||||
>
|
||||
{{ 'labels.inputs.General' | translate }}
|
||||
<nav mat-tab-nav-bar class="cv-tabs" [tabPanel]="tabPanel">
|
||||
<a mat-tab-link [routerLink]="['./general']" routerLinkActive #general="routerLinkActive" [active]="general.isActive">
|
||||
<i class="fa fa-tachometer cv-tab-icon"></i> {{ 'labels.inputs.General' | translate }}
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./personal-data']"
|
||||
routerLinkActive
|
||||
#personalData="routerLinkActive"
|
||||
[active]="personalData.isActive"
|
||||
>
|
||||
{{ 'labels.inputs.Personal Data' | translate }}
|
||||
<a mat-tab-link [routerLink]="['./personal-data']" routerLinkActive #personalData="routerLinkActive" [active]="personalData.isActive">
|
||||
<i class="fa fa-user cv-tab-icon"></i> {{ 'labels.inputs.Personal Data' | translate }}
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./address']"
|
||||
routerLinkActive
|
||||
#address="routerLinkActive"
|
||||
[active]="address.isActive"
|
||||
>
|
||||
{{ 'labels.inputs.Address' | translate }}
|
||||
<a mat-tab-link [routerLink]="['./address']" routerLinkActive #address="routerLinkActive" [active]="address.isActive">
|
||||
<i class="fa fa-map-marker cv-tab-icon"></i> {{ 'labels.inputs.Address' | translate }}
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./family-members']"
|
||||
routerLinkActive
|
||||
#familyMembers="routerLinkActive"
|
||||
[active]="familyMembers.isActive"
|
||||
>
|
||||
{{ 'labels.inputs.Family Members' | translate }}
|
||||
@if (clientViewData?.legalForm?.id !== 2) {
|
||||
<a mat-tab-link [routerLink]="['./family-members']" routerLinkActive #familyMembers="routerLinkActive" [active]="familyMembers.isActive">
|
||||
<i class="fa fa-users cv-tab-icon"></i> Family Members
|
||||
</a>
|
||||
}
|
||||
<a mat-tab-link [routerLink]="['./nominees']" routerLinkActive #nomineesTab="routerLinkActive" [active]="nomineesTab.isActive">
|
||||
<i class="fa fa-shield cv-tab-icon"></i> Nominee
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./identities']"
|
||||
routerLinkActive
|
||||
#identities="routerLinkActive"
|
||||
[active]="identities.isActive"
|
||||
*mifosxHasPermission="'READ_CLIENTIDENTIFIER'"
|
||||
>
|
||||
{{ 'labels.inputs.Identities' | translate }}
|
||||
<a mat-tab-link [routerLink]="['./identities']" routerLinkActive #identities="routerLinkActive" [active]="identities.isActive" *mifosxHasPermission="'READ_CLIENTIDENTIFIER'">
|
||||
<i class="fa fa-id-card cv-tab-icon"></i> {{ 'labels.inputs.Identities' | translate }}
|
||||
</a>
|
||||
<a mat-tab-link [routerLink]="['./kyc']" routerLinkActive #kyc="routerLinkActive" [active]="kyc.isActive">
|
||||
{{ 'labels.inputs.KYC' | translate }}
|
||||
<i class="fa fa-check-circle cv-tab-icon"></i> {{ 'labels.inputs.KYC' | translate }}
|
||||
</a>
|
||||
@if (clientViewData.legalForm?.id !== 1) {
|
||||
<a mat-tab-link [routerLink]="['./entity-classification']" routerLinkActive #ecTab="routerLinkActive" [active]="ecTab.isActive">
|
||||
<i class="fa fa-building cv-tab-icon"></i> Scheme & Sector
|
||||
</a>
|
||||
}
|
||||
<a mat-tab-link [routerLink]="['./ckyc']" routerLinkActive #ckyc="routerLinkActive" [active]="ckyc.isActive">
|
||||
{{ 'labels.inputs.CKYC' | translate }}
|
||||
<i class="fa fa-shield cv-tab-icon"></i> {{ 'labels.inputs.CKYC' | translate }}
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./form60']"
|
||||
routerLinkActive
|
||||
#form60="routerLinkActive"
|
||||
[active]="form60.isActive"
|
||||
>
|
||||
{{ 'labels.inputs.Form 60' | translate }}
|
||||
<a mat-tab-link [routerLink]="['./form60']" routerLinkActive #form60="routerLinkActive" [active]="form60.isActive">
|
||||
<i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }}
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./documents']"
|
||||
routerLinkActive
|
||||
#documents="routerLinkActive"
|
||||
[active]="documents.isActive"
|
||||
*mifosxHasPermission="'READ_DOCUMENT'"
|
||||
>
|
||||
{{ 'labels.inputs.Documents' | translate }}
|
||||
<a mat-tab-link [routerLink]="['./documents']" routerLinkActive #documents="routerLinkActive" [active]="documents.isActive" *mifosxHasPermission="'READ_DOCUMENT'">
|
||||
<i class="fa fa-folder-open cv-tab-icon"></i> {{ 'labels.inputs.Documents' | translate }}
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./notes']"
|
||||
routerLinkActive
|
||||
#notes="routerLinkActive"
|
||||
[active]="notes.isActive"
|
||||
*mifosxHasPermission="'READ_CLIENTNOTE'"
|
||||
>
|
||||
{{ 'labels.inputs.Notes' | translate }}
|
||||
<a mat-tab-link [routerLink]="['./notes']" routerLinkActive #notes="routerLinkActive" [active]="notes.isActive" *mifosxHasPermission="'READ_CLIENTNOTE'">
|
||||
<i class="fa fa-sticky-note cv-tab-icon"></i> {{ 'labels.inputs.Notes' | translate }}
|
||||
</a>
|
||||
@for (clientDatatable of clientDatatables; track clientDatatable) {
|
||||
<a
|
||||
|
||||
@@ -6,24 +6,10 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$dark-1: #0f1923;
|
||||
$dark-2: #162231;
|
||||
$dark-3: #1c2d3f;
|
||||
$accent: #38bdf8;
|
||||
$accent-dim: rgba(56, 189, 248, 0.15);
|
||||
$green: #34d399;
|
||||
$green-dim: rgba(52, 211, 153, 0.15);
|
||||
$red: #f87171;
|
||||
$red-dim: rgba(248, 113, 113, 0.15);
|
||||
$amber: #fbbf24;
|
||||
$white: #fff;
|
||||
$white-70: rgba(255, 255, 255, 0.7);
|
||||
$white-50: rgba(255, 255, 255, 0.5);
|
||||
$white-30: rgba(255, 255, 255, 0.3);
|
||||
$white-15: rgba(255, 255, 255, 0.15);
|
||||
$white-08: rgba(255, 255, 255, 0.08);
|
||||
// ── Design tokens (from home/toolbar) ──
|
||||
$brand-dark: #0d3d78;
|
||||
$accent: #0ea5e9;
|
||||
|
||||
// Main card
|
||||
:host {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
@@ -31,25 +17,46 @@ $white-08: rgba(255, 255, 255, 0.08);
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
// ── Card wrapper ──
|
||||
.cv-card {
|
||||
background: linear-gradient(135deg, $dark-1 0%, $dark-2 50%, $dark-3 100%) !important;
|
||||
border-radius: 12px;
|
||||
background: #fff !important;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
// Top section
|
||||
// ── Blue banner (top section) ──
|
||||
.cv-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
padding: 20px 24px 16px;
|
||||
padding: 20px 24px 18px;
|
||||
background: linear-gradient(135deg, $brand-dark 0%, #1a5276 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// Stripe overlay — matches home/toolbar
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: repeating-linear-gradient(108deg, transparent 0, transparent 80px, rgb(255 255 255 / 2.5%) 80px, rgb(255 255 255 / 2.5%) 160px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Avatar column
|
||||
// ── Avatar ──
|
||||
.cv-avatar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -60,66 +67,59 @@ $white-08: rgba(255, 255, 255, 0.08);
|
||||
&__img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 12px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
border: 2px solid $white-15;
|
||||
border: 2px solid rgba(255, 255, 255, 0.25);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
min-width: 28px !important;
|
||||
width: 26px !important;
|
||||
height: 26px !important;
|
||||
min-width: 26px !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 6px !important;
|
||||
background: $white-08 !important;
|
||||
color: $white-50 !important;
|
||||
border: 1px solid $white-15 !important;
|
||||
border-radius: 5px !important;
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
color: rgba(255, 255, 255, 0.6) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15) !important;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
transition: all 0.12s;
|
||||
|
||||
i {
|
||||
font-size: 11px;
|
||||
}
|
||||
i { font-size: 10px; }
|
||||
|
||||
&:hover {
|
||||
background: $white-15 !important;
|
||||
color: $white !important;
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
&--danger:hover {
|
||||
background: $red-dim !important;
|
||||
color: $red !important;
|
||||
border-color: rgba($red, 0.3) !important;
|
||||
background: rgba(239, 68, 68, 0.35) !important;
|
||||
border-color: rgba(239, 68, 68, 0.45) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__sig {
|
||||
font-size: 10px;
|
||||
color: $accent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
&:hover { color: #fff; text-decoration: underline; }
|
||||
}
|
||||
}
|
||||
|
||||
// Info column
|
||||
.cv-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
// ── Info column ──
|
||||
.cv-info { flex: 1; min-width: 0; }
|
||||
|
||||
// Name row
|
||||
// ── Name row ──
|
||||
.cv-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -132,44 +132,49 @@ $white-08: rgba(255, 255, 255, 0.08);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
&__dot {
|
||||
font-size: 10px;
|
||||
}
|
||||
&__dot { font-size: 10px; }
|
||||
|
||||
&__text {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: $white;
|
||||
color: #fff;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: $green-dim;
|
||||
color: $green;
|
||||
border: 1px solid rgba($green, 0.2);
|
||||
letter-spacing: 0.4px;
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #6ee7b7;
|
||||
border: 1px solid rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.cv-menu-btn {
|
||||
color: $white-50 !important;
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
border: 1px solid $white-15;
|
||||
border-radius: 8px !important;
|
||||
color: rgba(255, 255, 255, 0.7) !important;
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
min-width: 28px !important;
|
||||
padding: 0 !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 5px !important;
|
||||
line-height: 28px !important;
|
||||
|
||||
&:hover {
|
||||
background: $white-08;
|
||||
color: $white !important;
|
||||
&:hover { background: rgba(255, 255, 255, 0.1); color: #fff !important; }
|
||||
|
||||
.actions-menu {
|
||||
font-size: 14px !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
line-height: 14px !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ID chips
|
||||
// ── ID chips ──
|
||||
.cv-ids {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -181,119 +186,183 @@ $white-08: rgba(255, 255, 255, 0.08);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 12px;
|
||||
background: $white-08;
|
||||
border: 1px solid $white-15;
|
||||
border-radius: 6px;
|
||||
padding: 4px 11px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||
border-radius: 5px;
|
||||
|
||||
&__label {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: $white-30;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
&__val {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $white;
|
||||
color: #fff;
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
}
|
||||
|
||||
// Details grid
|
||||
// ── Details grid (inside banner, translucent cards) ──
|
||||
.cv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0;
|
||||
background: $white-08;
|
||||
border-radius: 8px;
|
||||
border: 1px solid $white-15;
|
||||
overflow: hidden;
|
||||
gap: 6px;
|
||||
padding: 0 24px 18px;
|
||||
background: linear-gradient(135deg, $brand-dark 0%, #1a5276 100%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cv-cell {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid $white-08;
|
||||
border-right: 1px solid $white-08;
|
||||
padding: 9px 13px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
transition: background 0.12s;
|
||||
|
||||
&:nth-child(4n) {
|
||||
border-right: none;
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: $white-30;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: $white;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
line-height: 1.35;
|
||||
}
|
||||
}
|
||||
|
||||
// Actions menu
|
||||
.actions-menu {
|
||||
color: $white !important;
|
||||
.cv-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
&--green { background: rgba(16, 185, 129, 0.2); color: #6ee7b7; }
|
||||
&--yellow { background: rgba(245, 158, 11, 0.2); color: #fcd34d; }
|
||||
&--red { background: rgba(239, 68, 68, 0.2); color: #fca5a5; }
|
||||
}
|
||||
|
||||
// Responsive
|
||||
.actions-menu { color: #fff !important; }
|
||||
|
||||
// ── Tab bar ──
|
||||
.cv-tabs {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
|
||||
::ng-deep .mat-mdc-tab-link {
|
||||
font-size: 12px !important;
|
||||
font-weight: 500 !important;
|
||||
letter-spacing: 0.2px;
|
||||
min-width: 0 !important;
|
||||
padding: 0 14px !important;
|
||||
height: 42px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cv-tab-icon {
|
||||
margin-right: 5px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
// Fix copy icons on dark background
|
||||
.cv-top, .cv-grid {
|
||||
::ng-deep {
|
||||
mifosx-external-identifier,
|
||||
mifosx-account-number {
|
||||
color: #fff !important;
|
||||
|
||||
.copy-icon,
|
||||
button,
|
||||
mat-icon,
|
||||
fa-icon {
|
||||
color: rgba(255, 255, 255, 0.4) !important;
|
||||
font-size: 11px !important;
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
min-width: 16px !important;
|
||||
padding: 0 !important;
|
||||
line-height: 16px !important;
|
||||
|
||||
&:hover {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Client menu styling ──
|
||||
::ng-deep .mat-mdc-menu-panel {
|
||||
border-radius: 10px !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12) !important;
|
||||
min-width: 200px !important;
|
||||
padding: 6px 0 !important;
|
||||
|
||||
.mat-mdc-menu-item {
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
height: 38px !important;
|
||||
min-height: 38px !important;
|
||||
padding: 0 16px !important;
|
||||
color: #333 !important;
|
||||
border-radius: 0 !important;
|
||||
margin: 0 !important;
|
||||
|
||||
&:hover {
|
||||
background: #f0f7ff !important;
|
||||
color: $brand-dark !important;
|
||||
}
|
||||
|
||||
.mat-icon,
|
||||
mat-icon {
|
||||
font-size: 15px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
min-width: 18px !important;
|
||||
margin-right: 10px !important;
|
||||
color: $accent !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-menu trigger arrow
|
||||
.mat-mdc-menu-item-submenu-trigger::after {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Responsive ──
|
||||
@media (max-width: 1024px) {
|
||||
.cv-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.cv-cell:nth-child(4n) {
|
||||
border-right: 1px solid $white-08;
|
||||
}
|
||||
|
||||
.cv-cell:nth-child(3n) {
|
||||
border-right: none;
|
||||
}
|
||||
.cv-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cv-top {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cv-name-row {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cv-ids {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cv-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.cv-cell:nth-child(3n) {
|
||||
border-right: 1px solid $white-08;
|
||||
}
|
||||
|
||||
.cv-cell:nth-child(2n) {
|
||||
border-right: none;
|
||||
}
|
||||
.cv-top { flex-direction: column; align-items: center; text-align: center; }
|
||||
.cv-name-row { justify-content: center; }
|
||||
.cv-ids { justify-content: center; }
|
||||
.cv-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.cv-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cv-cell {
|
||||
border-right: none !important;
|
||||
}
|
||||
.cv-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
<div class="ec-view">
|
||||
@if (loading) {
|
||||
<div class="ec-loading">
|
||||
<mat-progress-spinner mode="indeterminate" diameter="40"></mat-progress-spinner>
|
||||
<p>Loading classifications...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading) {
|
||||
<div class="ec-header">
|
||||
<h3><fa-icon icon="building"></fa-icon> Scheme & Sector Classification</h3>
|
||||
<span class="ec-count">{{ enabledCategories.length }} active</span>
|
||||
</div>
|
||||
|
||||
@if (enabledCategories.length === 0) {
|
||||
<div class="ec-empty">
|
||||
<mat-icon>category</mat-icon>
|
||||
<span>No classifications enabled for this entity.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (cat of enabledCategories; track cat.key) {
|
||||
<div class="ec-card">
|
||||
<div class="ec-card__header">
|
||||
<span class="ec-card__tag" [style.background]="cat.tagBg" [style.color]="cat.tagColor">{{ cat.tag }}</span>
|
||||
<span class="ec-card__title">{{ cat.label }}</span>
|
||||
<span class="ec-card__badge">Enabled</span>
|
||||
</div>
|
||||
<div class="ec-card__body">
|
||||
<div class="ec-card__grid">
|
||||
@for (field of getFieldPairs(getCategoryData(cat.key)); track field.label) {
|
||||
<div class="ec-card__field">
|
||||
<span class="ec-card__label">{{ field.label }}</span>
|
||||
<span class="ec-card__value">{{ field.value }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
.ec-view { padding: 16px 0; }
|
||||
|
||||
.ec-loading {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 12px;
|
||||
padding: 48px; color: #64748b;
|
||||
}
|
||||
|
||||
.ec-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
h3 { font-size: 16px; font-weight: 600; color: #1e293b; display: flex; align-items: center; gap: 8px;
|
||||
fa-icon { color: #1565c0; }
|
||||
}
|
||||
}
|
||||
|
||||
.ec-count {
|
||||
font-size: 12px; font-weight: 600; color: #059669;
|
||||
background: #ecfdf5; padding: 3px 10px; border-radius: 12px;
|
||||
}
|
||||
|
||||
.ec-empty {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||
padding: 40px; color: #94a3b8; font-size: 14px;
|
||||
background: #f8fafc; border: 2px dashed #e2e8f0; border-radius: 10px;
|
||||
mat-icon { font-size: 36px; width: 36px; height: 36px; }
|
||||
}
|
||||
|
||||
.ec-card {
|
||||
border: 1px solid #e2e8f0; border-radius: 10px; margin-bottom: 12px; overflow: hidden;
|
||||
}
|
||||
|
||||
.ec-card__header {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 16px; background: #f8fafc; border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.ec-card__tag {
|
||||
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 12px;
|
||||
}
|
||||
|
||||
.ec-card__title { font-size: 14px; font-weight: 600; color: #1e293b; flex: 1; }
|
||||
|
||||
.ec-card__badge {
|
||||
font-size: 10px; font-weight: 700; color: #059669;
|
||||
background: #ecfdf5; padding: 2px 8px; border-radius: 10px; border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
.ec-card__body { padding: 14px 16px; }
|
||||
|
||||
.ec-card__grid {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px 16px;
|
||||
}
|
||||
|
||||
.ec-card__field { display: flex; flex-direction: column; gap: 2px; }
|
||||
.ec-card__label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: #94a3b8; font-weight: 500; }
|
||||
.ec-card__value { font-size: 13px; font-weight: 500; color: #1e293b; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ec-card__grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-entity-classification-tab',
|
||||
templateUrl: './entity-classification-tab.component.html',
|
||||
styleUrls: ['./entity-classification-tab.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
MatIcon,
|
||||
MatProgressSpinner
|
||||
]
|
||||
})
|
||||
export class EntityClassificationTabComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private clientsService = inject(ClientsService);
|
||||
|
||||
clientId: string = '';
|
||||
loading = true;
|
||||
data: any = null;
|
||||
|
||||
/** Category metadata for display */
|
||||
categories = [
|
||||
{ key: 'msme', label: 'MSME', tag: 'Udyam / MSME', tagColor: '#0A5C6B', tagBg: '#E6F4F6', icon: 'cogs' },
|
||||
{ key: 'startup', label: 'Startup India', tag: 'DPIIT', tagColor: '#1A5FAB', tagBg: '#EAF2FB', icon: 'rocket' },
|
||||
{ key: 'mudra', label: 'Mudra Borrower', tag: 'PMMY', tagColor: '#8A5A00', tagBg: '#FEF3DC', icon: 'hand-holding-usd' },
|
||||
{ key: 'eou', label: 'Export Oriented Unit', tag: 'EOU', tagColor: '#1B7A4A', tagBg: '#E8F5EE', icon: 'ship' },
|
||||
{ key: 'importexport', label: 'Import Export', tag: 'DGFT / IEC', tagColor: '#5B3A8A', tagBg: '#F0EBF8', icon: 'exchange-alt' },
|
||||
{ key: 'psl', label: 'Priority Sector', tag: 'RBI PSL', tagColor: '#7B1F1F', tagBg: '#FCEAEA', icon: 'bullseye' },
|
||||
{ key: 'nps', label: 'Non-Priority Sector', tag: 'General', tagColor: '#444441', tagBg: '#F1EFE8', icon: 'building' }
|
||||
];
|
||||
|
||||
ngOnInit() {
|
||||
this.clientId = this.route.parent?.snapshot.paramMap.get('clientId') || '';
|
||||
if (this.clientId) {
|
||||
this.clientsService.getEntityClassifications(this.clientId).subscribe({
|
||||
next: (res: any) => { this.data = res; this.loading = false; },
|
||||
error: () => { this.loading = false; }
|
||||
});
|
||||
} else {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
get enabledCategories(): any[] {
|
||||
if (!this.data) return [];
|
||||
return this.categories.filter(c => this.data[c.key]?.enabled);
|
||||
}
|
||||
|
||||
getCategoryData(key: string): any {
|
||||
return this.data?.[key] || {};
|
||||
}
|
||||
|
||||
getFieldPairs(catData: any): { label: string, value: any }[] {
|
||||
if (!catData) return [];
|
||||
const skip = ['id', 'clientId', 'enabled', 'locale', 'dateFormat'];
|
||||
return Object.keys(catData)
|
||||
.filter(k => !skip.includes(k) && catData[k] != null && catData[k] !== '' && catData[k] !== false)
|
||||
.map(k => ({
|
||||
label: this.formatLabel(k),
|
||||
value: this.formatValue(catData[k])
|
||||
}));
|
||||
}
|
||||
|
||||
private formatLabel(key: string): string {
|
||||
return key
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, s => s.toUpperCase())
|
||||
.replace(/Id$/, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
private formatValue(val: any): string {
|
||||
if (Array.isArray(val) && val.length === 3) {
|
||||
return `${val[2]}/${val[1]}/${val[0]}`;
|
||||
}
|
||||
if (val === true) return 'Yes';
|
||||
if (typeof val === 'number') return val.toLocaleString('en-IN');
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
@@ -7,118 +7,83 @@
|
||||
-->
|
||||
|
||||
<div class="nom-container mat-typography">
|
||||
<!-- Header -->
|
||||
|
||||
<!-- ═══ FAMILY MEMBERS SECTION ═══ -->
|
||||
<div class="nom-header">
|
||||
<div class="nom-header__left">
|
||||
<i class="fa fa-users nom-header__icon"></i>
|
||||
<h3 class="nom-header__title">{{ 'labels.heading.Family Members' | translate }}</h3>
|
||||
<span class="nom-header__badge">{{ clientFamilyMembers?.length || 0 }}</span>
|
||||
<mat-icon class="nom-header__mat-icon">people</mat-icon>
|
||||
<h3 class="nom-header__title">Family Members</h3>
|
||||
<span class="nom-header__badge">{{ familyMembers.length }}</span>
|
||||
</div>
|
||||
<button mat-flat-button color="primary" class="nom-header__btn" (click)="addNominee()">
|
||||
<fa-icon icon="plus" class="m-r-10"></fa-icon>{{ 'labels.buttons.Add' | translate }}
|
||||
<button mat-flat-button color="primary" class="nom-header__btn" (click)="addFamilyMember()">
|
||||
<mat-icon>add</mat-icon> Add Member
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
@if (!clientFamilyMembers || clientFamilyMembers.length === 0) {
|
||||
@if (familyMembers.length === 0) {
|
||||
<div class="nom-empty">
|
||||
<i class="fa fa-user-o nom-empty__icon"></i>
|
||||
<p class="nom-empty__text">{{ 'labels.text.No nominees added yet' | translate }}</p>
|
||||
<mat-icon class="nom-empty__mat-icon">people_outline</mat-icon>
|
||||
<p class="nom-empty__text">No family members added yet.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Nominee Cards -->
|
||||
<mat-accordion multi>
|
||||
@for (member of clientFamilyMembers; track member.id; let i = $index) {
|
||||
@for (member of familyMembers; track member.id; let i = $index) {
|
||||
<mat-expansion-panel class="nom-panel">
|
||||
<mat-expansion-panel-header class="nom-panel__header">
|
||||
<mat-panel-title class="nom-panel__title">
|
||||
<i class="fa fa-user nom-panel__type-icon"></i>
|
||||
<mat-icon class="nom-panel__mat-icon">person</mat-icon>
|
||||
<span class="nom-panel__name">{{ displayName(member) }}</span>
|
||||
@if (member.relationship) {
|
||||
<span class="nom-panel__badge nom-panel__badge--relation">{{ member.relationship }}</span>
|
||||
}
|
||||
@if (member.nominationType) {
|
||||
<span class="nom-panel__badge nom-panel__badge--type">{{ member.nominationType }}</span>
|
||||
@if (getRelationship(member)) {
|
||||
<span class="nom-panel__badge nom-panel__badge--relation">{{ getRelationship(member) }}</span>
|
||||
}
|
||||
</mat-panel-title>
|
||||
<mat-panel-description class="nom-panel__desc">
|
||||
@if (member.percentageShare != null) {
|
||||
{{ member.percentageShare }}%
|
||||
@if (member.dateOfBirth) {
|
||||
{{ member.dateOfBirth | dateFormat }}
|
||||
}
|
||||
</mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
|
||||
<!-- Actions Bar -->
|
||||
<div class="nom-actions">
|
||||
<div class="nom-actions__buttons">
|
||||
<button mat-stroked-button color="primary" class="nom-actions__edit" (click)="editNominee(member, i)">
|
||||
<fa-icon icon="edit" class="m-r-10"></fa-icon>{{ 'labels.buttons.Edit' | translate }}
|
||||
<button mat-stroked-button color="primary" class="nom-actions__edit" (click)="editFamilyMember(member, i)">
|
||||
<fa-icon icon="edit" class="m-r-10"></fa-icon> Edit
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="warn"
|
||||
class="nom-actions__delete"
|
||||
(click)="deleteFamilyMember(member.clientId, member.id, member.firstName, i)"
|
||||
>
|
||||
<fa-icon icon="trash" class="m-r-10"></fa-icon>{{ 'labels.buttons.Delete' | translate }}
|
||||
<button mat-stroked-button color="warn" class="nom-actions__delete" (click)="deleteMember(member, 'Family Member')">
|
||||
<fa-icon icon="trash" class="m-r-10"></fa-icon> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Grid -->
|
||||
<div class="nom-grid">
|
||||
@if (member.nominationType) {
|
||||
<div class="nom-field">
|
||||
<div class="nom-field__label">{{ 'labels.inputs.Nomination Type' | translate }}</div>
|
||||
<div class="nom-field__value">{{ member.nominationType }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (member.firstName) {
|
||||
<div class="nom-field">
|
||||
<div class="nom-field__label">{{ 'labels.inputs.Nominee Name' | translate }}</div>
|
||||
<div class="nom-field__label">Name</div>
|
||||
<div class="nom-field__value">{{ member.firstName }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (member.relationship) {
|
||||
@if (getRelationship(member)) {
|
||||
<div class="nom-field">
|
||||
<div class="nom-field__label">{{ 'labels.inputs.Relationship' | translate }}</div>
|
||||
<div class="nom-field__value">{{ member.relationship }}</div>
|
||||
<div class="nom-field__label">Relationship</div>
|
||||
<div class="nom-field__value">{{ getRelationship(member) }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (member.dateOfBirth) {
|
||||
<div class="nom-field">
|
||||
<div class="nom-field__label">{{ 'labels.inputs.Date Of Birth' | translate }}</div>
|
||||
<div class="nom-field__label">Date of Birth</div>
|
||||
<div class="nom-field__value">{{ member.dateOfBirth | dateFormat }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (member.percentageShare != null) {
|
||||
<div class="nom-field">
|
||||
<div class="nom-field__label">{{ 'labels.inputs.Percentage Share' | translate }}</div>
|
||||
<div class="nom-field__value nom-field__value--mono">{{ member.percentageShare }}%</div>
|
||||
</div>
|
||||
}
|
||||
@if (member.isDependent != null) {
|
||||
<div class="nom-field">
|
||||
<div class="nom-field__label">{{ 'labels.inputs.Nominee is Minor' | translate }}</div>
|
||||
<div class="nom-field__label">Dependent</div>
|
||||
<div class="nom-field__value">{{ member.isDependent | yesNo }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (member.guardianName) {
|
||||
<div class="nom-field">
|
||||
<div class="nom-field__label">{{ 'labels.inputs.Guardian Name' | translate }}</div>
|
||||
<div class="nom-field__value">{{ member.guardianName }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (member.guardianRelationship) {
|
||||
<div class="nom-field">
|
||||
<div class="nom-field__label">{{ 'labels.inputs.Relationship with Minor' | translate }}</div>
|
||||
<div class="nom-field__value">{{ member.guardianRelationship }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (member.remarks) {
|
||||
<div class="nom-field">
|
||||
<div class="nom-field__label">{{ 'labels.inputs.Remarks' | translate }}</div>
|
||||
<div class="nom-field__label">Remarks</div>
|
||||
<div class="nom-field__value">{{ member.remarks }}</div>
|
||||
</div>
|
||||
}
|
||||
@@ -126,4 +91,5 @@
|
||||
</mat-expansion-panel>
|
||||
}
|
||||
</mat-accordion>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,10 @@ $orange-light: #fff3e0;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
margin: 28px 0 20px;
|
||||
}
|
||||
|
||||
// Header
|
||||
.nom-header {
|
||||
display: flex;
|
||||
@@ -46,6 +50,11 @@ $orange-light: #fff3e0;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
&__mat-icon {
|
||||
color: $primary;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
@@ -84,6 +93,14 @@ $orange-light: #fff3e0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&__mat-icon {
|
||||
font-size: 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: $gray-500;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
color: $gray-500;
|
||||
font-size: 14px;
|
||||
@@ -127,6 +144,23 @@ $orange-light: #fff3e0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&__mat-icon {
|
||||
font-size: 18px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $primary-light;
|
||||
color: $primary;
|
||||
border-radius: 6px;
|
||||
|
||||
&--nominee {
|
||||
background: $green-light;
|
||||
color: $green;
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import {
|
||||
MatAccordion,
|
||||
MatExpansionPanel,
|
||||
@@ -35,7 +36,7 @@ import { YesnoPipe } from '../../../pipes/yesno.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
* Client Family Members Tab
|
||||
* Client Family Members & Nominees Tab
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-family-members-tab',
|
||||
@@ -44,6 +45,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
MatIcon,
|
||||
MatAccordion,
|
||||
MatExpansionPanel,
|
||||
MatExpansionPanelHeader,
|
||||
@@ -62,37 +64,56 @@ export class FamilyMembersTabComponent {
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
|
||||
/** Client Family Members */
|
||||
clientFamilyMembers: any;
|
||||
/** All family members from API */
|
||||
clientFamilyMembers: any[] = [];
|
||||
/** Client ID */
|
||||
clientId: string;
|
||||
/** Family member template options (loaded on first dialog open) */
|
||||
/** Family member template options */
|
||||
familyMemberOptions: any;
|
||||
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { clientFamilyMembers: any }) => {
|
||||
this.clientFamilyMembers = data.clientFamilyMembers;
|
||||
this.clientFamilyMembers = data.clientFamilyMembers || [];
|
||||
});
|
||||
this.clientId = this.route.parent.snapshot.params['clientId'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the nominee dialog in Add mode.
|
||||
*/
|
||||
addNominee() {
|
||||
/** Family members (non-nominee entries) */
|
||||
get familyMembers(): any[] {
|
||||
return this.clientFamilyMembers.filter(m => !m.nominationType || m.nominationType === '');
|
||||
}
|
||||
|
||||
/** Nominees (entries with nominationType set) */
|
||||
get nominees(): any[] {
|
||||
return this.clientFamilyMembers.filter(m => m.nominationType && m.nominationType !== '');
|
||||
}
|
||||
|
||||
/** Get relationship display name */
|
||||
getRelationship(member: any): string {
|
||||
if (member.relationship) return member.relationship;
|
||||
if (typeof member.relationshipId === 'object' && member.relationshipId?.name) return member.relationshipId.name;
|
||||
return '';
|
||||
}
|
||||
|
||||
displayName(member: any): string {
|
||||
return member.firstName || '';
|
||||
}
|
||||
|
||||
// ═══ Family Member Actions ═══
|
||||
|
||||
addFamilyMember() {
|
||||
this.ensureTemplateLoaded().then(() => {
|
||||
const dialogRef = this.dialog.open(NomineeDialogComponent, {
|
||||
width: '700px',
|
||||
data: {
|
||||
title: this.translateService.instant('labels.buttons.Add') + ' ' + this.translateService.instant('labels.heading.Nominee'),
|
||||
familyMemberOptions: this.familyMemberOptions
|
||||
title: 'Add Family Member',
|
||||
familyMemberOptions: this.familyMemberOptions,
|
||||
isFamilyMember: true
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result: any) => {
|
||||
if (result) {
|
||||
this.clientsService.addFamilyMember(this.clientId, result).subscribe((res: any) => {
|
||||
// Refresh the list by fetching the member details
|
||||
this.clientsService.getClientFamilyMember(this.clientId, res.resourceId).subscribe((member: any) => {
|
||||
this.clientFamilyMembers.push(member);
|
||||
});
|
||||
@@ -102,25 +123,23 @@ export class FamilyMembersTabComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the nominee dialog in Edit mode, pre-filled with member data.
|
||||
*/
|
||||
editNominee(member: any, index: number) {
|
||||
editFamilyMember(member: any, index: number) {
|
||||
const realIndex = this.clientFamilyMembers.indexOf(member);
|
||||
this.ensureTemplateLoaded().then(() => {
|
||||
const dialogRef = this.dialog.open(NomineeDialogComponent, {
|
||||
width: '700px',
|
||||
data: {
|
||||
title: this.translateService.instant('labels.buttons.Edit') + ' ' + this.translateService.instant('labels.heading.Nominee'),
|
||||
title: 'Edit Family Member',
|
||||
familyMemberOptions: this.familyMemberOptions,
|
||||
member
|
||||
member,
|
||||
isFamilyMember: true
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result: any) => {
|
||||
if (result) {
|
||||
this.clientsService.editFamilyMember(member.clientId, member.id, result).subscribe(() => {
|
||||
this.clientsService.getClientFamilyMember(this.clientId, member.id).subscribe((updated: any) => {
|
||||
this.clientFamilyMembers[index] = updated;
|
||||
this.clientFamilyMembers[realIndex] = updated;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -128,29 +147,68 @@ export class FamilyMembersTabComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the family member.
|
||||
*/
|
||||
deleteFamilyMember(clientId: string, id: string, name: string, index: number) {
|
||||
// ═══ Nominee Actions ═══
|
||||
|
||||
addNominee() {
|
||||
this.ensureTemplateLoaded().then(() => {
|
||||
const dialogRef = this.dialog.open(NomineeDialogComponent, {
|
||||
width: '700px',
|
||||
data: {
|
||||
title: 'Add Nominee',
|
||||
familyMemberOptions: this.familyMemberOptions
|
||||
}
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((result: any) => {
|
||||
if (result) {
|
||||
this.clientsService.addFamilyMember(this.clientId, result).subscribe((res: any) => {
|
||||
this.clientsService.getClientFamilyMember(this.clientId, res.resourceId).subscribe((member: any) => {
|
||||
this.clientFamilyMembers.push(member);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
editNominee(member: any, index: number) {
|
||||
const realIndex = this.clientFamilyMembers.indexOf(member);
|
||||
this.ensureTemplateLoaded().then(() => {
|
||||
const dialogRef = this.dialog.open(NomineeDialogComponent, {
|
||||
width: '700px',
|
||||
data: {
|
||||
title: 'Edit Nominee',
|
||||
familyMemberOptions: this.familyMemberOptions,
|
||||
member
|
||||
}
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((result: any) => {
|
||||
if (result) {
|
||||
this.clientsService.editFamilyMember(member.clientId, member.id, result).subscribe(() => {
|
||||
this.clientsService.getClientFamilyMember(this.clientId, member.id).subscribe((updated: any) => {
|
||||
this.clientFamilyMembers[realIndex] = updated;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ Common ═══
|
||||
|
||||
deleteMember(member: any, label: string) {
|
||||
const realIndex = this.clientFamilyMembers.indexOf(member);
|
||||
const deleteFamilyMemberDialogRef = this.dialog.open(DeleteDialogComponent, {
|
||||
data: { deleteContext: `Nominee id:${id} name : ${name}` }
|
||||
data: { deleteContext: `${label} ${member.firstName || ''}` }
|
||||
});
|
||||
deleteFamilyMemberDialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response.delete) {
|
||||
this.clientsService.deleteFamilyMember(clientId, id).subscribe(() => {
|
||||
this.clientFamilyMembers.splice(index, 1);
|
||||
if (response?.delete) {
|
||||
this.clientsService.deleteFamilyMember(member.clientId || this.clientId, member.id).subscribe(() => {
|
||||
this.clientFamilyMembers.splice(realIndex, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
displayName(member: any): string {
|
||||
return member.firstName || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily loads family member template options from the API.
|
||||
*/
|
||||
private ensureTemplateLoaded(): Promise<void> {
|
||||
if (this.familyMemberOptions) {
|
||||
return Promise.resolve();
|
||||
|
||||
@@ -6,180 +6,197 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="form60-container">
|
||||
<!-- Warning Notice -->
|
||||
<div class="form60-notice">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<span>{{ 'labels.text.PAN not provided. Please fill Form 60 as per Income Tax Rule 114B.' | translate }}</span>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="form60-loading">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<div class="f60-container">
|
||||
@if (loading || checkingPan) {
|
||||
<div class="f60-loading">
|
||||
<mat-spinner diameter="36"></mat-spinner>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Read-only view when data exists and not editing -->
|
||||
}
|
||||
|
||||
<!-- ═══ PAN EXISTS — Form 60 Not Required ═══ -->
|
||||
@if (!loading && hasPan) {
|
||||
<div class="f60-pan-ok">
|
||||
<div class="f60-pan-ok__icon-wrap">
|
||||
<mat-icon class="f60-pan-ok__icon">verified</mat-icon>
|
||||
</div>
|
||||
<h3 class="f60-pan-ok__title">Form 60 Not Required</h3>
|
||||
<p class="f60-pan-ok__desc">
|
||||
PAN <strong>{{ panNumber }}</strong> is attached to this client.
|
||||
@if (panVerified) {
|
||||
<mat-icon class="f60-pan-ok__tick">check_circle</mat-icon> <span class="f60-pan-ok__verified">Verified</span>
|
||||
}
|
||||
</p>
|
||||
<p class="f60-pan-ok__rule">As per Income Tax Rule 114B, Form 60 is only required when PAN is not available.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ═══ NO PAN — Show Form 60 ═══ -->
|
||||
@if (!loading && !hasPan) {
|
||||
<!-- Warning Banner -->
|
||||
<div class="f60-warning">
|
||||
<mat-icon class="f60-warning__icon">warning_amber</mat-icon>
|
||||
<div class="f60-warning__text">
|
||||
<strong>PAN not provided</strong> — Form 60 declaration is mandatory as per Income Tax Rule 114B.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Read-only View -->
|
||||
@if (form60Data && !isEditMode) {
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{ 'labels.inputs.Form 60 Declaration' | translate }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="form60-summary">
|
||||
<div class="f60-card">
|
||||
<div class="f60-card__header">
|
||||
<mat-icon class="f60-card__header-icon">description</mat-icon>
|
||||
<h3>Form 60 Declaration</h3>
|
||||
<span class="f60-card__status">Submitted</span>
|
||||
<button mat-stroked-button color="primary" (click)="toggleEditMode()" class="f60-card__edit">
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
</button>
|
||||
</div>
|
||||
<div class="f60-card__body">
|
||||
<div class="f60-detail-grid">
|
||||
@if (form60Data.fullName) {
|
||||
<div class="form60-field">
|
||||
<span class="label">{{ 'labels.inputs.Full Name' | translate }}</span>
|
||||
<span class="value">{{ form60Data.fullName }}</span>
|
||||
<div class="f60-detail">
|
||||
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">person</mat-icon> Full Name</span>
|
||||
<span class="f60-detail__value">{{ form60Data.fullName }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (form60Data.dateOfBirth) {
|
||||
<div class="form60-field">
|
||||
<span class="label">{{ 'labels.inputs.Date Of Birth' | translate }}</span>
|
||||
<span class="value">{{ form60Data.dateOfBirth | dateFormat }}</span>
|
||||
<div class="f60-detail">
|
||||
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">calendar_today</mat-icon> Date of Birth</span>
|
||||
<span class="f60-detail__value">{{ form60Data.dateOfBirth | dateFormat }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (form60Data.fatherName) {
|
||||
<div class="form60-field">
|
||||
<span class="label">{{ "labels.inputs.Father's Name" | translate }}</span>
|
||||
<span class="value">{{ form60Data.fatherName }}</span>
|
||||
<div class="f60-detail">
|
||||
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">family_restroom</mat-icon> Father's Name</span>
|
||||
<span class="f60-detail__value">{{ form60Data.fatherName }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (form60Data.address) {
|
||||
<div class="form60-field">
|
||||
<span class="label">{{ 'labels.inputs.Address' | translate }}</span>
|
||||
<span class="value">{{ form60Data.address }}</span>
|
||||
<div class="f60-detail f60-detail--full">
|
||||
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">location_on</mat-icon> Address</span>
|
||||
<span class="f60-detail__value">{{ form60Data.address }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (form60Data.transactionType) {
|
||||
<div class="form60-field">
|
||||
<span class="label">{{ 'labels.inputs.Transaction Type' | translate }}</span>
|
||||
<span class="value">{{ form60Data.transactionType }}</span>
|
||||
<div class="f60-detail">
|
||||
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">receipt_long</mat-icon> Transaction Type</span>
|
||||
<span class="f60-detail__value">{{ form60Data.transactionType }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (form60Data.transactionAmount) {
|
||||
<div class="form60-field">
|
||||
<span class="label">{{ 'labels.inputs.Transaction Amount' | translate }}</span>
|
||||
<span class="value">{{ form60Data.transactionAmount }}</span>
|
||||
<div class="f60-detail">
|
||||
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">currency_rupee</mat-icon> Transaction Amount</span>
|
||||
<span class="f60-detail__value">₹ {{ form60Data.transactionAmount }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (form60Data.estimatedTotalIncome) {
|
||||
<div class="form60-field">
|
||||
<span class="label">{{ 'labels.inputs.Estimated Total Income' | translate }}</span>
|
||||
<span class="value">{{ form60Data.estimatedTotalIncome }}</span>
|
||||
<div class="f60-detail">
|
||||
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">account_balance_wallet</mat-icon> Estimated Total Income</span>
|
||||
<span class="f60-detail__value">₹ {{ form60Data.estimatedTotalIncome }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (form60Data.reasonForNoPan) {
|
||||
<div class="form60-field">
|
||||
<span class="label">{{ 'labels.inputs.Reason for Not Having PAN' | translate }}</span>
|
||||
<span class="value">{{ form60Data.reasonForNoPan }}</span>
|
||||
<div class="f60-detail">
|
||||
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">help_outline</mat-icon> Reason for No PAN</span>
|
||||
<span class="f60-detail__value">{{ form60Data.reasonForNoPan }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-card-content>
|
||||
<mat-card-actions>
|
||||
<button mat-raised-button color="primary" (click)="toggleEditMode()">
|
||||
<mat-icon>edit</mat-icon>
|
||||
{{ 'labels.buttons.Edit' | translate }}
|
||||
</button>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Edit / Create Form -->
|
||||
@if (isEditMode) {
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{ 'labels.inputs.Form 60 Declaration' | translate }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="f60-card">
|
||||
<div class="f60-card__header">
|
||||
<mat-icon class="f60-card__header-icon">edit_note</mat-icon>
|
||||
<h3>Form 60 Declaration</h3>
|
||||
</div>
|
||||
<div class="f60-card__body">
|
||||
<form [formGroup]="form60Form" (ngSubmit)="submit()">
|
||||
<div class="layout-row-wrap responsive-column align-start-center">
|
||||
<!-- Full Name -->
|
||||
<mat-form-field class="flex-50-minus-25px">
|
||||
<mat-label>{{ 'labels.inputs.Full Name' | translate }}</mat-label>
|
||||
<div class="f60-form-grid">
|
||||
<mat-form-field appearance="outline" class="f60-ff">
|
||||
<mat-label>Full Name</mat-label>
|
||||
<span matPrefix class="f60-prefix"><mat-icon>person</mat-icon></span>
|
||||
<input formControlName="fullName" required matInput />
|
||||
@if (form60Form.controls.fullName.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Full Name' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
<mat-error>Full Name is <strong>required</strong></mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Date of Birth -->
|
||||
<mat-form-field class="flex-50-minus-25px" (click)="dobPicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Date Of Birth' | translate }}</mat-label>
|
||||
<mat-form-field appearance="outline" class="f60-ff" (click)="dobPicker.open()">
|
||||
<mat-label>Date of Birth</mat-label>
|
||||
<span matPrefix class="f60-prefix"><mat-icon>calendar_today</mat-icon></span>
|
||||
<input formControlName="dateOfBirth" matInput [max]="maxDate" [matDatepicker]="dobPicker" />
|
||||
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #dobPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Father's Name -->
|
||||
<mat-form-field class="flex-50-minus-25px">
|
||||
<mat-label>{{ "labels.inputs.Father's Name" | translate }}</mat-label>
|
||||
<mat-form-field appearance="outline" class="f60-ff">
|
||||
<mat-label>Father's Name</mat-label>
|
||||
<span matPrefix class="f60-prefix"><mat-icon>family_restroom</mat-icon></span>
|
||||
<input formControlName="fatherName" matInput />
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Address -->
|
||||
<mat-form-field class="flex-50-minus-25px">
|
||||
<mat-label>{{ 'labels.inputs.Address' | translate }}</mat-label>
|
||||
<mat-form-field appearance="outline" class="f60-ff f60-ff--full">
|
||||
<mat-label>Address</mat-label>
|
||||
<span matPrefix class="f60-prefix"><mat-icon>location_on</mat-icon></span>
|
||||
<input formControlName="address" matInput />
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Transaction Type -->
|
||||
<mat-form-field class="flex-50-minus-25px">
|
||||
<mat-label>{{ 'labels.inputs.Transaction Type' | translate }}</mat-label>
|
||||
<mat-form-field appearance="outline" class="f60-ff">
|
||||
<mat-label>Transaction Type / Purpose</mat-label>
|
||||
<span matPrefix class="f60-prefix"><mat-icon>receipt_long</mat-icon></span>
|
||||
<mat-select formControlName="transactionType">
|
||||
@for (txnType of transactionTypes; track txnType.value) {
|
||||
<mat-option [value]="txnType.value">{{ txnType.key | translate }}</mat-option>
|
||||
<mat-option [value]="txnType.value">{{ txnType.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Transaction Amount -->
|
||||
<mat-form-field class="flex-50-minus-25px">
|
||||
<mat-label>{{ 'labels.inputs.Transaction Amount' | translate }}</mat-label>
|
||||
<mat-form-field appearance="outline" class="f60-ff">
|
||||
<mat-label>Transaction Amount</mat-label>
|
||||
<span matPrefix class="f60-prefix"><mat-icon>currency_rupee</mat-icon></span>
|
||||
<input type="number" formControlName="transactionAmount" matInput />
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Estimated Total Income -->
|
||||
<mat-form-field class="flex-50-minus-25px">
|
||||
<mat-label>{{ 'labels.inputs.Estimated Total Income' | translate }}</mat-label>
|
||||
<mat-form-field appearance="outline" class="f60-ff">
|
||||
<mat-label>Estimated Total Income</mat-label>
|
||||
<span matPrefix class="f60-prefix"><mat-icon>account_balance_wallet</mat-icon></span>
|
||||
<input type="number" formControlName="estimatedTotalIncome" matInput />
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Reason for Not Having PAN -->
|
||||
<mat-form-field class="flex-50-minus-25px">
|
||||
<mat-label>{{ 'labels.inputs.Reason for Not Having PAN' | translate }}</mat-label>
|
||||
<mat-form-field appearance="outline" class="f60-ff">
|
||||
<mat-label>Reason for Not Having PAN</mat-label>
|
||||
<span matPrefix class="f60-prefix"><mat-icon>help_outline</mat-icon></span>
|
||||
<mat-select formControlName="reasonForNoPan">
|
||||
@for (reason of reasonOptions; track reason.value) {
|
||||
<mat-option [value]="reason.value">{{ reason.key | translate }}</mat-option>
|
||||
<mat-option [value]="reason.value">{{ reason.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Declaration Checkbox -->
|
||||
<div class="form60-declaration">
|
||||
<!-- Declaration -->
|
||||
<div class="f60-declaration">
|
||||
<mat-checkbox formControlName="declaration">
|
||||
{{ 'labels.text.I declare that I do not have PAN and information provided is correct.' | translate }}
|
||||
I hereby declare that I do not have a Permanent Account Number (PAN) and the information provided above is true and correct to the best of my knowledge and belief.
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="layout-row gap-32px layout-align-center">
|
||||
<!-- Actions -->
|
||||
<div class="f60-actions">
|
||||
@if (form60Data) {
|
||||
<button type="button" mat-raised-button (click)="toggleEditMode()">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button type="button" mat-stroked-button (click)="toggleEditMode()">Cancel</button>
|
||||
}
|
||||
<button type="submit" mat-raised-button color="primary" [disabled]="!form60Form.valid">
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
<button type="submit" mat-flat-button color="primary" [disabled]="!form60Form.valid">
|
||||
<mat-icon>check</mat-icon> Submit Form 60
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -6,79 +6,119 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.form60-container {
|
||||
padding: 16px;
|
||||
}
|
||||
$primary: #1E6BB8;
|
||||
$primary-light: #EBF3FF;
|
||||
$green: #16a34a;
|
||||
$green-light: #dcfce7;
|
||||
$amber: #f59e0b;
|
||||
$amber-light: #fef3c7;
|
||||
$border: #E2E8F0;
|
||||
$text: #1A2332;
|
||||
$muted: #64748B;
|
||||
$bg: #F0F4F8;
|
||||
|
||||
.form60-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: #fff3cd;
|
||||
padding: 12px 16px;
|
||||
border-left: 5px solid #fc0;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
color: #856404;
|
||||
.f60-container { padding: 20px 24px; }
|
||||
.f60-loading { display: flex; justify-content: center; padding: 40px; }
|
||||
|
||||
mat-icon {
|
||||
color: #856404;
|
||||
/* ── PAN OK — Not Required ── */
|
||||
.f60-pan-ok {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
background: $green-light;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 12px;
|
||||
|
||||
&__icon-wrap {
|
||||
width: 72px; height: 72px; border-radius: 50%;
|
||||
background: #fff; display: inline-flex; align-items: center;
|
||||
justify-content: center; margin-bottom: 16px;
|
||||
box-shadow: 0 2px 8px rgba($green, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.form60-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.form60-summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.form60-field {
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
&__icon { font-size: 40px; width: 40px; height: 40px; color: $green; }
|
||||
&__title { font-size: 20px; font-weight: 700; color: $text; margin: 0 0 8px; }
|
||||
&__desc {
|
||||
font-size: 15px; color: $text; margin: 0 0 6px;
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px; flex-wrap: wrap;
|
||||
strong { font-family: 'JetBrains Mono', monospace; letter-spacing: 0.5px; }
|
||||
}
|
||||
&__tick { font-size: 18px !important; width: 18px !important; height: 18px !important; color: $green; }
|
||||
&__verified { font-size: 13px; font-weight: 600; color: $green; }
|
||||
&__rule { font-size: 12px; color: $muted; margin: 12px 0 0; }
|
||||
}
|
||||
|
||||
.form60-declaration {
|
||||
margin: 16px 0 24px;
|
||||
padding: 12px 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
/* ── Warning Banner ── */
|
||||
.f60-warning {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
background: $amber-light; padding: 14px 20px;
|
||||
border-left: 4px solid $amber; border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__icon { color: $amber; font-size: 24px; flex-shrink: 0; }
|
||||
&__text { font-size: 13px; color: #92400e; line-height: 1.5; strong { font-weight: 700; } }
|
||||
}
|
||||
|
||||
@media screen and (width >= 960px) {
|
||||
.layout-row-wrap > .flex-fill:nth-child(odd),
|
||||
.layout-row-wrap > .flex-50-minus-25px:nth-child(odd) {
|
||||
margin-right: 25px;
|
||||
}
|
||||
/* ── Card ── */
|
||||
.f60-card {
|
||||
background: #fff; border: 1px solid $border; border-radius: 12px; overflow: hidden;
|
||||
|
||||
.layout-row-wrap > .flex-fill:last-child,
|
||||
.layout-row-wrap > .flex-50-minus-25px:last-child {
|
||||
margin-right: 0;
|
||||
&__header {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 14px 20px; background: $bg; border-bottom: 1px solid $border;
|
||||
h3 { margin: 0; font-size: 15px; font-weight: 600; color: $text; flex: 1; }
|
||||
}
|
||||
&__header-icon { color: $primary; font-size: 22px; }
|
||||
&__status {
|
||||
font-size: 11px; font-weight: 600; color: $green; background: $green-light;
|
||||
padding: 3px 10px; border-radius: 12px;
|
||||
}
|
||||
&__edit {
|
||||
font-size: 12px !important; height: 30px !important; border-radius: 6px !important;
|
||||
mat-icon { font-size: 16px; width: 16px; height: 16px; margin-right: 4px; }
|
||||
}
|
||||
&__body { padding: 20px; }
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.form60-summary {
|
||||
grid-template-columns: 1fr;
|
||||
/* ── Detail Grid (read-only) ── */
|
||||
.f60-detail-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 16px 24px;
|
||||
@media (max-width: 600px) { grid-template-columns: 1fr; }
|
||||
}
|
||||
.f60-detail {
|
||||
display: flex; flex-direction: column;
|
||||
&--full { grid-column: 1 / -1; }
|
||||
&__label {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
color: $muted; letter-spacing: 0.5px; margin-bottom: 4px;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
&__icon { font-size: 15px !important; width: 15px !important; height: 15px !important; color: $primary; }
|
||||
&__value { font-size: 15px; font-weight: 600; color: $text; }
|
||||
}
|
||||
|
||||
/* ── Form Grid ── */
|
||||
.f60-form-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px;
|
||||
@media (max-width: 600px) { grid-template-columns: 1fr; }
|
||||
}
|
||||
.f60-ff { width: 100%; &--full { grid-column: 1 / -1; } }
|
||||
|
||||
.f60-prefix {
|
||||
display: inline-flex; align-items: center;
|
||||
padding-left: 4px; padding-right: 8px; margin-right: 8px;
|
||||
border-right: 1.5px solid #cbd5e1; line-height: 1;
|
||||
mat-icon { font-size: 20px; width: 20px; height: 20px; color: $primary; }
|
||||
}
|
||||
|
||||
/* ── Declaration ── */
|
||||
.f60-declaration {
|
||||
margin: 12px 0 20px; padding: 14px 16px;
|
||||
background: $bg; border-radius: 8px; border: 1px solid $border;
|
||||
font-size: 13px; line-height: 1.5; color: $muted;
|
||||
}
|
||||
|
||||
/* ── Actions ── */
|
||||
.f60-actions {
|
||||
display: flex; justify-content: flex-end; gap: 10px;
|
||||
button { border-radius: 8px !important; font-weight: 600 !important; }
|
||||
mat-icon { font-size: 18px; width: 18px; height: 18px; margin-right: 4px; vertical-align: middle; }
|
||||
}
|
||||
|
||||
@@ -54,32 +54,68 @@ export class Form60TabComponent implements OnInit, OnDestroy {
|
||||
form60Data: any = null;
|
||||
isEditMode = false;
|
||||
|
||||
/** PAN status — if PAN exists, Form 60 is not required */
|
||||
hasPan = false;
|
||||
panNumber: string | null = null;
|
||||
panVerified = false;
|
||||
checkingPan = true;
|
||||
|
||||
/** Maximum date allowed */
|
||||
maxDate = new Date();
|
||||
|
||||
/** Form */
|
||||
form60Form: UntypedFormGroup;
|
||||
|
||||
/** Dropdown options */
|
||||
transactionTypes = [
|
||||
{ value: 'Account Opening', key: 'labels.inputs.Account Opening' },
|
||||
{ value: 'Cash Deposit', key: 'labels.inputs.Cash Deposit' },
|
||||
{ value: 'Fixed Deposit', key: 'labels.inputs.Fixed Deposit' },
|
||||
{ value: 'Loan Processing', key: 'labels.inputs.Loan Processing' }
|
||||
];
|
||||
/** Dropdown options (loaded from API) */
|
||||
transactionTypes: any[] = [];
|
||||
loadingPurposes = true;
|
||||
|
||||
reasonOptions = [
|
||||
{ value: 'Applied for PAN', key: 'labels.inputs.Applied for PAN' },
|
||||
{ value: 'Income below taxable limit', key: 'labels.inputs.Income below taxable limit' },
|
||||
{ value: 'Other', key: 'labels.inputs.Other' }
|
||||
{ value: 'Applied for PAN', label: 'Applied for PAN' },
|
||||
{ value: 'Income below taxable limit', label: 'Income below taxable limit' },
|
||||
{ value: 'Agriculture income only', label: 'Agriculture income only' },
|
||||
{ value: 'NRI / Foreign National', label: 'NRI / Foreign National' },
|
||||
{ value: 'Other', label: 'Other' }
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.maxDate = this.settingsService.businessDate;
|
||||
this.createForm();
|
||||
this.loadForm60Purposes();
|
||||
this.route.parent.params.subscribe((params: any) => {
|
||||
this.clientId = params['clientId'];
|
||||
this.loadForm60Data();
|
||||
this.checkPanStatus();
|
||||
});
|
||||
}
|
||||
|
||||
private loadForm60Purposes(): void {
|
||||
this.loadingPurposes = true;
|
||||
this.clientsService.getCodeValuesByName('FORM_60_PURPOSE').pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe((values: any[]) => {
|
||||
this.transactionTypes = (values || [])
|
||||
.sort((a: any, b: any) => (a.position || 0) - (b.position || 0))
|
||||
.map((v: any) => ({ value: v.name, label: v.name }));
|
||||
this.loadingPurposes = false;
|
||||
});
|
||||
}
|
||||
|
||||
private checkPanStatus(): void {
|
||||
this.checkingPan = true;
|
||||
this.clientsService.getClientKyc(this.clientId).pipe(
|
||||
takeUntil(this.destroy$),
|
||||
catchError(() => of(null))
|
||||
).subscribe((kyc: any) => {
|
||||
this.checkingPan = false;
|
||||
if (kyc?.panNumber) {
|
||||
this.hasPan = true;
|
||||
this.panNumber = kyc.panNumber;
|
||||
this.panVerified = kyc.panVerified || false;
|
||||
this.loading = false;
|
||||
} else {
|
||||
this.hasPan = false;
|
||||
this.loadForm60Data();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,10 +152,15 @@ export class Form60TabComponent implements OnInit, OnDestroy {
|
||||
)
|
||||
.subscribe((data: any) => {
|
||||
this.loading = false;
|
||||
this.form60Data = data;
|
||||
if (data) {
|
||||
// API returns an array — use the latest (last) record
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
this.form60Data = data[data.length - 1];
|
||||
this.populateForm(this.form60Data);
|
||||
} else if (data && !Array.isArray(data)) {
|
||||
this.form60Data = data;
|
||||
this.populateForm(data);
|
||||
} else {
|
||||
this.form60Data = null;
|
||||
this.isEditMode = true;
|
||||
}
|
||||
});
|
||||
@@ -155,6 +196,11 @@ export class Form60TabComponent implements OnInit, OnDestroy {
|
||||
if (formValue.dateOfBirth) {
|
||||
if (formValue.dateOfBirth instanceof Date) {
|
||||
formValue.dateOfBirth = this.dateUtils.formatDate(formValue.dateOfBirth, dateFormat);
|
||||
} else if (typeof formValue.dateOfBirth === 'string') {
|
||||
const parsed = new Date(formValue.dateOfBirth);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
formValue.dateOfBirth = this.dateUtils.formatDate(parsed, dateFormat);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
delete formValue.dateOfBirth;
|
||||
@@ -170,13 +216,13 @@ export class Form60TabComponent implements OnInit, OnDestroy {
|
||||
.submitClientForm60(this.clientId, data)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.form60Data = response || data;
|
||||
next: () => {
|
||||
this.isEditMode = false;
|
||||
this.alertService.alert({
|
||||
type: 'success',
|
||||
message: 'Form 60 submitted successfully.'
|
||||
});
|
||||
this.loadForm60Data();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.alertService.alert({
|
||||
|
||||
@@ -6,134 +6,100 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="tab-container mat-typography">
|
||||
<div class="layout-row align-start">
|
||||
<div class="m-b-10">
|
||||
<h3>{{ 'labels.heading.Identities' | translate }}</h3>
|
||||
</div>
|
||||
<div class="action-button m-b-10 gap-25px">
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="addIdentifier()"
|
||||
*mifosxHasPermission="'CREATE_CLIENTIDENTIFIER'"
|
||||
>
|
||||
<fa-icon icon="plus" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Add' | translate }}
|
||||
</button>
|
||||
<div class="idt-container">
|
||||
<!-- Header -->
|
||||
<div class="idt-header">
|
||||
<div class="idt-header__left">
|
||||
<i class="fa fa-id-card idt-header__icon"></i>
|
||||
<h3 class="idt-header__title">{{ 'labels.heading.Identities' | translate }}</h3>
|
||||
<span class="idt-header__badge">{{ clientIdentities?.length || 0 }}</span>
|
||||
</div>
|
||||
<button mat-flat-button color="primary" class="idt-header__btn" (click)="addIdentifier()" *mifosxHasPermission="'CREATE_CLIENTIDENTIFIER'">
|
||||
<fa-icon icon="plus" class="m-r-10"></fa-icon>{{ 'labels.buttons.Add' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table mat-table #identifiersTable [dataSource]="clientIdentities" [ngStyle]="{ 'margin-top': '3%' }">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let identity">{{ identity.id }}</td>
|
||||
</ng-container>
|
||||
<!-- Empty State -->
|
||||
@if (!clientIdentities || clientIdentities.length === 0) {
|
||||
<div class="idt-empty">
|
||||
<i class="fa fa-id-badge idt-empty__icon"></i>
|
||||
<p class="idt-empty__text">{{ 'labels.messages.No Identities Available' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-container matColumnDef="type">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Type' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let identity">{{ identity.documentType.name }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="documentKey">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Document Key' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let identity">{{ identity.documentKey }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="description">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Description' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let identity">{{ identity.description }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="documents">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Identity Documents' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let identity">
|
||||
@if (identity.documents?.length) {
|
||||
<div class="document-grid">
|
||||
@for (document of identity.documents; track trackByDocumentId($index, document)) {
|
||||
<div class="document-card">
|
||||
<div
|
||||
class="thumb"
|
||||
[class.clickable]="isPreviewable(document)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="('labels.buttons.Preview' | translate) + ': ' + document.name"
|
||||
(keydown.enter)="isPreviewable(document) && openDocumentPreview(identity, document)"
|
||||
(keydown.space)="isPreviewable(document) && openDocumentPreview(identity, document)"
|
||||
(click)="isPreviewable(document) && openDocumentPreview(identity, document)"
|
||||
>
|
||||
@if (previewThumbnails[document.id]) {
|
||||
<img
|
||||
[src]="previewThumbnails[document.id]"
|
||||
[alt]="document.name"
|
||||
[title]="document.name"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<div class="placeholder">
|
||||
<fa-icon icon="file"></fa-icon>
|
||||
<span>{{ document.fileName || document.name }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (isPreviewable(document)) {
|
||||
<div class="preview-overlay">
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="title">{{ document.name }}</div>
|
||||
@if (document.fileName) {
|
||||
<div class="meta">{{ document.fileName }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<span class="muted">{{ 'labels.text.NoDocuments' | translate }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let identity">
|
||||
{{ identity.status === 'clientIdentifierStatusType.active' ? 'active' : 'inactive' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let identity; let i = index">
|
||||
<button
|
||||
class="identity-action-button"
|
||||
mat-raised-button
|
||||
color="warn"
|
||||
[title]="'labels.buttons.Delete' | translate"
|
||||
[attr.aria-label]="'labels.buttons.Delete' | translate"
|
||||
(click)="deleteIdentifier(identity.clientId, identity.id, i)"
|
||||
*mifosxHasPermission="'DELETE_CLIENTIDENTIFIER'"
|
||||
>
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="no-data">
|
||||
<td mat-footer-cell *matFooterCellDef [attr.colspan]="identitiesColumns.length">
|
||||
<div class="table-empty-state">
|
||||
<p>{{ 'labels.messages.No Identities Available' | translate }}</p>
|
||||
<!-- Identity Cards -->
|
||||
@for (identity of clientIdentities; track identity.id; let i = $index) {
|
||||
<div class="idt-card">
|
||||
<!-- Card Header -->
|
||||
<div class="idt-card__header">
|
||||
<div class="idt-card__title">
|
||||
<i class="fa fa-file-text idt-card__type-icon"></i>
|
||||
<span class="idt-card__type">{{ identity.documentType?.name }}</span>
|
||||
<span class="idt-card__status" [class.idt-card__status--active]="identity.status === 'clientIdentifierStatusType.active'" [class.idt-card__status--inactive]="identity.status !== 'clientIdentifierStatusType.active'">
|
||||
{{ identity.status === 'clientIdentifierStatusType.active' ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
<button mat-stroked-button color="warn" class="idt-card__delete" (click)="deleteIdentifier(identity.clientId, identity.id, i)" *mifosxHasPermission="'DELETE_CLIENTIDENTIFIER'">
|
||||
<i class="fa fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="identitiesColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: identitiesColumns"></tr>
|
||||
@if (clientIdentities?.length === 0) {
|
||||
<tr mat-footer-row *matFooterRowDef="['no-data']"></tr>
|
||||
}
|
||||
</table>
|
||||
<!-- Card Body -->
|
||||
<div class="idt-card__body">
|
||||
<div class="idt-grid">
|
||||
<div class="idt-field">
|
||||
<div class="idt-field__label">{{ 'labels.inputs.Id' | translate }}</div>
|
||||
<div class="idt-field__value idt-field__value--mono">{{ identity.id }}</div>
|
||||
</div>
|
||||
<div class="idt-field">
|
||||
<div class="idt-field__label">{{ 'labels.inputs.Document Key' | translate }}</div>
|
||||
<div class="idt-field__value idt-field__value--mono">{{ identity.documentKey }}</div>
|
||||
</div>
|
||||
@if (identity.description) {
|
||||
<div class="idt-field">
|
||||
<div class="idt-field__label">{{ 'labels.inputs.Description' | translate }}</div>
|
||||
<div class="idt-field__value">{{ identity.description }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Documents -->
|
||||
@if (identity.documents?.length) {
|
||||
<div class="idt-docs">
|
||||
<div class="idt-docs__title">
|
||||
<i class="fa fa-paperclip"></i> {{ 'labels.inputs.Identity Documents' | translate }}
|
||||
</div>
|
||||
<div class="idt-docs__grid">
|
||||
@for (document of identity.documents; track trackByDocumentId($index, document)) {
|
||||
<div class="idt-doc" [class.idt-doc--clickable]="isPreviewable(document)"
|
||||
(click)="isPreviewable(document) && openDocumentPreview(identity, document)"
|
||||
(keydown.enter)="isPreviewable(document) && openDocumentPreview(identity, document)"
|
||||
tabindex="0" role="button"
|
||||
[attr.aria-label]="('labels.buttons.Preview' | translate) + ': ' + document.name">
|
||||
@if (previewThumbnails[document.id]) {
|
||||
<img [src]="previewThumbnails[document.id]" [alt]="document.name" class="idt-doc__thumb" loading="lazy" />
|
||||
} @else {
|
||||
<div class="idt-doc__placeholder">
|
||||
<i class="fa fa-file-o"></i>
|
||||
</div>
|
||||
}
|
||||
<div class="idt-doc__info">
|
||||
<span class="idt-doc__name">{{ document.name }}</span>
|
||||
@if (document.fileName) {
|
||||
<span class="idt-doc__file">{{ document.fileName }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (isPreviewable(document)) {
|
||||
<i class="fa fa-eye idt-doc__preview"></i>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div #identityLightbox class="document-lightbox-host"></div>
|
||||
|
||||
@@ -6,142 +6,283 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@use '../../../../assets/styles/colours' as *;
|
||||
$primary: #1a73e8;
|
||||
$primary-light: #e8f0fe;
|
||||
$gray-50: #f8f9fa;
|
||||
$gray-100: #f1f3f4;
|
||||
$gray-200: #e8eaed;
|
||||
$gray-500: #9aa0a6;
|
||||
$gray-700: #5f6368;
|
||||
$gray-900: #202124;
|
||||
$white: #fff;
|
||||
$green: #34a853;
|
||||
$green-light: #e6f4ea;
|
||||
$red: #ea4335;
|
||||
$red-light: #fce8e6;
|
||||
|
||||
.tab-container {
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
.idt-container {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
margin-left: auto;
|
||||
// Header
|
||||
.idt-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
&__icon { font-size: 16px; color: $primary; }
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
&__badge {
|
||||
background: $primary-light;
|
||||
color: $primary;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.identity-action-button {
|
||||
min-width: 26px;
|
||||
padding: 0 6px;
|
||||
margin: 4px;
|
||||
line-height: 25px;
|
||||
&__btn {
|
||||
border-radius: 8px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
height: 36px !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Empty
|
||||
.idt-empty {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
background: $gray-50;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed $gray-200;
|
||||
|
||||
&__icon { font-size: 32px; color: $gray-500; margin-bottom: 8px; }
|
||||
&__text { color: $gray-500; font-size: 13px; margin: 0; }
|
||||
}
|
||||
|
||||
// Identity Card
|
||||
.idt-card {
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 18px;
|
||||
background: $gray-50;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__type-icon {
|
||||
font-size: 14px;
|
||||
color: $primary;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $primary-light;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&__type {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 9px;
|
||||
border-radius: 10px;
|
||||
|
||||
&--active { background: $green-light; color: $green; }
|
||||
&--inactive { background: $red-light; color: $red; }
|
||||
}
|
||||
|
||||
&__delete {
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
min-width: 30px !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 6px !important;
|
||||
border-color: $gray-200 !important;
|
||||
color: $gray-500 !important;
|
||||
|
||||
&:hover { color: $red !important; border-color: $red !important; background: $red-light !important; }
|
||||
|
||||
i { font-size: 12px; }
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
}
|
||||
|
||||
// Details grid
|
||||
.idt-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0;
|
||||
border: 1px solid $gray-100;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 600px) { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.idt-field {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid $gray-100;
|
||||
border-right: 1px solid $gray-100;
|
||||
|
||||
&:nth-child(3n) { border-right: none; }
|
||||
&:hover { background: $gray-50; }
|
||||
|
||||
&__label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: $gray-500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $gray-900;
|
||||
|
||||
&--mono {
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.document-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
// Documents section
|
||||
.idt-docs {
|
||||
margin-top: 12px;
|
||||
|
||||
.document-card {
|
||||
width: 200px;
|
||||
background: $white;
|
||||
border: 1px solid $silver;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba($dark-grey, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
&__title {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: $gray-500;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba($dark-grey, 0.14);
|
||||
i { color: $primary; margin-right: 4px; }
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: relative;
|
||||
background: $clouds;
|
||||
min-height: 7.5rem;
|
||||
.idt-doc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: default;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: $gray-50;
|
||||
border: 1px solid $gray-100;
|
||||
border-radius: 6px;
|
||||
transition: all 0.12s;
|
||||
min-width: 180px;
|
||||
|
||||
&.clickable {
|
||||
&--clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: $primary;
|
||||
background: $primary-light;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
&__thumb {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
position: absolute;
|
||||
bottom: 0.5rem;
|
||||
right: 0.5rem;
|
||||
background: rgba($white, 0.9);
|
||||
border-radius: 50%;
|
||||
padding: 0.4rem;
|
||||
color: $peter-river;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
.thumb:hover &,
|
||||
.thumb:focus & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: $light-grey;
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
fa-icon {
|
||||
font-size: 1.125rem;
|
||||
border: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
&__placeholder {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $gray-200;
|
||||
border-radius: 4px;
|
||||
color: $gray-500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $gray-900;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
&__file {
|
||||
font-size: 10px;
|
||||
color: $gray-500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
font-size: 12px;
|
||||
color: $primary;
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
&:hover .idt-doc__preview { opacity: 1; }
|
||||
}
|
||||
|
||||
.preview-btn {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
background: rgba($black, 0.65);
|
||||
color: $white;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.625rem 0.75rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
color: $dark-grey;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.75rem;
|
||||
color: $asbestos;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: $concrete;
|
||||
}
|
||||
|
||||
.document-lightbox-host {
|
||||
display: none;
|
||||
}
|
||||
.document-lightbox-host { display: none; }
|
||||
|
||||
@@ -97,8 +97,7 @@ export class IdentitiesTabComponent implements OnDestroy {
|
||||
'actions'
|
||||
];
|
||||
|
||||
/** Identifiers Table */
|
||||
@ViewChild('identifiersTable', { static: true }) identifiersTable: MatTable<Element>;
|
||||
/** (table removed — card layout now) */
|
||||
/** LightGallery host */
|
||||
@ViewChild('identityLightbox', { static: true }) identityLightbox: ElementRef<HTMLElement>;
|
||||
|
||||
@@ -215,20 +214,20 @@ export class IdentitiesTabComponent implements OnDestroy {
|
||||
};
|
||||
newIdentity.documents.push(newDoc);
|
||||
this.clientIdentities.push(newIdentity);
|
||||
this.identifiersTable.renderRows();
|
||||
// Array mutation triggers @for re-render
|
||||
this.setThumbnail(newDoc);
|
||||
},
|
||||
error: (err: any) => {
|
||||
console.error('Failed to upload document', err);
|
||||
// Still add the identifier even if document upload fails
|
||||
this.clientIdentities.push(newIdentity);
|
||||
this.identifiersTable.renderRows();
|
||||
// Array mutation triggers @for re-render
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// No file, just add the identifier
|
||||
this.clientIdentities.push(newIdentity);
|
||||
this.identifiersTable.renderRows();
|
||||
// Array mutation triggers @for re-render
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
@@ -253,7 +252,7 @@ export class IdentitiesTabComponent implements OnDestroy {
|
||||
if (response.delete) {
|
||||
this.clientService.deleteClientIdentifier(clientId, identifierId).subscribe((res) => {
|
||||
this.clientIdentities.splice(index, 1);
|
||||
this.identifiersTable.renderRows();
|
||||
// Array mutation triggers @for re-render
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -35,287 +35,226 @@
|
||||
|
||||
<!-- KYC Summary (read-only when not in edit mode) -->
|
||||
@if (!isEditMode && kycData) {
|
||||
<div class="kyc-summary-grid">
|
||||
<!-- PAN Card -->
|
||||
<mat-card class="kyc-card">
|
||||
<mat-card-content>
|
||||
<div class="kyc-card-header">
|
||||
<div class="kyc-card-label">
|
||||
<mat-icon class="kyc-card-icon">credit_card</mat-icon>
|
||||
<span>{{ 'labels.inputs.PAN Number' | translate }}</span>
|
||||
<span class="kyc-tag">KYC</span>
|
||||
</div>
|
||||
@if (kycData.panVerified) {
|
||||
<span class="status-chip verified">
|
||||
<mat-icon class="status-chip-icon">check_circle</mat-icon>
|
||||
{{ 'labels.inputs.Verified' | translate }}
|
||||
</span>
|
||||
} @else {
|
||||
<span class="status-chip not-verified">
|
||||
<mat-icon class="status-chip-icon">radio_button_unchecked</mat-icon>
|
||||
{{ 'labels.inputs.Not Verified' | translate }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<p class="kyc-value">{{ kycData.panNumber || '—' }}</p>
|
||||
@if (kycData.panVerifiedOn) {
|
||||
<p class="kyc-meta">
|
||||
{{ 'labels.inputs.Last Verified On' | translate }}: {{ formatDate(kycData.panVerifiedOn) }}
|
||||
</p>
|
||||
<!-- Verification Table -->
|
||||
<div class="kv-table">
|
||||
<div class="kv-table__header">
|
||||
<span class="kv-table__col kv-table__col--doc">Document</span>
|
||||
<span class="kv-table__col kv-table__col--num">Number</span>
|
||||
<span class="kv-table__col kv-table__col--status">Status</span>
|
||||
<span class="kv-table__col kv-table__col--date">Verified On</span>
|
||||
<span class="kv-table__col kv-table__col--action">Action</span>
|
||||
</div>
|
||||
|
||||
<!-- PAN Row -->
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">credit_card</mat-icon> PAN
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.panNumber || '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.panVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
@if (verificationError.pan) {
|
||||
<div class="kyc-error-msg">
|
||||
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
|
||||
<span>{{ verificationError.pan }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) {
|
||||
<p class="kyc-meta verification-detail-text">{{ verificationDetails.pan.name }}</p>
|
||||
}
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
class="re-verify-btn"
|
||||
type="button"
|
||||
[disabled]="verifying.pan || !kycData.panNumber"
|
||||
(click)="verifyPAN()"
|
||||
>
|
||||
@if (verifying.pan) {
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
{{ 'labels.inputs.Re-Verify' | translate }}
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">{{ kycData.panVerifiedOn ? formatDate(kycData.panVerifiedOn) : '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
<button mat-stroked-button class="kv-action-btn" type="button"
|
||||
[disabled]="verifying.pan || !kycData.panNumber" (click)="verifyPAN()">
|
||||
@if (verifying.pan) { <mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> }
|
||||
@else { Re-Verify }
|
||||
</button>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Aadhaar Card -->
|
||||
<mat-card class="kyc-card">
|
||||
<mat-card-content>
|
||||
<div class="kyc-card-header">
|
||||
<div class="kyc-card-label">
|
||||
<mat-icon class="kyc-card-icon">fingerprint</mat-icon>
|
||||
<span>{{ 'labels.inputs.Aadhaar Number' | translate }}</span>
|
||||
<span class="kyc-tag">KYC</span>
|
||||
</div>
|
||||
@if (kycData.aadhaarVerified) {
|
||||
<span class="status-chip verified">
|
||||
<mat-icon class="status-chip-icon">check_circle</mat-icon>
|
||||
{{ 'labels.inputs.Verified' | translate }}
|
||||
</span>
|
||||
} @else {
|
||||
<span class="status-chip not-verified">
|
||||
<mat-icon class="status-chip-icon">radio_button_unchecked</mat-icon>
|
||||
{{ 'labels.inputs.Not Verified' | translate }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<p class="kyc-value">{{ kycData.aadhaarNumberMasked || '—' }}</p>
|
||||
@if (kycData.aadhaarVerifiedOn) {
|
||||
<p class="kyc-meta">
|
||||
{{ 'labels.inputs.Last Verified On' | translate }}: {{ formatDate(kycData.aadhaarVerifiedOn) }}
|
||||
</p>
|
||||
<!-- Aadhaar Row -->
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">fingerprint</mat-icon> Aadhaar
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.aadhaarNumberMasked || '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.aadhaarVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
@if (verificationError.aadhaar) {
|
||||
<div class="kyc-error-msg">
|
||||
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
|
||||
<span>{{ verificationError.aadhaar }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (verificationStatus.aadhaar === 'VERIFIED' && verificationDetails.aadhaar?.name) {
|
||||
<p class="kyc-meta verification-detail-text">{{ verificationDetails.aadhaar.name }}</p>
|
||||
}
|
||||
|
||||
<!-- Step 1: Re-Verify button -->
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">{{ kycData.aadhaarVerifiedOn ? formatDate(kycData.aadhaarVerifiedOn) : '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
class="re-verify-btn"
|
||||
type="button"
|
||||
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked"
|
||||
(click)="startAadhaarReVerify()"
|
||||
>
|
||||
{{ 'labels.inputs.Re-Verify' | translate }}
|
||||
<button mat-stroked-button class="kv-action-btn" type="button"
|
||||
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked" (click)="startAadhaarReVerify()">
|
||||
Re-Verify
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Enter full Aadhaar + Send OTP -->
|
||||
@if (showAadhaarReVerify && !aadhaarOtpSent) {
|
||||
<div class="otp-section">
|
||||
<div class="otp-inline">
|
||||
<mat-form-field appearance="outline" class="otp-field">
|
||||
<mat-label>{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="aadhaarFullNumber"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
maxlength="12"
|
||||
type="password"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="accent"
|
||||
class="otp-verify-btn"
|
||||
type="button"
|
||||
[disabled]="!aadhaarFullNumber || aadhaarFullNumber.length !== 12 || verifying.aadhaar"
|
||||
(click)="sendAadhaarOtp()"
|
||||
>
|
||||
@if (verifying.aadhaar) {
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
Send OTP
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 3: Enter OTP + Verify -->
|
||||
@if (aadhaarOtpSent) {
|
||||
<div class="otp-section">
|
||||
<p class="otp-info">
|
||||
<mat-icon class="otp-info-icon">sms</mat-icon>
|
||||
OTP sent to Aadhaar-linked mobile
|
||||
</p>
|
||||
<div class="otp-inline">
|
||||
<mat-form-field appearance="outline" class="otp-field">
|
||||
<mat-label>Enter OTP</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="aadhaarOtp"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="accent"
|
||||
class="otp-verify-btn"
|
||||
type="button"
|
||||
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
|
||||
(click)="verifyAadhaarOtp()"
|
||||
>
|
||||
@if (aadhaarOtpVerifying) {
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
Verify OTP
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="otp-resend-btn"
|
||||
[disabled]="verifying.aadhaar"
|
||||
(click)="sendAadhaarOtp()"
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- GSTIN Card -->
|
||||
<mat-card class="kyc-card">
|
||||
<mat-card-content>
|
||||
<div class="kyc-card-header">
|
||||
<div class="kyc-card-label">
|
||||
<mat-icon class="kyc-card-icon">business</mat-icon>
|
||||
<span>{{ 'labels.inputs.GSTIN Number' | translate }}</span>
|
||||
<span class="kyc-tag">KYC</span>
|
||||
</div>
|
||||
<!-- GSTIN Row (Non-Individual only) -->
|
||||
@if (isNonIndividual) {
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">business</mat-icon> GSTIN
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.gstinNumber || '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.gstinVerified) {
|
||||
<span class="status-chip verified">
|
||||
<mat-icon class="status-chip-icon">check_circle</mat-icon>
|
||||
{{ 'labels.inputs.Verified' | translate }}
|
||||
</span>
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else {
|
||||
<span class="status-chip not-verified">
|
||||
<mat-icon class="status-chip-icon">radio_button_unchecked</mat-icon>
|
||||
{{ 'labels.inputs.Not Verified' | translate }}
|
||||
</span>
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</div>
|
||||
<p class="kyc-value">{{ kycData.gstinNumber || '—' }}</p>
|
||||
@if (kycData.gstinVerifiedOn) {
|
||||
<p class="kyc-meta">
|
||||
{{ 'labels.inputs.Last Verified On' | translate }}: {{ formatDate(kycData.gstinVerifiedOn) }}
|
||||
</p>
|
||||
}
|
||||
@if (verificationError.gstin) {
|
||||
<div class="kyc-error-msg">
|
||||
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
|
||||
<span>{{ verificationError.gstin }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (verificationStatus.gstin === 'VERIFIED' && verificationDetails.gstin?.businessName) {
|
||||
<p class="kyc-meta verification-detail-text">{{ verificationDetails.gstin.businessName }}</p>
|
||||
}
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
class="re-verify-btn"
|
||||
type="button"
|
||||
[disabled]="verifying.gstin || !kycData.gstinNumber"
|
||||
(click)="verifyGSTIN()"
|
||||
>
|
||||
@if (verifying.gstin) {
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">{{ kycData.gstinVerifiedOn ? formatDate(kycData.gstinVerifiedOn) : '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
<button mat-stroked-button class="kv-action-btn" type="button"
|
||||
[disabled]="verifying.gstin || !kycData.gstinNumber" (click)="verifyGSTIN()">
|
||||
@if (verifying.gstin) { <mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> }
|
||||
@else { Re-Verify }
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Enterprise PAN Row (Non-Individual) -->
|
||||
@if (isNonIndividual && kycData?.enterprisePan) {
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">credit_card</mat-icon> Enterprise PAN
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.enterprisePan }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.enterprisePanVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else {
|
||||
{{ 'labels.inputs.Re-Verify' | translate }}
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</button>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">{{ kycData.enterprisePanVerifiedOn ? formatDate(kycData.enterprisePanVerifiedOn) : '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--action"></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- CIN Row (Non-Individual / Company) -->
|
||||
@if (isNonIndividual && kycData?.cinNumber) {
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">verified_user</mat-icon> CIN
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.cinNumber }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.cinVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">—</span>
|
||||
<span class="kv-table__col kv-table__col--action"></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Incorporation Date Row (Non-Individual) -->
|
||||
@if (isNonIndividual && kycData?.incorporationDate) {
|
||||
<div class="kv-table__row kv-table__row--link">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">event</mat-icon> Incorporation Date
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num">{{ formatDate(kycData.incorporationDate) }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">—</span>
|
||||
<span class="kv-table__col kv-table__col--date">—</span>
|
||||
<span class="kv-table__col kv-table__col--action"></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- PAN-Aadhaar Link Row -->
|
||||
<div class="kv-table__row kv-table__row--link">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">link</mat-icon> PAN–Aadhaar Link
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num">Sec 139AA</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Linked</span>
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
|
||||
<span class="kv-status kv-status--fail"><mat-icon>cancel</mat-icon> Not Linked</span>
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
|
||||
<span class="kv-status kv-status--info"><mat-icon>info</mat-icon> Exempt</span>
|
||||
} @else {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">—</span>
|
||||
<span class="kv-table__col kv-table__col--action"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Info -->
|
||||
<mat-card class="kyc-info-card">
|
||||
<mat-card-content>
|
||||
<div class="kyc-info-grid">
|
||||
<div class="kyc-info-item">
|
||||
<span class="kyc-info-label">{{ 'labels.inputs.KYC Type' | translate }}</span>
|
||||
<span class="kyc-info-value">{{
|
||||
kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="kyc-info-item">
|
||||
<span class="kyc-info-label">{{ 'labels.inputs.Risk Category' | translate }}</span>
|
||||
<span class="kyc-info-value">{{
|
||||
kycData.riskCategory === 1
|
||||
? 'Low'
|
||||
: kycData.riskCategory === 2
|
||||
? 'Medium'
|
||||
: kycData.riskCategory === 3
|
||||
? 'High'
|
||||
: '—'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="kyc-info-item">
|
||||
<span class="kyc-info-label">{{ 'labels.inputs.Initiated On' | translate }}</span>
|
||||
<span class="kyc-info-value">{{ kycData.initiatedOn || '—' }}</span>
|
||||
</div>
|
||||
<div class="kyc-info-item">
|
||||
<span class="kyc-info-label">{{ 'labels.inputs.Last Verified On' | translate }}</span>
|
||||
<span class="kyc-info-value">{{ formatDate(kycData.verifiedOn) }}</span>
|
||||
</div>
|
||||
<div class="kyc-info-item">
|
||||
<span class="kyc-info-label">{{ 'labels.inputs.Expiry Date' | translate }}</span>
|
||||
<span class="kyc-info-value">{{ kycData.expiryDate || '—' }}</span>
|
||||
</div>
|
||||
@if (kycData.remarks) {
|
||||
<div class="kyc-info-item full-span">
|
||||
<span class="kyc-info-label">{{ 'labels.inputs.Remarks' | translate }}</span>
|
||||
<span class="kyc-info-value">{{ kycData.remarks }}</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Aadhaar OTP inline (shown below table when re-verifying) -->
|
||||
@if (showAadhaarReVerify && !aadhaarOtpSent) {
|
||||
<div class="kv-otp-bar">
|
||||
<mat-form-field appearance="outline" class="kv-otp-input">
|
||||
<mat-label>Enter 12-digit Aadhaar</mat-label>
|
||||
<input matInput [(ngModel)]="aadhaarFullNumber" [ngModelOptions]="{standalone: true}" maxlength="12" type="password" />
|
||||
</mat-form-field>
|
||||
<button mat-raised-button color="primary" type="button" class="kv-otp-btn"
|
||||
[disabled]="!aadhaarFullNumber || aadhaarFullNumber.length !== 12 || verifying.aadhaar" (click)="sendAadhaarOtp()">
|
||||
@if (verifying.aadhaar) { <mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> }
|
||||
@else { Send OTP }
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (aadhaarOtpSent) {
|
||||
<div class="kv-otp-bar">
|
||||
<span class="kv-otp-label"><mat-icon>sms</mat-icon> OTP sent</span>
|
||||
<mat-form-field appearance="outline" class="kv-otp-input">
|
||||
<mat-label>Enter OTP</mat-label>
|
||||
<input matInput [(ngModel)]="aadhaarOtp" [ngModelOptions]="{standalone: true}" maxlength="6" autocomplete="one-time-code" />
|
||||
</mat-form-field>
|
||||
<button mat-raised-button color="primary" type="button" class="kv-otp-btn"
|
||||
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying" (click)="verifyAadhaarOtp()">
|
||||
@if (aadhaarOtpVerifying) { <mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> }
|
||||
@else { Verify }
|
||||
</button>
|
||||
<button mat-stroked-button type="button" class="kv-otp-btn" [disabled]="verifying.aadhaar" (click)="sendAadhaarOtp()">Resend</button>
|
||||
</div>
|
||||
}
|
||||
@if (verificationError.aadhaar) {
|
||||
<div class="kyc-error-msg"><mat-icon class="kyc-error-icon">error_outline</mat-icon> {{ verificationError.aadhaar }}</div>
|
||||
}
|
||||
@if (verificationError.pan) {
|
||||
<div class="kyc-error-msg"><mat-icon class="kyc-error-icon">error_outline</mat-icon> {{ verificationError.pan }}</div>
|
||||
}
|
||||
|
||||
<!-- KYC Meta Grid -->
|
||||
<div class="kv-meta-grid">
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">KYC Type</span>
|
||||
<span class="kv-meta-value">{{ kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—' }}</span>
|
||||
</div>
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">Risk Category</span>
|
||||
<span class="kv-meta-value">{{ kycData.riskCategory === 1 ? 'Low' : kycData.riskCategory === 2 ? 'Medium' : kycData.riskCategory === 3 ? 'High' : '—' }}</span>
|
||||
</div>
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">Initiated On</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.initiatedOn) }}</span>
|
||||
</div>
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">Last Verified</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.verifiedOn) }}</span>
|
||||
</div>
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">Expiry Date</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.expiryDate) }}</span>
|
||||
</div>
|
||||
@if (kycData.remarks) {
|
||||
<div class="kv-meta-item kv-meta-item--full">
|
||||
<span class="kv-meta-label">Remarks</span>
|
||||
<span class="kv-meta-value">{{ kycData.remarks }}</span>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- No KYC Data State -->
|
||||
@@ -497,67 +436,69 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- GSTIN -->
|
||||
<div class="kyc-edit-field">
|
||||
<div class="kyc-field-label-row">
|
||||
<span class="kyc-field-title">{{ 'labels.inputs.GSTIN Number' | translate }}</span>
|
||||
<span class="kyc-tag">KYC</span>
|
||||
</div>
|
||||
<div class="kyc-input-row">
|
||||
<mat-form-field appearance="outline" class="kyc-input">
|
||||
<mat-label>{{ 'labels.inputs.GSTIN Number' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="gstinNumber"
|
||||
maxlength="15"
|
||||
style="text-transform: uppercase"
|
||||
(input)="
|
||||
kycForm
|
||||
.get('gstinNumber')
|
||||
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||
"
|
||||
/>
|
||||
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
|
||||
<mat-error>{{ 'labels.inputs.Enter 15-character GSTIN' | translate }}</mat-error>
|
||||
}
|
||||
@if (verificationStatus.gstin === 'VERIFIED') {
|
||||
<mat-icon matSuffix class="status-icon verified">check_circle</mat-icon>
|
||||
}
|
||||
@if (verificationStatus.gstin === 'FAILED') {
|
||||
<mat-icon matSuffix class="status-icon failed">cancel</mat-icon>
|
||||
}
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="accent"
|
||||
type="button"
|
||||
class="verify-btn"
|
||||
[disabled]="
|
||||
!kycId ||
|
||||
!kycForm.get('gstinNumber')?.value ||
|
||||
kycForm.get('gstinNumber')?.invalid ||
|
||||
verifying.gstin
|
||||
"
|
||||
(click)="verifyGSTIN()"
|
||||
[matTooltip]="!kycId ? 'Save KYC details first' : ''"
|
||||
>
|
||||
@if (verifying.gstin) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
{{ 'labels.inputs.Verify' | translate }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
@if (verificationError.gstin) {
|
||||
<div class="kyc-error-msg">
|
||||
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
|
||||
<span>{{ verificationError.gstin }}</span>
|
||||
<!-- GSTIN (Non-Individual only) -->
|
||||
@if (isNonIndividual) {
|
||||
<div class="kyc-edit-field">
|
||||
<div class="kyc-field-label-row">
|
||||
<span class="kyc-field-title">{{ 'labels.inputs.GSTIN Number' | translate }}</span>
|
||||
<span class="kyc-tag">KYC</span>
|
||||
</div>
|
||||
}
|
||||
@if (verificationStatus.gstin === 'VERIFIED' && verificationDetails.gstin?.businessName) {
|
||||
<span class="verification-detail-text">{{ verificationDetails.gstin.businessName }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="kyc-input-row">
|
||||
<mat-form-field appearance="outline" class="kyc-input">
|
||||
<mat-label>{{ 'labels.inputs.GSTIN Number' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="gstinNumber"
|
||||
maxlength="15"
|
||||
style="text-transform: uppercase"
|
||||
(input)="
|
||||
kycForm
|
||||
.get('gstinNumber')
|
||||
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||
"
|
||||
/>
|
||||
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
|
||||
<mat-error>{{ 'labels.inputs.Enter 15-character GSTIN' | translate }}</mat-error>
|
||||
}
|
||||
@if (verificationStatus.gstin === 'VERIFIED') {
|
||||
<mat-icon matSuffix class="status-icon verified">check_circle</mat-icon>
|
||||
}
|
||||
@if (verificationStatus.gstin === 'FAILED') {
|
||||
<mat-icon matSuffix class="status-icon failed">cancel</mat-icon>
|
||||
}
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="accent"
|
||||
type="button"
|
||||
class="verify-btn"
|
||||
[disabled]="
|
||||
!kycId ||
|
||||
!kycForm.get('gstinNumber')?.value ||
|
||||
kycForm.get('gstinNumber')?.invalid ||
|
||||
verifying.gstin
|
||||
"
|
||||
(click)="verifyGSTIN()"
|
||||
[matTooltip]="!kycId ? 'Save KYC details first' : ''"
|
||||
>
|
||||
@if (verifying.gstin) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
{{ 'labels.inputs.Verify' | translate }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
@if (verificationError.gstin) {
|
||||
<div class="kyc-error-msg">
|
||||
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
|
||||
<span>{{ verificationError.gstin }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (verificationStatus.gstin === 'VERIFIED' && verificationDetails.gstin?.businessName) {
|
||||
<span class="verification-detail-text">{{ verificationDetails.gstin.businessName }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- KYC Type -->
|
||||
@if (!kycId) {
|
||||
|
||||
@@ -369,3 +369,263 @@
|
||||
color: #c62828;
|
||||
}
|
||||
}
|
||||
|
||||
/* PAN-Aadhaar Link Card */
|
||||
.kyc-link-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
&.linked {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
&.not-linked {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&.exempt {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-aadhaar-detail {
|
||||
margin: 12px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pan-aadhaar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-aadhaar-label {
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.pan-aadhaar-value {
|
||||
color: #0f172a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: #059669 !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #dc2626 !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
/* ═══ KYC Verification Table ═══ */
|
||||
.kv-table {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.kv-table__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.kv-table__header .kv-table__col {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #64748b;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.kv-table__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding: 0;
|
||||
transition: background 0.1s;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
&:hover { background: #f8fafc; }
|
||||
|
||||
&--link { background: #fafbfc; }
|
||||
}
|
||||
|
||||
.kv-table__col {
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
|
||||
&--doc {
|
||||
flex: 0 0 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
&--num { flex: 1; }
|
||||
&--status { flex: 0 0 150px; }
|
||||
&--date { flex: 0 0 150px; color: #64748b; font-size: 12px; }
|
||||
&--action { flex: 0 0 110px; text-align: right; }
|
||||
}
|
||||
|
||||
.kv-doc-icon {
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
color: #1074b9;
|
||||
}
|
||||
|
||||
.kv-mono {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kv-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
}
|
||||
|
||||
&--ok { background: #ecfdf5; color: #059669; }
|
||||
&--pending { background: #f8fafc; color: #94a3b8; }
|
||||
&--fail { background: #fef2f2; color: #dc2626; }
|
||||
&--info { background: #eff6ff; color: #2563eb; }
|
||||
}
|
||||
|
||||
.kv-action-btn {
|
||||
font-size: 11px !important;
|
||||
height: 30px !important;
|
||||
line-height: 30px !important;
|
||||
padding: 0 12px !important;
|
||||
border-radius: 6px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #1074b9 !important;
|
||||
border-color: #1074b9 !important;
|
||||
}
|
||||
|
||||
/* OTP Bar (below table) */
|
||||
.kv-otp-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.kv-otp-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #1074b9;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
|
||||
mat-icon { font-size: 16px !important; width: 16px !important; height: 16px !important; }
|
||||
}
|
||||
|
||||
.kv-otp-input {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
|
||||
::ng-deep .mat-mdc-form-field-infix { padding: 6px 0 !important; min-height: 36px !important; }
|
||||
}
|
||||
|
||||
.kv-otp-btn {
|
||||
height: 36px !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 0 16px !important;
|
||||
}
|
||||
|
||||
/* Meta Grid */
|
||||
.kv-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.kv-meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 12px 14px;
|
||||
border-right: 1px solid #f1f5f9;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
&:nth-child(5n) { border-right: none; }
|
||||
&--full { grid-column: 1 / -1; border-right: none; }
|
||||
}
|
||||
|
||||
.kv-meta-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.kv-meta-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.kv-table__col {
|
||||
&--doc { flex: 0 0 120px; }
|
||||
&--date { display: none; }
|
||||
&--action { flex: 0 0 90px; }
|
||||
}
|
||||
.kv-meta-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
.kv-meta-item:nth-child(5n) { border-right: 1px solid #f1f5f9; }
|
||||
.kv-meta-item:nth-child(3n) { border-right: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.kv-table__row { flex-wrap: wrap; }
|
||||
.kv-table__col { &--status, &--action { flex: 0 0 50%; } }
|
||||
.kv-meta-grid { grid-template-columns: 1fr 1fr; }
|
||||
.kv-otp-bar { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
kycId: string | null = null;
|
||||
kycData: any = null;
|
||||
clientDateOfBirth: string | null = null;
|
||||
isNonIndividual = false;
|
||||
loading = true;
|
||||
saving = false;
|
||||
isEditMode = false;
|
||||
@@ -150,7 +151,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/** Load client's date of birth from parent route resolved data */
|
||||
/** Load client's date of birth and legal form from parent route resolved data */
|
||||
loadClientDateOfBirth(): void {
|
||||
this.route.parent.data.subscribe((data: any) => {
|
||||
if (data?.clientViewData?.dateOfBirth) {
|
||||
@@ -161,6 +162,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
this.clientDateOfBirth = dob;
|
||||
}
|
||||
}
|
||||
if (data?.clientViewData?.legalForm?.id === 2) {
|
||||
this.isNonIndividual = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -431,6 +435,15 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
return part.toLowerCase();
|
||||
}
|
||||
|
||||
getPanAadhaarStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'Y': return 'Linked (Seeded)';
|
||||
case 'N': return 'Not Linked';
|
||||
case 'E': return 'Exempt';
|
||||
default: return 'Pending Verification';
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
<div class="nom-container mat-typography">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="nom-page-header">
|
||||
<div class="nom-page-header__left">
|
||||
<mat-icon class="nom-page-header__icon">shield</mat-icon>
|
||||
<div>
|
||||
<h3 class="nom-page-header__title">Account Nominees</h3>
|
||||
<p class="nom-page-header__sub">Manage nominees for each account. Nominees are account-specific.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="nom-loading">
|
||||
<mat-progress-spinner diameter="24" mode="indeterminate"></mat-progress-spinner>
|
||||
<span>Loading accounts...</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading && !hasAccounts) {
|
||||
<div class="nom-no-accounts">
|
||||
<mat-icon class="nom-no-accounts__icon">account_balance_wallet</mat-icon>
|
||||
<p>No active accounts found.</p>
|
||||
<p class="nom-no-accounts__sub">Nominees can be added once an account is opened.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading && hasAccounts) {
|
||||
<!-- Compact: Type chips + Account dropdown + Add button in one row -->
|
||||
<div class="nom-toolbar">
|
||||
<div class="nom-type-chips">
|
||||
@for (group of accountGroups; track group.type) {
|
||||
@if (group.accounts.length > 0) {
|
||||
<button type="button" class="nom-type-chip"
|
||||
[class.active]="selectedType === group.type"
|
||||
(click)="selectedType = group.type; onTypeChange()">
|
||||
<mat-icon>{{ group.icon }}</mat-icon>
|
||||
{{ group.label }}
|
||||
<span class="nom-type-chip__count">{{ group.accounts.length }}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (selectedGroup && selectedGroup.accounts.length > 0) {
|
||||
<div class="nom-toolbar__right">
|
||||
<mat-form-field appearance="outline" class="nom-account-select">
|
||||
<mat-label>Account</mat-label>
|
||||
<mat-select [(ngModel)]="selectedAccountId" (ngModelChange)="onAccountChange()">
|
||||
@for (acc of selectedGroup.accounts; track acc.id) {
|
||||
<mat-option [value]="acc.id">{{ acc.productName }} — {{ acc.accountNo }}@if (acc.accountBalance != null) { ({{ acc.accountBalance | number:'1.2-2' }}) }</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (selectedAccountId && !showAddForm) {
|
||||
<button mat-flat-button color="primary" class="nom-add-btn"
|
||||
[disabled]="totalShare >= 100" (click)="showAddForm = true">
|
||||
<mat-icon>add</mat-icon> Add Nominee
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Nominees for selected account -->
|
||||
@if (selectedAccountId) {
|
||||
@if (nomineesLoading) {
|
||||
<div class="nom-loading nom-loading--sm">
|
||||
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Share bar + Nominee list -->
|
||||
@if (nominees.length > 0) {
|
||||
<div class="nom-share-bar">
|
||||
<span class="nom-share-bar__label">Share</span>
|
||||
<div class="nom-share-bar__track">
|
||||
<div class="nom-share-bar__fill"
|
||||
[style.width.%]="totalShare > 100 ? 100 : totalShare"
|
||||
[class.over]="totalShare > 100"
|
||||
[class.complete]="totalShare === 100"></div>
|
||||
</div>
|
||||
<span class="nom-share-bar__value" [class.over]="totalShare > 100">{{ totalShare }}%</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (nominee of nominees; track nominee.id; let i = $index) {
|
||||
<div class="nom-card">
|
||||
<div class="nom-card__main">
|
||||
<div class="nom-card__avatar">
|
||||
@if (nominee.profileImageUrl) {
|
||||
<img [src]="nominee.profileImageUrl" alt="" class="nom-card__photo zoomable"
|
||||
(click)="zoomImage(nominee.profileImageUrl, 'Nominee Photo')" />
|
||||
} @else {
|
||||
<mat-icon>person</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<div class="nom-card__info">
|
||||
<span class="nom-card__name">{{ nominee.nomineeDisplayName || nominee.firstName || '—' }}</span>
|
||||
<span class="nom-card__meta">
|
||||
{{ getRelationship(nominee) }}
|
||||
@if (nominee.nomineeDateOfBirth || nominee.dateOfBirth) { · DOB: {{ (nominee.nomineeDateOfBirth || nominee.dateOfBirth) | dateFormat }} }
|
||||
@if (nominee.nomineeMobileNo || nominee.mobileNo) { · {{ nominee.nomineeMobileNo || nominee.mobileNo }} }
|
||||
</span>
|
||||
</div>
|
||||
@if (nominee.signatureUrl) {
|
||||
<div class="nom-card__sig">
|
||||
<img [src]="nominee.signatureUrl" alt="Signature" class="zoomable"
|
||||
(click)="zoomImage(nominee.signatureUrl, 'Nominee Signature')" />
|
||||
</div>
|
||||
}
|
||||
@if (nominee.nomineeIdentifier) {
|
||||
<span class="nom-card__id">{{ nominee.nomineeIdentifier }}</span>
|
||||
}
|
||||
<div class="nom-card__share">
|
||||
<span class="nom-card__share-value">{{ nominee.percentageShare || 0 }}%</span>
|
||||
</div>
|
||||
<div class="nom-card__actions">
|
||||
<button mat-icon-button type="button" matTooltip="View" (click)="viewNominee(nominee)"><mat-icon>visibility</mat-icon></button>
|
||||
<button mat-icon-button type="button" matTooltip="Edit" (click)="editNominee(nominee, i)"><mat-icon>edit</mat-icon></button>
|
||||
<button mat-icon-button type="button" matTooltip="Delete" class="nom-card__delete" (click)="deleteNominee(nominee, i)"><mat-icon>delete</mat-icon></button>
|
||||
</div>
|
||||
</div>
|
||||
@if (nominee.nomineeIsMinor && (nominee.guardianDisplayName || nominee.guardianName)) {
|
||||
<div class="nom-card__guardian">
|
||||
<mat-icon>shield</mat-icon>
|
||||
Guardian: <strong>{{ nominee.guardianDisplayName || nominee.guardianName }}</strong>
|
||||
@if (nominee.guardianRelationshipName) { ({{ nominee.guardianRelationshipName }}) }
|
||||
</div>
|
||||
}
|
||||
<!-- Inline Edit -->
|
||||
@if (editingIndex === i) {
|
||||
<div class="nom-card__edit">
|
||||
<div class="nom-edit-row">
|
||||
<mat-form-field appearance="outline" class="nom-edit-field">
|
||||
<mat-label>Type</mat-label>
|
||||
<mat-select [(ngModel)]="editNomType">
|
||||
@for (opt of nominationTypeOptions; track opt.value) { <mat-option [value]="opt.value">{{ opt.label }}</mat-option> }
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="nom-edit-field">
|
||||
<mat-label>Relationship</mat-label>
|
||||
<mat-select [(ngModel)]="editRelId">
|
||||
@for (rel of relationshipOptions; track rel) { <mat-option [value]="rel.id">{{ rel.name }}</mat-option> }
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="nom-edit-field nom-edit-field--sm">
|
||||
<mat-label>Share %</mat-label>
|
||||
<input matInput type="number" [(ngModel)]="editShare" min="1" [max]="maxShareForEdit(i)" />
|
||||
@if (editShare && editShare > maxShareForEdit(i)) { <mat-error>Max {{ maxShareForEdit(i) }}%</mat-error> }
|
||||
</mat-form-field>
|
||||
<button mat-flat-button color="primary" type="button" class="nom-edit-save"
|
||||
[disabled]="!editRelId || !editShare || editShare > maxShareForEdit(i)"
|
||||
(click)="saveEdit(nominee, i)"><mat-icon>check</mat-icon></button>
|
||||
<button mat-stroked-button type="button" class="nom-edit-cancel" (click)="cancelEdit()"><mat-icon>close</mat-icon></button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Empty -->
|
||||
@if (!nomineesLoading && nominees.length === 0 && !showAddForm) {
|
||||
<div class="nom-empty">
|
||||
<mat-icon>person_add</mat-icon>
|
||||
<span>No nominees for this account.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ═══ ADD NOMINEE FORM ═══ -->
|
||||
@if (showAddForm) {
|
||||
<div class="nom-add-form">
|
||||
<div class="nom-add-form__title"><fa-icon icon="search"></fa-icon> Search & Link Nominee</div>
|
||||
|
||||
@if (!selectedClient) {
|
||||
<div class="nom-search-wrapper">
|
||||
<mat-form-field appearance="outline" class="nom-search-field">
|
||||
<mat-label>Search by Name, Client ID or Mobile</mat-label>
|
||||
<span matPrefix class="nom-search-prefix"><mat-icon>search</mat-icon></span>
|
||||
<input matInput [value]="searchText" (input)="onSearch($any($event.target).value)" (blur)="dismissSearch()" placeholder="Type at least 2 characters..." autocomplete="off" />
|
||||
</mat-form-field>
|
||||
@if (searchResults.length > 0 || searching) {
|
||||
<div class="nom-search-results">
|
||||
@if (searching) { <div class="nom-search-loading"><mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Searching...</div> }
|
||||
@for (client of searchResults; track client.id) {
|
||||
<div class="nom-search-item" (mousedown)="selectClient(client); $event.preventDefault()">
|
||||
<mat-icon class="nom-search-item__icon">person</mat-icon>
|
||||
<div class="nom-search-item__info">
|
||||
<span class="nom-search-item__name">{{ client.displayName }}</span>
|
||||
<span class="nom-search-item__meta">ID: {{ client.id }} · {{ client.officeName }}@if (client.dateOfBirth) { · DOB: {{ client.dateOfBirth | dateFormat }} }@if (client.fatherSpouseName) { · {{ client.fatherSpouseName }} }@if (client.mobileNo) { · {{ client.mobileNo }} }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (!searching && searchText.length >= 2 && searchResults.length === 0) {
|
||||
<div class="search-no-results-hint">No clients found</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedClient) {
|
||||
<!-- Linked Card -->
|
||||
<div class="nom-linked-card">
|
||||
<div class="nom-linked-card__left">
|
||||
@if (linkedProfileImage) {
|
||||
<img [src]="linkedProfileImage" class="nom-linked-card__photo zoomable" (click)="zoomImage(linkedProfileImage, 'Photo')" />
|
||||
} @else { <mat-icon class="nom-linked-card__icon">person</mat-icon> }
|
||||
</div>
|
||||
<div class="nom-linked-card__info">
|
||||
<span class="nom-linked-card__name">{{ selectedClient.displayName }}</span>
|
||||
<span class="nom-linked-card__meta">ID: {{ selectedClient.id }}@if (selectedClient.mobileNo) { · {{ selectedClient.mobileNo }} }</span>
|
||||
</div>
|
||||
@if (linkedSignatureImage) {
|
||||
<div class="nom-linked-card__sig">
|
||||
<img [src]="linkedSignatureImage" alt="Signature" class="zoomable" (click)="zoomImage(linkedSignatureImage, 'Signature')" />
|
||||
</div>
|
||||
}
|
||||
<button mat-icon-button type="button" (click)="removeSelectedClient()"><mat-icon>close</mat-icon></button>
|
||||
</div>
|
||||
|
||||
@if (fetchingDetails) {
|
||||
<div class="nom-fetching-hint"><mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> Fetching details...</div>
|
||||
}
|
||||
|
||||
@if (!fetchingDetails) {
|
||||
<!-- KYC details -->
|
||||
@if (selectedClient.dateOfBirth || selectedClient.panNumber || selectedClient.aadhaarMasked) {
|
||||
<div class="nom-readonly-row">
|
||||
@if (selectedClient.dateOfBirth) { <span class="nom-ro-item"><span class="nom-ro-label">DOB</span> {{ selectedClient.dateOfBirth | dateFormat }}</span> }
|
||||
@if (selectedClient.panNumber) { <span class="nom-ro-item"><span class="nom-ro-label">PAN</span> {{ selectedClient.panNumber }} @if (linkedKycStatus?.panVerified) { <mat-icon class="nom-ro-ok">check_circle</mat-icon> }</span> }
|
||||
@if (selectedClient.aadhaarMasked) { <span class="nom-ro-item"><span class="nom-ro-label">Aadhaar</span> {{ selectedClient.aadhaarMasked }} @if (linkedKycStatus?.aadhaarVerified) { <mat-icon class="nom-ro-ok">check_circle</mat-icon> }</span> }
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Nominee fields inline -->
|
||||
<div class="nom-fields-row">
|
||||
<mat-form-field appearance="outline" class="nom-field-item">
|
||||
<mat-label>Nomination Type</mat-label>
|
||||
<mat-select [(ngModel)]="nomineeNomType">
|
||||
@for (opt of nominationTypeOptions; track opt.value) { <mat-option [value]="opt.value">{{ opt.label }}</mat-option> }
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="nom-field-item">
|
||||
<mat-label>Relationship</mat-label>
|
||||
<mat-select [(ngModel)]="nomineeRelationshipId">
|
||||
@for (rel of relationshipOptions; track rel) { <mat-option [value]="rel.id">{{ rel.name }}</mat-option> }
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="nom-field-item nom-field-item--sm">
|
||||
<mat-label>Share %</mat-label>
|
||||
<input matInput type="number" [(ngModel)]="nomineeShare" min="1" [max]="maxAllowedShare" />
|
||||
@if (nomineeShare && nomineeShare > maxAllowedShare) { <mat-error>Max {{ maxAllowedShare }}%</mat-error> }
|
||||
</mat-form-field>
|
||||
<div class="nom-field-item nom-field-item--toggle">
|
||||
<mat-slide-toggle [(ngModel)]="nomineeIsMinor">Minor</mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (nomineeIsMinor) {
|
||||
<div class="nom-guardian-section">
|
||||
<span class="nom-guardian-label"><mat-icon>shield</mat-icon> Guardian</span>
|
||||
@if (!selectedGuardian) {
|
||||
<mat-form-field appearance="outline" class="nom-search-field">
|
||||
<input matInput [ngModel]="guardianSearchText" (ngModelChange)="onGuardianSearch($event)" placeholder="Search guardian..." autocomplete="off" />
|
||||
</mat-form-field>
|
||||
@if (guardianSearchResults.length > 0 || guardianSearching) {
|
||||
<div class="nom-search-results nom-search-results--inline">
|
||||
@if (guardianSearching) { <div class="nom-search-loading"><mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner></div> }
|
||||
@for (client of guardianSearchResults; track client.id) {
|
||||
<div class="nom-search-item" (click)="selectGuardian(client)">
|
||||
<mat-icon class="nom-search-item__icon">person</mat-icon>
|
||||
<span class="nom-search-item__name">{{ client.displayName }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<span class="nom-guardian-selected">{{ selectedGuardian.displayName }} <button mat-icon-button (click)="selectedGuardian = null"><mat-icon>close</mat-icon></button></span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="nom-remarks-field">
|
||||
<mat-label>Remarks</mat-label>
|
||||
<input matInput [(ngModel)]="nomineeRemarks" placeholder="Optional" />
|
||||
</mat-form-field>
|
||||
|
||||
<div class="nom-form-actions">
|
||||
<button mat-stroked-button type="button" (click)="resetAddForm()">Cancel</button>
|
||||
<button mat-raised-button color="primary" type="button"
|
||||
[disabled]="!nomineeRelationshipId || !nomineeShare || nomineeSaving || (nomineeShare > maxAllowedShare) || (nomineeIsMinor && !selectedGuardian)"
|
||||
(click)="saveNominee()">
|
||||
@if (nomineeSaving) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> }
|
||||
@else { <mat-icon>check</mat-icon> Save }
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
$primary: #1074b9;
|
||||
$primary-light: #e8f0fe;
|
||||
|
||||
.nom-container { padding: 16px 24px; }
|
||||
|
||||
/* Header */
|
||||
.nom-page-header {
|
||||
background: #0f172a; padding: 14px 20px; border-radius: 10px; margin-bottom: 16px;
|
||||
&__left { display: flex; align-items: center; gap: 10px; }
|
||||
&__icon { color: #60a5fa; }
|
||||
&__title { font-size: 15px; font-weight: 600; color: #fff; margin: 0; }
|
||||
&__sub { font-size: 10px; color: rgba(255,255,255,0.45); margin: 1px 0 0; }
|
||||
}
|
||||
|
||||
.nom-loading { display: flex; align-items: center; gap: 8px; padding: 24px 0; justify-content: center; font-size: 13px; color: #64748b;
|
||||
&--sm { padding: 12px 0; }
|
||||
}
|
||||
|
||||
.nom-no-accounts {
|
||||
text-align: center; padding: 36px 20px; background: #f8fafc; border-radius: 8px; border: 1px dashed #e2e8f0;
|
||||
&__icon { font-size: 40px; width: 40px; height: 40px; color: #94a3b8; margin-bottom: 8px; }
|
||||
p { color: #64748b; font-size: 13px; margin: 2px 0; }
|
||||
&__sub { font-size: 11px; color: #94a3b8; }
|
||||
}
|
||||
|
||||
/* Toolbar: chips on top, account + add below */
|
||||
.nom-toolbar {
|
||||
display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px;
|
||||
&__right { display: flex; align-items: center; gap: 10px; }
|
||||
}
|
||||
.nom-type-chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.nom-type-chip {
|
||||
display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px;
|
||||
border: 1.5px solid #e2e8f0; border-radius: 6px; background: #fff;
|
||||
font-size: 12px; font-weight: 500; color: #64748b; cursor: pointer; transition: all 0.15s;
|
||||
mat-icon { font-size: 16px; width: 16px; height: 16px; }
|
||||
&__count { font-size: 9px; font-weight: 700; background: #f1f5f9; color: #64748b; padding: 0 5px; border-radius: 6px; }
|
||||
&.active { border-color: $primary; background: $primary-light; color: $primary;
|
||||
.nom-type-chip__count { background: $primary; color: #fff; }
|
||||
}
|
||||
&:hover:not(.active) { border-color: #94a3b8; }
|
||||
}
|
||||
.nom-account-select { flex: 1; min-width: 280px; }
|
||||
.nom-add-btn { border-radius: 6px !important; font-size: 12px !important; height: 36px !important; white-space: nowrap; }
|
||||
|
||||
/* Share bar */
|
||||
.nom-share-bar {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 12px;
|
||||
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 8px;
|
||||
&__label { font-size: 11px; font-weight: 600; color: #64748b; white-space: nowrap; }
|
||||
&__track { flex: 1; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; }
|
||||
&__fill {
|
||||
height: 100%; border-radius: 3px; transition: width 0.3s;
|
||||
background: linear-gradient(90deg, #3b82f6, #10b981);
|
||||
&.over { background: #ef4444; } &.complete { background: linear-gradient(90deg, #10b981, #059669); }
|
||||
}
|
||||
&__value { font-size: 13px; font-weight: 700; color: #10b981; min-width: 35px; text-align: right; &.over { color: #ef4444; } }
|
||||
}
|
||||
|
||||
/* Nominee card — compact */
|
||||
.nom-card { border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 6px; overflow: hidden; }
|
||||
.nom-card__main { display: flex; align-items: center; gap: 10px; padding: 8px 12px; }
|
||||
.nom-card__avatar {
|
||||
width: 32px; height: 32px; border-radius: 50%; background: #dbeafe;
|
||||
display: flex; align-items: center; justify-content: center; color: #1565c0; flex-shrink: 0;
|
||||
mat-icon { font-size: 18px; }
|
||||
}
|
||||
.nom-card__photo { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||
.nom-card__sig {
|
||||
flex-shrink: 0;
|
||||
img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 3px; background: #fff; padding: 1px 3px; }
|
||||
}
|
||||
.nom-card__info { flex: 1; min-width: 0; }
|
||||
.nom-card__name { font-weight: 600; font-size: 13px; color: #1e293b; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nom-card__meta { font-size: 11px; color: #64748b; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nom-card__id {
|
||||
font-size: 10px; font-weight: 600; color: #1d4ed8; background: #eff6ff;
|
||||
padding: 2px 8px; border-radius: 4px; white-space: nowrap;
|
||||
font-family: 'Roboto Mono', monospace; letter-spacing: 0.3px; flex-shrink: 0;
|
||||
}
|
||||
.nom-card__share { text-align: center; min-width: 40px; flex-shrink: 0; }
|
||||
.nom-card__share-value { font-size: 15px; font-weight: 700; color: #1565c0; }
|
||||
.nom-card__actions { display: flex; gap: 0; flex-shrink: 0;
|
||||
button { width: 32px !important; height: 32px !important; line-height: 32px !important;
|
||||
mat-icon { font-size: 18px; }
|
||||
}
|
||||
}
|
||||
.nom-card__delete { color: #94a3b8 !important; &:hover { color: #ef4444 !important; } }
|
||||
.nom-card__guardian {
|
||||
display: flex; align-items: center; gap: 5px; padding: 5px 12px; background: #fff7ed;
|
||||
font-size: 11px; color: #92400e; border-top: 1px solid #fde68a;
|
||||
mat-icon { font-size: 14px; width: 14px; height: 14px; }
|
||||
}
|
||||
|
||||
/* Inline edit */
|
||||
.nom-card__edit { padding: 8px 12px; border-top: 1px solid #e2e8f0; background: #f8fafc; }
|
||||
.nom-edit-row { display: flex; align-items: center; gap: 8px; }
|
||||
.nom-edit-field { flex: 1; &--sm { max-width: 100px; } }
|
||||
.nom-edit-save, .nom-edit-cancel { min-width: 36px !important; height: 36px !important; padding: 0 !important; }
|
||||
|
||||
/* Empty */
|
||||
.nom-empty {
|
||||
text-align: center; padding: 24px; background: #f8fafc; border-radius: 6px; border: 1px dashed #e2e8f0;
|
||||
mat-icon { font-size: 28px; width: 28px; height: 28px; color: #94a3b8; margin-bottom: 4px; display: block; margin: 0 auto 4px; }
|
||||
span { font-size: 12px; color: #94a3b8; }
|
||||
}
|
||||
|
||||
/* ═══ Add Form ═══ */
|
||||
.nom-add-form {
|
||||
border: 1px solid #e2e8f0; border-radius: 8px; padding: 14px; margin-top: 8px; background: #fafbfc;
|
||||
&__title { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: #1e293b; margin-bottom: 12px; }
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.nom-search-wrapper { position: relative; margin-bottom: 8px; }
|
||||
.nom-search-field { width: 100%; }
|
||||
.nom-search-prefix { color: #94a3b8; }
|
||||
.nom-search-results {
|
||||
position: absolute; top: 56px; left: 0; right: 0; z-index: 100;
|
||||
background: #fff; border: 1px solid #e2e8f0; border-radius: 6px;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.1); max-height: 220px; overflow-y: auto;
|
||||
&--inline { position: relative; top: 0; margin-top: 4px; }
|
||||
}
|
||||
.nom-search-loading { display: flex; align-items: center; gap: 6px; padding: 10px 12px; font-size: 12px; color: #64748b; }
|
||||
.nom-search-item {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #f1f5f9;
|
||||
&:last-child { border-bottom: none; } &:hover { background: #f0f7ff; }
|
||||
&__icon { color: $primary; font-size: 18px; }
|
||||
&__info { display: flex; flex-direction: column; flex: 1; }
|
||||
&__name { font-size: 13px; font-weight: 600; color: #1e293b; }
|
||||
&__meta { font-size: 10px; color: #64748b; }
|
||||
}
|
||||
.search-no-results-hint { font-size: 11px; color: #94a3b8; text-align: center; padding: 4px 0; }
|
||||
|
||||
/* Linked card */
|
||||
.nom-linked-card {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
||||
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 8px;
|
||||
&__left { flex-shrink: 0; }
|
||||
&__icon { color: $primary; }
|
||||
&__photo { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; border: 2px solid #e2e8f0; }
|
||||
&__info { flex: 1; display: flex; flex-direction: column; }
|
||||
&__name { font-size: 13px; font-weight: 600; color: #1e293b; }
|
||||
&__meta { font-size: 11px; color: #64748b; font-family: 'Roboto Mono', monospace; }
|
||||
&__sig {
|
||||
flex-shrink: 0;
|
||||
img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 3px; background: #fff; padding: 1px 3px; }
|
||||
}
|
||||
}
|
||||
.nom-fetching-hint { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #64748b; margin: -4px 0 8px; }
|
||||
|
||||
/* Read-only KYC row */
|
||||
.nom-readonly-row {
|
||||
display: flex; flex-wrap: wrap; gap: 16px; padding: 8px 12px;
|
||||
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 10px;
|
||||
}
|
||||
.nom-ro-item { font-size: 12px; color: #1e293b; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.nom-ro-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; margin-right: 2px; }
|
||||
.nom-ro-ok { font-size: 14px !important; width: 14px !important; height: 14px !important; color: #16a34a; }
|
||||
|
||||
/* Nominee fields */
|
||||
.nom-fields-row { display: flex; flex-wrap: wrap; gap: 0 12px; align-items: center; margin-bottom: 4px; }
|
||||
.nom-field-item { flex: 1; min-width: 160px; &--sm { max-width: 100px; min-width: 80px; } &--toggle { padding-top: 8px; } }
|
||||
.nom-remarks-field { width: 100%; }
|
||||
.nom-form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
||||
|
||||
/* Guardian */
|
||||
.nom-guardian-section { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.nom-guardian-label { display: flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 600; color: #92400e; mat-icon { font-size: 16px; } }
|
||||
.nom-guardian-selected { display: flex; align-items: center; gap: 4px; font-size: 13px; font-weight: 500; color: #1e293b;
|
||||
button { width: 24px !important; height: 24px !important; line-height: 24px !important; mat-icon { font-size: 14px; } }
|
||||
}
|
||||
|
||||
/* Zoom */
|
||||
.zoomable { cursor: zoom-in; transition: transform 0.15s; &:hover { transform: scale(1.08); box-shadow: 0 4px 12px rgba(0,0,0,0.15); } }
|
||||
::ng-deep .image-zoom-panel { .mat-mdc-dialog-container { padding: 0 !important; border-radius: 8px !important; overflow: hidden; } }
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { DeleteDialogComponent } from '../../../shared/delete-dialog/delete-dialog.component';
|
||||
import { ImageZoomDialogComponent } from '../../client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
interface AccountGroup {
|
||||
type: string;
|
||||
apiType: string; // SB, CB, FD, RD, LA
|
||||
label: string;
|
||||
icon: string;
|
||||
accounts: any[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-nominee-tab',
|
||||
templateUrl: './nominee-tab.component.html',
|
||||
styleUrls: ['./nominee-tab.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatIcon,
|
||||
MatProgressSpinner,
|
||||
MatTooltip,
|
||||
MatSlideToggle,
|
||||
DateFormatPipe
|
||||
]
|
||||
})
|
||||
export class NomineeTabComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private clientsService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dialog = inject(MatDialog);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
|
||||
clientId: string;
|
||||
loading = true;
|
||||
accountGroups: AccountGroup[] = [];
|
||||
selectedType: string = '';
|
||||
selectedAccountId: number | null = null;
|
||||
|
||||
/** Nominees for selected account */
|
||||
nominees: any[] = [];
|
||||
nomineesLoading = false;
|
||||
|
||||
/** Add nominee form */
|
||||
showAddForm = false;
|
||||
searchText = '';
|
||||
searchResults: any[] = [];
|
||||
searching = false;
|
||||
private searchTimer: any = null;
|
||||
selectedClient: any = null;
|
||||
fetchingDetails = false;
|
||||
|
||||
/** Nominee form fields */
|
||||
nomineeRelationshipId: number | null = null;
|
||||
nomineeShare: number | null = null;
|
||||
nomineeIsMinor = false;
|
||||
nomineeSaving = false;
|
||||
nomineeRemarks = '';
|
||||
|
||||
/** Guardian */
|
||||
guardianSearchText = '';
|
||||
guardianSearchResults: any[] = [];
|
||||
guardianSearching = false;
|
||||
selectedGuardian: any = null;
|
||||
private guardianSearchTimer: any = null;
|
||||
|
||||
/** Linked client details */
|
||||
linkedProfileImage: SafeUrl | null = null;
|
||||
linkedSignatureImage: SafeUrl | null = null;
|
||||
linkedKycStatus: any = null;
|
||||
|
||||
/** Template data from API */
|
||||
relationshipOptions: any[] = [];
|
||||
|
||||
/** Nomination type options */
|
||||
nominationTypeOptions = [
|
||||
{ value: 'DA1', label: 'DA1 - Add Nominee' },
|
||||
{ value: 'DA5', label: 'DA5 - Modify / Cancel Nominee' }
|
||||
];
|
||||
|
||||
/** Add form nomination type */
|
||||
nomineeNomType = 'DA1';
|
||||
|
||||
/** Editing state */
|
||||
editingIndex: number | null = null;
|
||||
editRelId: number | null = null;
|
||||
editShare: number | null = null;
|
||||
editNomType: string = 'DA1';
|
||||
|
||||
get hasAccounts(): boolean {
|
||||
return this.accountGroups.some(g => g.accounts.length > 0);
|
||||
}
|
||||
|
||||
get selectedGroup(): AccountGroup | undefined {
|
||||
return this.accountGroups.find(g => g.type === this.selectedType);
|
||||
}
|
||||
|
||||
get selectedAccount(): any {
|
||||
return this.selectedGroup?.accounts.find((a: any) => a.id === this.selectedAccountId);
|
||||
}
|
||||
|
||||
get totalShare(): number {
|
||||
return this.nominees.reduce((sum, n) => sum + (n.percentageShare || 0), 0);
|
||||
}
|
||||
|
||||
get maxAllowedShare(): number {
|
||||
return 100 - this.totalShare;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.clientId = this.route.parent.snapshot.params['clientId'];
|
||||
this.loadAccounts();
|
||||
this.loadTemplate();
|
||||
}
|
||||
|
||||
private loadTemplate(): void {
|
||||
this.clientsService.getClientNomineeTemplate(this.clientId).subscribe({
|
||||
next: (template: any) => {
|
||||
this.relationshipOptions = template?.relationshipOptions || [];
|
||||
},
|
||||
error: () => {
|
||||
// Fallback to family member template
|
||||
this.clientsService.getClientTemplate().subscribe((t: any) => {
|
||||
this.relationshipOptions = t?.familyMemberOptions?.relationshipIdOptions || [];
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadAccounts(): void {
|
||||
this.loading = true;
|
||||
this.clientsService.getClientAccountData(this.clientId).subscribe({
|
||||
next: (data: any) => {
|
||||
this.loading = false;
|
||||
this.accountGroups = [
|
||||
{ type: 'savings', apiType: 'SB', label: 'Savings Account', icon: 'savings', accounts: this.filterActive(data.savingsAccounts) },
|
||||
{ type: 'current', apiType: 'CB', label: 'Current Account', icon: 'account_balance', accounts: this.filterActive(data.currentAccounts) },
|
||||
{ type: 'fd', apiType: 'FD', label: 'Fixed Deposit', icon: 'lock', accounts: this.filterActive(data.fixedDepositAccounts) },
|
||||
{ type: 'rd', apiType: 'RD', label: 'Recurring Deposit', icon: 'autorenew', accounts: this.filterActive(data.recurringDepositAccounts) },
|
||||
{ type: 'loan', apiType: 'LA', label: 'Loan Account', icon: 'credit_score', accounts: this.filterActive(data.loanAccounts) }
|
||||
];
|
||||
const firstWithAccounts = this.accountGroups.find(g => g.accounts.length > 0);
|
||||
if (firstWithAccounts) {
|
||||
this.selectedType = firstWithAccounts.type;
|
||||
// Auto-select if only 1 account in the group
|
||||
if (firstWithAccounts.accounts.length === 1) {
|
||||
this.selectedAccountId = firstWithAccounts.accounts[0].id;
|
||||
this.loadNominees();
|
||||
}
|
||||
}
|
||||
},
|
||||
error: () => { this.loading = false; }
|
||||
});
|
||||
}
|
||||
|
||||
private filterActive(accounts: any[]): any[] {
|
||||
if (!accounts) return [];
|
||||
// Include all non-closed accounts (active, disbursed, pending, etc.)
|
||||
return accounts.filter((a: any) =>
|
||||
a.status?.active ||
|
||||
a.status?.value === 'Active' ||
|
||||
!a.status?.closed
|
||||
);
|
||||
}
|
||||
|
||||
onTypeChange(): void {
|
||||
this.selectedAccountId = null;
|
||||
this.nominees = [];
|
||||
this.resetAddForm();
|
||||
// Auto-select if only 1 account
|
||||
if (this.selectedGroup?.accounts.length === 1) {
|
||||
this.selectedAccountId = this.selectedGroup.accounts[0].id;
|
||||
this.loadNominees();
|
||||
}
|
||||
}
|
||||
|
||||
onAccountChange(): void {
|
||||
this.nominees = [];
|
||||
this.resetAddForm();
|
||||
if (this.selectedAccountId) { this.loadNominees(); }
|
||||
}
|
||||
|
||||
private loadNominees(): void {
|
||||
this.nomineesLoading = true;
|
||||
const account = this.selectedAccount;
|
||||
const accountType = this.selectedGroup?.apiType || '';
|
||||
this.clientsService.getClientNominees(this.clientId, account?.accountNo, accountType).subscribe({
|
||||
next: (data: any) => {
|
||||
this.nomineesLoading = false;
|
||||
this.nominees = Array.isArray(data) ? data : [];
|
||||
// Enrich each nominee with profile image & signature
|
||||
this.nominees.forEach(n => this.enrichNomineeImages(n));
|
||||
},
|
||||
error: () => { this.nomineesLoading = false; this.nominees = []; }
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch profile image & signature for a nominee card */
|
||||
private enrichNomineeImages(nominee: any): void {
|
||||
const cid = nominee.nomineeClientId?.toString();
|
||||
if (!cid) return;
|
||||
|
||||
// Profile image
|
||||
this.clientsService.getClientProfileImage(cid).subscribe({
|
||||
next: (img: any) => {
|
||||
if (img) { nominee.profileImageUrl = this.sanitizer.bypassSecurityTrustResourceUrl(img); }
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
|
||||
// Signature
|
||||
this.clientsService.getClientDocuments(cid).subscribe({
|
||||
next: (docs: any[]) => {
|
||||
const sigDoc = docs?.find((d: any) =>
|
||||
d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
|
||||
);
|
||||
if (sigDoc) {
|
||||
this.clientsService.getClientSignatureImage(cid, sigDoc.id.toString()).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
nominee.signatureUrl = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ Search & Link ═══
|
||||
|
||||
onSearch(text: string): void {
|
||||
this.searchText = text;
|
||||
clearTimeout(this.searchTimer);
|
||||
if (!text || text.length < 2) { this.searchResults = []; return; }
|
||||
this.searching = true;
|
||||
this.searchTimer = setTimeout(() => {
|
||||
this.clientsService.searchByText(text, 0, 8).subscribe({
|
||||
next: (data: any) => {
|
||||
this.searchResults = data?.content || [];
|
||||
this.searching = false;
|
||||
this.clientsService.enrichSearchResults(this.searchResults);
|
||||
},
|
||||
error: () => { this.searchResults = []; this.searching = false; }
|
||||
});
|
||||
}, 400);
|
||||
}
|
||||
|
||||
dismissSearch(): void {
|
||||
setTimeout(() => { this.searchResults = []; this.searching = false; }, 200);
|
||||
}
|
||||
|
||||
selectClient(client: any): void {
|
||||
this.selectedClient = client;
|
||||
this.searchResults = [];
|
||||
this.searchText = '';
|
||||
this.fetchClientDetails(client.id);
|
||||
}
|
||||
|
||||
removeSelectedClient(): void {
|
||||
this.selectedClient = null;
|
||||
this.linkedProfileImage = null;
|
||||
this.linkedSignatureImage = null;
|
||||
this.linkedKycStatus = null;
|
||||
}
|
||||
|
||||
private fetchClientDetails(clientId: number): void {
|
||||
this.fetchingDetails = true;
|
||||
this.linkedProfileImage = null;
|
||||
this.linkedSignatureImage = null;
|
||||
this.linkedKycStatus = null;
|
||||
let pending = 4;
|
||||
const done = () => { pending--; if (pending <= 0) { this.fetchingDetails = false; } };
|
||||
|
||||
this.clientsService.getClientData(clientId.toString()).subscribe({
|
||||
next: (data: any) => {
|
||||
if (data?.dateOfBirth) { this.selectedClient = { ...this.selectedClient, dateOfBirth: data.dateOfBirth }; }
|
||||
done();
|
||||
},
|
||||
error: () => done()
|
||||
});
|
||||
|
||||
this.clientsService.getClientKyc(clientId.toString()).subscribe({
|
||||
next: (kyc: any) => {
|
||||
if (kyc) {
|
||||
this.selectedClient = { ...this.selectedClient, panNumber: kyc.panNumber, aadhaarMasked: kyc.aadhaarNumberMasked };
|
||||
this.linkedKycStatus = { panVerified: kyc.panVerified || false, aadhaarVerified: kyc.aadhaarVerified || false };
|
||||
}
|
||||
done();
|
||||
},
|
||||
error: () => done()
|
||||
});
|
||||
|
||||
this.clientsService.getClientProfileImage(clientId.toString()).subscribe({
|
||||
next: (img: any) => { if (img) { this.linkedProfileImage = this.sanitizer.bypassSecurityTrustResourceUrl(img); } done(); },
|
||||
error: () => done()
|
||||
});
|
||||
|
||||
this.clientsService.getClientDocuments(clientId.toString()).subscribe({
|
||||
next: (docs: any[]) => {
|
||||
const sigDoc = docs?.find((d: any) => d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature'));
|
||||
if (sigDoc) {
|
||||
this.clientsService.getClientSignatureImage(clientId.toString(), sigDoc.id.toString()).subscribe({
|
||||
next: (blob: Blob) => { this.linkedSignatureImage = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob)); done(); },
|
||||
error: () => done()
|
||||
});
|
||||
} else { done(); }
|
||||
},
|
||||
error: () => done()
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ Guardian ═══
|
||||
|
||||
onGuardianSearch(text: string): void {
|
||||
this.guardianSearchText = text;
|
||||
clearTimeout(this.guardianSearchTimer);
|
||||
if (!text || text.length < 2) { this.guardianSearchResults = []; return; }
|
||||
this.guardianSearching = true;
|
||||
this.guardianSearchTimer = setTimeout(() => {
|
||||
this.clientsService.searchByText(text, 0, 8).subscribe({
|
||||
next: (data: any) => { this.guardianSearchResults = data?.content || []; this.guardianSearching = false; this.clientsService.enrichSearchResults(this.guardianSearchResults); },
|
||||
error: () => { this.guardianSearchResults = []; this.guardianSearching = false; }
|
||||
});
|
||||
}, 400);
|
||||
}
|
||||
|
||||
selectGuardian(client: any): void {
|
||||
this.selectedGuardian = client;
|
||||
this.guardianSearchResults = [];
|
||||
this.guardianSearchText = '';
|
||||
}
|
||||
|
||||
// ═══ Save Nominee ═══
|
||||
|
||||
saveNominee(): void {
|
||||
if (!this.selectedClient || !this.nomineeRelationshipId || !this.nomineeShare) return;
|
||||
if (this.totalShare + this.nomineeShare > 100) return;
|
||||
|
||||
this.nomineeSaving = true;
|
||||
const account = this.selectedAccount;
|
||||
const accountType = this.selectedGroup?.apiType || '';
|
||||
|
||||
const payload: any = {
|
||||
nomineeClientId: this.selectedClient.id,
|
||||
accountNo: account?.accountNo,
|
||||
accountType,
|
||||
nominationType: this.nomineeNomType,
|
||||
relationshipId: this.nomineeRelationshipId,
|
||||
percentageShare: this.nomineeShare,
|
||||
nomineeIsMinor: this.nomineeIsMinor,
|
||||
remarks: this.nomineeRemarks || null,
|
||||
locale: 'en'
|
||||
};
|
||||
|
||||
if (this.nomineeIsMinor && this.selectedGuardian) {
|
||||
payload.guardianClientId = this.selectedGuardian.id;
|
||||
}
|
||||
|
||||
this.clientsService.addNominee(this.clientId, payload).subscribe({
|
||||
next: (res: any) => {
|
||||
this.nomineeSaving = false;
|
||||
// Reload nominees to get the full response including nomineeIdentifier
|
||||
this.loadNominees();
|
||||
this.resetAddForm();
|
||||
},
|
||||
error: () => { this.nomineeSaving = false; }
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ View & Edit ═══
|
||||
|
||||
viewNominee(nominee: any): void {
|
||||
// Resolve the linked client ID — could be nomineeClientId or nested
|
||||
const clientId = nominee.nomineeClientId || nominee.linkedClientId || nominee.clientId;
|
||||
const displayName = nominee.nomineeDisplayName || nominee.firstName || '';
|
||||
import('../../client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component').then(m => {
|
||||
this.dialog.open(m.ClientFamilyMemberDialogComponent, {
|
||||
data: {
|
||||
context: 'View',
|
||||
member: {
|
||||
firstName: displayName,
|
||||
relationshipId: nominee.relationshipId,
|
||||
dateOfBirth: nominee.nomineeDateOfBirth || nominee.dateOfBirth,
|
||||
linkedClientId: clientId,
|
||||
linkedClientName: displayName,
|
||||
isAlive: true,
|
||||
isDependent: nominee.nomineeIsMinor,
|
||||
panNumber: nominee.panNumber || '',
|
||||
aadhaarNumber: nominee.aadhaarMasked || ''
|
||||
},
|
||||
options: { relationshipIdOptions: this.relationshipOptions }
|
||||
},
|
||||
width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
editNominee(nominee: any, index: number): void {
|
||||
this.editingIndex = index;
|
||||
this.editRelId = nominee.relationshipId || null;
|
||||
this.editShare = nominee.percentageShare;
|
||||
this.editNomType = nominee.nominationType || 'DA1';
|
||||
}
|
||||
|
||||
cancelEdit(): void { this.editingIndex = null; }
|
||||
|
||||
maxShareForEdit(index: number): number {
|
||||
return 100 - this.nominees.reduce((sum, n, i) => i === index ? sum : sum + (n.percentageShare || 0), 0);
|
||||
}
|
||||
|
||||
saveEdit(nominee: any, index: number): void {
|
||||
if (!this.editRelId || !this.editShare || this.editShare > this.maxShareForEdit(index)) return;
|
||||
|
||||
const payload: any = {
|
||||
relationshipId: this.editRelId,
|
||||
percentageShare: this.editShare,
|
||||
nominationType: this.editNomType,
|
||||
locale: 'en'
|
||||
};
|
||||
|
||||
this.clientsService.updateNominee(this.clientId, nominee.id.toString(), payload).subscribe({
|
||||
next: () => {
|
||||
// Reload to get updated data
|
||||
this.loadNominees();
|
||||
this.editingIndex = null;
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
deleteNominee(nominee: any, index: number): void {
|
||||
const ref = this.dialog.open(DeleteDialogComponent, {
|
||||
data: { deleteContext: `nominee ${nominee.nomineeDisplayName || nominee.firstName || ''}` }
|
||||
});
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (response?.delete) {
|
||||
this.clientsService.deleteNominee(this.clientId, nominee.id.toString()).subscribe({
|
||||
next: () => this.nominees.splice(index, 1),
|
||||
error: () => this.nominees.splice(index, 1)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
zoomImage(imageSrc: any, title: string): void {
|
||||
this.dialog.open(ImageZoomDialogComponent, {
|
||||
data: { imageSrc, title },
|
||||
panelClass: 'image-zoom-panel',
|
||||
maxWidth: '90vw', maxHeight: '90vh'
|
||||
});
|
||||
}
|
||||
|
||||
getRelationship(member: any): string {
|
||||
if (member.relationshipName) return member.relationshipName;
|
||||
if (member.relationship) return member.relationship;
|
||||
if (typeof member.relationshipId === 'object' && member.relationshipId?.name) return member.relationshipId.name;
|
||||
const opt = this.relationshipOptions.find((r: any) => r.id === member.relationshipId);
|
||||
return opt?.name || '';
|
||||
}
|
||||
|
||||
resetAddForm(): void {
|
||||
this.showAddForm = false;
|
||||
this.selectedClient = null;
|
||||
this.linkedProfileImage = null;
|
||||
this.linkedSignatureImage = null;
|
||||
this.linkedKycStatus = null;
|
||||
this.nomineeNomType = 'DA1';
|
||||
this.nomineeRelationshipId = null;
|
||||
this.nomineeShare = null;
|
||||
this.nomineeIsMinor = false;
|
||||
this.nomineeRemarks = '';
|
||||
this.selectedGuardian = null;
|
||||
this.searchText = '';
|
||||
this.guardianSearchText = '';
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,12 @@
|
||||
</div>
|
||||
}
|
||||
@if (isPerson()) {
|
||||
@if (clientViewData.salutation) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Salutation</div>
|
||||
<div class="pd-field__value">{{ clientViewData.salutation.name }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.firstname) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">{{ 'labels.inputs.First Name' | translate }}</div>
|
||||
@@ -44,7 +50,13 @@
|
||||
@if (clientViewData.dateOfBirth) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">{{ 'labels.inputs.Date of Birth' | translate }}</div>
|
||||
<div class="pd-field__value">{{ clientViewData.dateOfBirth | dateFormat }}</div>
|
||||
<div class="pd-field__value">{{ clientViewData.dateOfBirth | dateFormat:'DD-MMMM-YYYY' }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.ageYears != null) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Age</div>
|
||||
<div class="pd-field__value">{{ clientViewData.ageYears }}y {{ clientViewData.ageMonths || 0 }}m {{ clientViewData.ageDays || 0 }}d</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.gender) {
|
||||
@@ -53,11 +65,29 @@
|
||||
<div class="pd-field__value">{{ clientViewData.gender.name }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.maritalStatus) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Marital Status</div>
|
||||
<div class="pd-field__value">{{ clientViewData.maritalStatus.name }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.isMinor != null) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Minor</div>
|
||||
<div class="pd-field__value">
|
||||
<span class="pd-verify-badge" [class.pd-verify-badge--verified]="!clientViewData.isMinor" [class.pd-verify-badge--pending]="clientViewData.isMinor">
|
||||
{{ clientViewData.isMinor ? 'Yes (Age < 10)' : 'No' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">{{ 'labels.inputs.Is staff' | translate }}</div>
|
||||
<div class="pd-field__value">{{
|
||||
clientViewData.isStaff ? ('labels.commons.Yes' | translate) : ('labels.commons.No' | translate)
|
||||
}}</div>
|
||||
<div class="pd-field__value">
|
||||
<span class="pd-verify-badge" [class.pd-verify-badge--verified]="clientViewData.isStaff" [class.pd-verify-badge--pending]="!clientViewData.isStaff">
|
||||
{{ clientViewData.isStaff ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (isLegalEntity()) {
|
||||
@@ -70,16 +100,20 @@
|
||||
@if (clientViewData.dateOfBirth) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">{{ 'labels.inputs.Incorporation Date' | translate }}</div>
|
||||
<div class="pd-field__value">{{ clientViewData.dateOfBirth | dateFormat }}</div>
|
||||
<div class="pd-field__value">{{ clientViewData.dateOfBirth | dateFormat:'DD-MM-YYYY' }}</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (clientViewData.legalForm) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">{{ 'labels.inputs.Legal Form' | translate }}</div>
|
||||
<div class="pd-field__value">{{ clientViewData.legalForm.value }}</div>
|
||||
<div class="pd-field__value">{{ clientViewData.legalForm.id === 1 ? 'Individual' : 'Non-Individual' }}</div>
|
||||
</div>
|
||||
}
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Nationality</div>
|
||||
<div class="pd-field__value">{{ clientViewData.nationalityId || clientViewData.nationality || 'Indian' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,10 +134,6 @@
|
||||
<div class="pd-field__label">{{ 'labels.inputs.CIF Number' | translate }}</div>
|
||||
<div class="pd-field__value monospace">{{ clientViewData.cifNumber || '-' }}</div>
|
||||
</div>
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">{{ 'labels.inputs.External Id' | translate }}</div>
|
||||
<div class="pd-field__value monospace">{{ clientViewData.externalId || '-' }}</div>
|
||||
</div>
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">{{ 'labels.inputs.Office' | translate }}</div>
|
||||
<div class="pd-field__value">{{ clientViewData.officeName || '-' }}</div>
|
||||
@@ -112,10 +142,10 @@
|
||||
<div class="pd-field__label">{{ 'labels.inputs.Staff' | translate }}</div>
|
||||
<div class="pd-field__value">{{ clientViewData.staffName || ('labels.inputs.Unassigned' | translate) }}</div>
|
||||
</div>
|
||||
@if (clientViewData.savingsAccountId) {
|
||||
@if (clientViewData.savingsAccountNo) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">{{ 'labels.inputs.Default Savings Account' | translate }}</div>
|
||||
<div class="pd-field__value monospace">{{ clientViewData.savingsAccountId }}</div>
|
||||
<div class="pd-field__value monospace">{{ clientViewData.savingsAccountNo }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -146,7 +176,7 @@
|
||||
@if (clientViewData.clientNonPersonDetails.incorpValidityTillDate) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">{{ 'labels.inputs.Incorporation Validity Till Date' | translate }}</div>
|
||||
<div class="pd-field__value">{{ clientViewData.clientNonPersonDetails.incorpValidityTillDate | dateFormat }}</div>
|
||||
<div class="pd-field__value">{{ clientViewData.clientNonPersonDetails.incorpValidityTillDate | dateFormat:'DD-MM-YYYY' }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.clientNonPersonDetails.incorpNumber) {
|
||||
@@ -166,11 +196,11 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Contact Information -->
|
||||
<!-- Contact Details -->
|
||||
<div class="pd-section">
|
||||
<div class="pd-section__header">
|
||||
<i class="fa fa-phone"></i>
|
||||
<h3>{{ 'labels.heading.Contact Information' | translate }}</h3>
|
||||
<h3>Contact Details</h3>
|
||||
</div>
|
||||
<div class="pd-section__body">
|
||||
<div class="pd-grid">
|
||||
@@ -180,6 +210,14 @@
|
||||
<span class="pd-contact-icon"><i class="fa fa-mobile"></i> {{ clientViewData.mobileNo || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@if (clientViewData.alternateMobileNo) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Alternate Mobile No</div>
|
||||
<div class="pd-field__value">
|
||||
<span class="pd-contact-icon"><i class="fa fa-mobile"></i> {{ clientViewData.alternateMobileNo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">{{ 'labels.inputs.Email' | translate }}</div>
|
||||
<div class="pd-field__value">
|
||||
@@ -190,14 +228,125 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Classification -->
|
||||
<!-- DIP & Sanctions Verification -->
|
||||
<div class="pd-section">
|
||||
<div class="pd-section__header">
|
||||
<i class="fa fa-tag"></i>
|
||||
<h3>{{ 'labels.heading.Classification' | translate }}</h3>
|
||||
<i class="fa fa-shield"></i>
|
||||
<h3>DIP & Sanctions Verification</h3>
|
||||
</div>
|
||||
<div class="pd-section__body">
|
||||
<div class="pd-grid">
|
||||
<!-- Primary Mobile DIP -->
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Mobile DIP Status</div>
|
||||
<div class="pd-field__value">
|
||||
@if (clientViewData.mobileVerified === true || clientViewData.mobileVerified === 'true' || clientViewData.mobileVerified === 1
|
||||
|| clientViewData.dipVerified === true || clientViewData.dipVerified === 'true' || clientViewData.dipVerified === 1) {
|
||||
<span class="pd-verify-badge pd-verify-badge--verified">
|
||||
<i class="fa fa-check-circle"></i> Verified
|
||||
</span>
|
||||
} @else {
|
||||
<span class="pd-verify-badge pd-verify-badge--pending">
|
||||
<i class="fa fa-clock-o"></i> Pending
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (clientViewData.dipTimestamp) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">DIP Verified On</div>
|
||||
<div class="pd-field__value monospace">{{ formatDateTime(clientViewData.dipTimestamp) }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.dipLookupLogId) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">DIP Lookup Log ID</div>
|
||||
<div class="pd-field__value monospace">{{ clientViewData.dipLookupLogId }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Alternate Mobile DIP -->
|
||||
@if (clientViewData.alternateMobileNo) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Alternate Mobile DIP Status</div>
|
||||
<div class="pd-field__value">
|
||||
@if (clientViewData.alternateDipVerified === true || clientViewData.alternateDipVerified === 'true' || clientViewData.alternateDipVerified === 1) {
|
||||
<span class="pd-verify-badge pd-verify-badge--verified">
|
||||
<i class="fa fa-check-circle"></i> Verified
|
||||
</span>
|
||||
} @else {
|
||||
<span class="pd-verify-badge pd-verify-badge--pending">
|
||||
<i class="fa fa-clock-o"></i> Pending
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (clientViewData.alternateDipTimestamp) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Alternate DIP Verified On</div>
|
||||
<div class="pd-field__value monospace">{{ formatDateTime(clientViewData.alternateDipTimestamp) }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.alternateDipLookupLogId) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Alternate DIP Log ID</div>
|
||||
<div class="pd-field__value monospace">{{ clientViewData.alternateDipLookupLogId }}</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Sanctions / Name Screening -->
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Name Screening (Sanctions)</div>
|
||||
<div class="pd-field__value">
|
||||
@if (clientViewData.nameVerified === true || clientViewData.nameVerified === 'true' || clientViewData.nameVerified === 1) {
|
||||
<span class="pd-verify-badge pd-verify-badge--verified"><i class="fa fa-check-circle"></i> Clear</span>
|
||||
} @else if (clientViewData.nameVerified === false || clientViewData.nameVerified === 'false' || clientViewData.nameVerified === 0) {
|
||||
<span class="pd-verify-badge pd-verify-badge--danger"><i class="fa fa-exclamation-triangle"></i> Match Found</span>
|
||||
} @else {
|
||||
<span class="pd-verify-badge pd-verify-badge--pending"><i class="fa fa-clock-o"></i> Pending</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (clientViewData.screeningLogId) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Screening Log ID</div>
|
||||
<div class="pd-field__value monospace">{{ clientViewData.screeningLogId }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Financial & Classification (Individual only) -->
|
||||
@if (clientViewData?.legalForm?.id !== 2) {
|
||||
<div class="pd-section">
|
||||
<div class="pd-section__header">
|
||||
<i class="fa fa-chart-line"></i>
|
||||
<h3>Financial & Classification</h3>
|
||||
</div>
|
||||
<div class="pd-section__body">
|
||||
<div class="pd-grid">
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Monthly Income</div>
|
||||
<div class="pd-field__value">
|
||||
@if (clientViewData.monthlyIncome != null && clientViewData.monthlyIncome !== '') {
|
||||
<span class="monospace">₹ {{ clientViewData.monthlyIncome | number }}</span>
|
||||
} @else {
|
||||
-
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Annual Turnover (Last FY)</div>
|
||||
<div class="pd-field__value">
|
||||
@if (clientViewData.annualTurnover != null && clientViewData.annualTurnover !== '') {
|
||||
<span class="monospace">₹ {{ clientViewData.annualTurnover | number }}</span>
|
||||
} @else {
|
||||
-
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (clientViewData.clientType) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">{{ 'labels.inputs.Client Type' | translate }}</div>
|
||||
@@ -222,14 +371,39 @@
|
||||
<div class="pd-field__value">{{ clientViewData.occupation.name }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (!clientViewData.clientType && !clientViewData.clientClassification) {
|
||||
<div class="pd-field full-width">
|
||||
<div class="pd-field__value no-data">{{ 'labels.commons.No classification data available' | translate }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Guardian Details (Minor) -->
|
||||
@if (clientViewData.isMinor && clientViewData.guardianClientId) {
|
||||
<div class="pd-section">
|
||||
<div class="pd-section__header">
|
||||
<i class="fa fa-user-shield"></i>
|
||||
<h3>Guardian Details</h3>
|
||||
<span class="pd-section__badge pd-section__badge--minor">Minor</span>
|
||||
</div>
|
||||
<div class="pd-section__body">
|
||||
<div class="pd-grid">
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Guardian Name</div>
|
||||
<div class="pd-field__value">{{ clientViewData.guardianClientDisplayName || '-' }}</div>
|
||||
</div>
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Guardian Client ID</div>
|
||||
<div class="pd-field__value monospace">{{ clientViewData.guardianClientId }}</div>
|
||||
</div>
|
||||
@if (clientViewData.guardianRelation) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Relationship</div>
|
||||
<div class="pd-field__value">{{ clientViewData.guardianRelation }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Group Membership -->
|
||||
@if (clientViewData.groups && clientViewData.groups.length > 0) {
|
||||
@@ -262,19 +436,19 @@
|
||||
@if (clientViewData.timeline?.submittedOnDate) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">{{ 'labels.inputs.Submitted On' | translate }}</div>
|
||||
<div class="pd-field__value">{{ clientViewData.timeline.submittedOnDate | dateFormat }}</div>
|
||||
<div class="pd-field__value">{{ clientViewData.timeline.submittedOnDate | dateFormat:'DD-MM-YYYY' }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.activationDate) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">{{ 'labels.inputs.Activation Date' | translate }}</div>
|
||||
<div class="pd-field__value">{{ clientViewData.activationDate | dateFormat }}</div>
|
||||
<div class="pd-field__value">{{ clientViewData.activationDate | dateFormat:'DD-MM-YYYY' }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.timeline?.closedOnDate) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">{{ 'labels.inputs.Closed Date' | translate }}</div>
|
||||
<div class="pd-field__value">{{ clientViewData.timeline.closedOnDate | dateFormat }}</div>
|
||||
<div class="pd-field__value">{{ clientViewData.timeline.closedOnDate | dateFormat:'DD-MM-YYYY' }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -59,6 +59,20 @@ $teal: #00897b;
|
||||
}
|
||||
}
|
||||
|
||||
.pd-section__badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
margin-left: auto;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
&--minor {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.pd-section__body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
@@ -205,3 +219,34 @@ $teal: #00897b;
|
||||
color: $gray-500;
|
||||
}
|
||||
}
|
||||
|
||||
// DIP Verification badges
|
||||
.pd-verify-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 3px 12px;
|
||||
border-radius: 12px;
|
||||
|
||||
i { font-size: 12px; }
|
||||
|
||||
&--verified {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
/** Custom Imports */
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { LegalFormId } from 'app/clients/models/legal-form.enum';
|
||||
|
||||
/** Interfaces */
|
||||
@@ -56,7 +57,8 @@ interface ClientViewData {
|
||||
standalone: true,
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
DateFormatPipe
|
||||
DateFormatPipe,
|
||||
DecimalPipe
|
||||
]
|
||||
})
|
||||
export class PersonalDataTabComponent {
|
||||
@@ -84,4 +86,24 @@ export class PersonalDataTabComponent {
|
||||
isLegalEntity(): boolean {
|
||||
return this.clientViewData?.legalForm?.id === LegalFormId.ENTITY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ISO timestamp to DD-MM-YYYY HH:MM:SS
|
||||
*/
|
||||
formatDateTime(value: string): string {
|
||||
if (!value) return '-';
|
||||
try {
|
||||
const d = new Date(value);
|
||||
if (isNaN(d.getTime())) return value;
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const yyyy = d.getFullYear();
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const min = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${dd}-${mm}-${yyyy} ${hh}:${min}:${ss}`;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,8 @@
|
||||
<!-- Name Column -->
|
||||
<ng-container matColumnDef="displayName">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row" [routerLink]="[row.id, 'general']" class="cell-link">
|
||||
<td mat-cell *matCellDef="let row" class="cell-link">
|
||||
<mat-icon class="row-legal-icon" [ngClass]="row.legalForm?.id === 2 ? 'entity' : 'person'">{{ row.legalForm?.id === 2 ? 'business' : 'person' }}</mat-icon>
|
||||
{{ hideClientData ? maskName(row.displayName) : row.displayName }}
|
||||
</td>
|
||||
</ng-container>
|
||||
@@ -85,11 +86,13 @@
|
||||
<td mat-cell *matCellDef="let row" class="cell-mono">{{ row.cifNumber }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- External ID Column -->
|
||||
<ng-container matColumnDef="externalId">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.External Id' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row" class="cell-mono">
|
||||
<mifosx-external-identifier display="left" externalId="{{ row.externalId }}"></mifosx-external-identifier>
|
||||
<!-- Legal Form Column -->
|
||||
<ng-container matColumnDef="legalForm">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Legal Form' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<span class="legal-form-badge" [ngClass]="row.legalForm?.id === 2 ? 'entity' : 'person'">
|
||||
{{ row.legalForm?.id === 2 ? 'Non-Individual' : 'Individual' }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@@ -103,12 +106,43 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Profile Completion Column -->
|
||||
<ng-container matColumnDef="profileCompletion">
|
||||
<th mat-header-cell *matHeaderCellDef>Profile</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div class="profile-steps">
|
||||
@for (step of getProfileSteps(row); track step.label) {
|
||||
<span class="step-dot" [class.done]="step.done" [matTooltip]="step.label + (step.done ? ' ✓' : ' — Pending')">
|
||||
{{ step.label.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Office Column -->
|
||||
<ng-container matColumnDef="officeName">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Office Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.officeName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row" (click)="$event.stopPropagation()">
|
||||
<div class="action-icons">
|
||||
<button mat-button class="action-chip action-chip--view" [routerLink]="[row.id, 'general']">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
View
|
||||
</button>
|
||||
<button mat-button class="action-chip action-chip--edit" [routerLink]="[row.id, 'edit']">
|
||||
<mat-icon>edit_note</mat-icon>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
|
||||
</table>
|
||||
@@ -123,7 +157,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" [hidden]="!notExistsClientsToFilter">
|
||||
<div class="empty-state" *ngIf="notExistsClientsToFilter">
|
||||
<fa-icon icon="search" class="empty-icon"></fa-icon>
|
||||
<p class="empty-text">{{ 'labels.text.No client was found' | translate }}</p>
|
||||
</div>
|
||||
|
||||
@@ -29,11 +29,15 @@ import {
|
||||
import { environment } from '../../environments/environment';
|
||||
import { ClientsService } from './clients.service';
|
||||
import { NgClass } from '@angular/common';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||
import { AccountNumberComponent } from '../shared/account-number/account-number.component';
|
||||
import { ExternalIdentifierComponent } from '../shared/external-identifier/external-identifier.component';
|
||||
import { StatusLookupPipe } from '../pipes/status-lookup.pipe';
|
||||
import { MatIconButton, MatButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
@@ -54,14 +58,17 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
AccountNumberComponent,
|
||||
ExternalIdentifierComponent,
|
||||
NgClass,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
MatPaginator,
|
||||
StatusLookupPipe
|
||||
StatusLookupPipe,
|
||||
MatIconButton,
|
||||
MatButton,
|
||||
MatIcon,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class ClientsComponent implements OnInit {
|
||||
@@ -87,9 +94,11 @@ export class ClientsComponent implements OnInit {
|
||||
'displayName',
|
||||
'accountNumber',
|
||||
'cifNumber',
|
||||
'externalId',
|
||||
'legalForm',
|
||||
'status',
|
||||
'officeName'
|
||||
'profileCompletion',
|
||||
'officeName',
|
||||
'actions'
|
||||
];
|
||||
dataSource: MatTableDataSource<any> = new MatTableDataSource();
|
||||
|
||||
@@ -131,12 +140,13 @@ export class ClientsComponent implements OnInit {
|
||||
.subscribe(
|
||||
(data: any) => {
|
||||
this.dataSource.data = data.content;
|
||||
|
||||
this.totalRows = data.totalElements;
|
||||
|
||||
this.existsClientsToFilter = data.numberOfElements > 0;
|
||||
this.notExistsClientsToFilter = !this.existsClientsToFilter;
|
||||
this.isLoading = false;
|
||||
|
||||
// Fetch KYC & address status for visible rows
|
||||
this.loadProfileStatus(data.content);
|
||||
},
|
||||
(error: any) => {
|
||||
this.isLoading = false;
|
||||
@@ -144,6 +154,43 @@ export class ClientsComponent implements OnInit {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches KYC and address status for each client row to update profile dots.
|
||||
*/
|
||||
private loadProfileStatus(clients: any[]) {
|
||||
if (!clients?.length) return;
|
||||
|
||||
// Use individual subscriptions instead of forkJoin to avoid EMPTY observable issues
|
||||
clients.forEach((client, i) => {
|
||||
this.clientService.getClientData(client.id?.toString()).pipe(
|
||||
catchError(() => of(null))
|
||||
).subscribe((data: any) => {
|
||||
if (data?.legalForm) {
|
||||
clients[i].legalForm = data.legalForm;
|
||||
this.dataSource.data = [...clients];
|
||||
}
|
||||
});
|
||||
|
||||
this.clientService.getClientKyc(client.id?.toString()).pipe(
|
||||
catchError(() => of(null))
|
||||
).subscribe((kyc: any) => {
|
||||
if (kyc) {
|
||||
clients[i]._kycDone = kyc.kycStatus === 300;
|
||||
this.dataSource.data = [...clients];
|
||||
}
|
||||
});
|
||||
|
||||
this.clientService.getClientAddressData(client.id?.toString()).pipe(
|
||||
catchError(() => of(null))
|
||||
).subscribe((addr: any) => {
|
||||
if (addr && Array.isArray(addr) && addr.length > 0) {
|
||||
clients[i]._addressDone = true;
|
||||
this.dataSource.data = [...clients];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pageChanged(event: PageEvent) {
|
||||
this.pageSize = event.pageSize;
|
||||
this.currentPage = event.pageIndex;
|
||||
@@ -162,6 +209,22 @@ export class ClientsComponent implements OnInit {
|
||||
this.getClients();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns profile completion steps for a client row.
|
||||
* Each step: { label, done }
|
||||
*/
|
||||
getProfileSteps(row: any): { label: string; done: boolean }[] {
|
||||
return [
|
||||
{ label: 'General', done: !!(row.firstname || row.displayName) && !!row.officeName },
|
||||
{ label: 'KYC', done: !!row._kycDone },
|
||||
{ label: 'Address', done: !!row._addressDone }
|
||||
];
|
||||
}
|
||||
|
||||
getCompletedCount(row: any): number {
|
||||
return this.getProfileSteps(row).filter(s => s.done).length;
|
||||
}
|
||||
|
||||
private resetPaginator() {
|
||||
this.currentPage = 0;
|
||||
this.paginator.firstPage();
|
||||
|
||||
@@ -12,6 +12,7 @@ import { HttpClient, HttpParams, HttpBackend, HttpHeaders } from '@angular/commo
|
||||
|
||||
/** rxjs Imports */
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { map, catchError } from 'rxjs/operators';
|
||||
|
||||
import { environment } from 'environments/environment';
|
||||
@@ -191,13 +192,9 @@ export class ClientsService {
|
||||
})
|
||||
.pipe(
|
||||
// Handle the error here and return null when no image is found (404)
|
||||
catchError((error) => {
|
||||
if (error.status === 404) {
|
||||
// Client has no profile image - return null without propagating error
|
||||
return of(null);
|
||||
}
|
||||
// For other errors, rethrow the error
|
||||
return throwError(() => error);
|
||||
catchError(() => {
|
||||
// Client has no profile image (404/403) - return null
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -233,6 +230,43 @@ export class ClientsService {
|
||||
return this.http.get(`/clients/${clientId}/familymembers`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich search results with DOB and father/spouse name.
|
||||
* Fetches client data (for DOB) and family members (for father/spouse).
|
||||
*/
|
||||
enrichSearchResults(results: any[]): void {
|
||||
const getRelName = (m: any): string => {
|
||||
if (typeof m.relationshipId === 'object' && m.relationshipId?.name) {
|
||||
return m.relationshipId.name.toUpperCase();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
for (const client of results) {
|
||||
// Fetch DOB from client data
|
||||
this.getClientData(client.id.toString()).pipe(
|
||||
catchError(() => of(null))
|
||||
).subscribe((data: any) => {
|
||||
if (data?.dateOfBirth) {
|
||||
client.dateOfBirth = data.dateOfBirth;
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch father/spouse from family members
|
||||
this.getClientFamilyMembers(client.id.toString()).pipe(
|
||||
catchError(() => of([]))
|
||||
).subscribe((members: any) => {
|
||||
const list = Array.isArray(members) ? members : [];
|
||||
const spouse = list.find((m: any) => getRelName(m) === 'SPOUSE');
|
||||
const father = list.find((m: any) => getRelName(m) === 'FATHER');
|
||||
if (spouse) {
|
||||
client.fatherSpouseName = `S/o: ${spouse.firstName}`;
|
||||
} else if (father) {
|
||||
client.fatherSpouseName = `F/o: ${father.firstName}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getClientFamilyMember(clientId: string, familyMemberId: string) {
|
||||
return this.http.get(`/clients/${clientId}/familymembers/${familyMemberId}`);
|
||||
}
|
||||
@@ -249,6 +283,35 @@ export class ClientsService {
|
||||
return this.http.delete(`/clients/${clientId}/familymembers/${familyMemberId}`);
|
||||
}
|
||||
|
||||
uploadFamilyMemberDocument(clientId: string, familyMemberId: string, formData: FormData): Observable<any> {
|
||||
return this.http.post(`/clients/${clientId}/familymembers/${familyMemberId}/documents`, formData);
|
||||
}
|
||||
|
||||
// ═══ Nominee APIs ═══
|
||||
|
||||
getClientNominees(clientId: string, accountNo?: string, accountType?: string): Observable<any> {
|
||||
let params = new HttpParams();
|
||||
if (accountNo) { params = params.set('accountNo', accountNo); }
|
||||
if (accountType) { params = params.set('accountType', accountType); }
|
||||
return this.http.get(`/clients/${clientId}/nominees`, { params });
|
||||
}
|
||||
|
||||
getClientNomineeTemplate(clientId: string): Observable<any> {
|
||||
return this.http.get(`/clients/${clientId}/nominees/template`);
|
||||
}
|
||||
|
||||
addNominee(clientId: string, data: any): Observable<any> {
|
||||
return this.http.post(`/clients/${clientId}/nominees`, data);
|
||||
}
|
||||
|
||||
updateNominee(clientId: string, nomineeId: string, data: any): Observable<any> {
|
||||
return this.http.put(`/clients/${clientId}/nominees/${nomineeId}`, data);
|
||||
}
|
||||
|
||||
deleteNominee(clientId: string, nomineeId: string): Observable<any> {
|
||||
return this.http.delete(`/clients/${clientId}/nominees/${nomineeId}`);
|
||||
}
|
||||
|
||||
getClientIdentifiers(clientId: string) {
|
||||
return this.http.get(`/clients/${clientId}/identifiers`);
|
||||
}
|
||||
@@ -481,6 +544,33 @@ export class ClientsService {
|
||||
return this.http.get(`/clients/${clientId}/kyc`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets KYC template for a client — returns required documents based on client type.
|
||||
* @param clientId Client ID
|
||||
*/
|
||||
getClientKycTemplate(clientId: string): Observable<any> {
|
||||
return this.http.get(`/clients/${clientId}/kyc/template`);
|
||||
}
|
||||
|
||||
/** Fetch code values by code ID */
|
||||
getCodeValuesById(codeId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
|
||||
}
|
||||
|
||||
/** Fetch code values by code name (e.g. 'FORM_60_PURPOSE') */
|
||||
getCodeValuesByName(codeName: string): Observable<any[]> {
|
||||
return this.http.get<any[]>('/codes').pipe(
|
||||
switchMap((codes: any[]) => {
|
||||
const code = codes.find((c: any) => c.name === codeName);
|
||||
if (code) {
|
||||
return this.http.get<any[]>(`/codes/${code.id}/codevalues`);
|
||||
}
|
||||
return of([]);
|
||||
}),
|
||||
catchError(() => of([]))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves KYC details for a client.
|
||||
* @param clientId Client ID
|
||||
@@ -551,6 +641,16 @@ export class ClientsService {
|
||||
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-gstin`, {});
|
||||
}
|
||||
|
||||
// ═══ Entity Classification APIs ═══
|
||||
|
||||
getEntityClassifications(clientId: string): Observable<any> {
|
||||
return this.http.get(`/clients/${clientId}/entityclassification`);
|
||||
}
|
||||
|
||||
saveEntityClassification(clientId: string, category: string, data: any): Observable<any> {
|
||||
return this.http.post(`/clients/${clientId}/entityclassification/${category}`, data);
|
||||
}
|
||||
|
||||
getClientCkyc(clientId: string): Observable<any> {
|
||||
return this.http.get(`/clients/${clientId}/ckyc`);
|
||||
}
|
||||
|
||||
@@ -19,9 +19,23 @@
|
||||
<fa-icon icon="user" class="tab-icon"></fa-icon>
|
||||
{{ 'labels.inputs.GENERAL' | translate }}
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(0)">
|
||||
@if (0 < highestTabReached) {
|
||||
<div class="tab-mode-bar">
|
||||
@if (isTabReadOnly(0)) {
|
||||
<button mat-flat-button class="tab-mode-btn tab-mode-btn--edit" (click)="enableTabEdit(0)">
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-stroked-button class="tab-mode-btn tab-mode-btn--cancel" (click)="cancelTabEdit(0)">
|
||||
<mat-icon>close</mat-icon> Cancel
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<mifosx-client-general-step
|
||||
[clientTemplate]="clientTemplate"
|
||||
[readOnly]="isTabReadOnly(0)"
|
||||
(legalFormChangeEvent)="legalFormChange($event)"
|
||||
></mifosx-client-general-step>
|
||||
</div>
|
||||
@@ -31,32 +45,31 @@
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="id-card" class="tab-icon"></fa-icon>
|
||||
{{ 'labels.inputs.KYC' | translate }} & {{ 'labels.inputs.Nominee' | translate }}
|
||||
KYC & Documents
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
<div class="tab-section">
|
||||
<mifosx-client-kyc-step #clientKyc></mifosx-client-kyc-step>
|
||||
</div>
|
||||
|
||||
<mat-divider class="section-divider"></mat-divider>
|
||||
|
||||
<div class="tab-section">
|
||||
<mifosx-client-family-members-step
|
||||
#clientFamily
|
||||
[clientTemplate]="clientTemplate"
|
||||
></mifosx-client-family-members-step>
|
||||
</div>
|
||||
|
||||
@if (clientTemplate.isAddressEnabled) {
|
||||
<mat-divider class="section-divider"></mat-divider>
|
||||
<div class="tab-section">
|
||||
<mifosx-client-address-step
|
||||
#clientAddress
|
||||
[clientTemplate]="clientTemplate"
|
||||
[clientAddressFieldConfig]="clientAddressFieldConfig"
|
||||
></mifosx-client-address-step>
|
||||
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(1)">
|
||||
@if (1 < highestTabReached) {
|
||||
<div class="tab-mode-bar">
|
||||
@if (isTabReadOnly(1)) {
|
||||
<button mat-flat-button class="tab-mode-btn tab-mode-btn--edit" (click)="enableTabEdit(1)">
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-stroked-button class="tab-mode-btn tab-mode-btn--cancel" (click)="cancelTabEdit(1)">
|
||||
<mat-icon>close</mat-icon> Cancel
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="tab-section">
|
||||
<mifosx-client-kyc-step #clientKyc
|
||||
[isNonIndividual]="legalFormType === 2"
|
||||
[clientId]="createdClientId"
|
||||
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
|
||||
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
|
||||
[readOnly]="isTabReadOnly(1)"
|
||||
></mifosx-client-kyc-step>
|
||||
</div>
|
||||
|
||||
@for (datatable of datatables; track datatable) {
|
||||
<mat-divider class="section-divider"></mat-divider>
|
||||
@@ -68,11 +81,102 @@
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 3: Preview & Submit -->
|
||||
<!-- Tab 3: Entity MSME & KYC (Non-Individual only) -->
|
||||
@if (legalFormType === 2) {
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="building" class="tab-icon"></fa-icon>
|
||||
Scheme & Sector Classification
|
||||
</ng-template>
|
||||
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(2)">
|
||||
@if (2 < highestTabReached) {
|
||||
<div class="tab-mode-bar">
|
||||
@if (isTabReadOnly(2)) {
|
||||
<button mat-flat-button class="tab-mode-btn tab-mode-btn--edit" (click)="enableTabEdit(2)">
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-stroked-button class="tab-mode-btn tab-mode-btn--cancel" (click)="cancelTabEdit(2)">
|
||||
<mat-icon>close</mat-icon> Cancel
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<mifosx-client-entity-kyc-step
|
||||
#clientEntityKyc
|
||||
[constitutionType]="getConstitutionType()"
|
||||
[clientId]="createdClientId"
|
||||
[readOnly]="isTabReadOnly(2)"
|
||||
></mifosx-client-entity-kyc-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
}
|
||||
|
||||
<!-- Tab: Address -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="map" class="tab-icon"></fa-icon>
|
||||
ADDRESS
|
||||
</ng-template>
|
||||
@let addressTabIdx = legalFormType === 2 ? 3 : 2;
|
||||
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(addressTabIdx)">
|
||||
@if (addressTabIdx < highestTabReached) {
|
||||
<div class="tab-mode-bar">
|
||||
@if (isTabReadOnly(addressTabIdx)) {
|
||||
<button mat-flat-button class="tab-mode-btn tab-mode-btn--edit" (click)="enableTabEdit(addressTabIdx)">
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-stroked-button class="tab-mode-btn tab-mode-btn--cancel" (click)="cancelTabEdit(addressTabIdx)">
|
||||
<mat-icon>close</mat-icon> Cancel
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<mifosx-client-india-address-step #clientIndiaAddress
|
||||
[readOnly]="isTabReadOnly(addressTabIdx)"
|
||||
></mifosx-client-india-address-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab: Family Members / Nominees (Individual only) -->
|
||||
@if (legalFormType !== 2) {
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="users" class="tab-icon"></fa-icon>
|
||||
FAMILY
|
||||
</ng-template>
|
||||
@let familyTabIdx = legalFormType === 2 ? 4 : 3;
|
||||
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(familyTabIdx)">
|
||||
@if (familyTabIdx < highestTabReached) {
|
||||
<div class="tab-mode-bar">
|
||||
@if (isTabReadOnly(familyTabIdx)) {
|
||||
<button mat-flat-button class="tab-mode-btn tab-mode-btn--edit" (click)="enableTabEdit(familyTabIdx)">
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-stroked-button class="tab-mode-btn tab-mode-btn--cancel" (click)="cancelTabEdit(familyTabIdx)">
|
||||
<mat-icon>close</mat-icon> Cancel
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<mifosx-client-family-members-step #clientFamilyMembers
|
||||
[clientTemplate]="clientTemplate"
|
||||
[clientId]="createdClientId"
|
||||
[maritalStatusId]="clientGeneralStep?.createClientForm?.get('maritalStatusId')?.value"
|
||||
[maritalStatusOptions]="clientGeneralStep?.maritalStatusOptions || []"
|
||||
[readOnly]="isTabReadOnly(familyTabIdx)"
|
||||
></mifosx-client-family-members-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
}
|
||||
|
||||
<!-- Tab: Application Summary & Submit -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="eye" class="tab-icon"></fa-icon>
|
||||
{{ 'labels.inputs.PREVIEW' | translate }}
|
||||
Application Summary
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
<mifosx-client-preview-step
|
||||
@@ -80,12 +184,28 @@
|
||||
[clientAddressFieldConfig]="clientAddressFieldConfig"
|
||||
[client]="client"
|
||||
[kycData]="clientKycStep?.kycData"
|
||||
[addressData]="clientIndiaAddressStep?.addressData"
|
||||
[guardian]="clientGeneralStep?.selectedGuardian"
|
||||
[guardianRelation]="clientGeneralStep?.createClientForm?.get('guardianDetails.guardianRelation')?.value || ''"
|
||||
[entityClassificationData]="clientEntityKycStep?.classificationData"
|
||||
[familyMembers]="clientFamilyMembersStep?.clientFamilyMembers || []"
|
||||
[maritalStatusId]="clientGeneralStep?.createClientForm?.get('maritalStatusId')?.value"
|
||||
[maritalStatusOptions]="clientGeneralStep?.maritalStatusOptions || []"
|
||||
(submitEvent)="submit()"
|
||||
></mifosx-client-preview-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
|
||||
<!-- Validation Error Banner -->
|
||||
@if (validationError) {
|
||||
<div class="validation-error-bar">
|
||||
<mat-icon>error</mat-icon>
|
||||
<span>{{ validationError }}</span>
|
||||
<button mat-icon-button (click)="validationError = ''"><mat-icon>close</mat-icon></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Bottom Action Bar -->
|
||||
<div class="action-bar">
|
||||
<button mat-raised-button [routerLink]="['../']" class="cancel-btn">
|
||||
@@ -98,13 +218,24 @@
|
||||
{{ 'labels.buttons.Previous' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (selectedTabIndex < 2) {
|
||||
<button mat-raised-button color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
</button>
|
||||
@if (selectedTabIndex < totalTabs - 1) {
|
||||
<div class="save-next-wrapper">
|
||||
<button mat-raised-button color="primary" class="save-next-btn" (click)="onNext()" [disabled]="savingClient || (selectedTabIndex === 0 && clientGeneralStep?.sanctionsBlocked)">
|
||||
@if (savingClient) {
|
||||
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10 save-spinner"></mat-progress-spinner>
|
||||
Saving...
|
||||
} @else {
|
||||
<mat-icon class="m-r-6">save</mat-icon>
|
||||
Save & Next
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
}
|
||||
</button>
|
||||
@if (savingClient) {
|
||||
<mat-progress-bar mode="determinate" [value]="saveProgress" class="save-progress-bar"></mat-progress-bar>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (selectedTabIndex === 2 && areFormvalids()) {
|
||||
@if (selectedTabIndex === totalTabs - 1 && areFormvalids()) {
|
||||
<button mat-raised-button color="primary" (click)="submit()">
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
|
||||
@@ -30,6 +30,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
.client-id-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
padding: 5px 14px;
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
border: 1px solid #a7f3d0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
}
|
||||
|
||||
.client-tabs {
|
||||
background: var(--md-sys-color-surface, #fff);
|
||||
border-radius: 8px;
|
||||
@@ -106,3 +121,111 @@
|
||||
.cancel-btn {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* ─── Read-only tab state ─── */
|
||||
.tab-content--readonly {
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ─── Tab mode toolbar ─── */
|
||||
.tab-mode-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
|
||||
.tab-mode-btn {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
height: 34px !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 0 14px !important;
|
||||
|
||||
.mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-mode-btn--edit {
|
||||
background: #1074b9 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.tab-mode-btn--cancel {
|
||||
background: #fff !important;
|
||||
color: #ef4444 !important;
|
||||
border: 1px solid #fca5a5 !important;
|
||||
}
|
||||
|
||||
/* Save & Next button wrapper */
|
||||
.save-next-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.save-next-btn {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
.m-r-6 {
|
||||
margin-right: 6px;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.save-spinner {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.save-progress-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px !important;
|
||||
border-radius: 0 0 4px 4px;
|
||||
|
||||
::ng-deep .mdc-linear-progress__bar-inner {
|
||||
border-color: #fff !important;
|
||||
}
|
||||
::ng-deep .mdc-linear-progress__buffer-bar {
|
||||
background-color: rgba(255, 255, 255, 0.3) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.validation-error-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-left: 4px solid #dc2626;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: #991b1b;
|
||||
|
||||
mat-icon {
|
||||
color: #dc2626;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span { flex: 1; }
|
||||
|
||||
button {
|
||||
flex-shrink: 0;
|
||||
color: #991b1b !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,18 +15,25 @@ import { ClientsService } from '../clients.service';
|
||||
|
||||
/** Custom Components */
|
||||
import { ClientGeneralStepComponent } from '../client-stepper/client-general-step/client-general-step.component';
|
||||
import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component';
|
||||
import { ClientAddressStepComponent } from '../client-stepper/client-address-step/client-address-step.component';
|
||||
import { ClientIndiaAddressStepComponent } from '../client-stepper/client-india-address-step/client-india-address-step.component';
|
||||
import { ClientEntityKycStepComponent } from '../client-stepper/client-entity-kyc-step/client-entity-kyc-step.component';
|
||||
import { ClientDatatableStepComponent } from '../client-stepper/client-datatable-step/client-datatable-step.component';
|
||||
import { ClientKycStepComponent } from '../client-stepper/client-kyc-step/client-kyc-step.component';
|
||||
import { ClientPreviewStepComponent } from '../client-stepper/client-preview-step/client-preview-step.component';
|
||||
import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component';
|
||||
|
||||
/** Custom Services */
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatIconModule, MatIcon } from '@angular/material/icon';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatDialog, MAT_DIALOG_DATA, MatDialogClose } from '@angular/material/dialog';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Inject } from '@angular/core';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
@@ -42,12 +49,17 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
MatIconModule,
|
||||
MatDivider,
|
||||
FaIconComponent,
|
||||
MatProgressSpinner,
|
||||
ClientGeneralStepComponent,
|
||||
ClientFamilyMembersStepComponent,
|
||||
ClientAddressStepComponent,
|
||||
ClientIndiaAddressStepComponent,
|
||||
ClientEntityKycStepComponent,
|
||||
ClientDatatableStepComponent,
|
||||
ClientKycStepComponent,
|
||||
ClientPreviewStepComponent
|
||||
ClientPreviewStepComponent,
|
||||
ClientFamilyMembersStepComponent,
|
||||
MatProgressBarModule,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class CreateClientComponent {
|
||||
@@ -55,16 +67,26 @@ export class CreateClientComponent {
|
||||
private router = inject(Router);
|
||||
private clientsService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
@ViewChild(ClientGeneralStepComponent) clientGeneralStep: ClientGeneralStepComponent;
|
||||
@ViewChild('clientFamily') clientFamilyMembersStep: ClientFamilyMembersStepComponent;
|
||||
@ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent;
|
||||
@ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent;
|
||||
@ViewChild('clientEntityKyc') clientEntityKycStep: ClientEntityKycStepComponent;
|
||||
@ViewChild('clientKyc') clientKycStep: ClientKycStepComponent;
|
||||
@ViewChild('clientFamilyMembers') clientFamilyMembersStep: ClientFamilyMembersStepComponent;
|
||||
@ViewChildren('dtclient') clientDatatables: QueryList<ClientDatatableStepComponent>;
|
||||
|
||||
datatables: any = [];
|
||||
legalFormType = 1;
|
||||
selectedTabIndex = 0;
|
||||
createdClientId = '';
|
||||
savingClient = false;
|
||||
|
||||
/** Track which tabs are in edit mode (false/undefined = read-only for completed tabs) */
|
||||
tabEditMode: Record<number, boolean> = {};
|
||||
/** Highest tab index reached — tabs below this show read-only when navigated back */
|
||||
highestTabReached = 0;
|
||||
|
||||
/** Client Template */
|
||||
clientTemplate: any;
|
||||
@@ -86,15 +108,40 @@ export class CreateClientComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/** Check if a tab is in read-only mode (completed tab navigated back to) */
|
||||
isTabReadOnly(tabIndex: number): boolean {
|
||||
// Only tabs that have been passed (below highest reached) AND not explicitly set to edit mode
|
||||
return tabIndex < this.highestTabReached && !this.tabEditMode[tabIndex];
|
||||
}
|
||||
|
||||
/** Enable editing for a specific tab */
|
||||
enableTabEdit(tabIndex: number): void {
|
||||
this.tabEditMode[tabIndex] = true;
|
||||
}
|
||||
|
||||
/** Cancel editing — revert tab back to read-only */
|
||||
cancelTabEdit(tabIndex: number): void {
|
||||
this.tabEditMode[tabIndex] = false;
|
||||
}
|
||||
|
||||
get clientGeneralForm() {
|
||||
return this.clientGeneralStep?.createClientForm;
|
||||
}
|
||||
|
||||
/** Total tab count: Individual has Family tab, Non-Individual has Scheme & Sector tab — both 5 */
|
||||
get totalTabs(): number {
|
||||
return 5;
|
||||
}
|
||||
|
||||
/** Get constitution type from general step for entity KYC */
|
||||
getConstitutionType(): string {
|
||||
return this.clientGeneralStep?.createClientForm?.get('clientNonPersonDetails.constitutionId')?.value || '';
|
||||
}
|
||||
|
||||
get client() {
|
||||
const general = this.clientGeneralStep?.clientGeneralDetails || {};
|
||||
const family = this.clientFamilyMembersStep?.familyMembers || {};
|
||||
const addr = this.clientTemplate.isAddressEnabled && this.clientAddressStep ? this.clientAddressStep.address : {};
|
||||
return { ...general, ...family, ...addr };
|
||||
return { ...general, ...addr };
|
||||
}
|
||||
|
||||
areFormvalids(): boolean {
|
||||
@@ -131,26 +178,111 @@ export class CreateClientComponent {
|
||||
}
|
||||
}
|
||||
|
||||
legalFormChange(eventData: { legalForm: number }) {
|
||||
this.legalFormType = eventData.legalForm;
|
||||
this.setDatatables();
|
||||
/**
|
||||
* Next button handler — saves client on General tab before moving forward.
|
||||
*/
|
||||
/** Save progress percentage for progress bar */
|
||||
saveProgress = 0;
|
||||
|
||||
onNext() {
|
||||
// If on General tab and client not yet created — save first
|
||||
if (this.selectedTabIndex === 0 && !this.createdClientId) {
|
||||
if (!this.clientGeneralForm?.valid) {
|
||||
this.clientGeneralForm?.markAllAsTouched();
|
||||
setTimeout(() => {
|
||||
const firstInvalid = document.querySelector('.mat-mdc-form-field.ng-invalid');
|
||||
if (firstInvalid) {
|
||||
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation dialog
|
||||
const dialogRef = this.dialog.open(SaveConfirmDialogComponent, {
|
||||
width: '440px',
|
||||
disableClose: true
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((confirmed: boolean) => {
|
||||
if (!confirmed) return;
|
||||
this.saveAndProceed();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Save current tab data before moving to next
|
||||
if (this.createdClientId) {
|
||||
this.saveCurrentTabData();
|
||||
}
|
||||
|
||||
this.selectedTabIndex = this.selectedTabIndex + 1;
|
||||
if (this.selectedTabIndex > this.highestTabReached) {
|
||||
this.highestTabReached = this.selectedTabIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the create client form.
|
||||
*/
|
||||
submit() {
|
||||
/** Save the current tab's data to the API */
|
||||
private saveCurrentTabData() {
|
||||
const tabIndex = this.selectedTabIndex;
|
||||
const clientId = this.createdClientId;
|
||||
|
||||
// KYC tab
|
||||
const kycTabIndex = 1;
|
||||
if (tabIndex === kycTabIndex) {
|
||||
// Save KYC
|
||||
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
|
||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} });
|
||||
}
|
||||
// Upload photo & signature
|
||||
if (this.clientKycStep?.photographFile) {
|
||||
this.clientsService.uploadClientProfileImage(clientId, this.clientKycStep.photographFile).subscribe({ error: () => {} });
|
||||
}
|
||||
if (this.clientKycStep?.signatureFile) {
|
||||
this.clientsService.uploadClientSignatureImage(clientId, this.clientKycStep.signatureFile).subscribe({ error: () => {} });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Address tab (index 2 for Individual, 3 for Non-Individual)
|
||||
const addressTabIndex = this.legalFormType === 2 ? 3 : 2;
|
||||
if (tabIndex === addressTabIndex) {
|
||||
if (this.clientIndiaAddressStep?.hasAddressData) {
|
||||
this.saveClientAddresses(clientId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Family tab (index 3 for Individual, 4 for Non-Individual)
|
||||
const familyTabIndex = this.legalFormType === 2 ? 4 : 3;
|
||||
if (tabIndex === familyTabIndex) {
|
||||
if (this.clientFamilyMembersStep?.hasFamilyMembers) {
|
||||
this.saveClientFamilyMembers(clientId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Executes the save API call with progress animation */
|
||||
private saveAndProceed() {
|
||||
this.savingClient = true;
|
||||
this.saveProgress = 0;
|
||||
|
||||
// Animate progress bar
|
||||
const progressInterval = setInterval(() => {
|
||||
if (this.saveProgress < 90) {
|
||||
this.saveProgress += Math.random() * 15;
|
||||
if (this.saveProgress > 90) this.saveProgress = 90;
|
||||
}
|
||||
}, 200);
|
||||
|
||||
const locale = this.settingsService.language.code;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const clientData = {
|
||||
...this.client,
|
||||
dateFormat,
|
||||
locale
|
||||
};
|
||||
const clientData = { ...this.client, dateFormat, locale };
|
||||
|
||||
if (this.clientTemplate.datatables && this.clientTemplate.datatables.length > 0) {
|
||||
const datatables: any[] = [];
|
||||
this.clientDatatables.forEach((clientDatatable: ClientDatatableStepComponent) => {
|
||||
this.clientDatatables?.forEach((clientDatatable: ClientDatatableStepComponent) => {
|
||||
datatables.push(clientDatatable.payload);
|
||||
});
|
||||
if (datatables.length > 0) {
|
||||
@@ -158,36 +290,279 @@ export class CreateClientComponent {
|
||||
}
|
||||
}
|
||||
|
||||
this.clientsService.createClient(clientData).subscribe((response: any) => {
|
||||
const clientId = response.resourceId;
|
||||
if (this.clientKycStep?.hasKycData) {
|
||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
|
||||
complete: () =>
|
||||
this.router.navigate(
|
||||
[
|
||||
'../',
|
||||
clientId
|
||||
],
|
||||
{ relativeTo: this.route }
|
||||
),
|
||||
error: () =>
|
||||
this.router.navigate(
|
||||
[
|
||||
'../',
|
||||
clientId
|
||||
],
|
||||
{ relativeTo: this.route }
|
||||
)
|
||||
});
|
||||
} else {
|
||||
this.router.navigate(
|
||||
[
|
||||
'../',
|
||||
clientId
|
||||
],
|
||||
{ relativeTo: this.route }
|
||||
);
|
||||
this.clientsService.createClient(clientData).subscribe({
|
||||
next: (response: any) => {
|
||||
clearInterval(progressInterval);
|
||||
this.saveProgress = 100;
|
||||
this.createdClientId = response.resourceId;
|
||||
// Brief delay to show 100% before transitioning
|
||||
setTimeout(() => {
|
||||
this.savingClient = false;
|
||||
this.saveProgress = 0;
|
||||
this.selectedTabIndex = 1;
|
||||
this.highestTabReached = 1;
|
||||
}, 400);
|
||||
},
|
||||
error: () => {
|
||||
clearInterval(progressInterval);
|
||||
this.savingClient = false;
|
||||
this.saveProgress = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
legalFormChange(eventData: { legalForm: number }) {
|
||||
this.legalFormType = eventData.legalForm;
|
||||
this.setDatatables();
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the create client form.
|
||||
* If the client was already created via onNext(), reuse that client ID
|
||||
* instead of creating a duplicate.
|
||||
*/
|
||||
/** Validation error message for display */
|
||||
validationError = '';
|
||||
|
||||
submit() {
|
||||
// Validate: at least one family member required (Individual clients)
|
||||
if (this.legalFormType === 1 && this.clientFamilyMembersStep && !this.clientFamilyMembersStep.hasFamilyMembers) {
|
||||
this.validationError = 'At least one family member is required. Please add a family member before submitting.';
|
||||
// Switch to Family tab
|
||||
this.selectedTabIndex = 3;
|
||||
return;
|
||||
}
|
||||
this.validationError = '';
|
||||
|
||||
if (this.createdClientId) {
|
||||
// Client already created in onNext() — just finalize (upload files, navigate)
|
||||
this.finalizeClient(this.createdClientId);
|
||||
} else {
|
||||
// Fallback: create client now (shouldn't happen in normal flow)
|
||||
const locale = this.settingsService.language.code;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const clientData = {
|
||||
...this.client,
|
||||
dateFormat,
|
||||
locale
|
||||
};
|
||||
|
||||
if (this.clientTemplate.datatables && this.clientTemplate.datatables.length > 0) {
|
||||
const datatables: any[] = [];
|
||||
this.clientDatatables.forEach((clientDatatable: ClientDatatableStepComponent) => {
|
||||
datatables.push(clientDatatable.payload);
|
||||
});
|
||||
if (datatables.length > 0) {
|
||||
clientData['datatables'] = datatables;
|
||||
}
|
||||
}
|
||||
|
||||
this.clientsService.createClient(clientData).subscribe((response: any) => {
|
||||
this.createdClientId = response.resourceId;
|
||||
this.finalizeClient(this.createdClientId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize: upload photo/signature, save KYC, save addresses, then navigate.
|
||||
*/
|
||||
private finalizeClient(clientId: string) {
|
||||
const navigateToClient = () => this.router.navigate(['../', clientId], { relativeTo: this.route });
|
||||
|
||||
// Upload photo if selected
|
||||
if (this.clientKycStep?.photographFile) {
|
||||
this.clientsService.uploadClientProfileImage(clientId, this.clientKycStep.photographFile).subscribe({ error: () => {} });
|
||||
}
|
||||
|
||||
// Upload signature if selected
|
||||
if (this.clientKycStep?.signatureFile) {
|
||||
this.clientsService.uploadClientSignatureImage(clientId, this.clientKycStep.signatureFile).subscribe({ error: () => {} });
|
||||
}
|
||||
|
||||
// Save addresses from India address step
|
||||
if (this.clientIndiaAddressStep?.hasAddressData) {
|
||||
this.saveClientAddresses(clientId);
|
||||
}
|
||||
|
||||
// Save family members
|
||||
if (this.clientFamilyMembersStep?.hasFamilyMembers) {
|
||||
this.saveClientFamilyMembers(clientId);
|
||||
}
|
||||
|
||||
// Save KYC only if it wasn't already created during the KYC step
|
||||
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
|
||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
|
||||
next: () => navigateToClient(),
|
||||
error: () => navigateToClient()
|
||||
});
|
||||
} else {
|
||||
navigateToClient();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save addresses from the India address step to Fineract address API.
|
||||
* Fetches address template to resolve address type IDs, then POSTs each address.
|
||||
*/
|
||||
private saveClientAddresses(clientId: string) {
|
||||
const addressData = this.clientIndiaAddressStep.addressData;
|
||||
// Map tab names to Fineract address type names (as defined in address template)
|
||||
const typeNameMap: Record<string, string[]> = {
|
||||
permanent: ['Permanent', 'Permanent Address'],
|
||||
current: ['Residential', 'Current', 'Current / Residential', 'Communication'],
|
||||
office: ['Office', 'Office / Work', 'Business', 'Work'],
|
||||
correspondence: ['Correspondence', 'Mailing', 'Postal']
|
||||
};
|
||||
|
||||
this.clientsService.getClientAddressTemplate().subscribe({
|
||||
next: (template: any) => {
|
||||
const typeOptions = template?.addressTypeIdOptions || [];
|
||||
for (const [tab, addr] of Object.entries(addressData)) {
|
||||
if (!addr || !(addr as any).addressLine1) continue;
|
||||
|
||||
// Find matching address type ID
|
||||
const possibleNames = typeNameMap[tab] || [tab];
|
||||
let typeId = typeOptions.find((t: any) =>
|
||||
possibleNames.some((n: string) => t.name?.toLowerCase().includes(n.toLowerCase()))
|
||||
)?.id;
|
||||
|
||||
// Fallback: use first available type if no match
|
||||
if (!typeId && typeOptions.length > 0) {
|
||||
typeId = typeOptions[0].id;
|
||||
}
|
||||
if (!typeId) continue;
|
||||
|
||||
const payload: any = {
|
||||
addressLine1: (addr as any).addressLine1 || '',
|
||||
addressLine2: (addr as any).addressLine2 || '',
|
||||
addressLine3: '',
|
||||
townVillage: (addr as any).locality || '',
|
||||
postalCode: (addr as any).pinCode || '',
|
||||
city: (addr as any).city || '',
|
||||
countyDistrict: (addr as any).district || '',
|
||||
street: (addr as any).landmark || '',
|
||||
addressType: typeId,
|
||||
isActive: tab === 'permanent'
|
||||
};
|
||||
|
||||
// Try to resolve state to stateProvinceId
|
||||
if ((addr as any).state && template?.stateProvinceIdOptions?.length) {
|
||||
const stateMatch = template.stateProvinceIdOptions.find((s: any) =>
|
||||
s.name?.toLowerCase() === (addr as any).state?.toLowerCase()
|
||||
);
|
||||
if (stateMatch) payload.stateProvinceId = stateMatch.id;
|
||||
}
|
||||
|
||||
this.clientsService.createClientAddress(clientId, typeId.toString(), payload).subscribe({ error: () => {} });
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save family members via POST /clients/{id}/familymembers
|
||||
*/
|
||||
private saveClientFamilyMembers(clientId: string) {
|
||||
const members = this.clientFamilyMembersStep.clientFamilyMembers;
|
||||
for (const member of members) {
|
||||
const marriageCertFile = member._marriageCertFile || null;
|
||||
const payload = { ...member };
|
||||
// Remove internal/transient fields
|
||||
delete payload._linkedClient;
|
||||
delete payload._marriageCertFile;
|
||||
delete payload.dateFormat;
|
||||
delete payload.locale;
|
||||
payload.dateFormat = this.settingsService.dateFormat;
|
||||
payload.locale = this.settingsService.language.code;
|
||||
this.clientsService.addFamilyMember(clientId, payload).subscribe({
|
||||
next: (res: any) => {
|
||||
// Upload marriage certificate if present
|
||||
if (marriageCertFile && res?.resourceId) {
|
||||
this.uploadFamilyMemberDocument(clientId, res.resourceId, marriageCertFile, 'Marriage Certificate');
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a document for a family member.
|
||||
*/
|
||||
private uploadFamilyMemberDocument(clientId: string, familyMemberId: string, file: File, docName: string) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('name', docName);
|
||||
formData.append('description', docName);
|
||||
this.clientsService.uploadFamilyMemberDocument(clientId, familyMemberId, formData).subscribe({ error: () => {} });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save & Next Confirmation Dialog
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-save-confirm-dialog',
|
||||
template: `
|
||||
<div class="scd">
|
||||
<div class="scd-header">
|
||||
<div class="scd-header__icon-wrap">
|
||||
<mat-icon class="scd-header__icon">save</mat-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="scd-header__title">Save & Proceed</h3>
|
||||
<p class="scd-header__sub">Client general details will be saved</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scd-body">
|
||||
<p>Are you sure you want to save the client details and proceed to the next step?</p>
|
||||
<div class="scd-info">
|
||||
<mat-icon class="scd-info__icon">info</mat-icon>
|
||||
<span>Once saved, a client record will be created. You can update the details later.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scd-actions">
|
||||
<button mat-stroked-button [mat-dialog-close]="false" class="scd-cancel">
|
||||
<mat-icon>close</mat-icon>
|
||||
Cancel
|
||||
</button>
|
||||
<button mat-flat-button color="primary" [mat-dialog-close]="true" class="scd-confirm">
|
||||
<mat-icon>check</mat-icon>
|
||||
Save & Proceed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.scd { background: #fff; border-radius: 12px; overflow: hidden; }
|
||||
.scd-header {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 20px 24px 16px;
|
||||
}
|
||||
.scd-header__icon-wrap {
|
||||
width: 44px; height: 44px; border-radius: 12px;
|
||||
background: #eff6ff; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.scd-header__icon { color: #1074b9; font-size: 24px; width: 24px; height: 24px; }
|
||||
.scd-header__title { margin: 0; font-size: 17px; font-weight: 700; color: #0f172a; }
|
||||
.scd-header__sub { margin: 2px 0 0; font-size: 12px; color: #64748b; }
|
||||
.scd-body { padding: 0 24px 20px; }
|
||||
.scd-body p { margin: 0 0 14px; font-size: 14px; color: #374151; line-height: 1.5; }
|
||||
.scd-info {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
padding: 10px 14px; background: #f0f9ff; border: 1px solid #bae6fd;
|
||||
border-radius: 8px; font-size: 12px; color: #0c4a6e;
|
||||
}
|
||||
.scd-info__icon { font-size: 16px !important; width: 16px !important; height: 16px !important; color: #0284c7; flex-shrink: 0; margin-top: 1px; }
|
||||
.scd-actions {
|
||||
display: flex; justify-content: flex-end; gap: 10px;
|
||||
padding: 14px 24px; border-top: 1px solid #f1f5f9; background: #f8fafc;
|
||||
}
|
||||
.scd-cancel, .scd-confirm { display: flex !important; align-items: center; gap: 6px; font-size: 13px !important; height: 38px !important; border-radius: 8px !important; }
|
||||
.scd-confirm { font-weight: 600 !important; }
|
||||
`],
|
||||
imports: [MatIcon, MatDialogClose]
|
||||
})
|
||||
export class SaveConfirmDialogComponent {}
|
||||
|
||||
@@ -6,366 +6,198 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<mat-card>
|
||||
<form [formGroup]="editClientForm">
|
||||
<mat-card-content>
|
||||
<!-- First Row: Office, Legal Form, Account No, External ID -->
|
||||
<div class="responsive-row">
|
||||
<mat-form-field class="flex-24">
|
||||
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
|
||||
<mat-select required formControlName="officeId">
|
||||
@for (office of officeOptions; track office) {
|
||||
<mat-option [value]="office.id">
|
||||
{{ office.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (editClientForm.controls.officeId.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Office' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
<div class="create-client-container">
|
||||
<div class="client-header">
|
||||
<fa-icon icon="user" class="header-icon"></fa-icon>
|
||||
<h2>{{ 'labels.heading.Edit Client' | translate }}</h2>
|
||||
</div>
|
||||
|
||||
<mat-tab-group class="client-tabs" animationDuration="200ms" [preserveContent]="true" [(selectedIndex)]="selectedTabIndex">
|
||||
<!-- Tab 1: General Details -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="user" class="tab-icon"></fa-icon>
|
||||
{{ 'labels.inputs.GENERAL' | translate }}
|
||||
</ng-template>
|
||||
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(0)">
|
||||
<div class="tab-mode-bar">
|
||||
@if (isTabReadOnly(0)) {
|
||||
<button mat-flat-button class="tab-mode-btn tab-mode-btn--edit" (click)="enableTabEdit(0)">
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-stroked-button class="tab-mode-btn tab-mode-btn--cancel" (click)="cancelTabEdit(0)">
|
||||
<mat-icon>close</mat-icon> Cancel
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<mifosx-client-general-step
|
||||
[clientTemplate]="clientDataAndTemplate"
|
||||
[clientData]="clientDataAndTemplate"
|
||||
[readOnly]="isTabReadOnly(0)"
|
||||
(legalFormChangeEvent)="legalFormChange($event)"
|
||||
></mifosx-client-general-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 2: KYC & Documents -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="id-card" class="tab-icon"></fa-icon>
|
||||
KYC & Documents
|
||||
</ng-template>
|
||||
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(1)">
|
||||
<div class="tab-mode-bar">
|
||||
@if (isTabReadOnly(1)) {
|
||||
<button mat-flat-button class="tab-mode-btn tab-mode-btn--edit" (click)="enableTabEdit(1)">
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-stroked-button class="tab-mode-btn tab-mode-btn--cancel" (click)="cancelTabEdit(1)">
|
||||
<mat-icon>close</mat-icon> Cancel
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="tab-section">
|
||||
<mifosx-client-kyc-step #clientKyc
|
||||
[isNonIndividual]="legalFormType === 2"
|
||||
[clientId]="clientId"
|
||||
[readOnly]="isTabReadOnly(1)"
|
||||
></mifosx-client-kyc-step>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 3: Entity MSME & KYC (Non-Individual only) -->
|
||||
@if (legalFormType === 2) {
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="building" class="tab-icon"></fa-icon>
|
||||
Scheme & Sector Classification
|
||||
</ng-template>
|
||||
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(2)">
|
||||
<div class="tab-mode-bar">
|
||||
@if (isTabReadOnly(2)) {
|
||||
<button mat-flat-button class="tab-mode-btn tab-mode-btn--edit" (click)="enableTabEdit(2)">
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-stroked-button class="tab-mode-btn tab-mode-btn--cancel" (click)="cancelTabEdit(2)">
|
||||
<mat-icon>close</mat-icon> Cancel
|
||||
</button>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="flex-24">
|
||||
<mat-label>{{ 'labels.inputs.Legal Form' | translate }}</mat-label>
|
||||
<mat-select formControlName="legalFormId">
|
||||
@for (legalForm of legalFormOptions; track legalForm) {
|
||||
<mat-option [value]="legalForm.id">
|
||||
{{ legalForm.value }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="flex-24">
|
||||
<mat-label>{{ 'labels.inputs.Account No' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="accountNo"
|
||||
[placeholder]="'labels.inputs.Account No' | translate"
|
||||
[title]="'labels.inputs.Account No' | translate"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="flex-24">
|
||||
<mat-label>{{ 'labels.inputs.External Id' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="externalId"
|
||||
[placeholder]="'labels.inputs.External Id' | translate"
|
||||
[title]="'labels.inputs.External Id' | translate"
|
||||
/>
|
||||
@if (!externalNationalIdService.isLoading && externalNationalIdService.statusMessageKey) {
|
||||
<mat-hint>
|
||||
<span
|
||||
class="external-id-status"
|
||||
[ngClass]="{
|
||||
success: externalNationalIdService.statusMessageKey === 'External ID verified successfully',
|
||||
error: externalNationalIdService.statusMessageKey !== 'External ID verified successfully'
|
||||
}"
|
||||
>
|
||||
{{ 'labels.inputs.' + externalNationalIdService.statusMessageKey | translate }}
|
||||
</span>
|
||||
</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Name fields section -->
|
||||
<div class="responsive-row">
|
||||
@if (editClientForm.get('fullname')) {
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{
|
||||
'labels.inputs.' + getDateLabel(legalFormId, ['Name', 'Entity Name']) | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="fullname"
|
||||
[placeholder]="'labels.inputs.' + getDateLabel(legalFormId, ['Name', 'Entity Name']) | translate"
|
||||
[title]="'labels.inputs.' + getDateLabel(legalFormId, ['Name', 'Entity Name']) | translate"
|
||||
/>
|
||||
@if (editClientForm.controls.fullname.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Client name' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (editClientForm.get('firstname')) {
|
||||
<mat-form-field class="flex-32">
|
||||
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="firstname"
|
||||
[placeholder]="'labels.inputs.First Name' | translate"
|
||||
[title]="'labels.inputs.First Name' | translate"
|
||||
/>
|
||||
@if (editClientForm.controls.firstname.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Client first name' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (editClientForm.get('middlename')) {
|
||||
<mat-form-field class="flex-32">
|
||||
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="middlename"
|
||||
[placeholder]="'labels.inputs.Middle Name' | translate"
|
||||
[title]="'labels.inputs.Middle Name' | translate"
|
||||
/>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (editClientForm.get('lastname')) {
|
||||
<mat-form-field class="flex-32">
|
||||
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="lastname"
|
||||
[placeholder]="'labels.inputs.Last Name' | translate"
|
||||
[title]="'labels.inputs.Last Name' | translate"
|
||||
/>
|
||||
@if (editClientForm.controls.lastname.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Client last name' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-divider class="section-divider"></mat-divider>
|
||||
|
||||
<!-- Date of Birth and Gender Row -->
|
||||
<div class="responsive-row">
|
||||
<mat-form-field class="flex-48" (click)="dateOfBirthDatePicker.open()">
|
||||
<mat-label>{{
|
||||
'labels.inputs.' + getDateLabel(legalFormId, ['Date of Birth', 'Incorporation Date']) | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[max]="maxDate"
|
||||
[matDatepicker]="dateOfBirthDatePicker"
|
||||
formControlName="dateOfBirth"
|
||||
[placeholder]="
|
||||
'labels.inputs.' + getDateLabel(legalFormId, ['Date of Birth', 'Incorporation Date']) | translate
|
||||
"
|
||||
[title]="
|
||||
'labels.inputs.' + getDateLabel(legalFormId, ['Date of Birth', 'Incorporation Date']) | translate
|
||||
"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="dateOfBirthDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #dateOfBirthDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
@if (legalFormId === LegalFormId.PERSON) {
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Gender' | translate }}</mat-label>
|
||||
<mat-select formControlName="genderId">
|
||||
@for (gender of genderOptions; track gender) {
|
||||
<mat-option [value]="gender.id">
|
||||
{{ gender.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Staff and Is Staff Row -->
|
||||
<div class="responsive-row align-center">
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Staff' | translate }}</mat-label>
|
||||
<mat-select formControlName="staffId">
|
||||
@for (staff of staffOptions; track staff) {
|
||||
<mat-option [value]="staff.id">
|
||||
{{ staff.displayName }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
@if (legalFormId === LegalFormId.PERSON) {
|
||||
<mat-checkbox class="flex-48 margin-v" labelPosition="before" formControlName="isStaff">
|
||||
{{ 'labels.inputs.Is staff' | translate }}?
|
||||
</mat-checkbox>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (editClientForm.contains('clientNonPersonDetails')) {
|
||||
<div class="flex-100 layout-row-wrap gap-2percent column-on-small" formGroupName="clientNonPersonDetails">
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Constitution' | translate }}</mat-label>
|
||||
<mat-select formControlName="constitutionId" required>
|
||||
@for (constitution of constitutionOptions; track constitution) {
|
||||
<mat-option [value]="constitution.id">
|
||||
{{ constitution.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Main Business Line' | translate }}</mat-label>
|
||||
<mat-select formControlName="mainBusinessLineId">
|
||||
@for (business of businessLineOptions; track business) {
|
||||
<mat-option [value]="business.id">
|
||||
{{ business.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="flex-48" (click)="incorpValidityTillDateDatePicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Incorporation Validity Till Date' | translate }}</mat-label>
|
||||
<input
|
||||
[placeholder]="'labels.inputs.Incorporation Validity Till Date' | translate"
|
||||
[title]="'labels.inputs.Incorporation Validity Till Date' | translate"
|
||||
matInput
|
||||
[min]="minDate"
|
||||
[max]="maxDate"
|
||||
[matDatepicker]="incorpValidityTillDateDatePicker"
|
||||
formControlName="incorpValidityTillDate"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="incorpValidityTillDateDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #incorpValidityTillDateDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Incorporation No' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="incorpNumber"
|
||||
[placeholder]="'labels.inputs.Incorporation No' | translate"
|
||||
[title]="'labels.inputs.Incorporation No' | translate"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="flex-98">
|
||||
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="remarks"
|
||||
cdkTextareaAutosize
|
||||
cdkAutosizeMinRows="2"
|
||||
[placeholder]="'labels.inputs.Remarks' | translate"
|
||||
[title]="'labels.inputs.Remarks' | translate"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Mobile No and Email Address Row -->
|
||||
<div class="responsive-row">
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Mobile No' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
formControlName="mobileNo"
|
||||
[placeholder]="'labels.inputs.Mobile No' | translate"
|
||||
[title]="'labels.inputs.Mobile No' | translate"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Email Address' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="emailAddress"
|
||||
[placeholder]="'labels.inputs.Email Address' | translate"
|
||||
[title]="'labels.inputs.Email Address' | translate"
|
||||
/>
|
||||
@if (editClientForm.controls.emailAddress.errors?.email) {
|
||||
<mat-error> Email not valid </mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mifosx-client-entity-kyc-step
|
||||
#clientEntityKyc
|
||||
[constitutionType]="getConstitutionType()"
|
||||
[readOnly]="isTabReadOnly(2)"
|
||||
></mifosx-client-entity-kyc-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
}
|
||||
|
||||
<!-- Client Type and Client Classification Row -->
|
||||
<div class="responsive-row">
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Client Type' | translate }}</mat-label>
|
||||
<mat-select formControlName="clientTypeId">
|
||||
@for (clientType of clientTypeOptions; track clientType) {
|
||||
<mat-option [value]="clientType.id">
|
||||
{{ clientType.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Client Classification' | translate }}</mat-label>
|
||||
<mat-select formControlName="clientClassificationId">
|
||||
@for (clientClassification of clientClassificationTypeOptions; track clientClassification) {
|
||||
<mat-option [value]="clientClassification.id">
|
||||
{{ clientClassification.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<!-- Tab: Address -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="map" class="tab-icon"></fa-icon>
|
||||
ADDRESS
|
||||
</ng-template>
|
||||
@let addressTabIdx = legalFormType === 2 ? 3 : 2;
|
||||
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(addressTabIdx)">
|
||||
<div class="tab-mode-bar">
|
||||
@if (isTabReadOnly(addressTabIdx)) {
|
||||
<button mat-flat-button class="tab-mode-btn tab-mode-btn--edit" (click)="enableTabEdit(addressTabIdx)">
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-stroked-button class="tab-mode-btn tab-mode-btn--cancel" (click)="cancelTabEdit(addressTabIdx)">
|
||||
<mat-icon>close</mat-icon> Cancel
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<mifosx-client-india-address-step #clientIndiaAddress
|
||||
[clientId]="clientId"
|
||||
[readOnly]="isTabReadOnly(addressTabIdx)"
|
||||
></mifosx-client-india-address-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Submitted On and Activated On Row -->
|
||||
<div class="responsive-row">
|
||||
<mat-form-field class="flex-48" (click)="submittedOnDatePicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Submitted On' | translate }}</mat-label>
|
||||
<input
|
||||
[placeholder]="'labels.inputs.Submitted On' | translate"
|
||||
[title]="'labels.inputs.Submitted On' | translate"
|
||||
matInput
|
||||
[min]="minDate"
|
||||
[max]="maxDate"
|
||||
[matDatepicker]="submittedOnDatePicker"
|
||||
formControlName="submittedOnDate"
|
||||
required
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="submittedOnDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #submittedOnDatePicker></mat-datepicker>
|
||||
@if (editClientForm.controls.submittedOnDate.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Submitted Date' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="flex-48" (click)="activatedOnDatePicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Activated On' | translate }}</mat-label>
|
||||
<input
|
||||
[placeholder]="'labels.inputs.Activated On' | translate"
|
||||
[title]="'labels.inputs.Activated On' | translate"
|
||||
matInput
|
||||
[min]="editClientForm.value.submittedOnDate"
|
||||
[max]="maxDate"
|
||||
[matDatepicker]="activatedOnDatePicker"
|
||||
formControlName="activationDate"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="activatedOnDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #activatedOnDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<!-- Tab: Family Members / Nominees -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="users" class="tab-icon"></fa-icon>
|
||||
FAMILY
|
||||
</ng-template>
|
||||
@let familyTabIdx = legalFormType === 2 ? 4 : 3;
|
||||
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(familyTabIdx)">
|
||||
<div class="tab-mode-bar">
|
||||
@if (isTabReadOnly(familyTabIdx)) {
|
||||
<button mat-flat-button class="tab-mode-btn tab-mode-btn--edit" (click)="enableTabEdit(familyTabIdx)">
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-stroked-button class="tab-mode-btn tab-mode-btn--cancel" (click)="cancelTabEdit(familyTabIdx)">
|
||||
<mat-icon>close</mat-icon> Cancel
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</mat-card-content>
|
||||
<mifosx-client-family-members-step #clientFamilyMembers
|
||||
[clientTemplate]="clientDataAndTemplate"
|
||||
[clientId]="clientId"
|
||||
[readOnly]="isTabReadOnly(familyTabIdx)"
|
||||
></mifosx-client-family-members-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-card-actions class="button-container">
|
||||
<button type="button" mat-raised-button [routerLink]="['../general']">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
<!-- Tab: Application Summary & Submit -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="eye" class="tab-icon"></fa-icon>
|
||||
Application Summary
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
<mifosx-client-preview-step
|
||||
[clientTemplate]="clientDataAndTemplate"
|
||||
[client]="client"
|
||||
[kycData]="clientKycStep?.kycData"
|
||||
[addressData]="clientIndiaAddressStep?.addressData"
|
||||
[guardian]="clientGeneralStep?.selectedGuardian"
|
||||
[guardianRelation]="clientGeneralStep?.createClientForm?.get('guardianDetails.guardianRelation')?.value || ''"
|
||||
(submitEvent)="submit()"
|
||||
></mifosx-client-preview-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
|
||||
<!-- Bottom Action Bar -->
|
||||
<div class="action-bar">
|
||||
<button mat-raised-button [routerLink]="['../']" class="cancel-btn">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<div class="action-bar-right">
|
||||
@if (selectedTabIndex > 0) {
|
||||
<button mat-raised-button (click)="selectedTabIndex = selectedTabIndex - 1">
|
||||
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Previous' | translate }}
|
||||
</button>
|
||||
<button type="submit" mat-raised-button color="primary" [disabled]="!editClientForm.valid" (click)="submit()">
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
}
|
||||
@if (selectedTabIndex < totalTabs - 1) {
|
||||
<button mat-raised-button color="primary" (click)="onNext()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
</button>
|
||||
</mat-card-actions>
|
||||
</form>
|
||||
</mat-card>
|
||||
}
|
||||
@if (selectedTabIndex === totalTabs - 1 && areFormvalids()) {
|
||||
<button mat-raised-button color="primary" (click)="submit()" [disabled]="savingClient">
|
||||
@if (savingClient) {
|
||||
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
|
||||
Saving...
|
||||
} @else {
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,243 +6,146 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.margin-v {
|
||||
margin: 2em 0 0;
|
||||
.create-client-container {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.flex-32 {
|
||||
flex: 0 1 32%;
|
||||
max-width: 32%;
|
||||
}
|
||||
|
||||
.gap-2percent {
|
||||
gap: 2%;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Enhanced layout styles with Material compatibility
|
||||
.container {
|
||||
margin: 24px auto;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
padding: 0 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// More specific Material overrides
|
||||
:host ::ng-deep .mat-card {
|
||||
padding: 32px;
|
||||
margin: 16px 0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
:host ::ng-deep .mat-card-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// Flex utilities with better responsive support
|
||||
.flex-24 {
|
||||
flex: 0 1 calc(25% - 15px);
|
||||
max-width: calc(25% - 15px);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.flex-48 {
|
||||
flex: 0 1 calc(50% - 10px);
|
||||
max-width: calc(50% - 10px);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.responsive-row {
|
||||
.client-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.header-icon {
|
||||
font-size: 20px;
|
||||
color: var(--md-sys-color-primary, #1074b9);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.responsive-row:last-child {
|
||||
margin-bottom: 0;
|
||||
.client-id-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
padding: 5px 14px;
|
||||
background: #eff6ff;
|
||||
color: #1074b9;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
}
|
||||
|
||||
// Enhanced Material form field styling
|
||||
:host ::ng-deep .mat-form-field {
|
||||
.client-tabs {
|
||||
background: var(--md-sys-color-surface, #fff);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
|
||||
::ng-deep {
|
||||
.mat-mdc-tab-header {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.mat-mdc-tab {
|
||||
min-width: 160px;
|
||||
padding: 0 24px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body-wrapper {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body-content {
|
||||
overflow: visible !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.tab-section {
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:host ::ng-deep .mat-form-field:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
// Ensure consistent form field appearance
|
||||
:host ::ng-deep .responsive-row .mat-form-field {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .responsive-row .mat-form-field .mat-form-field-wrapper {
|
||||
padding-bottom: 1.3438em;
|
||||
}
|
||||
|
||||
// Error state styling and form field appearance consistency
|
||||
:host ::ng-deep .mat-form-field-appearance-outline {
|
||||
.mat-form-field-outline {
|
||||
color: rgb(0 0 0 / 12%);
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep .mat-form-field.mat-form-field-invalid {
|
||||
.mat-form-field-outline-thick {
|
||||
color: #f44336;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep .mat-error {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
margin: 32px 0 24px;
|
||||
border-color: rgb(0 0 0 / 12%);
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
:host ::ng-deep .mat-checkbox {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
// Enhanced button styling with better Material integration
|
||||
:host ::ng-deep mat-card-actions {
|
||||
.action-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 24px 0;
|
||||
margin-top: 32px;
|
||||
border-top: 1px solid rgb(0 0 0 / 12%);
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
:host ::ng-deep mat-card-actions button {
|
||||
min-width: 100px;
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.3px;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
.action-bar-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// Enhanced responsive breakpoints
|
||||
@media (width <= 1024px) {
|
||||
.flex-24 {
|
||||
flex: 0 1 calc(50% - 10px);
|
||||
max-width: calc(50% - 10px);
|
||||
}
|
||||
.cancel-btn {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.flex-48 {
|
||||
flex: 0 1 100%;
|
||||
max-width: 100%;
|
||||
/* ─── Read-only tab state ─── */
|
||||
.tab-content--readonly {
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ─── Tab mode toolbar ─── */
|
||||
.tab-mode-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
|
||||
.tab-mode-btn {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
height: 34px !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 0 14px !important;
|
||||
|
||||
.mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.flex-32,
|
||||
.flex-24,
|
||||
.flex-48 {
|
||||
flex: 0 1 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 16px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .mat-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.responsive-row {
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.responsive-row .mat-form-field {
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
margin-right: 0;
|
||||
}
|
||||
.tab-mode-btn--edit {
|
||||
background: #1074b9 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
:host ::ng-deep mat-card-actions {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
:host ::ng-deep mat-card-actions button {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 480px) {
|
||||
:host ::ng-deep .mat-card {
|
||||
padding: 16px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.responsive-row {
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// Fix for potential Material Design conflicts
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Ensure proper form styling
|
||||
:host ::ng-deep form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// DatePicker specific styling
|
||||
:host ::ng-deep .mat-datepicker-toggle {
|
||||
color: rgb(0 0 0 / 54%);
|
||||
}
|
||||
|
||||
.external-id-status {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&.loading {
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: #388e3c;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: #d32f2f;
|
||||
}
|
||||
}
|
||||
|
||||
// Select dropdown styling
|
||||
:host ::ng-deep .mat-select-panel {
|
||||
max-height: 256px;
|
||||
.tab-mode-btn--cancel {
|
||||
background: #fff !important;
|
||||
color: #ef4444 !important;
|
||||
border: 1px solid #fca5a5 !important;
|
||||
}
|
||||
|
||||
@@ -7,226 +7,159 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { LegalFormId } from 'app/clients/models/legal-form.enum';
|
||||
import {
|
||||
UntypedFormBuilder,
|
||||
UntypedFormGroup,
|
||||
Validators,
|
||||
UntypedFormControl,
|
||||
ReactiveFormsModule
|
||||
} from '@angular/forms';
|
||||
import { Component, QueryList, ViewChild, ViewChildren, inject } from '@angular/core';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
|
||||
|
||||
/** Custom Components */
|
||||
import { ClientGeneralStepComponent } from '../client-stepper/client-general-step/client-general-step.component';
|
||||
import { ClientAddressStepComponent } from '../client-stepper/client-address-step/client-address-step.component';
|
||||
import { ClientIndiaAddressStepComponent } from '../client-stepper/client-india-address-step/client-india-address-step.component';
|
||||
import { ClientEntityKycStepComponent } from '../client-stepper/client-entity-kyc-step/client-entity-kyc-step.component';
|
||||
import { ClientDatatableStepComponent } from '../client-stepper/client-datatable-step/client-datatable-step.component';
|
||||
import { ClientKycStepComponent } from '../client-stepper/client-kyc-step/client-kyc-step.component';
|
||||
import { ClientPreviewStepComponent } from '../client-stepper/client-preview-step/client-preview-step.component';
|
||||
import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component';
|
||||
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
* Edit Client Component
|
||||
* Edit Client Component — uses the same wizard/stepper layout as Create Client.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-edit-client',
|
||||
templateUrl: './edit-client.component.html',
|
||||
styleUrls: ['./edit-client.component.scss'],
|
||||
providers: [ExternalNationalIdService],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatTabsModule,
|
||||
MatIconModule,
|
||||
MatDivider,
|
||||
CdkTextareaAutosize,
|
||||
MatCheckbox
|
||||
FaIconComponent,
|
||||
MatProgressSpinner,
|
||||
MatTooltip,
|
||||
ClientGeneralStepComponent,
|
||||
ClientAddressStepComponent,
|
||||
ClientIndiaAddressStepComponent,
|
||||
ClientEntityKycStepComponent,
|
||||
ClientDatatableStepComponent,
|
||||
ClientKycStepComponent,
|
||||
ClientPreviewStepComponent,
|
||||
ClientFamilyMembersStepComponent
|
||||
]
|
||||
})
|
||||
export class EditClientComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
export class EditClientComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private clientsService = inject(ClientsService);
|
||||
private dateUtils = inject(Dates);
|
||||
private settingsService = inject(SettingsService);
|
||||
externalNationalIdService = inject(ExternalNationalIdService);
|
||||
|
||||
/** Minimum date allowed. */
|
||||
minDate = new Date(2000, 0, 1);
|
||||
/** Maximum date allowed. */
|
||||
maxDate = new Date();
|
||||
@ViewChild(ClientGeneralStepComponent) clientGeneralStep: ClientGeneralStepComponent;
|
||||
@ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent;
|
||||
@ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent;
|
||||
@ViewChild('clientEntityKyc') clientEntityKycStep: ClientEntityKycStepComponent;
|
||||
@ViewChild('clientKyc') clientKycStep: ClientKycStepComponent;
|
||||
@ViewChild('clientFamilyMembers') clientFamilyMembersStep: ClientFamilyMembersStepComponent;
|
||||
@ViewChildren('dtclient') clientDatatables: QueryList<ClientDatatableStepComponent>;
|
||||
|
||||
/** Client Data and Template */
|
||||
datatables: any = [];
|
||||
legalFormType = 1;
|
||||
selectedTabIndex = 0;
|
||||
savingClient = false;
|
||||
|
||||
/** Track which tabs are in edit mode (false = read-only, true = editable) */
|
||||
tabEditMode: Record<number, boolean> = {};
|
||||
|
||||
/** Client data and template (merged object from resolver) */
|
||||
clientDataAndTemplate: any;
|
||||
/** Edit Client Form */
|
||||
editClientForm: UntypedFormGroup;
|
||||
/** Client ID */
|
||||
clientId: string;
|
||||
|
||||
/** Office Options */
|
||||
officeOptions: any;
|
||||
/** Staff Options */
|
||||
staffOptions: any;
|
||||
/** Legal Form Options */
|
||||
legalFormOptions: any;
|
||||
/** Client Type Options */
|
||||
clientTypeOptions: any;
|
||||
/** Client Classification Options */
|
||||
clientClassificationTypeOptions: any;
|
||||
/** Business Line Options */
|
||||
businessLineOptions: any;
|
||||
/** Constitution Options */
|
||||
constitutionOptions: any;
|
||||
/** Gender Options */
|
||||
genderOptions: any;
|
||||
legalFormId = LegalFormId.PERSON;
|
||||
|
||||
/** Expose enum to template */
|
||||
readonly LegalFormId = LegalFormId;
|
||||
|
||||
/**
|
||||
* Fetches client template data from `resolve`
|
||||
* @param {FormBuilder} formBuilder Form Builder
|
||||
* @param {ActivatedRoute} route ActivatedRoute
|
||||
* @param {Router} router Router
|
||||
* @param {ClientsService} clientsService Clients Service
|
||||
* @param {Dates} dateUtils Date Utils
|
||||
* @param {SettingsService} settingsService Settings Service
|
||||
*/
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { clientDataAndTemplate: any }) => {
|
||||
this.clientDataAndTemplate = data.clientDataAndTemplate;
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.maxDate = this.settingsService.businessDate;
|
||||
this.createEditClientForm();
|
||||
this.setOptions();
|
||||
this.buildDependencies();
|
||||
this.legalFormId = LegalFormId.PERSON;
|
||||
this.editClientForm.patchValue({
|
||||
officeId: this.clientDataAndTemplate.officeId,
|
||||
staffId: this.clientDataAndTemplate.staffId,
|
||||
legalFormId: this.clientDataAndTemplate.legalForm && this.clientDataAndTemplate.legalForm.id,
|
||||
accountNo: this.clientDataAndTemplate.accountNo,
|
||||
externalId: this.clientDataAndTemplate.externalId,
|
||||
genderId: this.clientDataAndTemplate.gender && this.clientDataAndTemplate.gender.id,
|
||||
isStaff: this.clientDataAndTemplate.isStaff,
|
||||
active: this.clientDataAndTemplate.active,
|
||||
mobileNo: this.clientDataAndTemplate.mobileNo,
|
||||
emailAddress: this.clientDataAndTemplate.emailAddress,
|
||||
dateOfBirth: this.clientDataAndTemplate.dateOfBirth && new Date(this.clientDataAndTemplate.dateOfBirth),
|
||||
clientTypeId: this.clientDataAndTemplate.clientType && this.clientDataAndTemplate.clientType.id,
|
||||
clientClassificationId:
|
||||
this.clientDataAndTemplate.clientClassification && this.clientDataAndTemplate.clientClassification.id,
|
||||
submittedOnDate:
|
||||
this.clientDataAndTemplate.timeline.submittedOnDate &&
|
||||
new Date(this.clientDataAndTemplate.timeline.submittedOnDate),
|
||||
activationDate:
|
||||
this.clientDataAndTemplate.timeline.activatedOnDate &&
|
||||
new Date(this.clientDataAndTemplate.timeline.activatedOnDate)
|
||||
});
|
||||
if (this.clientDataAndTemplate.legalForm) {
|
||||
this.legalFormId = this.clientDataAndTemplate.legalForm.id;
|
||||
}
|
||||
// skipInitialValue=true: avoid re-fetching data for an already-saved external ID
|
||||
this.externalNationalIdService.watchExternalId(this.editClientForm, this.genderOptions, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the edit client form.
|
||||
*/
|
||||
createEditClientForm() {
|
||||
this.editClientForm = this.formBuilder.group({
|
||||
officeId: [{ value: '', disabled: true }],
|
||||
staffId: [''],
|
||||
legalFormId: [{ value: '', disabled: true }],
|
||||
isStaff: [false],
|
||||
active: [false],
|
||||
accountNo: [{ value: '', disabled: true }],
|
||||
externalId: [''],
|
||||
genderId: [''],
|
||||
mobileNo: [''],
|
||||
emailAddress: [
|
||||
'',
|
||||
Validators.email
|
||||
],
|
||||
dateOfBirth: [''],
|
||||
clientTypeId: [''],
|
||||
clientClassificationId: [''],
|
||||
submittedOnDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
activationDate: ['']
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets select dropdown options.
|
||||
*/
|
||||
setOptions() {
|
||||
this.officeOptions = this.clientDataAndTemplate.officeOptions;
|
||||
this.staffOptions = this.clientDataAndTemplate.staffOptions;
|
||||
this.legalFormOptions = this.clientDataAndTemplate.clientLegalFormOptions;
|
||||
this.clientTypeOptions = this.clientDataAndTemplate.clientTypeOptions;
|
||||
this.clientClassificationTypeOptions = this.clientDataAndTemplate.clientClassificationOptions;
|
||||
this.businessLineOptions = this.clientDataAndTemplate.clientNonPersonMainBusinessLineOptions;
|
||||
this.constitutionOptions = this.clientDataAndTemplate.clientNonPersonConstitutionOptions;
|
||||
this.genderOptions = this.clientDataAndTemplate.genderOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds controls conditionally.
|
||||
*/
|
||||
buildDependencies() {
|
||||
this.editClientForm.get('legalFormId').valueChanges.subscribe((legalFormId: any) => {
|
||||
if (legalFormId === LegalFormId.PERSON) {
|
||||
this.editClientForm.removeControl('fullname');
|
||||
this.editClientForm.removeControl('clientNonPersonDetails');
|
||||
this.editClientForm.addControl(
|
||||
'firstname',
|
||||
new UntypedFormControl(this.clientDataAndTemplate.firstname, Validators.required)
|
||||
);
|
||||
this.editClientForm.addControl('middlename', new UntypedFormControl(this.clientDataAndTemplate.middlename));
|
||||
this.editClientForm.addControl(
|
||||
'lastname',
|
||||
new UntypedFormControl(this.clientDataAndTemplate.lastname, Validators.required)
|
||||
);
|
||||
} else {
|
||||
this.editClientForm.removeControl('firstname');
|
||||
this.editClientForm.removeControl('middlename');
|
||||
this.editClientForm.removeControl('lastname');
|
||||
this.editClientForm.addControl(
|
||||
'fullname',
|
||||
new UntypedFormControl(this.clientDataAndTemplate.fullname, Validators.required)
|
||||
);
|
||||
this.editClientForm.addControl(
|
||||
'clientNonPersonDetails',
|
||||
this.formBuilder.group({
|
||||
constitutionId: [
|
||||
this.clientDataAndTemplate.clientNonPersonDetails.constitution &&
|
||||
this.clientDataAndTemplate.clientNonPersonDetails.constitution.id,
|
||||
Validators.required
|
||||
],
|
||||
incorpValidityTillDate: [
|
||||
this.clientDataAndTemplate.clientNonPersonDetails.incorpValidityTillDate &&
|
||||
new Date(this.clientDataAndTemplate.clientNonPersonDetails.incorpValidityTillDate)
|
||||
],
|
||||
incorpNumber: [this.clientDataAndTemplate.clientNonPersonDetails.incorpNumber],
|
||||
mainBusinessLineId: [
|
||||
this.clientDataAndTemplate.clientNonPersonDetails.mainBusinessLine &&
|
||||
this.clientDataAndTemplate.clientNonPersonDetails.mainBusinessLine.id
|
||||
],
|
||||
remarks: [this.clientDataAndTemplate.clientNonPersonDetails.remarks]
|
||||
})
|
||||
);
|
||||
this.clientId = this.clientDataAndTemplate.id;
|
||||
if (this.clientDataAndTemplate.legalForm) {
|
||||
this.legalFormType = this.clientDataAndTemplate.legalForm.id;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getDateLabel(legalFormId: number, values: string[]): string {
|
||||
return legalFormId === LegalFormId.PERSON ? values[0] : values[1];
|
||||
/** Check if a tab is in read-only mode */
|
||||
isTabReadOnly(tabIndex: number): boolean {
|
||||
return !this.tabEditMode[tabIndex];
|
||||
}
|
||||
|
||||
/** Enable editing for a specific tab */
|
||||
enableTabEdit(tabIndex: number): void {
|
||||
this.tabEditMode[tabIndex] = true;
|
||||
}
|
||||
|
||||
/** Cancel editing — revert tab back to read-only */
|
||||
cancelTabEdit(tabIndex: number): void {
|
||||
this.tabEditMode[tabIndex] = false;
|
||||
}
|
||||
|
||||
get clientGeneralForm() {
|
||||
return this.clientGeneralStep?.createClientForm;
|
||||
}
|
||||
|
||||
/** Total tab count (+1 for Nominees, +1 for Non-Individual Entity KYC) */
|
||||
get totalTabs(): number {
|
||||
return this.legalFormType === 2 ? 6 : 5;
|
||||
}
|
||||
|
||||
/** Get constitution type from general step for entity KYC */
|
||||
getConstitutionType(): string {
|
||||
return this.clientGeneralStep?.createClientForm?.get('clientNonPersonDetails.constitutionId')?.value || '';
|
||||
}
|
||||
|
||||
get client() {
|
||||
const general = this.clientGeneralStep?.clientGeneralDetails || {};
|
||||
return { ...general };
|
||||
}
|
||||
|
||||
areFormvalids(): boolean {
|
||||
if (!this.clientGeneralStep || !this.clientGeneralForm) {
|
||||
return false;
|
||||
}
|
||||
let areValids = this.clientGeneralForm.valid;
|
||||
if (this.clientGeneralStep.sanctionsBlocked) {
|
||||
return false;
|
||||
}
|
||||
return areValids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Next button handler — just navigates between tabs (client already exists in edit mode).
|
||||
*/
|
||||
onNext() {
|
||||
if (this.selectedTabIndex === 0) {
|
||||
if (!this.clientGeneralForm?.valid) {
|
||||
this.clientGeneralForm?.markAllAsTouched();
|
||||
setTimeout(() => {
|
||||
const firstInvalid = document.querySelector('.mat-mdc-form-field.ng-invalid');
|
||||
if (firstInvalid) {
|
||||
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.selectedTabIndex = this.selectedTabIndex + 1;
|
||||
}
|
||||
|
||||
legalFormChange(eventData: { legalForm: number }) {
|
||||
this.legalFormType = eventData.legalForm;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,33 +168,144 @@ export class EditClientComponent implements OnInit {
|
||||
submit() {
|
||||
const locale = this.settingsService.language.code;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const editClientFormValue: any = this.editClientForm.getRawValue();
|
||||
const clientData = {
|
||||
...editClientFormValue,
|
||||
dateOfBirth:
|
||||
editClientFormValue.dateOfBirth && this.dateUtils.formatDate(editClientFormValue.dateOfBirth, dateFormat),
|
||||
submittedOnDate:
|
||||
editClientFormValue.submittedOnDate &&
|
||||
this.dateUtils.formatDate(editClientFormValue.submittedOnDate, dateFormat),
|
||||
activationDate: this.dateUtils.formatDate(editClientFormValue.activationDate, dateFormat),
|
||||
...this.client,
|
||||
dateFormat,
|
||||
locale
|
||||
};
|
||||
|
||||
// Remove fields not accepted by update API
|
||||
delete clientData.officeId;
|
||||
if (editClientFormValue.clientNonPersonDetails) {
|
||||
clientData.clientNonPersonDetails = {
|
||||
...editClientFormValue.clientNonPersonDetails,
|
||||
incorpValidityTillDate:
|
||||
editClientFormValue.clientNonPersonDetails.incorpValidityTillDate &&
|
||||
this.dateUtils.formatDate(editClientFormValue.clientNonPersonDetails.incorpValidityTillDate, dateFormat),
|
||||
dateFormat,
|
||||
locale
|
||||
};
|
||||
} else {
|
||||
clientData.clientNonPersonDetails = {};
|
||||
}
|
||||
this.clientsService.updateClient(this.clientDataAndTemplate.id, clientData).subscribe(() => {
|
||||
this.router.navigate(['../'], { relativeTo: this.route });
|
||||
|
||||
this.savingClient = true;
|
||||
this.clientsService.updateClient(this.clientId, clientData).subscribe({
|
||||
next: () => {
|
||||
this.savingClient = false;
|
||||
const navigateBack = () => this.router.navigate(['../'], { relativeTo: this.route });
|
||||
|
||||
// Upload photo if selected
|
||||
if (this.clientKycStep?.photographFile) {
|
||||
this.clientsService.uploadClientProfileImage(this.clientId, this.clientKycStep.photographFile).subscribe({ error: () => {} });
|
||||
}
|
||||
|
||||
// Upload signature if selected
|
||||
if (this.clientKycStep?.signatureFile) {
|
||||
this.clientsService.uploadClientSignatureImage(this.clientId, this.clientKycStep.signatureFile).subscribe({ error: () => {} });
|
||||
}
|
||||
|
||||
// Save addresses from India address step
|
||||
if (this.clientIndiaAddressStep?.hasAddressData) {
|
||||
this.saveClientAddresses(this.clientId);
|
||||
}
|
||||
|
||||
// Save new family members
|
||||
if (this.clientFamilyMembersStep?.hasFamilyMembers) {
|
||||
this.saveClientFamilyMembers(this.clientId);
|
||||
}
|
||||
|
||||
// Save/update KYC data if available
|
||||
if (this.clientKycStep?.hasKycData) {
|
||||
this.clientsService.saveClientKyc(this.clientId, this.clientKycStep.kycData).subscribe({
|
||||
next: () => navigateBack(),
|
||||
error: () => navigateBack()
|
||||
});
|
||||
} else {
|
||||
navigateBack();
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.savingClient = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save addresses from the India address step to Fineract address API.
|
||||
*/
|
||||
private saveClientAddresses(clientId: string) {
|
||||
const addressData = this.clientIndiaAddressStep.addressData;
|
||||
const typeNameMap: Record<string, string[]> = {
|
||||
permanent: ['Permanent', 'Permanent Address'],
|
||||
current: ['Residential', 'Current', 'Current / Residential', 'Communication'],
|
||||
office: ['Office', 'Office / Work', 'Business', 'Work'],
|
||||
correspondence: ['Correspondence', 'Mailing', 'Postal']
|
||||
};
|
||||
|
||||
this.clientsService.getClientAddressTemplate().subscribe({
|
||||
next: (template: any) => {
|
||||
const typeOptions = template?.addressTypeIdOptions || [];
|
||||
for (const [tab, addr] of Object.entries(addressData)) {
|
||||
if (!addr || !(addr as any).addressLine1) continue;
|
||||
|
||||
const possibleNames = typeNameMap[tab] || [tab];
|
||||
let typeId = typeOptions.find((t: any) =>
|
||||
possibleNames.some((n: string) => t.name?.toLowerCase().includes(n.toLowerCase()))
|
||||
)?.id;
|
||||
|
||||
if (!typeId && typeOptions.length > 0) typeId = typeOptions[0].id;
|
||||
if (!typeId) continue;
|
||||
|
||||
const payload: any = {
|
||||
addressLine1: (addr as any).addressLine1 || '',
|
||||
addressLine2: (addr as any).addressLine2 || '',
|
||||
addressLine3: '',
|
||||
townVillage: (addr as any).locality || '',
|
||||
postalCode: (addr as any).pinCode || '',
|
||||
city: (addr as any).city || '',
|
||||
countyDistrict: (addr as any).district || '',
|
||||
street: (addr as any).landmark || '',
|
||||
addressType: typeId,
|
||||
isActive: tab === 'permanent'
|
||||
};
|
||||
|
||||
if ((addr as any).state && template?.stateProvinceIdOptions?.length) {
|
||||
const stateMatch = template.stateProvinceIdOptions.find((s: any) =>
|
||||
s.name?.toLowerCase() === (addr as any).state?.toLowerCase()
|
||||
);
|
||||
if (stateMatch) payload.stateProvinceId = stateMatch.id;
|
||||
}
|
||||
|
||||
this.clientsService.createClientAddress(clientId, typeId.toString(), payload).subscribe({ error: () => {} });
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
private saveClientFamilyMembers(clientId: string) {
|
||||
const members = this.clientFamilyMembersStep.clientFamilyMembers;
|
||||
for (const member of members) {
|
||||
// Skip existing members (already have an id from the API)
|
||||
if (member.id) continue;
|
||||
const marriageCertFile = member._marriageCertFile || null;
|
||||
const payload = { ...member };
|
||||
// Remove internal/transient fields
|
||||
delete payload._linkedClient;
|
||||
delete payload._marriageCertFile;
|
||||
delete payload.dateFormat;
|
||||
delete payload.locale;
|
||||
payload.dateFormat = this.settingsService.dateFormat;
|
||||
payload.locale = this.settingsService.language.code;
|
||||
this.clientsService.addFamilyMember(clientId, payload).subscribe({
|
||||
next: (res: any) => {
|
||||
// Upload marriage certificate if present
|
||||
if (marriageCertFile && res?.resourceId) {
|
||||
this.uploadFamilyMemberDocument(clientId, res.resourceId, marriageCertFile, 'Marriage Certificate');
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a document for a family member.
|
||||
*/
|
||||
private uploadFamilyMemberDocument(clientId: string, familyMemberId: string, file: File, docName: string) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('name', docName);
|
||||
formData.append('description', docName);
|
||||
this.clientsService.uploadFamilyMemberDocument(clientId, familyMemberId, formData).subscribe({ error: () => {} });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,12 +52,20 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
const isClientImage404 = status === 404 && request.url.includes('/clients/') && request.url.includes('/images');
|
||||
const isClientImageRequest = request.url.includes('/clients/') && request.url.includes('/images');
|
||||
const isClientKycRequest = request.url.match(/\/clients\/\d+\/kyc$/);
|
||||
const isSilentRequest = isClientImageRequest || isClientKycRequest;
|
||||
|
||||
if (!environment.production && !isClientImage404) {
|
||||
if (!environment.production && !isSilentRequest) {
|
||||
log.error(`Request Error: ${errorMessage}`);
|
||||
}
|
||||
|
||||
// Silently handle expected 403/404 errors (no image, no KYC record)
|
||||
// Throw instead of EMPTY so component-level catchError can handle it
|
||||
if (isSilentRequest && (status === 403 || status === 404)) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
if (status === 401 || (environment.oauth.enabled && status === 400)) {
|
||||
this.alertService.alert({ type: 'Authentication Error', message: 'Invalid User Details. Please try again!' });
|
||||
} else if (status === 403 && errorMessage === 'The provided one time token is invalid') {
|
||||
@@ -73,12 +81,7 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
||||
message: errorMessage || 'You are not authorized for this request!'
|
||||
});
|
||||
} else if (status === 404) {
|
||||
// Check if this is an image request that should be silently handled (client profile image)
|
||||
if (isClientImage404) {
|
||||
// Don't show alerts for missing client images
|
||||
// This is an expected condition, not an error
|
||||
return EMPTY;
|
||||
} else {
|
||||
{
|
||||
this.alertService.alert({
|
||||
type: this.translate.instant('error.resource.not.found'),
|
||||
message: errorMessage || 'Resource does not exist!'
|
||||
@@ -94,8 +97,11 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
||||
type: this.translate.instant('error.resource.notImplemented.type'),
|
||||
message: this.translate.instant('error.resource.notImplemented.message')
|
||||
});
|
||||
} else {
|
||||
this.alertService.alert({ type: 'Unknown Error', message: 'Unknown Error. Please try again later.' });
|
||||
} else if (status === 503) {
|
||||
this.alertService.alert({
|
||||
type: 'Service Unavailable',
|
||||
message: errorMessage || 'The server is currently unable to handle the request. Please try after some time.'
|
||||
});
|
||||
}
|
||||
|
||||
throw response;
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
|
||||
<!-- Content -->
|
||||
<mat-sidenav-content class="sidenav">
|
||||
<!-- Sidebar Toggle Handle -->
|
||||
<!-- Sidebar Toggle Handle (hidden on small/handset screens) -->
|
||||
@if ((isHandset$ | async) === false) {
|
||||
<div
|
||||
class="sidebar-handle"
|
||||
[class.collapsed]="sidenavCollapsed"
|
||||
@@ -42,99 +43,100 @@
|
||||
}
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Toolbar -->
|
||||
<mifosx-toolbar [sidenav]="sidenav" (collapse)="toggleCollapse($event)"></mifosx-toolbar>
|
||||
|
||||
<!-- Main Header — 2-Row Navigation -->
|
||||
<div class="main-header">
|
||||
<!-- Row 1 — 11 items -->
|
||||
<!-- Row 1 — 11 items (permission-gated) -->
|
||||
<div class="nav-row r1">
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="institutionMenu">
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="institutionMenu" *mifosxHasPermission="'MENU_INSTITUTION'">
|
||||
<fa-icon icon="university" class="ni-ico"></fa-icon>Institution
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" routerLink="/checker-inbox-and-tasks/checker-inbox">
|
||||
<a class="ni" tabindex="0" routerLink="/checker-inbox-and-tasks/checker-inbox" *mifosxHasPermission="'MENU_TASKS'">
|
||||
<fa-icon icon="check" class="ni-ico"></fa-icon>Tasks
|
||||
</a>
|
||||
<a class="ni" tabindex="0" routerLink="/accounting">
|
||||
<a class="ni" tabindex="0" routerLink="/accounting" *mifosxHasPermission="'MENU_ACCOUNTING'">
|
||||
<fa-icon icon="money-bill-alt" class="ni-ico"></fa-icon>Accounting
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="reportsMenu">
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="reportsMenu" *mifosxHasPermission="'MENU_REPORTS'">
|
||||
<fa-icon icon="chart-bar" class="ni-ico"></fa-icon>Reports
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="adminMenu">
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="adminMenu" *mifosxHasPermission="'MENU_ADMIN'">
|
||||
<fa-icon icon="shield-alt" class="ni-ico"></fa-icon>Admin
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" (click)="subLedgerOpen = !subLedgerOpen; glMenuOpen = false">
|
||||
<a class="ni" tabindex="0" (click)="subLedgerOpen = !subLedgerOpen; glMenuOpen = false" *mifosxHasPermission="'MENU_SUB_LEDGER'">
|
||||
<fa-icon icon="book" class="ni-ico"></fa-icon>Sub Ledger
|
||||
<fa-icon [icon]="subLedgerOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" (click)="glMenuOpen = !glMenuOpen; subLedgerOpen = false">
|
||||
<a class="ni" tabindex="0" (click)="glMenuOpen = !glMenuOpen; subLedgerOpen = false" *mifosxHasPermission="'MENU_GENERAL_LEDGER'">
|
||||
<fa-icon icon="book-open" class="ni-ico"></fa-icon>General Ledger
|
||||
<fa-icon [icon]="glMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu">
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'">
|
||||
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0">
|
||||
<a class="ni" tabindex="0" *mifosxHasPermission="'MENU_OFFICER'">
|
||||
<fa-icon icon="user-tie" class="ni-ico"></fa-icon>Officer
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="depositsMenu">
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="depositsMenu" *mifosxHasPermission="'MENU_DEPOSITS'">
|
||||
<fa-icon icon="piggy-bank" class="ni-ico"></fa-icon>Deposits
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="clearingMenu">
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="clearingMenu" *mifosxHasPermission="'MENU_CLEARING'">
|
||||
<fa-icon icon="exchange" class="ni-ico"></fa-icon>Clearing
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Row 2 — 11 items -->
|
||||
<!-- Row 2 — 11 items (permission-gated) -->
|
||||
<div class="nav-row r2">
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="hrmMenu">
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="hrmMenu" *mifosxHasPermission="'MENU_HRM'">
|
||||
<fa-icon icon="money-check" class="ni-ico"></fa-icon>HRM
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="shareMenu">
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="shareMenu" *mifosxHasPermission="'MENU_SHARE'">
|
||||
<fa-icon icon="coins" class="ni-ico"></fa-icon>Share
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="othersMenu">
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="othersMenu" *mifosxHasPermission="'MENU_OTHERS'">
|
||||
<fa-icon icon="folder-open" class="ni-ico"></fa-icon>Others
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="npaMenu">
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="npaMenu" *mifosxHasPermission="'MENU_NPA'">
|
||||
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu">
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
||||
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="insuranceMenu">
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="insuranceMenu" *mifosxHasPermission="'MENU_INSURANCE'">
|
||||
<fa-icon icon="shield-alt" class="ni-ico"></fa-icon>Insurance
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" (click)="rbiMenuOpen = !rbiMenuOpen; glMenuOpen = false">
|
||||
<a class="ni" tabindex="0" (click)="rbiMenuOpen = !rbiMenuOpen; glMenuOpen = false" *mifosxHasPermission="'MENU_RBI'">
|
||||
<fa-icon icon="university" class="ni-ico"></fa-icon>RBI
|
||||
<fa-icon [icon]="rbiMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="digitalMenu">
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="digitalMenu" *mifosxHasPermission="'MENU_DIGITAL'">
|
||||
<fa-icon icon="mobile-alt" class="ni-ico"></fa-icon>Digital
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" (click)="openConfigWizard()">
|
||||
<a class="ni" tabindex="0" (click)="openConfigWizard()" *mifosxHasPermission="'MENU_CONFIG_WIZARD'">
|
||||
<fa-icon icon="cog" class="ni-ico"></fa-icon>Config Wizard
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="ckycMenu">
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="ckycMenu" *mifosxHasPermission="'MENU_CKYC_MGMT'">
|
||||
<fa-icon icon="fingerprint" class="ni-ico"></fa-icon>CKYC Mgmt
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="letterMgmtMenu">
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="letterMgmtMenu" *mifosxHasPermission="'MENU_LETTER_MGMT'">
|
||||
<fa-icon icon="envelope" class="ni-ico"></fa-icon>Letter Mgmt
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
@@ -142,33 +144,65 @@
|
||||
</div>
|
||||
|
||||
<!-- Row 1 Menus -->
|
||||
<mat-menu #institutionMenu="matMenu" [overlapTrigger]="false">
|
||||
<button mat-menu-item routerLink="/clients" tabindex="0">Clients</button>
|
||||
<button mat-menu-item routerLink="/groups" tabindex="0">Groups</button>
|
||||
<button mat-menu-item routerLink="/centers" tabindex="0">Centers</button>
|
||||
<mat-menu #institutionMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
|
||||
<button mat-menu-item routerLink="/clients" tabindex="0">
|
||||
<i class="fa fa-users menu-item-icon"></i> Clients
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/groups" tabindex="0">
|
||||
<i class="fa fa-object-group menu-item-icon"></i> Groups
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/centers" tabindex="0">
|
||||
<i class="fa fa-building menu-item-icon"></i> Centers
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #reportsMenu="matMenu" [overlapTrigger]="false">
|
||||
<button mat-menu-item [routerLink]="['/reports']" tabindex="0">All</button>
|
||||
<button mat-menu-item [routerLink]="['/reports', 'Client']" tabindex="0">Clients</button>
|
||||
<button mat-menu-item [routerLink]="['/reports', 'Loan']" tabindex="0">Loans</button>
|
||||
<button mat-menu-item [routerLink]="['/reports', 'Savings']" tabindex="0">Savings</button>
|
||||
<button mat-menu-item [routerLink]="['/reports', 'Fund']" tabindex="0">Funds</button>
|
||||
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">Accounting</button>
|
||||
<mat-menu #reportsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
|
||||
<button mat-menu-item [routerLink]="['/reports']" tabindex="0">
|
||||
<i class="fa fa-th-list menu-item-icon"></i> All Reports
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="['/reports', 'Client']" tabindex="0">
|
||||
<i class="fa fa-user menu-item-icon"></i> Clients
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="['/reports', 'Loan']" tabindex="0">
|
||||
<i class="fa fa-credit-card menu-item-icon"></i> Loans
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="['/reports', 'Savings']" tabindex="0">
|
||||
<i class="fa fa-piggy-bank menu-item-icon"></i> Savings
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="['/reports', 'Fund']" tabindex="0">
|
||||
<i class="fa fa-hand-holding-usd menu-item-icon"></i> Funds
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
|
||||
<i class="fa fa-calculator menu-item-icon"></i> Accounting
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #adminMenu="matMenu" [overlapTrigger]="false">
|
||||
<button mat-menu-item [routerLink]="['/appusers']" tabindex="0">Users</button>
|
||||
<button mat-menu-item [routerLink]="['/organization']" tabindex="0">Organization</button>
|
||||
<button mat-menu-item [routerLink]="['/system']" tabindex="0">System</button>
|
||||
<button mat-menu-item [routerLink]="['/products']" tabindex="0">Products</button>
|
||||
<button mat-menu-item routerLink="/templates" tabindex="0">Templates</button>
|
||||
<mat-menu #adminMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
|
||||
<button mat-menu-item [routerLink]="['/appusers']" tabindex="0">
|
||||
<i class="fa fa-user-cog menu-item-icon"></i> Users
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="['/organization']" tabindex="0">
|
||||
<i class="fa fa-sitemap menu-item-icon"></i> Organization
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="['/system']" tabindex="0">
|
||||
<i class="fa fa-server menu-item-icon"></i> System
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="['/products']" tabindex="0">
|
||||
<i class="fa fa-box menu-item-icon"></i> Products
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/templates" tabindex="0">
|
||||
<i class="fa fa-file-alt menu-item-icon"></i> Templates
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<!-- Sub Ledger Mega Menu -->
|
||||
@if (subLedgerOpen) {
|
||||
<div class="gl-backdrop" (click)="subLedgerOpen = false"></div>
|
||||
<div class="gl-mega-menu">
|
||||
<div class="gl-mega-menu-header">
|
||||
<fa-icon icon="book" class="gl-mega-menu-header-icon"></fa-icon>
|
||||
<span>Sub Ledger</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="gl-search"
|
||||
@@ -323,6 +357,10 @@
|
||||
@if (glMenuOpen) {
|
||||
<div class="gl-backdrop" (click)="glMenuOpen = false"></div>
|
||||
<div class="gl-mega-menu">
|
||||
<div class="gl-mega-menu-header">
|
||||
<fa-icon icon="book-open" class="gl-mega-menu-header-icon"></fa-icon>
|
||||
<span>General Ledger</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="gl-search"
|
||||
@@ -392,7 +430,7 @@
|
||||
}
|
||||
|
||||
<!-- Sub Menu Dropdowns -->
|
||||
<mat-menu #cashierMenu="matMenu" [overlapTrigger]="false">
|
||||
<mat-menu #cashierMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
|
||||
</button>
|
||||
@@ -467,7 +505,7 @@
|
||||
</div>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #clearingMenu="matMenu" [overlapTrigger]="false">
|
||||
<mat-menu #clearingMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cheque Honoured
|
||||
</button>
|
||||
@@ -698,7 +736,7 @@
|
||||
</div>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #ckycMenu="matMenu" [overlapTrigger]="false">
|
||||
<mat-menu #ckycMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="camera" class="menu-item-icon"></fa-icon> Photo</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="pen" class="menu-item-icon"></fa-icon> Sign Upload</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
@@ -819,7 +857,7 @@
|
||||
</div>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #insuranceMenu="matMenu" [overlapTrigger]="false">
|
||||
<mat-menu #insuranceMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="building" class="menu-item-icon"></fa-icon> DICGC</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMJBY</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMSBY</button>
|
||||
@@ -840,7 +878,7 @@
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #digitalMenu="matMenu" [overlapTrigger]="false">
|
||||
<mat-menu #digitalMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NEFT / RTGS</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="bolt" class="menu-item-icon"></fa-icon> IMPS / BANL</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> UPI</button>
|
||||
@@ -863,6 +901,10 @@
|
||||
@if (rbiMenuOpen) {
|
||||
<div class="gl-backdrop" (click)="rbiMenuOpen = false"></div>
|
||||
<div class="gl-mega-menu rbi-mega-menu">
|
||||
<div class="gl-mega-menu-header">
|
||||
<fa-icon icon="university" class="gl-mega-menu-header-icon"></fa-icon>
|
||||
<span>RBI</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="gl-search"
|
||||
|
||||
@@ -187,10 +187,42 @@ $gray-700: #334155;
|
||||
}
|
||||
|
||||
.menu-item-icon {
|
||||
margin-right: 10px;
|
||||
margin-right: 8px;
|
||||
width: 16px;
|
||||
color: #667085;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
color: $brand;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
// ===== Nav Dropdown Menus =====
|
||||
::ng-deep .nav-dropdown {
|
||||
border-radius: 8px !important;
|
||||
border: 1px solid $gray-200 !important;
|
||||
box-shadow: 0 6px 24px rgb(0 0 0 / 10%) !important;
|
||||
min-width: 180px !important;
|
||||
padding: 4px 0 !important;
|
||||
margin-top: 2px !important;
|
||||
|
||||
.mat-mdc-menu-item {
|
||||
font-size: 12.5px !important;
|
||||
font-weight: 500 !important;
|
||||
height: 34px !important;
|
||||
min-height: 34px !important;
|
||||
padding: 0 14px !important;
|
||||
color: $gray-700 !important;
|
||||
font-family: 'DM Sans', 'Roboto', sans-serif !important;
|
||||
|
||||
&:hover {
|
||||
background: $brand-hover !important;
|
||||
color: $brand !important;
|
||||
|
||||
.menu-item-icon {
|
||||
opacity: 1;
|
||||
color: $brand;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Deposits Mega Panel (inside mat-menu overlay) */
|
||||
@@ -240,6 +272,27 @@ $gray-700: #334155;
|
||||
border-top: 3px solid #0d47a1;
|
||||
}
|
||||
|
||||
.gl-mega-menu-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
|
||||
span {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #0d47a1;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
}
|
||||
|
||||
.gl-mega-menu-header-icon {
|
||||
font-size: 18px;
|
||||
color: #0d47a1;
|
||||
}
|
||||
|
||||
.gl-search {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
matTooltip="{{ 'tooltips.Dashboard' | translate }}"
|
||||
routerLinkActive="active-menu"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
*mifosxHasPermission="'SIDEBAR_DASHBOARD'"
|
||||
>
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="tachometer-alt" size="sm"></fa-icon>
|
||||
@@ -44,7 +45,7 @@
|
||||
matTooltip="{{ 'tooltips.Navigation' | translate }}"
|
||||
routerLinkActive="active-menu"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
*mifosxHasPermission="'READ_CLIENT'"
|
||||
*mifosxHasPermission="'SIDEBAR_NAVIGATION'"
|
||||
>
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="location-arrow" size="sm"></fa-icon>
|
||||
@@ -57,20 +58,33 @@
|
||||
matTooltip="{{ 'tooltips.CKYC' | translate }}"
|
||||
routerLinkActive="active-menu"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
*mifosxHasPermission="'READ_CLIENT'"
|
||||
*mifosxHasPermission="'SIDEBAR_CKYC'"
|
||||
>
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="id-card" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<a matLine>{{ 'labels.menus.CKYC' | translate }}</a>
|
||||
</mat-list-item>
|
||||
<mat-list-item
|
||||
[routerLink]="['/bank-calendar']"
|
||||
[matTooltipPosition]="tooltipPosition"
|
||||
matTooltip="Bank Calendar"
|
||||
routerLinkActive="active-menu"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
*mifosxHasPermission="'SIDEBAR_CALENDAR'"
|
||||
>
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="calendar-alt" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<a matLine>Calendar</a>
|
||||
</mat-list-item>
|
||||
<mat-list-item
|
||||
[routerLink]="['/checker-inbox-and-tasks/checker-inbox']"
|
||||
[matTooltipPosition]="tooltipPosition"
|
||||
matTooltip="{{ 'tooltips.Checker Inbox and Tasks' | translate }}"
|
||||
routerLinkActive="active-menu"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
*mifosxHasPermission="'READ_MAKERCHECKER'"
|
||||
*mifosxHasPermission="'SIDEBAR_CHECKER_INBOX'"
|
||||
>
|
||||
<mat-icon matListIcon>
|
||||
<i class="fa fa-check"></i>
|
||||
@@ -83,7 +97,7 @@
|
||||
matTooltip="{{ 'labels.menus.Collection Sheet' | translate }}"
|
||||
routerLinkActive="active-menu"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
*mifosxHasPermission="'READ_COLLECTIONSHEET'"
|
||||
*mifosxHasPermission="'SIDEBAR_COLLECTION_SHEET'"
|
||||
>
|
||||
<mat-icon matListIcon>
|
||||
<i class="fa fa-tasks"></i>
|
||||
@@ -96,13 +110,14 @@
|
||||
matTooltip="{{ 'tooltips.Individual Collection Sheet' | translate }}"
|
||||
routerLinkActive="active-menu"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
*mifosxHasPermission="'READ_COLLECTIONSHEET'"
|
||||
*mifosxHasPermission="'SIDEBAR_COLLECTION_SHEET'"
|
||||
>
|
||||
<mat-icon matListIcon>
|
||||
<i class="fa fa-tasks"></i>
|
||||
</mat-icon>
|
||||
<a matLine>{{ 'labels.menus.Individual Collection Sheet' | translate }}</a>
|
||||
</mat-list-item>
|
||||
<ng-container *mifosxHasPermission="'SIDEBAR_REMITTANCES'">
|
||||
<mat-list-item
|
||||
*ngIf="mifosRemittanceEnabled"
|
||||
[routerLink]="['/remittances/process']"
|
||||
@@ -116,6 +131,8 @@
|
||||
</mat-icon>
|
||||
<a matLine>{{ 'labels.menus.Remittances' | translate }}</a>
|
||||
</mat-list-item>
|
||||
</ng-container>
|
||||
<ng-container *mifosxHasPermission="'SIDEBAR_ANALYTICS'">
|
||||
<mat-list-item
|
||||
*ngIf="supersetEnabled"
|
||||
[routerLink]="['/analytics']"
|
||||
@@ -129,7 +146,9 @@
|
||||
</mat-icon>
|
||||
<a matLine>{{ 'labels.menus.Analytics' | translate }}</a>
|
||||
</mat-list-item>
|
||||
</ng-container>
|
||||
<!-- CFCFRMS Section -->
|
||||
<ng-container *mifosxHasPermission="'SIDEBAR_CFCFRMS'">
|
||||
<div class="nav-item-sep mt-1">
|
||||
<mat-divider></mat-divider>
|
||||
<span class="text-muted">{{ 'labels.menus.CFCFRMS' | translate }}</span>
|
||||
@@ -206,8 +225,10 @@
|
||||
</mat-icon>
|
||||
<a matLine>{{ 'labels.menus.Report Fraud Txn' | translate }}</a>
|
||||
</mat-list-item>
|
||||
</ng-container>
|
||||
|
||||
<!-- RBI Integration Section -->
|
||||
<ng-container *mifosxHasPermission="'SIDEBAR_RBI_INTEGRATION'">
|
||||
<div class="nav-item-sep mt-1">
|
||||
<mat-divider></mat-divider>
|
||||
<span class="text-muted">{{ 'labels.menus.RBI Integration' | translate }}</span>
|
||||
@@ -236,6 +257,7 @@
|
||||
</mat-icon>
|
||||
<a matLine>{{ 'labels.menus.CFR Lookup' | translate }}</a>
|
||||
</mat-list-item>
|
||||
</ng-container>
|
||||
|
||||
<div class="nav-item-sep mt-1">
|
||||
<mat-divider></mat-divider>
|
||||
@@ -248,6 +270,7 @@
|
||||
matTooltip="{{ 'tooltips.RBI Circulars' | translate }}"
|
||||
routerLinkActive="active-menu"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
*mifosxHasPermission="'SIDEBAR_RBI_CIRCULARS'"
|
||||
>
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="newspaper" size="sm"></fa-icon>
|
||||
@@ -260,7 +283,7 @@
|
||||
matTooltip="{{ 'tooltips.Notifications' | translate }}"
|
||||
routerLinkActive="active-menu"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
*mifosxHasPermission="'READ_NOTIFICATION'"
|
||||
*mifosxHasPermission="'SIDEBAR_NOTIFICATIONS'"
|
||||
>
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="bell" size="sm"></fa-icon>
|
||||
@@ -273,7 +296,7 @@
|
||||
matTooltip="{{ 'tooltips.Frequent Postings' | translate }}"
|
||||
routerLinkActive="active-menu"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
*mifosxHasPermission="'CREATE_JOURNALENTRY'"
|
||||
*mifosxHasPermission="'SIDEBAR_FREQUENT_POSTINGS'"
|
||||
>
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="sync" size="sm"></fa-icon>
|
||||
@@ -286,7 +309,7 @@
|
||||
matTooltip="{{ 'tooltips.Create Journal Entry' | translate }}"
|
||||
routerLinkActive="active-menu"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
*mifosxHasPermission="'CREATE_JOURNALENTRY'"
|
||||
*mifosxHasPermission="'SIDEBAR_JOURNAL_ENTRY'"
|
||||
>
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="plus" size="sm"></fa-icon>
|
||||
@@ -299,7 +322,7 @@
|
||||
matTooltip="{{ 'tooltips.Chart Of Accounts' | translate }}"
|
||||
routerLinkActive="active-menu"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
*mifosxHasPermission="'READ_GLACCOUNT'"
|
||||
*mifosxHasPermission="'SIDEBAR_CHART_OF_ACCOUNTS'"
|
||||
>
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="sitemap" size="sm"></fa-icon>
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
<div class="t-chip">Branch: <strong>HO</strong></div>
|
||||
<div class="t-chip">Date: <strong>{{ today | date:'dd MMM yyyy' }}</strong></div>
|
||||
<div class="t-chip">Last login: <strong>{{ lastLoginTime }}</strong></div>
|
||||
<div class="t-sep"></div>
|
||||
<div class="t-chip t-chip--secure"><i class="fas fa-lock t-chip-icon t-chip-icon--green"></i>SSL/TLS <strong>256-bit</strong></div>
|
||||
<div class="t-chip t-chip--secure"><i class="fas fa-certificate t-chip-icon t-chip-icon--blue"></i>ISO 27001 <strong>Certified</strong></div>
|
||||
<div class="t-chip t-chip--timer"><i class="fas fa-clock t-chip-icon t-chip-icon--amber"></i>Session <strong>{{ sessionTimer }}</strong></div>
|
||||
</div>
|
||||
|
||||
<!-- Right Actions -->
|
||||
@@ -48,6 +52,10 @@
|
||||
@if (subLedgerOpen) {
|
||||
<div class="subledger-backdrop" (click)="subLedgerOpen = false"></div>
|
||||
<div class="subledger-mega-menu">
|
||||
<div class="subledger-mega-menu-header">
|
||||
<fa-icon icon="book" class="subledger-mega-menu-header-icon"></fa-icon>
|
||||
<span>Sub Ledger</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="subledger-search"
|
||||
@@ -164,6 +172,10 @@
|
||||
@if (glMenuOpen) {
|
||||
<div class="subledger-backdrop" (click)="glMenuOpen = false"></div>
|
||||
<div class="subledger-mega-menu">
|
||||
<div class="subledger-mega-menu-header">
|
||||
<fa-icon icon="book-open" class="subledger-mega-menu-header-icon"></fa-icon>
|
||||
<span>General Ledger</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="subledger-search"
|
||||
|
||||
@@ -104,6 +104,32 @@ $top-h: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.t-sep {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: rgb(255 255 255 / 15%);
|
||||
margin: 0 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.t-chip-icon {
|
||||
font-size: 9px;
|
||||
|
||||
&--green { color: #34d399; }
|
||||
&--blue { color: #60a5fa; }
|
||||
&--amber { color: #fbbf24; }
|
||||
}
|
||||
|
||||
.t-chip--secure {
|
||||
color: rgb(255 255 255 / 55%);
|
||||
}
|
||||
|
||||
.t-chip--timer {
|
||||
color: rgb(255 255 255 / 55%);
|
||||
|
||||
strong { color: #fbbf24 !important; font-weight: 600; }
|
||||
}
|
||||
|
||||
.t-live {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
@@ -265,6 +291,27 @@ $icon-hover-bg: rgb(255 255 255 / 10%);
|
||||
animation: slideDown 0.13s ease;
|
||||
}
|
||||
|
||||
.subledger-mega-menu-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
|
||||
span {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #0d47a1;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
}
|
||||
|
||||
.subledger-mega-menu-header-icon {
|
||||
font-size: 18px;
|
||||
color: #0d47a1;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(-4px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
|
||||
@@ -116,6 +116,11 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
|
||||
return `Today, ${h}:${m} ${ampm}`;
|
||||
}
|
||||
|
||||
/** Session timer */
|
||||
sessionTimer = '15:00';
|
||||
private sessionSeconds = 15 * 60;
|
||||
private sessionInterval: any;
|
||||
|
||||
/** Sub Ledger mega menu state */
|
||||
subLedgerOpen = false;
|
||||
subLedgerSearch = '';
|
||||
@@ -143,6 +148,21 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
|
||||
this.toggleSidenavCollapse(false);
|
||||
}
|
||||
});
|
||||
this.startSessionTimer();
|
||||
}
|
||||
|
||||
private startSessionTimer() {
|
||||
this.sessionInterval = setInterval(() => {
|
||||
if (this.sessionSeconds > 0) {
|
||||
this.sessionSeconds--;
|
||||
const m = Math.floor(this.sessionSeconds / 60);
|
||||
const s = this.sessionSeconds % 60;
|
||||
this.sessionTimer = `${m}:${s < 10 ? '0' + s : s}`;
|
||||
} else {
|
||||
clearInterval(this.sessionInterval);
|
||||
this.sessionTimer = '0:00';
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
ngAfterContentChecked(): void {
|
||||
|
||||
@@ -70,12 +70,16 @@ export class HasPermissionDirective {
|
||||
* - No value was passed to the has permission directive.
|
||||
*/
|
||||
private hasPermission(permission: string) {
|
||||
// If RBAC is disabled, show all menus/buttons (backward compatibility)
|
||||
if (!this.rbacEnabled) {
|
||||
permission = permission.trim();
|
||||
|
||||
// Menu & Sidebar permissions are ALWAYS enforced (hide/show nav items based on role)
|
||||
const isMenuPermission = permission.startsWith('MENU_') || permission.startsWith('SIDEBAR_');
|
||||
|
||||
// For non-menu permissions: if RBAC is disabled, show everything (backward compatibility)
|
||||
if (!this.rbacEnabled && !isMenuPermission) {
|
||||
return true;
|
||||
}
|
||||
|
||||
permission = permission.trim();
|
||||
if (this.userPermissions.includes('ALL_FUNCTIONS')) {
|
||||
return true;
|
||||
} else if (permission !== '') {
|
||||
|
||||
@@ -7,68 +7,78 @@
|
||||
-->
|
||||
|
||||
<mat-card class="container">
|
||||
<div class="layout-column layout-gt-sm-row align-gt-sm-start-center">
|
||||
<div class="search-box m-r-30">
|
||||
<mat-form-field class="search-box">
|
||||
<!-- Header Bar -->
|
||||
<div class="table-header">
|
||||
<div class="table-title-group">
|
||||
<h2 class="table-title">
|
||||
<div class="t-logomark"><span></span><span></span><span></span><span></span></div>
|
||||
{{ 'labels.menus.Groups' | translate }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="table-actions">
|
||||
<mat-form-field class="table-search" appearance="outline">
|
||||
<input matInput placeholder="{{ 'labels.text.SearchByName' | translate }}" [formControl]="name" />
|
||||
</mat-form-field>
|
||||
<mat-checkbox #showClosedGroups labelPosition="after" (change)="changeShowClosedGroups()" class="m-r-10">{{
|
||||
'labels.inputs.Show Closed Groups' | translate
|
||||
}}</mat-checkbox>
|
||||
</div>
|
||||
<div class="action-button gap-25px">
|
||||
<button mat-raised-button color="primary" [routerLink]="['../', 'organization', 'bulk-import', 'Groups']">
|
||||
<fa-icon icon="download" class="m-r-20"></fa-icon>{{ 'labels.buttons.Import Group' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" [routerLink]="['create']" *mifosxHasPermission="'CREATE_GROUP'">
|
||||
<fa-icon icon="plus" class="m-r-20"></fa-icon>{{ 'labels.buttons.Create Group' | translate }}
|
||||
</button>
|
||||
|
||||
<mat-checkbox #showClosedGroups labelPosition="after" (change)="changeShowClosedGroups()" class="table-checkbox">
|
||||
{{ 'labels.inputs.Show Closed Groups' | translate }}
|
||||
</mat-checkbox>
|
||||
|
||||
<div class="table-buttons">
|
||||
<button mat-stroked-button [routerLink]="['../', 'organization', 'bulk-import', 'Groups']" class="btn-import">
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
{{ 'labels.buttons.Import Group' | translate }}
|
||||
</button>
|
||||
<button mat-flat-button color="primary" [routerLink]="['create']" *mifosxHasPermission="'CREATE_GROUP'" class="btn-create">
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
{{ 'labels.buttons.Create Group' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table mat-table [dataSource]="dataSource" matSort class="bordered-table">
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let group">{{ group.name }}</td>
|
||||
</ng-container>
|
||||
<!-- Table -->
|
||||
<div class="table-container">
|
||||
<table mat-table [dataSource]="dataSource" matSort class="modern-table">
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let group" class="cell-link">{{ group.name }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="accountNo">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account' | translate }} #</th>
|
||||
<td mat-cell *matCellDef="let group">{{ group.accountNo }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="accountNo">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account' | translate }} #</th>
|
||||
<td mat-cell *matCellDef="let group" class="cell-mono">{{ group.accountNo }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="externalId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.External Id' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let group">{{ group.externalId }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="externalId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.External Id' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let group" class="cell-mono">{{ group.externalId }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div [ngClass]="row.status.code | statusLookup">
|
||||
<fa-icon icon="stop"></fa-icon>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<span class="status-badge" [ngClass]="row.status.code | statusLookup">
|
||||
{{ row.status.value }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="officeName">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Office Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let group">{{ group.officeName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="officeName">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Office Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let group">{{ group.officeName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
[routerLink]="[row.id, 'general']"
|
||||
class="select-row"
|
||||
></tr>
|
||||
</table>
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id, 'general']" class="table-row"></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator
|
||||
[length]="dataSource?.records$ | async"
|
||||
[pageSize]="10"
|
||||
[pageSizeOptions]="[10, 25, 50, 100]"
|
||||
showFirstLastButtons
|
||||
></mat-paginator>
|
||||
<mat-paginator
|
||||
[length]="dataSource?.records$ | async"
|
||||
[pageSize]="10"
|
||||
[pageSizeOptions]="[10, 25, 50, 100]"
|
||||
showFirstLastButtons
|
||||
></mat-paginator>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
@@ -306,30 +306,5 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ SECTION: Analytics Charts ═══════ -->
|
||||
<div class="section-header analytics">
|
||||
<fa-icon icon="chart-bar" class="section-icon"></fa-icon>
|
||||
<span>Analytics</span>
|
||||
</div>
|
||||
<div class="charts-section">
|
||||
<div class="top-card">
|
||||
<div class="trends-bar">
|
||||
<mifosx-client-trends-bar></mifosx-client-trends-bar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom-card">
|
||||
<div class="amount-disbursed">
|
||||
<mifosx-amount-disbursed-pie></mifosx-amount-disbursed-pie>
|
||||
</div>
|
||||
|
||||
<div class="amount-collected">
|
||||
<mifosx-amount-collected-pie></mifosx-amount-collected-pie>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="cbs-footer">CBS Monitoring Dashboard • Internal Use Only</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user