Browse Source

added piechart and days wise count and vendor wise count added for payment tracker project

master
yoga-agoy 4 months ago
parent
commit
5e583558d8
  1. 123
      src/main/java/com/gcc_manpower/gccservicedocuments/controller/GccServiceDocumentsController.java
  2. 97
      src/main/java/com/gcc_manpower/gccservicedocuments/service/GccServiceDocumentsService.java
  3. 20
      src/main/java/com/gcc_manpower/gccservicepaymenttracker/controller/GccServicePaymentTrackerController.java
  4. 53
      src/main/java/com/gcc_manpower/gccservicepaymenttracker/service/GccServicePaymentTrackerService.java
  5. 4
      src/main/resources/application.properties
  6. 32
      src/main/resources/templates/fragments/navbar.html
  7. 185
      src/main/resources/templates/gccservicedocuments/view.html
  8. 611
      src/main/resources/templates/gccservicepaymenttracker/dashboard.html

123
src/main/java/com/gcc_manpower/gccservicedocuments/controller/GccServiceDocumentsController.java

@ -33,13 +33,17 @@ public class GccServiceDocumentsController {
@GetMapping({ "", "/" })
public String viewPortal(Model model, HttpSession session) {
String usrName = (String) session.getAttribute("usrName");
String usrName = (String) session.getAttribute("doc_usrName");
if (usrName == null) {
return "redirect:/gccservicedocuments/login";
}
String usrRole = (String) session.getAttribute("usrRole");
String usrRole = (String) session.getAttribute("doc_usrRole");
boolean isAdmin = "ADMIN".equalsIgnoreCase(usrRole);
if (!isAdmin) {
return "redirect:/gccservicedocuments/view";
}
model.addAttribute("isLoggedIn", true);
model.addAttribute("isAdmin", isAdmin);
model.addAttribute("usrName", usrName);
@ -50,11 +54,11 @@ public class GccServiceDocumentsController {
@GetMapping("/view")
public String viewReportPage(Model model, HttpSession session) {
String usrName = (String) session.getAttribute("usrName");
String usrName = (String) session.getAttribute("doc_usrName");
if (usrName == null) {
return "redirect:/gccservicedocuments/login";
}
String usrRole = (String) session.getAttribute("usrRole");
String usrRole = (String) session.getAttribute("doc_usrRole");
boolean isAdmin = "ADMIN".equalsIgnoreCase(usrRole);
model.addAttribute("isLoggedIn", true);
@ -67,7 +71,7 @@ public class GccServiceDocumentsController {
@GetMapping("/login")
public String login(HttpSession session) {
if (session.getAttribute("usrName") != null) {
if (session.getAttribute("doc_usrName") != null) {
return "redirect:/gccservicedocuments/view";
}
return "gccservicedocuments/login";
@ -84,8 +88,8 @@ public class GccServiceDocumentsController {
Map<String, Object> response = new HashMap<>();
if (Boolean.TRUE.equals(authResult.get("success"))) {
session.setAttribute("usrName", authResult.get("usrName"));
session.setAttribute("usrRole", authResult.get("usrRole"));
session.setAttribute("doc_usrName", authResult.get("usrName"));
session.setAttribute("doc_usrRole", authResult.get("usrRole"));
session.setMaxInactiveInterval(1800); // 30 minutes session timeout
response.put("result", true);
response.put("usrName", authResult.get("usrName"));
@ -99,9 +103,8 @@ public class GccServiceDocumentsController {
@GetMapping("/logout")
public String logout(HttpSession session) {
session.removeAttribute("usrName");
session.removeAttribute("usrRole");
session.invalidate();
session.removeAttribute("doc_usrName");
session.removeAttribute("doc_usrRole");
return "redirect:/gccservicedocuments/login";
}
@ -150,10 +153,11 @@ public class GccServiceDocumentsController {
HttpSession session) {
Map<String, Object> response = new HashMap<>();
String usrName = (String) session.getAttribute("usrName");
if (usrName == null) {
String usrName = (String) session.getAttribute("doc_usrName");
String usrRole = (String) session.getAttribute("doc_usrRole");
if (usrName == null || !"ADMIN".equalsIgnoreCase(usrRole)) {
response.put("success", false);
response.put("error", "Unauthorized access! Please log in.");
response.put("error", "Unauthorized access! Admin role required.");
return response;
}
@ -232,8 +236,8 @@ public class GccServiceDocumentsController {
@ResponseBody
public Map<String, Object> deleteFile(@RequestParam("id") Long id, HttpSession session) {
Map<String, Object> response = new HashMap<>();
String usrName = (String) session.getAttribute("usrName");
String usrRole = (String) session.getAttribute("usrRole");
String usrName = (String) session.getAttribute("doc_usrName");
String usrRole = (String) session.getAttribute("doc_usrRole");
if (usrName == null || !"ADMIN".equalsIgnoreCase(usrRole)) {
response.put("success", false);
response.put("error", "Unauthorized access! Admin role required.");
@ -251,4 +255,93 @@ public class GccServiceDocumentsController {
return response;
}
@PostMapping("/api/update")
@ResponseBody
public Map<String, Object> updateFile(
@RequestParam("id") Long id,
@RequestParam("title") String title,
@RequestParam("subCategory") String subCategory,
@RequestParam("fileType") String fileType,
@RequestParam(value = "file", required = false) MultipartFile file,
HttpSession session) {
Map<String, Object> response = new HashMap<>();
String usrName = (String) session.getAttribute("doc_usrName");
String usrRole = (String) session.getAttribute("doc_usrRole");
if (usrName == null || !"ADMIN".equalsIgnoreCase(usrRole)) {
response.put("success", false);
response.put("error", "Unauthorized access! Admin role required.");
return response;
}
if (title == null || title.trim().isEmpty() || subCategory == null || subCategory.trim().isEmpty()
|| fileType == null || fileType.trim().isEmpty()) {
response.put("success", false);
response.put("error", "Required fields cannot be empty.");
return response;
}
String fileWebUrl = null;
String originalFilename = null;
File destFile = null;
try {
if (file != null && !file.isEmpty()) {
// Save new file
String cleanCategory = subCategory.trim().replaceAll("[^a-zA-Z0-9-_ ]", "").trim();
if (cleanCategory.isEmpty()) {
cleanCategory = "General";
}
File uploadDir = new File(documentUploadPathConfig, cleanCategory).getAbsoluteFile();
if (!uploadDir.exists()) {
uploadDir.mkdirs();
}
originalFilename = file.getOriginalFilename();
String nameWithoutExt = "";
String ext = "";
if (originalFilename != null && originalFilename.contains(".")) {
int dotIdx = originalFilename.lastIndexOf(".");
nameWithoutExt = originalFilename.substring(0, dotIdx);
ext = originalFilename.substring(dotIdx);
} else {
nameWithoutExt = originalFilename != null ? originalFilename : "file";
}
nameWithoutExt = nameWithoutExt.replaceAll("[^a-zA-Z0-9-_]", "_");
String savedFilename = nameWithoutExt + "_" + System.currentTimeMillis() + ext;
destFile = new File(uploadDir, savedFilename).getAbsoluteFile();
file.transferTo(destFile);
fileWebUrl = "/gccservicedocuments/files/" + cleanCategory + "/" + savedFilename;
}
boolean updated = documentService.updateDocument(id, title.trim(), subCategory.trim(), fileType.trim(),
fileWebUrl, originalFilename);
if (updated) {
if (file != null && !file.isEmpty() && "video".equalsIgnoreCase(fileType)) {
videoCompressionService.compressVideoAsync(destFile);
}
response.put("success", true);
response.put("message", "Document updated successfully.");
} else {
if (destFile != null && destFile.exists()) {
destFile.delete();
}
response.put("success", false);
response.put("error", "Failed to update document details in the database.");
}
} catch (Exception e) {
if (destFile != null && destFile.exists()) {
destFile.delete();
}
response.put("success", false);
response.put("error", "Document update failed: " + e.getMessage());
}
return response;
}
}

97
src/main/java/com/gcc_manpower/gccservicedocuments/service/GccServiceDocumentsService.java

@ -63,7 +63,8 @@ public class GccServiceDocumentsService {
try {
int id = Integer.parseInt(subCategoryName.trim());
String checkIdSql = "SELECT id FROM sub_category_list WHERE id = ? AND mid = ? AND is_delete = 0";
List<Integer> ids = jdbcTemplate.queryForList(checkIdSql, Integer.class, id, String.valueOf(mainCategoryId));
List<Integer> ids = jdbcTemplate.queryForList(checkIdSql, Integer.class, id,
String.valueOf(mainCategoryId));
if (!ids.isEmpty()) {
return ids.get(0);
}
@ -73,7 +74,8 @@ public class GccServiceDocumentsService {
String selectSql = "SELECT id FROM sub_category_list WHERE category_name = ? AND mid = ? AND is_delete = 0";
try {
List<Integer> ids = jdbcTemplate.queryForList(selectSql, Integer.class, subCategoryName.trim(), String.valueOf(mainCategoryId));
List<Integer> ids = jdbcTemplate.queryForList(selectSql, Integer.class, subCategoryName.trim(),
String.valueOf(mainCategoryId));
if (!ids.isEmpty()) {
return ids.get(0);
}
@ -86,7 +88,8 @@ public class GccServiceDocumentsService {
try {
jdbcTemplate.update(insertSql, subCategoryName.trim(), String.valueOf(mainCategoryId));
// Fetch the generated ID
List<Integer> ids = jdbcTemplate.queryForList(selectSql, Integer.class, subCategoryName.trim(), String.valueOf(mainCategoryId));
List<Integer> ids = jdbcTemplate.queryForList(selectSql, Integer.class, subCategoryName.trim(),
String.valueOf(mainCategoryId));
if (!ids.isEmpty()) {
return ids.get(0);
}
@ -150,7 +153,8 @@ public class GccServiceDocumentsService {
+
"VALUES (?, ?, ?, ?, ?, 1, 0)";
try {
int rows = jdbcTemplate.update(sql, title, String.valueOf(subCategoryId), String.valueOf(fileTypeId), filePath,
int rows = jdbcTemplate.update(sql, title, String.valueOf(subCategoryId), String.valueOf(fileTypeId),
filePath,
originalFilename);
return rows > 0;
} catch (Exception e) {
@ -159,7 +163,8 @@ public class GccServiceDocumentsService {
}
}
public List<Map<String, Object>> searchDocuments(String mainCategory, String subCategory, String fromDate, String toDate) {
public List<Map<String, Object>> searchDocuments(String mainCategory, String subCategory, String fromDate,
String toDate) {
StringBuilder sql = new StringBuilder(
"SELECT d.id, d.title, s.category_name AS category, m.main_category_name AS main_category, f.file_type_name AS file_type, d.file_path, d.original_filename, d.upload_date "
+
@ -208,6 +213,88 @@ public class GccServiceDocumentsService {
}
}
public Map<String, Object> getDocumentById(Long id) {
String sql = "SELECT * FROM documents_uploads WHERE id = ?";
try {
List<Map<String, Object>> list = jdbcTemplate.queryForList(sql, id);
if (!list.isEmpty()) {
return list.get(0);
}
} catch (Exception e) {
System.err.println("Error getting document ID " + id + ": " + e.getMessage());
}
return null;
}
public boolean updateDocument(Long id, String title, String subCategory, String fileType, String filePath,
String originalFilename) {
// Insert old record into history before update
try {
Map<String, Object> old = getDocumentById(id);
if (old != null) {
String insertHistorySql = "INSERT INTO documents_upload_history ("
+ "document_id, title, category, file_type, file_path, original_filename, upload_date, is_active, is_delete"
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
jdbcTemplate.update(insertHistorySql,
old.get("id"),
old.get("title"),
old.get("category"),
old.get("file_type"),
old.get("file_path"),
old.get("original_filename"),
old.get("upload_date"),
old.get("is_active"),
old.get("is_delete"));
}
} catch (Exception e) {
System.err.println("Error inserting document history for ID " + id + ": " + e.getMessage());
}
// Resolve subCategoryId and fileTypeId
int subCategoryId = 0;
String findSubSql = "SELECT id FROM sub_category_list WHERE category_name = ? AND is_delete = 0 LIMIT 1";
try {
List<Integer> ids = jdbcTemplate.queryForList(findSubSql, Integer.class, subCategory.trim());
if (!ids.isEmpty()) {
subCategoryId = ids.get(0);
} else {
// Find old document's main category ID
String oldMainCatSql = "SELECT s.mid FROM documents_uploads d JOIN sub_category_list s ON d.category = s.id WHERE d.id = ?";
List<Integer> mids = jdbcTemplate.queryForList(oldMainCatSql, Integer.class, id);
int mid = mids.isEmpty() ? 1 : mids.get(0);
subCategoryId = getOrCreateSubCategory(subCategory, mid);
}
} catch (Exception e) {
System.err.println("Error resolving subcategory: " + e.getMessage());
}
int fileTypeId = getOrCreateFileType(fileType);
// Build the update query
StringBuilder sql = new StringBuilder("UPDATE documents_uploads SET title = ?, category = ?, file_type = ?");
List<Object> params = new ArrayList<>();
params.add(title.trim());
params.add(String.valueOf(subCategoryId));
params.add(String.valueOf(fileTypeId));
if (filePath != null && !filePath.trim().isEmpty()) {
sql.append(", file_path = ?, original_filename = ?");
params.add(filePath.trim());
params.add(originalFilename.trim());
}
sql.append(" WHERE id = ?");
params.add(id);
try {
int rows = jdbcTemplate.update(sql.toString(), params.toArray());
return rows > 0;
} catch (Exception e) {
System.err.println("Error updating service document ID " + id + ": " + e.getMessage());
return false;
}
}
public List<String> getAllCategories() {
String sql = "SELECT category_name FROM sub_category_list WHERE is_active = 1 AND is_delete = 0 ORDER BY category_name";
try {

20
src/main/java/com/gcc_manpower/gccservicepaymenttracker/controller/GccServicePaymentTrackerController.java

@ -31,12 +31,12 @@ public class GccServicePaymentTrackerController {
@GetMapping({ "", "/", "/dashboard" })
public String viewDashboard(Model model, HttpSession session) {
String username = (String) session.getAttribute("usrName");
String username = (String) session.getAttribute("pay_usrName");
if (username == null) {
return "redirect:/gccservicepaymenttracker/login";
}
String usrRole = (String) session.getAttribute("usrRole");
String usrRole = (String) session.getAttribute("pay_usrRole");
boolean isAdmin = "ADMIN".equalsIgnoreCase(usrRole);
List<Map<String, Object>> vendorDetails = appUserService.getVendorDetailsWithCounts();
@ -52,7 +52,7 @@ public class GccServicePaymentTrackerController {
@GetMapping("/login")
public String login(HttpSession session) {
if (session.getAttribute("usrName") != null) {
if (session.getAttribute("pay_usrName") != null) {
return "redirect:/gccservicepaymenttracker/dashboard";
}
return "gccservicepaymenttracker/login";
@ -69,8 +69,8 @@ public class GccServicePaymentTrackerController {
Map<String, Object> response = new HashMap<>();
if (Boolean.TRUE.equals(authResult.get("success"))) {
session.setAttribute("usrName", authResult.get("usrName"));
session.setAttribute("usrRole", authResult.get("usrRole"));
session.setAttribute("pay_usrName", authResult.get("usrName"));
session.setAttribute("pay_usrRole", authResult.get("usrRole"));
session.setMaxInactiveInterval(1800); // 30 minutes session timeout
response.put("result", true);
response.put("usrName", authResult.get("usrName"));
@ -84,17 +84,15 @@ public class GccServicePaymentTrackerController {
@PostMapping("/logout")
public String logout(HttpSession session) {
session.removeAttribute("usrName");
session.removeAttribute("usrRole");
session.invalidate();
session.removeAttribute("pay_usrName");
session.removeAttribute("pay_usrRole");
return "redirect:/gccservicepaymenttracker/login";
}
@GetMapping("/logout")
public String logoutGet(HttpSession session) {
session.removeAttribute("usrName");
session.removeAttribute("usrRole");
session.invalidate();
session.removeAttribute("pay_usrName");
session.removeAttribute("pay_usrRole");
return "redirect:/gccservicepaymenttracker/login";
}

53
src/main/java/com/gcc_manpower/gccservicepaymenttracker/service/GccServicePaymentTrackerService.java

@ -83,21 +83,27 @@ public class GccServicePaymentTrackerService {
if (type != null && !type.trim().isEmpty()) {
String category = type.trim().toLowerCase();
if (category.equals("asd")) {
sql.append(" AND (LOWER(p.type) = 'asd' OR LOWER(p.type) LIKE '%asd%' OR LOWER(t.type_name) LIKE '%asd%' OR LOWER(t.type_name) LIKE '%additional security%')");
sql.append(
" AND (LOWER(p.type) = 'asd' OR LOWER(p.type) LIKE '%asd%' OR LOWER(t.type_name) LIKE '%asd%' OR LOWER(t.type_name) LIKE '%additional security%')");
} else if (category.equals("agreement")) {
sql.append(" AND (LOWER(p.type) LIKE '%agreement%' OR LOWER(t.type_name) LIKE '%agreement%')");
} else if (category.equals("sd")) {
sql.append(" AND ((LOWER(p.type) = 'sd' OR LOWER(p.type) LIKE '%security%' OR LOWER(t.type_name) LIKE '%security%') "
sql.append(
" AND ((LOWER(p.type) = 'sd' OR LOWER(p.type) LIKE '%security%' OR LOWER(t.type_name) LIKE '%security%') "
+ "AND LOWER(p.type) NOT LIKE '%additional security%' AND LOWER(p.type) NOT LIKE '%asd%' "
+ "AND (t.type_name IS NULL OR (LOWER(t.type_name) NOT LIKE '%additional security%' AND LOWER(t.type_name) NOT LIKE '%asd%')))");
} else if (category.equals("tax invoice")) {
sql.append(" AND (LOWER(p.type) LIKE '%invoice%' OR LOWER(p.type) LIKE '%bill%' OR LOWER(t.type_name) LIKE '%invoice%' OR LOWER(t.type_name) LIKE '%bill%')");
sql.append(
" AND (LOWER(p.type) LIKE '%invoice%' OR LOWER(p.type) LIKE '%bill%' OR LOWER(t.type_name) LIKE '%invoice%' OR LOWER(t.type_name) LIKE '%bill%')");
} else if (category.equals("emd")) {
sql.append(" AND (LOWER(p.type) = 'emd' OR LOWER(p.type) LIKE '%earnest%' OR LOWER(t.type_name) LIKE '%emd%' OR LOWER(t.type_name) LIKE '%earnest%')");
sql.append(
" AND (LOWER(p.type) = 'emd' OR LOWER(p.type) LIKE '%earnest%' OR LOWER(t.type_name) LIKE '%emd%' OR LOWER(t.type_name) LIKE '%earnest%')");
} else if (category.equals("bank guarantee")) {
sql.append(" AND (LOWER(p.type) LIKE '%guarantee%' OR LOWER(p.type) = 'bg' OR LOWER(t.type_name) LIKE '%guarantee%' OR LOWER(t.type_name) = 'bg')");
sql.append(
" AND (LOWER(p.type) LIKE '%guarantee%' OR LOWER(p.type) = 'bg' OR LOWER(t.type_name) LIKE '%guarantee%' OR LOWER(t.type_name) = 'bg')");
} else if (category.equals("delivery chellan")) {
sql.append(" AND (LOWER(p.type) LIKE '%chellan%' OR LOWER(p.type) LIKE '%challan%' OR LOWER(p.type) = 'dc' "
sql.append(
" AND (LOWER(p.type) LIKE '%chellan%' OR LOWER(p.type) LIKE '%challan%' OR LOWER(p.type) = 'dc' "
+ "OR LOWER(t.type_name) LIKE '%chellan%' OR LOWER(t.type_name) LIKE '%challan%' OR LOWER(t.type_name) = 'dc')");
} else {
sql.append(" AND (LOWER(p.type) = ? OR LOWER(t.type_name) = ?)");
@ -166,6 +172,41 @@ public class GccServicePaymentTrackerService {
if (idObj != null && !String.valueOf(idObj).trim().isEmpty()) {
// Update
int id = Integer.parseInt(String.valueOf(idObj).trim());
// Insert old record into history before update
try {
Map<String, Object> old = getDetailById(id);
if (old != null) {
String insertHistorySql = "INSERT INTO payment_tracking_details_history ("
+ "detail_id, received_date, vendor_name, subject, amount, invoice_no, receiptno, "
+ "invoice_date, type, physical_file_no, remarks, tat, status, followup, myfollowup, "
+ "is_active, is_delete, order_by, file_path"
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
jdbcTemplate.update(insertHistorySql,
old.get("id"),
old.get("received_date"),
old.get("vendor_name"),
old.get("subject"),
old.get("amount"),
old.get("invoice_no"),
old.get("receiptno"),
old.get("invoice_date"),
old.get("type"),
old.get("physical_file_no"),
old.get("remarks"),
old.get("tat"),
old.get("status"),
old.get("followup"),
old.get("myfollowup"),
old.get("is_active"),
old.get("is_delete"),
old.get("order_by"),
old.get("file_path"));
}
} catch (Exception e) {
System.err.println("Error saving history for payment tracking detail ID " + id + ": " + e.getMessage());
}
String sql = "UPDATE payment_tracking_details SET "
+ "received_date = ?, vendor_name = ?, subject = ?, amount = ?, invoice_no = ?, "
+ "receiptno = ?, invoice_date = ?, type = ?, physical_file_no = ?, remarks = ?, "

4
src/main/resources/application.properties

@ -1,5 +1,5 @@
#spring.profiles.active=local
spring.profiles.active=prod
spring.profiles.active=local
#spring.profiles.active=prod
spring.application.name=gcc-manpower
server.servlet.session.timeout=10m

32
src/main/resources/templates/fragments/navbar.html

@ -16,10 +16,10 @@
class=" fa-solid fa-house"></i>
Home
</a></li>
<li class="nav-item"><a class="nav-link" th:href="@{/gccservicepaymenttracker/}"> <i
<!-- <li class="nav-item"><a class="nav-link" th:href="@{/gccservicepaymenttracker/}"> <i
class="fa-solid fa-file-invoice-dollar"></i>
Payment Tracker
</a></li>
</a></li> -->
<!-- Admin-only links, visible only if isAdmin is true -->
<th:block th:if="${isAdmin}">
<li class="nav-item">
@ -55,26 +55,32 @@
</li> -->
</ul>
<!--Navigation item on the right-->
<!-- Admin-only links, visible only if isAdmin is true -->
<th:block th:if="${isAdmin}">
<ul class=" navbar-nav ms-auto" th:if="${curPage == 'home'}">
<th:block th:if="${session.usrName != null}">
<ul class="navbar-nav ms-auto align-items-center flex-row gap-3">
<li class="nav-item text-secondary fw-semibold" style="font-size: 0.9rem;">
<i class="fa-solid fa-user me-1 text-primary"></i>
<span th:text="${session.usrName} + ' (' + ${session.usrRole} + ')'">admin (ADMIN)</span>
</li>
<li class="nav-item">
<form th:action="@{/usr-logout}" method="post" class="m-0 p-0">
<form th:action="@{/usr-logout}" method="post" class="m-0 p-0 d-inline">
<input th:if="${_csrf != null}" type="hidden" th:name="${_csrf.parameterName}"
th:value="${_csrf.token}" />
<button type="submit" class="dropdown-item">
<button type="submit"
class="nav-link btn btn-link text-decoration-none border-0 bg-transparent py-2"
style="display: inline-flex; align-items: center; gap: 5px;">
<i class="fa-solid fa-right-from-bracket"></i> Logout
</button>
</form>
</li>
</ul>
</th:block>
<th:block th:unless="${isAdmin}">
<ul class="navbar-nav ms-auto" th:if="${curPage == 'home'}">
<li class="nav-item"><a class="nav-link" th:href="@{/login}" th:if="${curPage == 'home'}"> <i
class="fa-solid fa-right-to-bracket"></i>
Login
</a></li>
<th:block th:unless="${session.usrName != null}">
<ul class="navbar-nav ms-auto" th:if="${curPage != 'login'}">
<li class="nav-item">
<a class="nav-link" th:href="@{/login}">
<i class="fa-solid fa-right-to-bracket"></i> Login
</a>
</li>
</ul>
</th:block>
</div>

185
src/main/resources/templates/gccservicedocuments/view.html

@ -396,6 +396,21 @@
transform: scale(1.1);
}
.btn-card-edit-modal {
background-color: transparent;
color: #0f5c78;
border: none;
cursor: pointer;
transition: all 0.2s ease;
font-size: 1.1rem;
padding: 4px;
}
.btn-card-edit-modal:hover {
color: #1781a6;
transform: scale(1.1);
}
/* Empty State */
.empty-state {
text-align: center;
@ -573,19 +588,22 @@
</h1>
<div class="header-actions" style="display: flex; align-items: center; gap: 16px; flex-wrap: wrap;">
<!-- User welcome badge -->
<div th:if="${isLoggedIn}" class="user-badge" style="background-color: #e2e8f0; padding: 8px 16px; border-radius: 20px; font-size: 0.9rem; font-weight: 600; color: #475569; display: flex; align-items: center; gap: 8px;">
<div th:if="${isLoggedIn}" class="user-badge"
style="background-color: #e2e8f0; padding: 8px 16px; border-radius: 20px; font-size: 0.9rem; font-weight: 600; color: #475569; display: flex; align-items: center; gap: 8px;">
<i class="fa-solid fa-circle-user" style="color: #0f5c78; font-size: 1.1rem;"></i>
<span th:text="${usrName}">User</span>
</div>
<!-- Show upload portal only if user is logged in -->
<a th:if="${isLoggedIn}" th:href="@{/gccservicedocuments}" class="btn-upload-portal">
<!-- Show upload portal only if user is logged in and is admin -->
<a th:if="${isLoggedIn and isAdmin}" th:href="@{/gccservicedocuments}" class="btn-upload-portal">
<i class="fa-solid fa-cloud-arrow-up"></i>
Upload Documents
</a>
<!-- Logout link -->
<a th:if="${isLoggedIn}" th:href="@{/gccservicedocuments/logout}" class="btn-logout-link" style="color: #ef4444; font-weight: 600; font-size: 0.95rem; text-decoration: none; display: flex; align-items: center; gap: 6px; transition: color 0.2s ease;" onmouseover="this.style.color='#b91c1c'" onmouseout="this.style.color='#ef4444'">
<a th:if="${isLoggedIn}" th:href="@{/gccservicedocuments/logout}" class="btn-logout-link"
style="color: #ef4444; font-weight: 600; font-size: 0.95rem; text-decoration: none; display: flex; align-items: center; gap: 6px; transition: color 0.2s ease;"
onmouseover="this.style.color='#b91c1c'" onmouseout="this.style.color='#ef4444'">
<i class="fa-solid fa-right-from-bracket"></i>
Logout
</a>
@ -649,8 +667,7 @@
</div>
<!-- Category Files Modal -->
<div class="modal fade" id="categoryFilesModal" tabindex="-1" aria-labelledby="categoryFilesModalLabel"
aria-hidden="true">
<div class="modal fade" id="categoryFilesModal" aria-labelledby="categoryFilesModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content"
style="border-radius: 14px; border: none; box-shadow: 0 10px 40px rgba(0,0,0,0.2);">
@ -681,6 +698,7 @@
<th>File Type</th>
<th>Upload Date</th>
<th>Original Filename</th>
<th th:if="${isAdmin}" style="width: 80px;" class="text-center">Edit</th>
<th th:if="${isAdmin}" style="width: 80px;" class="text-center">Delete</th>
<th style="width: 160px;" class="text-center">Action</th>
</tr>
@ -717,10 +735,22 @@
<!-- Scripting for API Calls -->
<script th:inline="javascript">
// Disable Bootstrap modal focus trap completely to prevent blocking SweetAlert2 inputs
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
bootstrap.Modal.prototype._initializeFocusTrap = function () {
return {
activate: () => { },
deactivate: () => { }
};
};
bootstrap.Modal.prototype._handleFocusin = function () { };
}
const contextPath = /*[[@{/}]]*/ '';
const isAdmin = /*[[${isAdmin}]]*/ false;
let allDocuments = [];
let filteredDocuments = [];
let allFileTypes = [];
// Modal states
let currentModalCategoryName = "";
@ -781,6 +811,13 @@
populateSubcategoryFilter();
})
.catch(err => console.error("Error loading subcategories:", err));
fetch(contextPath + 'gccservicedocuments/api/file-types')
.then(response => response.json())
.then(fileTypes => {
allFileTypes = fileTypes;
})
.catch(err => console.error("Error loading file types:", err));
}
function populateSubcategoryFilter(filteredSubs) {
@ -1041,8 +1078,16 @@
const tr = document.createElement('tr');
let editButtonHtml = '';
let deleteButtonHtml = '';
if (isAdmin) {
editButtonHtml = `
<td class="text-center">
<button class="btn-card-edit-modal" onclick="editDocument(event, ${doc.id}, '${escapeHtml(doc.title)}', '${escapeHtml(doc.category || 'General')}', '${escapeHtml(doc.file_type)}')" title="Edit Document">
<i class="fa-solid fa-pen-to-square"></i>
</button>
</td>
`;
deleteButtonHtml = `
<td class="text-center">
<button class="btn-card-delete-modal" onclick="deleteDocument(event, ${doc.id})" title="Delete Document">
@ -1064,6 +1109,7 @@
</td>
<td>${escapeHtml(formattedDate)}</td>
<td class="text-secondary" style="font-size: 0.85rem;" title="${escapeHtml(doc.original_filename)}">${escapeHtml(truncateString(doc.original_filename, 24))}</td>
${editButtonHtml}
${deleteButtonHtml}
<td class="text-center">
${isVideo ?
@ -1111,6 +1157,133 @@
}, 300);
}
function editDocument(event, id, currentTitle, currentSubcategory, currentFileType) {
event.stopPropagation();
// Build options for subcategory dropdown
let subcatOptions = '';
allFilterSubcategories.forEach(sub => {
const isSelected = sub.category_name.toLowerCase() === currentSubcategory.toLowerCase() ? 'selected' : '';
subcatOptions += `<option value="${escapeHtml(sub.category_name)}" ${isSelected}>${escapeHtml(sub.category_name)}</option>`;
});
// Build options for filetype dropdown
let fileTypeOptions = '';
allFileTypes.forEach(ft => {
const isSelected = ft.file_type_name.toLowerCase() === currentFileType.toLowerCase() ? 'selected' : '';
fileTypeOptions += `<option value="${escapeHtml(ft.file_type_name)}" ${isSelected}>${escapeHtml(ft.file_type_name)}</option>`;
});
Swal.fire({
title: 'Edit Document Details',
html: `
<div style="text-align: left; margin-bottom: 15px;">
<label for="swal-title" style="font-weight: 600; display: block; margin-bottom: 5px; color: #0f5c78;">Document/Video Title</label>
<input id="swal-title" class="form-control" style="width: 100%; border-radius: 8px; border: 1.5px solid #e2e8f0; padding: 10px;" value="${escapeHtml(currentTitle)}">
</div>
<div style="text-align: left; margin-bottom: 15px;">
<label for="swal-subcategory" style="font-weight: 600; display: block; margin-bottom: 5px; color: #0f5c78;">Subcategory</label>
<select id="swal-subcategory" class="form-control" style="width: 100%; border-radius: 8px; border: 1.5px solid #e2e8f0; padding: 10px;">
${subcatOptions}
</select>
</div>
<div style="text-align: left; margin-bottom: 15px;">
<label for="swal-filetype" style="font-weight: 600; display: block; margin-bottom: 5px; color: #0f5c78;">File Type</label>
<select id="swal-filetype" class="form-control" style="width: 100%; border-radius: 8px; border: 1.5px solid #e2e8f0; padding: 10px;">
${fileTypeOptions}
</select>
</div>
<div style="text-align: left;">
<label for="swal-file" style="font-weight: 600; display: block; margin-bottom: 5px; color: #0f5c78;">Replace File (Optional)</label>
<input id="swal-file" type="file" class="form-control" style="width: 100%; border-radius: 8px; border: 1.5px solid #e2e8f0; padding: 6px 10px;">
</div>
`,
focusConfirm: false,
showCancelButton: true,
confirmButtonColor: '#0f5c78',
cancelButtonColor: '#ef4444',
confirmButtonText: 'Save Changes',
preConfirm: () => {
const title = document.getElementById('swal-title').value.trim();
const subCategory = document.getElementById('swal-subcategory').value;
const fileType = document.getElementById('swal-filetype').value;
const fileInput = document.getElementById('swal-file');
const file = fileInput.files[0] || null;
if (!title) {
Swal.showValidationMessage('Document/Video Title cannot be empty');
return false;
}
return { title, subCategory, fileType, file };
}
}).then((result) => {
if (result.isConfirmed) {
const { title, subCategory, fileType, file } = result.value;
// CSRF security token fetch
const csrfMeta = document.querySelector('meta[name="_csrf"]');
const csrfHeaderMeta = document.querySelector('meta[name="_csrf_header"]');
const formData = new FormData();
formData.append('id', id);
formData.append('title', title);
formData.append('subCategory', subCategory);
formData.append('fileType', fileType);
if (file) {
formData.append('file', file);
}
const headers = {};
if (csrfMeta && csrfHeaderMeta) {
headers[csrfHeaderMeta.content] = csrfMeta.content;
}
// Show loading overlay
showLoadingOverlay("Saving Changes...");
fetch(contextPath + 'gccservicedocuments/api/update', {
method: 'POST',
headers: headers,
body: formData
})
.then(res => res.json())
.then(data => {
hideLoadingOverlay();
if (data.success) {
Swal.fire({
title: 'Updated!',
text: data.message || 'Changes saved successfully.',
icon: 'success',
confirmButtonColor: '#0f5c78'
});
// Close files modal to allow refresh
const filesModalElement = document.getElementById('categoryFilesModal');
const filesModal = bootstrap.Modal.getOrCreateInstance(filesModalElement);
filesModal.hide();
loadDocuments(); // Refresh documents list
} else {
Swal.fire({
title: 'Failed!',
text: data.error || 'Failed to update details.',
icon: 'error',
confirmButtonColor: '#0f5c78'
});
}
})
.catch(err => {
hideLoadingOverlay();
console.error("Error updating document:", err);
Swal.fire({
title: 'Error!',
text: 'An error occurred while saving changes.',
icon: 'error',
confirmButtonColor: '#0f5c78'
});
});
}
});
}
function deleteDocument(event, id) {
event.stopPropagation();
if (!confirm("Are you sure you want to delete this document record?")) {

611
src/main/resources/templates/gccservicepaymenttracker/dashboard.html

@ -22,6 +22,8 @@
<!-- Flatpickr Datepicker -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Custom Style Sheet mapping to theme -->
<style>
@ -770,6 +772,133 @@
align-items: center;
gap: 8px;
}
/* Interactive Summary Cell Styles */
.cursor-pointer {
cursor: pointer;
transition: all 0.2s ease;
}
.cursor-pointer:hover {
background-color: #cbd5e1 !important;
transform: scale(1.08);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08);
z-index: 5;
position: relative;
}
/* Customized premium scrollbar for responsive tables */
.table-responsive::-webkit-scrollbar {
height: 6px;
width: 6px;
}
.table-responsive::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.table-responsive::-webkit-scrollbar-track {
background: #f8fafc;
}
/* Force table cells to wrap cleanly and prevent word clipping */
.grid-table th,
.grid-table td {
white-space: normal;
}
/* Keep straight horizontal scrolling layout only on mobile phone viewports */
@media (max-width: 767.98px) {
.grid-table th,
.grid-table td {
white-space: nowrap;
}
}
/* Media Queries for all devices */
@media (max-width: 991.98px) {
.header {
flex-direction: column;
gap: 15px;
text-align: center;
padding: 20px;
}
.header-title h2 {
font-size: 1.35rem;
}
.user-menu {
width: 100%;
justify-content: center;
}
.main-container {
max-width: 100%;
margin: 15px auto;
}
.category-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 15px;
margin-bottom: 25px;
}
.card-value {
font-size: 1.5rem;
}
.card-icon {
font-size: 1.85rem;
}
}
@media (max-width: 575.98px) {
.header-title h2 {
font-size: 1.15rem;
}
.category-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.category-card {
padding: 16px;
border-radius: 12px;
}
.card-value {
font-size: 1.25rem;
}
.card-icon {
font-size: 1.5rem;
}
.filter-card,
.table-card {
padding: 16px;
border-radius: 12px;
}
.table-controls-row {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.table-controls-right input {
width: 100% !important;
}
.btn-add-record {
width: 100%;
justify-content: center;
}
}
</style>
</head>
@ -878,6 +1007,135 @@
</div>
<!-- Executive Summary & Charts Section -->
<div class="row g-4 mb-4">
<!-- Executive Summary Table Card -->
<div class="col-lg-8">
<div class="card h-100 border-0 shadow-sm" style="border-radius: 16px; overflow: hidden;">
<div class="card-header bg-white py-3 border-0">
<h4 class="card-title mb-0 fw-bold"
style="font-family: 'Outfit', sans-serif; color: #09303d; font-size: 1.15rem;">
<i class="fa-solid fa-chart-line me-2 text-primary"></i>Executive Summary (Overall Invoices
Status)
</h4>
</div>
<div class="card-body p-3">
<div class="table-responsive">
<table class="table table-bordered text-center align-middle mb-0"
style="font-size: 0.82rem; border-color: #e2e8f0;">
<thead class="table-light text-secondary fw-bold" style="font-size: 0.75rem;">
<tr>
<th style="background-color: #f8fafc; color: #475569;">Total Invoices</th>
<th style="background-color: #f0fdf4; color: #166534;">Completed</th>
<th style="background-color: #fffbeb; color: #92400e;">Awaiting Details</th>
<th style="background-color: #f0f9ff; color: #075985;">Pending with Dept</th>
<th style="background-color: #fef2f2; color: #991b1b;">Pending</th>
<th style="background-color: #fafaf9; color: #44403c;">1 to 15 Days</th>
<th style="background-color: #fafaf9; color: #44403c;">16 to 30 Days</th>
<th style="background-color: #fafaf9; color: #44403c;">31 to 60 Days</th>
<th style="background-color: #fafaf9; color: #44403c;">61 to 90 Days</th>
<th style="background-color: #fafaf9; color: #44403c;">91 to 120 Days</th>
<th style="background-color: #fafaf9; color: #44403c;">Over 120 Days</th>
</tr>
</thead>
<tbody>
<tr class="fw-bold" style="font-size: 1rem; color: #1e293b;">
<td id="summary-total" class="cursor-pointer text-primary"
style="background-color: #f8fafc;" onclick="clearClickFilter()">0</td>
<td id="summary-completed" class="cursor-pointer text-success"
onclick="setClickFilter('status', 'Completed', 'Status: Completed')">0</td>
<td id="summary-awaiting" class="cursor-pointer text-warning"
onclick="setClickFilter('status', 'Awaiting Details', 'Status: Awaiting Details')">
0</td>
<td id="summary-dept" class="cursor-pointer text-info"
onclick="setClickFilter('status', 'Pending with Dept', 'Status: Pending with Dept')">
0</td>
<td id="summary-pending" class="cursor-pointer text-danger"
onclick="setClickFilter('status', 'Pending', 'Status: Pending')">0</td>
<td id="summary-1-15" class="cursor-pointer"
onclick="setClickFilter('age', [1, 15], 'Age: 1 to 15 Days')">0</td>
<td id="summary-16-30" class="cursor-pointer"
onclick="setClickFilter('age', [16, 30], 'Age: 16 to 30 Days')">0</td>
<td id="summary-31-60" class="cursor-pointer"
onclick="setClickFilter('age', [31, 60], 'Age: 31 to 60 Days')">0</td>
<td id="summary-61-90" class="cursor-pointer"
onclick="setClickFilter('age', [61, 90], 'Age: 61 to 90 Days')">0</td>
<td id="summary-91-120" class="cursor-pointer"
onclick="setClickFilter('age', [91, 120], 'Age: 91 to 120 Days')">0</td>
<td id="summary-over-120" class="cursor-pointer text-danger"
onclick="setClickFilter('age', [121, null], 'Age: Over 120 Days')">0</td>
</tr>
</tbody>
</table>
</div>
<p class="text-muted mt-2 mb-0" style="font-size: 0.72rem; font-style: italic;">
<i class="fa-solid fa-circle-info me-1"></i>Tip: Click on any count cell to filter the
detailed payment tracker list below.
</p>
</div>
</div>
</div>
<!-- Pie Chart Card -->
<div class="col-lg-4">
<div class="card h-100 border-0 shadow-sm" style="border-radius: 16px;">
<div class="card-header bg-white py-3 border-0">
<h4 class="card-title mb-0 fw-bold"
style="font-family: 'Outfit', sans-serif; color: #09303d; font-size: 1.15rem;">
<i class="fa-solid fa-chart-pie me-2 text-primary"></i>Invoices Status Distribution
</h4>
</div>
<div class="card-body d-flex align-items-center justify-content-center p-2"
style="position: relative; height: 180px;">
<canvas id="statusPieChart" style="max-height: 100%; max-width: 100%;"></canvas>
</div>
</div>
</div>
</div>
<!-- Vendor Breakdown Details Card -->
<div class="card border-0 shadow-sm mb-4" style="border-radius: 16px; overflow: hidden;">
<div
class="card-header bg-white py-3 border-0 d-flex justify-content-between align-items-center flex-wrap gap-2">
<h4 class="card-title mb-0 fw-bold"
style="font-family: 'Outfit', sans-serif; color: #09303d; font-size: 1.15rem;">
<i class="fa-solid fa-users-viewfinder me-2 text-primary"></i>Vendor Breakdown Details (Invoices)
</h4>
<div style="font-size: 0.8rem; color: #64748b;">
<input type="text" id="vendor-breakdown-search" class="form-control form-control-sm"
style="width: 200px; border-radius: 8px;" placeholder="Search vendor breakdown..."
oninput="filterVendorBreakdown(this.value)">
</div>
</div>
<div class="card-body p-3">
<div class="table-responsive" style="max-height: 280px; overflow-y: auto;">
<table class="table table-striped table-bordered text-center align-middle mb-0"
style="font-size: 0.8rem; border-color: #e2e8f0;">
<thead class="table-light sticky-top text-secondary fw-bold"
style="font-size: 0.72rem; z-index: 10;">
<tr>
<th style="text-align: left; background-color: #f8fafc;">Vendor Name</th>
<th style="background-color: #f8fafc;">Total Invoices</th>
<th style="background-color: #f0fdf4;">Completed</th>
<th style="background-color: #fffbeb;">Awaiting Details</th>
<th style="background-color: #f0f9ff;">Pending with Dept</th>
<th style="background-color: #fef2f2;">Pending</th>
<th style="background-color: #fafaf9;">1 to 15 Days</th>
<th style="background-color: #fafaf9;">16 to 30 Days</th>
<th style="background-color: #fafaf9;">31 to 60 Days</th>
<th style="background-color: #fafaf9;">61 to 90 Days</th>
<th style="background-color: #fafaf9;">91 to 120 Days</th>
<th style="background-color: #fafaf9;">Over 120 Days</th>
</tr>
</thead>
<tbody id="vendor-breakdown-tbody">
<!-- Populated dynamically via JS -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Filter Card -->
<div class="filter-card">
<div class="filter-grid">
@ -949,6 +1207,8 @@
<i class="fa-solid fa-table-list text-primary"></i>
<span>Payment Tracking Details</span>
<span class="active-filter-badge" id="active-category-badge">Type: All Types</span>
<span class="active-filter-badge ms-2" id="click-filter-badge"
style="display: none; background-color: #fee2e2; color: #b91c1c;"></span>
</h3>
<button th:if="${isAdmin}" class="btn-add-record" onclick="openAddModal()">
<i class="fa-solid fa-circle-plus"></i> Add Payment Tracking Record
@ -1246,6 +1506,11 @@
let currentPage = 1;
let pageSize = 20;
// Custom dashboard filter & chart instances
let activeClickFilter = null;
let statusChartInstance = null;
let vendorBreakdownData = [];
// Dynamic lists for types and vendors
let activeTypes = [];
let activeVendors = [];
@ -1418,6 +1683,9 @@
// Refresh list
loadList();
// Smoothly scroll down to the table card
document.querySelector('.table-card')?.scrollIntoView({ behavior: 'smooth' });
}
// Loading and Rendering list data
@ -1449,6 +1717,8 @@
.then(data => {
allRecords = data;
currentPage = 1;
clearClickFilter(null);
calculateSummaries();
renderTable();
})
.catch(err => {
@ -1466,6 +1736,44 @@
let query = searchVal.toLowerCase().trim();
let filtered = allRecords.filter(row => {
// Apply activeClickFilter if set
if (activeClickFilter) {
const age = parseInt(calculateAgeOfBill(row.received_date), 10);
const status = String(row.status || '').trim();
const vendor = String(row.resolved_vendor || row.vendor_name || 'Unknown').trim();
if (activeClickFilter.type === 'status') {
if (activeClickFilter.value === 'Pending') {
if (status === 'Completed' || status === 'Awaiting Details' || status === 'Pending with Dept') return false;
} else {
if (status !== activeClickFilter.value) return false;
}
} else if (activeClickFilter.type === 'age') {
const [min, max] = activeClickFilter.value;
if (max !== null) {
if (age < min || age > max) return false;
} else {
if (age < min) return false;
}
} else if (activeClickFilter.type === 'vendor') {
if (vendor !== activeClickFilter.vendorName) return false;
if (activeClickFilter.col) {
if (activeClickFilter.col === 'completed' && status !== 'Completed') return false;
if (activeClickFilter.col === 'awaiting' && status !== 'Awaiting Details') return false;
if (activeClickFilter.col === 'pending_dept' && status !== 'Pending with Dept') return false;
if (activeClickFilter.col === 'pending' && (status === 'Completed' || status === 'Awaiting Details' || status === 'Pending with Dept')) return false;
if (activeClickFilter.col === 'age_1_15' && (age < 1 || age > 15)) return false;
if (activeClickFilter.col === 'age_16_30' && (age < 16 || age > 30)) return false;
if (activeClickFilter.col === 'age_31_60' && (age < 31 || age > 60)) return false;
if (activeClickFilter.col === 'age_61_90' && (age < 61 || age > 90)) return false;
if (activeClickFilter.col === 'age_91_120' && (age < 91 || age > 120)) return false;
if (activeClickFilter.col === 'age_over_120' && age <= 120) return false;
}
}
}
if (!query) return true;
const vName = String(row.resolved_vendor || row.vendor_name || '').toLowerCase();
const tName = String(row.resolved_type || row.type || '').toLowerCase();
@ -1553,32 +1861,7 @@
return dateTimeStr;
};
// Calculate age of bill (days) dynamically from received date:
const calculateAgeOfBill = receivedDateStr => {
if (!receivedDateStr) return '0';
try {
const recDate = new Date(receivedDateStr);
if (isNaN(recDate.getTime())) return '0';
recDate.setHours(0, 0, 0, 0);
const today = new Date();
today.setHours(0, 0, 0, 0);
const graceDate = new Date(recDate);
graceDate.setDate(graceDate.getDate() + 7);
if (today <= graceDate) {
return '0';
} else {
const diffTime = today.getTime() - graceDate.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
return String(diffDays);
}
} catch (e) {
console.error("Error calculating age of bill:", e);
return '0';
}
};
const getTypeBadge = typeVal => {
const cleanType = String(typeVal || '').trim().toLowerCase();
@ -2107,6 +2390,284 @@
}
}
// Calculate age of bill (days) dynamically from received date
function calculateAgeOfBill(receivedDateStr) {
if (!receivedDateStr) return '0';
try {
const recDate = new Date(receivedDateStr);
if (isNaN(recDate.getTime())) return '0';
recDate.setHours(0, 0, 0, 0);
const today = new Date();
today.setHours(0, 0, 0, 0);
const graceDate = new Date(recDate);
graceDate.setDate(graceDate.getDate() + 7);
if (today <= graceDate) {
return '0';
} else {
const diffTime = today.getTime() - graceDate.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
return String(diffDays);
}
} catch (e) {
console.error("Error calculating age of bill:", e);
return '0';
}
}
// --- NEW METRICS & INTERACTIVE CLICK FILTER LOGIC ---
function calculateSummaries() {
let total = allRecords.length;
let completed = 0;
let awaiting = 0;
let dept = 0;
let pending = 0;
let age_1_15 = 0;
let age_16_30 = 0;
let age_31_60 = 0;
let age_61_90 = 0;
let age_91_120 = 0;
let age_over_120 = 0;
const vendorMap = {};
allRecords.forEach(row => {
const status = String(row.status || '').trim();
const age = parseInt(calculateAgeOfBill(row.received_date), 10);
const vendorName = String(row.resolved_vendor || row.vendor_name || 'Unknown').trim();
if (status === 'Completed') completed++;
else if (status === 'Awaiting Details') awaiting++;
else if (status === 'Pending with Dept') dept++;
else pending++;
if (age >= 1 && age <= 15) age_1_15++;
else if (age >= 16 && age <= 30) age_16_30++;
else if (age >= 31 && age <= 60) age_31_60++;
else if (age >= 61 && age <= 90) age_61_90++;
else if (age >= 91 && age <= 120) age_91_120++;
else if (age > 120) age_over_120++;
if (!vendorMap[vendorName]) {
vendorMap[vendorName] = {
vendorName: vendorName,
total: 0,
completed: 0,
awaiting: 0,
dept: 0,
pending: 0,
age_1_15: 0,
age_16_30: 0,
age_31_60: 0,
age_61_90: 0,
age_91_120: 0,
age_over_120: 0
};
}
const vData = vendorMap[vendorName];
vData.total++;
if (status === 'Completed') vData.completed++;
else if (status === 'Awaiting Details') vData.awaiting++;
else if (status === 'Pending with Dept') vData.dept++;
else vData.pending++;
if (age >= 1 && age <= 15) vData.age_1_15++;
else if (age >= 16 && age <= 30) vData.age_16_30++;
else if (age >= 31 && age <= 60) vData.age_31_60++;
else if (age >= 61 && age <= 90) vData.age_61_90++;
else if (age >= 91 && age <= 120) vData.age_91_120++;
else if (age > 120) vData.age_over_120++;
});
document.getElementById('summary-total').textContent = total;
document.getElementById('summary-completed').textContent = completed;
document.getElementById('summary-awaiting').textContent = awaiting;
document.getElementById('summary-dept').textContent = dept;
document.getElementById('summary-pending').textContent = pending;
document.getElementById('summary-1-15').textContent = age_1_15;
document.getElementById('summary-16-30').textContent = age_16_30;
document.getElementById('summary-31-60').textContent = age_31_60;
document.getElementById('summary-61-90').textContent = age_61_90;
document.getElementById('summary-91-120').textContent = age_91_120;
document.getElementById('summary-over-120').textContent = age_over_120;
renderStatusChart(completed, awaiting, dept, pending);
vendorBreakdownData = Object.values(vendorMap).sort((a, b) => b.total - a.total);
renderVendorBreakdown();
}
function renderStatusChart(completed, awaiting, dept, pending) {
const ctx = document.getElementById('statusPieChart')?.getContext('2d');
if (!ctx) return;
if (statusChartInstance) {
statusChartInstance.destroy();
}
statusChartInstance = new Chart(ctx, {
type: 'pie',
data: {
labels: ['Completed', 'Awaiting Details', 'Pending with Dept', 'Pending'],
datasets: [{
data: [completed, awaiting, dept, pending],
backgroundColor: [
'#2ecc71',
'#f1c40f',
'#3498db',
'#e74c3c'
],
borderWidth: 1,
borderColor: '#ffffff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
boxWidth: 12,
font: {
size: 10,
family: 'Poppins'
}
}
}
}
}
});
}
function renderVendorBreakdown() {
const tbody = document.getElementById('vendor-breakdown-tbody');
if (!tbody) return;
tbody.innerHTML = '';
const searchVal = document.getElementById('vendor-breakdown-search')?.value || '';
const query = searchVal.toLowerCase().trim();
const filtered = vendorBreakdownData.filter(v => v.vendorName.toLowerCase().includes(query));
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="12" class="text-secondary py-3">No matching vendors found</td></tr>`;
return;
}
// Keep track of column sums
let sumTotal = 0;
let sumCompleted = 0;
let sumAwaiting = 0;
let sumDept = 0;
let sumPending = 0;
let sumAge1_15 = 0;
let sumAge16_30 = 0;
let sumAge31_60 = 0;
let sumAge61_90 = 0;
let sumAge91_120 = 0;
let sumAgeOver120 = 0;
filtered.forEach(v => {
sumTotal += v.total;
sumCompleted += v.completed;
sumAwaiting += v.awaiting;
sumDept += v.dept;
sumPending += v.pending;
sumAge1_15 += v.age_1_15;
sumAge16_30 += v.age_16_30;
sumAge31_60 += v.age_31_60;
sumAge61_90 += v.age_61_90;
sumAge91_120 += v.age_91_120;
sumAgeOver120 += v.age_over_120;
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="fw-semibold text-start text-dark" style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(v.vendorName)}</td>
<td class="cursor-pointer text-primary fw-bold" onclick="setClickFilter('vendor', null, 'Vendor: ${escapeHtml(v.vendorName)}', '${escapeHtml(v.vendorName)}')">${v.total}</td>
<td class="cursor-pointer text-success" onclick="setClickFilter('vendor', null, 'Vendor: ${escapeHtml(v.vendorName)} (Completed)', '${escapeHtml(v.vendorName)}', 'completed')">${v.completed}</td>
<td class="cursor-pointer text-warning" onclick="setClickFilter('vendor', null, 'Vendor: ${escapeHtml(v.vendorName)} (Awaiting Details)', '${escapeHtml(v.vendorName)}', 'awaiting')">${v.awaiting}</td>
<td class="cursor-pointer text-info" onclick="setClickFilter('vendor', null, 'Vendor: ${escapeHtml(v.vendorName)} (Pending with Dept)', '${escapeHtml(v.vendorName)}', 'pending_dept')">${v.dept}</td>
<td class="cursor-pointer text-danger" onclick="setClickFilter('vendor', null, 'Vendor: ${escapeHtml(v.vendorName)} (Pending)', '${escapeHtml(v.vendorName)}', 'pending')">${v.pending}</td>
<td class="cursor-pointer" onclick="setClickFilter('vendor', null, 'Vendor: ${escapeHtml(v.vendorName)} (1-15 Days)', '${escapeHtml(v.vendorName)}', 'age_1_15')">${v.age_1_15}</td>
<td class="cursor-pointer" onclick="setClickFilter('vendor', null, 'Vendor: ${escapeHtml(v.vendorName)} (16-30 Days)', '${escapeHtml(v.vendorName)}', 'age_16_30')">${v.age_16_30}</td>
<td class="cursor-pointer" onclick="setClickFilter('vendor', null, 'Vendor: ${escapeHtml(v.vendorName)} (31-60 Days)', '${escapeHtml(v.vendorName)}', 'age_31_60')">${v.age_31_60}</td>
<td class="cursor-pointer" onclick="setClickFilter('vendor', null, 'Vendor: ${escapeHtml(v.vendorName)} (61-90 Days)', '${escapeHtml(v.vendorName)}', 'age_61_90')">${v.age_61_90}</td>
<td class="cursor-pointer" onclick="setClickFilter('vendor', null, 'Vendor: ${escapeHtml(v.vendorName)} (91-120 Days)', '${escapeHtml(v.vendorName)}', 'age_91_120')">${v.age_91_120}</td>
<td class="cursor-pointer text-danger" onclick="setClickFilter('vendor', null, 'Vendor: ${escapeHtml(v.vendorName)} (Over 120 Days)', '${escapeHtml(v.vendorName)}', 'age_over_120')">${v.age_over_120}</td>
`;
tbody.appendChild(tr);
});
// Append TOTAL row at the bottom
const totalTr = document.createElement('tr');
totalTr.className = 'table-warning fw-bold text-dark';
totalTr.style.borderTop = '2px solid #cbd5e1';
totalTr.innerHTML = `
<td class="text-start">TOTAL</td>
<td>${sumTotal}</td>
<td>${sumCompleted}</td>
<td>${sumAwaiting}</td>
<td>${sumDept}</td>
<td>${sumPending}</td>
<td>${sumAge1_15}</td>
<td>${sumAge16_30}</td>
<td>${sumAge31_60}</td>
<td>${sumAge61_90}</td>
<td>${sumAge91_120}</td>
<td class="text-danger">${sumAgeOver120}</td>
`;
tbody.appendChild(totalTr);
}
function filterVendorBreakdown(val) {
renderVendorBreakdown();
}
function setClickFilter(type, value, displayName, vendorName = null, col = null) {
activeClickFilter = { type, value, displayName, vendorName, col };
const badge = document.getElementById('click-filter-badge');
if (badge) {
badge.innerHTML = `Filter: ${displayName} <i class="fa-solid fa-circle-xmark ms-1 cursor-pointer" onclick="clearClickFilter(event)"></i>`;
badge.style.display = 'inline-block';
badge.style.cursor = 'pointer';
}
currentPage = 1;
renderTable();
// Smoothly scroll down to the table card
document.querySelector('.table-card')?.scrollIntoView({ behavior: 'smooth' });
}
function clearClickFilter(event) {
if (event) event.stopPropagation();
activeClickFilter = null;
const badge = document.getElementById('click-filter-badge');
if (badge) {
badge.style.display = 'none';
}
currentPage = 1;
renderTable();
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// --- END OF NEW METRICS & INTERACTIVE CLICK FILTER LOGIC ---
// Delete record API call
function deleteRecord(id) {
if (!confirm('Are you sure you want to delete this payment tracking record?')) {

Loading…
Cancel
Save