From a9727b1f44864020d29d5c9a31168ba777842ae3 Mon Sep 17 00:00:00 2001 From: chark1es Date: Wed, 19 Feb 2025 05:08:19 -0800 Subject: [PATCH] added reimbursement system --- bun.lock | 26 +- package.json | 4 + .../Officer_ReimbursementManagement.astro | 7 + .../reimbursement/ReimbursementForm.tsx | 12 +- .../reimbursement/ReimbursementList.tsx | 10 +- .../ReimbursementManagementPortal.tsx | 1692 +++++++++++++++++ src/lib/pocketbase.ts | 5 + src/scripts/pocketbase/Authentication.ts | 7 + 8 files changed, 1749 insertions(+), 14 deletions(-) create mode 100644 src/components/dashboard/Officer_ReimbursementManagement.astro create mode 100644 src/components/dashboard/reimbursement/ReimbursementManagementPortal.tsx create mode 100644 src/lib/pocketbase.ts diff --git a/bun.lock b/bun.lock index 8fc5dcf..06daca5 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,8 @@ "astro": "5.1.1", "astro-expressive-code": "^0.40.2", "astro-icon": "^1.1.5", + "chart.js": "^4.4.7", + "framer-motion": "^12.4.4", "highlight.js": "^11.11.1", "js-yaml": "^4.1.0", "jszip": "^3.10.1", @@ -26,7 +28,9 @@ "pocketbase": "^0.25.1", "prismjs": "^1.29.0", "react": "^19.0.0", + "react-chartjs-2": "^5.3.0", "react-dom": "^19.0.0", + "react-hot-toast": "^2.5.2", "react-icons": "^5.4.0", "rehype-expressive-code": "^0.40.2", "tailwindcss": "^3.4.16", @@ -226,6 +230,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw=="], "@next/env": ["@next/env@15.1.3", "", {}, "sha512-Q1tXwQCGWyA3ehMph3VO+E6xFPHDKdHFYosadt0F78EObYxPio0S09H9UGYznDe6Wc8eLKLG89GqcFJJDiK5xw=="], @@ -476,6 +482,8 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "chart.js": ["chart.js@4.4.7", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-pwkcKfdzTMAU/+jNosKhNL2bHtJc/sSmYgVbuGTEDhzkrhmyihmP7vUc/5ZK9WopidMDHNe3Wm7jOd/WhuHWuw=="], + "cheerio": ["cheerio@1.0.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "encoding-sniffer": "^0.2.0", "htmlparser2": "^9.1.0", "parse5": "^7.1.2", "parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-parser-stream": "^7.1.2", "undici": "^6.19.5", "whatwg-mimetype": "^4.0.0" } }, "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww=="], "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], @@ -678,7 +686,7 @@ "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], - "framer-motion": ["framer-motion@11.15.0", "", { "dependencies": { "motion-dom": "^11.14.3", "motion-utils": "^11.14.3", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-MLk8IvZntxOMg7lDBLw2qgTHHv664bYoYmnFTmE0Gm/FW67aOJk0WM3ctMcG+Xhcv+vh5uyyXwxvxhSeJzSe+w=="], + "framer-motion": ["framer-motion@12.4.4", "", { "dependencies": { "motion-dom": "^12.4.4", "motion-utils": "^12.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-JWkVwbJBgVkeZHNcnk8ififgwTF+5de9wbJnTLI+g9YqaGo75Xd5uRVDm9FR8chqRDOKcXv/71f40CGescYVmg=="], "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], @@ -704,6 +712,8 @@ "globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], + "goober": ["goober@2.1.16", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "h3": ["h3@1.13.0", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": ">=0.2.0 <0.4.0", "defu": "^6.1.4", "destr": "^2.0.3", "iron-webcrypto": "^1.2.1", "ohash": "^1.1.4", "radix3": "^1.1.2", "ufo": "^1.5.4", "uncrypto": "^0.1.3", "unenv": "^1.10.0" } }, "sha512-vFEAu/yf8UMUcB4s43OaDaigcqpQd14yanmOsn+NcRX3/guSKncyE2rOYhq8RIchgJrPSs/QiIddnTTR1ddiAg=="], @@ -984,9 +994,9 @@ "motion": ["motion@11.15.0", "", { "dependencies": { "framer-motion": "^11.15.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-iZ7dwADQJWGsqsSkBhNHdI2LyYWU+hA1Nhy357wCLZq1yHxGImgt3l7Yv0HT/WOskcYDq9nxdedyl4zUv7UFFw=="], - "motion-dom": ["motion-dom@11.14.3", "", {}, "sha512-lW+D2wBy5vxLJi6aCP0xyxTxlTfiu+b+zcpVbGVFUxotwThqhdpPRSmX8xztAgtZMPMeU0WGVn/k1w4I+TbPqA=="], + "motion-dom": ["motion-dom@12.4.4", "", { "dependencies": { "motion-utils": "^12.0.0" } }, "sha512-D8Kjp8oqUNqxoAVmIlOH+YCMov/4koBAmG4OJs0VWfh18xkQEIsx9+S7yrXyx0XaMBEPtre6e9LiSW2Zs7vIhA=="], - "motion-utils": ["motion-utils@11.14.3", "", {}, "sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ=="], + "motion-utils": ["motion-utils@12.0.0", "", {}, "sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA=="], "mrmime": ["mrmime@2.0.0", "", {}, "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw=="], @@ -1128,8 +1138,12 @@ "react": ["react@19.0.0", "", {}, "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ=="], + "react-chartjs-2": ["react-chartjs-2@5.3.0", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw=="], + "react-dom": ["react-dom@19.0.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ=="], + "react-hot-toast": ["react-hot-toast@2.5.2", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw=="], + "react-icons": ["react-icons@5.4.0", "", { "peerDependencies": { "react": "*" } }, "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ=="], "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], @@ -1450,6 +1464,8 @@ "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "motion/framer-motion": ["framer-motion@11.15.0", "", { "dependencies": { "motion-dom": "^11.14.3", "motion-utils": "^11.14.3", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-MLk8IvZntxOMg7lDBLw2qgTHHv664bYoYmnFTmE0Gm/FW67aOJk0WM3ctMcG+Xhcv+vh5uyyXwxvxhSeJzSe+w=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], @@ -1514,6 +1530,10 @@ "load-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "motion/framer-motion/motion-dom": ["motion-dom@11.14.3", "", {}, "sha512-lW+D2wBy5vxLJi6aCP0xyxTxlTfiu+b+zcpVbGVFUxotwThqhdpPRSmX8xztAgtZMPMeU0WGVn/k1w4I+TbPqA=="], + + "motion/framer-motion/motion-utils": ["motion-utils@11.14.3", "", {}, "sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ=="], + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="], diff --git a/package.json b/package.json index 2f0c8a3..b7ec99b 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "astro": "5.1.1", "astro-expressive-code": "^0.40.2", "astro-icon": "^1.1.5", + "chart.js": "^4.4.7", + "framer-motion": "^12.4.4", "highlight.js": "^11.11.1", "js-yaml": "^4.1.0", "jszip": "^3.10.1", @@ -33,7 +35,9 @@ "pocketbase": "^0.25.1", "prismjs": "^1.29.0", "react": "^19.0.0", + "react-chartjs-2": "^5.3.0", "react-dom": "^19.0.0", + "react-hot-toast": "^2.5.2", "react-icons": "^5.4.0", "rehype-expressive-code": "^0.40.2", "tailwindcss": "^3.4.16" diff --git a/src/components/dashboard/Officer_ReimbursementManagement.astro b/src/components/dashboard/Officer_ReimbursementManagement.astro new file mode 100644 index 0000000..040df5a --- /dev/null +++ b/src/components/dashboard/Officer_ReimbursementManagement.astro @@ -0,0 +1,7 @@ +--- +import ReimbursementManagementPortal from "./reimbursement/ReimbursementManagementPortal"; +--- + +
+ +
diff --git a/src/components/dashboard/reimbursement/ReimbursementForm.tsx b/src/components/dashboard/reimbursement/ReimbursementForm.tsx index 930cbb8..a400585 100644 --- a/src/components/dashboard/reimbursement/ReimbursementForm.tsx +++ b/src/components/dashboard/reimbursement/ReimbursementForm.tsx @@ -28,7 +28,7 @@ interface ReimbursementRequest { status: 'submitted' | 'under_review' | 'approved' | 'rejected' | 'paid' | 'in_progress'; submitted_by?: string; additional_info: string; - reciepts: string[]; + receipts: string[]; department: 'internal' | 'external' | 'projects' | 'events' | 'other'; } @@ -64,7 +64,7 @@ export default function ReimbursementForm() { payment_method: '', status: 'submitted', additional_info: '', - reciepts: [], + receipts: [], department: 'internal' }); @@ -95,7 +95,7 @@ export default function ReimbursementForm() { formData.append('location_address', receiptData.location_address); formData.append('notes', receiptData.notes); - const response = await pb.collection('reciepts').create(formData); + const response = await pb.collection('receipts').create(formData); // Add receipt to state setReceipts(prev => [...prev, { ...receiptData, id: response.id }]); @@ -105,7 +105,7 @@ export default function ReimbursementForm() { setRequest(prev => ({ ...prev, total_amount: prev.total_amount + totalAmount, - reciepts: [...prev.reciepts, response.id] + receipts: [...prev.receipts, response.id] })); setShowReceiptForm(false); @@ -152,7 +152,7 @@ export default function ReimbursementForm() { formData.append('status', 'submitted'); formData.append('submitted_by', userId); formData.append('additional_info', request.additional_info); - formData.append('reciepts', JSON.stringify(request.reciepts)); + formData.append('receipts', JSON.stringify(request.receipts)); formData.append('department', request.department); await pb.collection('reimbursement').create(formData); @@ -165,7 +165,7 @@ export default function ReimbursementForm() { payment_method: '', status: 'submitted', additional_info: '', - reciepts: [], + receipts: [], department: 'internal' }); setReceipts([]); diff --git a/src/components/dashboard/reimbursement/ReimbursementList.tsx b/src/components/dashboard/reimbursement/ReimbursementList.tsx index 0ddec17..161bfca 100644 --- a/src/components/dashboard/reimbursement/ReimbursementList.tsx +++ b/src/components/dashboard/reimbursement/ReimbursementList.tsx @@ -20,7 +20,7 @@ interface ReimbursementRequest { status: 'submitted' | 'under_review' | 'approved' | 'rejected' | 'paid' | 'in_progress'; submitted_by: string; additional_info: string; - reciepts: string[]; + receipts: string[]; department: 'internal' | 'external' | 'projects' | 'events' | 'other'; created: string; updated: string; @@ -111,7 +111,7 @@ export default function ReimbursementList() { status: record.status, submitted_by: record.submitted_by, additional_info: record.additional_info || '', - reciepts: record.reciepts || [], + receipts: record.receipts || [], department: record.department, created: record.created, updated: record.updated @@ -131,7 +131,7 @@ export default function ReimbursementList() { const pb = auth.getPocketBase(); // Get the receipt record using its ID - const receiptRecord = await pb.collection('reciepts').getOne(receiptId, { + const receiptRecord = await pb.collection('receipts').getOne(receiptId, { $autoCancel: false }); @@ -157,7 +157,7 @@ export default function ReimbursementList() { }); // Get the file URL using the PocketBase URL and collection info - const url = `${pb.baseUrl}/api/files/reciepts/${receiptRecord.id}/${receiptRecord.field}`; + const url = `${pb.baseUrl}/api/files/receipts/${receiptRecord.id}/${receiptRecord.field}`; setPreviewUrl(url); setPreviewFilename(receiptRecord.field); setShowPreview(true); @@ -283,7 +283,7 @@ export default function ReimbursementList() {
- {(selectedRequest.reciepts || []).map((receiptId, index) => ( + {(selectedRequest.receipts || []).map((receiptId, index) => ( + )} +
+
+ +
+
+
+ +
+ + + {filters.department.length > 0 && ( + + )} +
+
+ +
+
+
+ +
+ +
+
+ +
+
+
+ +
+ + +
+
+ + + + {loading ? ( +
+
+
+
+ +
+
+

Loading reimbursements...

+
+ ) : reimbursements.length === 0 ? ( + + +

No reimbursements found

+
+ ) : ( + +
+ {reimbursements.map((reimbursement, index) => ( + setSelectedReimbursement(reimbursement)} + > +
+
+
+

+ {reimbursement.title} +

+
+
+ + {new Date(reimbursement.date_of_purchase).toLocaleDateString()} +
+
+ + {reimbursement.department} +
+
+
+
+ + ${reimbursement.total_amount.toFixed(2)} + + + + {reimbursement.status.replace('_', ' ')} + +
+
+
+
+ ))} +
+
+ )} + + + {/* Right side - Selected reimbursement details */} + + {selectedReimbursement ? ( + +
+
+
+

{selectedReimbursement.title}

+
+
+ Submitted by: + +
+ + {showUserProfile === selectedReimbursement.submitted_by && ( +
+
+
+ {selectedReimbursement.submitter?.avatar && ( + + )} +
+

{selectedReimbursement.submitter?.name}

+

{selectedReimbursement.submitter?.email}

+
+
+
+

Member since {new Date(selectedReimbursement.submitter?.created || '').toLocaleDateString()}

+

Last updated {new Date(selectedReimbursement.submitter?.updated || '').toLocaleDateString()}

+
+
+
+ )} +
+
+
+ {selectedReimbursement.status === 'submitted' && ( + + )} + {selectedReimbursement.status === 'under_review' && ( + <> + + + + )} + {selectedReimbursement.status === 'approved' && ( + + )} + {selectedReimbursement.status === 'in_progress' && ( + + )} + +
+
+ +
+
+
+

Date of Purchase

+

+ + {new Date(selectedReimbursement.date_of_purchase).toLocaleDateString()} +

+
+
+
+
+

Payment Method

+

+ + {selectedReimbursement.payment_method} +

+
+
+
+
+

Department

+

+ + {selectedReimbursement.department.replace('_', ' ')} +

+
+
+
+
+

Total Amount

+

+ ${selectedReimbursement.total_amount.toFixed(2)} +

+
+
+
+ +
+ Receipts ({selectedReimbursement.receipts?.length || 0}) +
+ +
+ {selectedReimbursement.receipts?.length === 0 ? ( +
+ +

No receipts attached

+
+ ) : ( + selectedReimbursement.receipts?.map(receiptId => { + const receipt = receipts[receiptId]; + if (!receipt) { + return ( +
+
+ + Receipt not found (ID: {receiptId}) +
+
+ ); + } + + const isExpanded = expandedReceipts.has(receipt.id); + + return ( + +
+
toggleReceipt(receipt.id)} + > +
+

+ {receipt.location_name} +

+
+
+ + {receipt.location_address} +
+
+ + {new Date(receipt.date).toLocaleDateString()} +
+
+
+ + + +
+ + + {isExpanded && ( + +
+ +
+ {receipt.field.toLowerCase().match(/\.(jpg|jpeg|png|gif|webp)$/) ? ( + + ) : receipt.field.toLowerCase().match(/\.(pdf)$/) ? ( +
+ + PDF Document +
+ ) : receipt.field.toLowerCase().match(/\.(doc|docx)$/) ? ( +
+ + Word Document +
+ ) : ( +
+ + Document +
+ )} +
+
+ +
+ +
+ + + + + + + + + + {(() => { + try { + const expenses: ItemizedExpense[] = typeof receipt.itemized_expenses === 'string' + ? JSON.parse(receipt.itemized_expenses) + : receipt.itemized_expenses; + + if (!Array.isArray(expenses)) { + console.error('Itemized expenses is not an array:', expenses); + return ( + + + + ); + } + + return expenses.map((item: ItemizedExpense, index: number) => ( + + + + + + )); + } catch (error) { + console.error('Error parsing itemized expenses:', error); + return ( + + + + ); + } + })()} + + + + + +
DescriptionCategoryAmount
+ Invalid expense data format +
{item.description} + + {item.category} + + + ${item.amount.toFixed(2)} +
+ Error loading expense data +
+ Tax: + + ${receipt.tax.toFixed(2)} +
+
+
+ + {receipt.notes && ( +
+ +
+

{receipt.notes}

+
+
+ )} + +
+
+ + Last updated {new Date(receipt.updated).toLocaleString()} +
+ +
+ + Audited by: + {receipt.audited_by.length === 0 ? ( + No auditors yet + ) : ( +
+ {receipt.auditor_names?.map((name, index) => ( + + {name} + + ))} +
+ )} +
+ + {selectedReimbursement.status === 'under_review' && ( +
+ {(() => { + const userId = Authentication.getInstance().getUserId(); + if (!userId) return null; + + return receipt.audited_by.includes(userId) ? ( + + + You have audited this receipt + + ) : ( + + ); + })()} +
+ )} +
+
+ )} +
+
+
+ ); + }) + )} +
+ + {selectedReimbursement.additional_info && ( + <> +
Additional Information from Member
+
+

{selectedReimbursement.additional_info}

+
+ + )} + + {selectedReimbursement.audit_logs && ( + <> +
setExpandedReceipts(prev => { + const next = new Set(prev); + if (next.has('audit_logs')) { + next.delete('audit_logs'); + } else { + next.add('audit_logs'); + } + return next; + })} + > +
+ System Audit Logs + +
+
+ + {expandedReceipts.has('audit_logs') && ( + + {(() => { + if (!selectedReimbursement.audit_logs) return null; + + let logs = []; + try { + if (typeof selectedReimbursement.audit_logs === 'string') { + logs = JSON.parse(selectedReimbursement.audit_logs); + } else { + logs = selectedReimbursement.audit_logs; + } + if (!Array.isArray(logs)) { + logs = []; + } + } catch (error) { + console.error('Error parsing audit logs:', error); + logs = []; + } + + if (logs.length === 0) { + return ( +
+ No audit logs yet +
+ ); + } + + const totalPages = Math.ceil(logs.length / logsPerPage); + const startIndex = (currentLogPage - 1) * logsPerPage; + const endIndex = startIndex + logsPerPage; + const currentLogs = logs.slice(startIndex, endIndex); + + return ( + <> + {currentLogs.map((log: { action: string; from?: string; to?: string; receipt_id?: string; receipt_name?: string; receipt_date?: string; receipt_amount?: number; auditor_id: string; timestamp: string }, index: number) => ( +
+
+
+ + {log.action === 'status_change' ? `Changed to ${log.to?.replace('_', ' ')}` : + log.action === 'receipt_audit' ? 'Audited Receipt' : log.action} + + + by {getAuditorName(log.auditor_id)} + +
+ + {new Date(log.timestamp).toLocaleString()} + +
+ {log.action === 'status_change' && log.from && ( +

+ Previous status: {log.from.replace('_', ' ')} +

+ )} + {log.action === 'receipt_audit' && log.receipt_name && ( +

+ Receipt from {log.receipt_name} +

+ )} +
+ ))} + + {totalPages > 1 && ( +
+ + + Page {currentLogPage} of {totalPages} + + +
+ )} + + ); + })()} +
+ )} +
+ + )} + + {selectedReimbursement.audit_notes && ( + <> +
setExpandedReceipts(prev => { + const next = new Set(prev); + if (next.has('audit_notes')) { + next.delete('audit_notes'); + } else { + next.add('audit_notes'); + } + return next; + })} + > +
+ Auditor Notes + +
+
+ + {expandedReceipts.has('audit_notes') && ( + + {(() => { + if (!selectedReimbursement.audit_notes) return null; + + let notes = []; + try { + if (typeof selectedReimbursement.audit_notes === 'string') { + notes = JSON.parse(selectedReimbursement.audit_notes); + } else { + notes = selectedReimbursement.audit_notes; + } + if (!Array.isArray(notes)) { + notes = []; + } + } catch (error) { + console.error('Error parsing audit notes:', error); + notes = []; + } + + if (notes.length === 0) { + return ( +
+ No audit notes yet +
+ ); + } + + const totalPages = Math.ceil(notes.length / notesPerPage); + const startIndex = (currentNotePage - 1) * notesPerPage; + const endIndex = startIndex + notesPerPage; + const currentNotes = notes.slice(startIndex, endIndex); + + return ( + <> + {currentNotes.map((note: { note: string; auditor_id: string; timestamp: string; is_private: boolean }, index: number) => ( +
+
+
+ + Note by {getAuditorName(note.auditor_id)} + + {note.is_private && ( + + + Private + + )} +
+ + {new Date(note.timestamp).toLocaleString()} + +
+

{note.note}

+
+ ))} + + {totalPages > 1 && ( +
+ + + Page {currentNotePage} of {totalPages} + + +
+ )} + + ); + })()} +
+ )} +
+ + )} + +
+ +
+