From 600146296b2497a1fdffd54573b3d1c1c92bac7f Mon Sep 17 00:00:00 2001 From: chark1es Date: Wed, 19 Feb 2025 01:05:14 -0800 Subject: [PATCH] basic reimbursement concept working --- .../dashboard/ReimbursementSection.astro | 58 ++- .../reimbursement/ReimbursementForm.tsx | 437 ++++++++++++++++++ .../reimbursement/ReimbursementList.tsx | 302 ++++++++++++ .../reimbursement/Reimbursement.astro | 0 4 files changed, 787 insertions(+), 10 deletions(-) create mode 100644 src/components/dashboard/reimbursement/ReimbursementForm.tsx create mode 100644 src/components/dashboard/reimbursement/ReimbursementList.tsx delete mode 100644 src/components/reimbursement/Reimbursement.astro diff --git a/src/components/dashboard/ReimbursementSection.astro b/src/components/dashboard/ReimbursementSection.astro index 17214eb..8299fb5 100644 --- a/src/components/dashboard/ReimbursementSection.astro +++ b/src/components/dashboard/ReimbursementSection.astro @@ -1,18 +1,56 @@ --- -import { Icon } from "astro-icon/components"; +import ReimbursementForm from "./reimbursement/ReimbursementForm"; +import ReimbursementList from "./reimbursement/ReimbursementList"; --- -
-
-

Reimbursement

-

Manage your reimbursement requests

+
+
+

Reimbursement

+

Manage your reimbursement requests

+
+ +
+ + +
+ +
+
+
-
+ +
+ + diff --git a/src/components/dashboard/reimbursement/ReimbursementForm.tsx b/src/components/dashboard/reimbursement/ReimbursementForm.tsx new file mode 100644 index 0000000..3bf016a --- /dev/null +++ b/src/components/dashboard/reimbursement/ReimbursementForm.tsx @@ -0,0 +1,437 @@ +import React, { useState, useCallback } from 'react'; +import { Icon } from '@iconify/react'; +import FilePreview from '../universal/FilePreview'; +import { FileManager } from '../../../scripts/pocketbase/FileManager'; +import { Authentication } from '../../../scripts/pocketbase/Authentication'; +import { Update } from '../../../scripts/pocketbase/Update'; + +interface ExpenseItem { + description: string; + amount: number; + category: string; +} + +interface ReimbursementRequest { + id?: string; + title: string; + total_amount: number; + date_of_purchase: string; + payment_method: string; + status: 'draft' | 'submitted' | 'under_review' | 'approved' | 'rejected' | 'paid'; + expense_items: ExpenseItem[]; + receipts: string[]; + submitted_by?: string; +} + +const EXPENSE_CATEGORIES = [ + 'Travel', + 'Meals', + 'Supplies', + 'Equipment', + 'Software', + 'Event Expenses', + 'Other' +]; + +const PAYMENT_METHODS = [ + 'Personal Credit Card', + 'Personal Debit Card', + 'Cash', + 'Personal Check', + 'Other' +]; + +const MAX_REIMBURSEMENT_AMOUNT = 1000; // Maximum amount in dollars + +export default function ReimbursementForm() { + const [request, setRequest] = useState({ + title: '', + total_amount: 0, + date_of_purchase: new Date().toISOString().split('T')[0], + payment_method: '', + status: 'draft', + expense_items: [{ description: '', amount: 0, category: '' }], + receipts: [] + }); + + const [selectedFiles, setSelectedFiles] = useState>(new Map()); + const [previewUrl, setPreviewUrl] = useState(''); + const [previewFilename, setPreviewFilename] = useState(''); + const [showPreview, setShowPreview] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(''); + + const fileManager = FileManager.getInstance(); + const auth = Authentication.getInstance(); + const update = Update.getInstance(); + + const handleExpenseItemChange = (index: number, field: keyof ExpenseItem, value: string | number) => { + const newExpenseItems = [...request.expense_items]; + newExpenseItems[index] = { + ...newExpenseItems[index], + [field]: value + }; + + // Recalculate total amount + const newTotalAmount = newExpenseItems.reduce((sum, item) => sum + Number(item.amount), 0); + + setRequest(prev => ({ + ...prev, + expense_items: newExpenseItems, + total_amount: newTotalAmount + })); + }; + + const addExpenseItem = () => { + setRequest(prev => ({ + ...prev, + expense_items: [...prev.expense_items, { description: '', amount: 0, category: '' }] + })); + }; + + const removeExpenseItem = (index: number) => { + if (request.expense_items.length === 1) { + return; // Keep at least one expense item + } + + const newExpenseItems = request.expense_items.filter((_, i) => i !== index); + const newTotalAmount = newExpenseItems.reduce((sum, item) => sum + Number(item.amount), 0); + + setRequest(prev => ({ + ...prev, + expense_items: newExpenseItems, + total_amount: newTotalAmount + })); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files) { + const newFiles = new Map(selectedFiles); + Array.from(e.target.files).forEach(file => { + // Validate file type + if (!file.type.match('image/*') && file.type !== 'application/pdf') { + setError('Only images and PDF files are allowed'); + return; + } + // Validate file size (5MB limit) + if (file.size > 5 * 1024 * 1024) { + setError('File size must be less than 5MB'); + return; + } + newFiles.set(file.name, file); + }); + setSelectedFiles(newFiles); + setError(''); + } + }; + + const handlePreviewFile = (url: string, filename: string) => { + setPreviewUrl(url); + setPreviewFilename(filename); + setShowPreview(true); + }; + + const validateForm = (): boolean => { + if (!request.title.trim()) { + setError('Title is required'); + return false; + } + if (!request.payment_method) { + setError('Payment method is required'); + return false; + } + if (request.total_amount <= 0) { + setError('Total amount must be greater than 0'); + return false; + } + if (request.total_amount > MAX_REIMBURSEMENT_AMOUNT) { + setError(`Total amount cannot exceed $${MAX_REIMBURSEMENT_AMOUNT}`); + return false; + } + if (request.expense_items.some(item => !item.description || !item.category || item.amount <= 0)) { + setError('All expense items must be filled out completely'); + return false; + } + if (selectedFiles.size === 0 && request.receipts.length === 0) { + setError('At least one receipt is required'); + return false; + } + return true; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (isSubmitting) return; + + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + setError(''); + + try { + const pb = auth.getPocketBase(); + const userId = pb.authStore.model?.id; + + if (!userId) { + throw new Error('User not authenticated'); + } + + // Create reimbursement record + const formData = new FormData(); + formData.append('title', request.title); + formData.append('total_amount', request.total_amount.toString()); + formData.append('date_of_purchase', new Date(request.date_of_purchase).toISOString()); + formData.append('payment_method', request.payment_method); + formData.append('status', 'draft'); + formData.append('expense_items', JSON.stringify(request.expense_items)); + formData.append('submitted_by', userId); + + // Upload files + Array.from(selectedFiles.values()).forEach(file => { + formData.append('receipts', file); + }); + + // Submit the request + const response = await pb.collection('reimbursement').create(formData); + + // Reset form + setRequest({ + title: '', + total_amount: 0, + date_of_purchase: new Date().toISOString().split('T')[0], + payment_method: '', + status: 'draft', + expense_items: [{ description: '', amount: 0, category: '' }], + receipts: [] + }); + setSelectedFiles(new Map()); + setError(''); + + // Show success message + alert('Reimbursement request submitted successfully!'); + } catch (error) { + console.error('Error submitting reimbursement request:', error); + setError('Failed to submit reimbursement request. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ {error && ( +
+ + {error} +
+ )} + + {/* Title */} +
+ + setRequest(prev => ({ ...prev, title: e.target.value }))} + required + /> +
+ + {/* Date of Purchase */} +
+ + setRequest(prev => ({ ...prev, date_of_purchase: e.target.value }))} + required + /> +
+ + {/* Payment Method */} +
+ + +
+ + {/* Expense Items */} +
+
+ + +
+ + {request.expense_items.map((item, index) => ( +
+
+
+ + handleExpenseItemChange(index, 'description', e.target.value)} + required + /> +
+ +
+ + +
+ +
+ +
+ handleExpenseItemChange(index, 'amount', Number(e.target.value))} + min="0" + step="0.01" + required + /> + {request.expense_items.length > 1 && ( + + )} +
+
+
+
+ ))} + +
+

+ Total Amount: ${request.total_amount.toFixed(2)} +

+
+
+ + {/* Receipt Upload */} +
+ + + + + {/* Selected Files Preview */} +
+ {Array.from(selectedFiles.entries()).map(([name, file]) => ( +
+ {name} +
+
New
+ +
+
+ ))} +
+
+ + {/* Submit Button */} +
+ +
+ + {/* File Preview Modal */} + {showPreview && ( +
+
+ +
+ +
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/dashboard/reimbursement/ReimbursementList.tsx b/src/components/dashboard/reimbursement/ReimbursementList.tsx new file mode 100644 index 0000000..aac2194 --- /dev/null +++ b/src/components/dashboard/reimbursement/ReimbursementList.tsx @@ -0,0 +1,302 @@ +import React, { useState, useEffect } from 'react'; +import { Icon } from '@iconify/react'; +import { Get } from '../../../scripts/pocketbase/Get'; +import { Authentication } from '../../../scripts/pocketbase/Authentication'; +import { FileManager } from '../../../scripts/pocketbase/FileManager'; +import FilePreview from '../universal/FilePreview'; + +interface ExpenseItem { + description: string; + amount: number; + category: string; +} + +interface ReimbursementRequest { + id: string; + title: string; + total_amount: number; + date_of_purchase: string; + business_purpose: string; + payment_method: string; + status: 'draft' | 'submitted' | 'under_review' | 'approved' | 'rejected' | 'paid'; + expense_items: ExpenseItem[]; + receipts: string[]; + submitted_by: string; + submitted_at: string; + last_updated: string; +} + +const STATUS_COLORS = { + draft: 'badge-ghost', + submitted: 'badge-primary', + under_review: 'badge-warning', + approved: 'badge-success', + rejected: 'badge-error', + paid: 'badge-success' +}; + +const STATUS_LABELS = { + draft: 'Draft', + submitted: 'Submitted', + under_review: 'Under Review', + approved: 'Approved', + rejected: 'Rejected', + paid: 'Paid' +}; + +export default function ReimbursementList() { + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [selectedRequest, setSelectedRequest] = useState(null); + const [showPreview, setShowPreview] = useState(false); + const [previewUrl, setPreviewUrl] = useState(''); + const [previewFilename, setPreviewFilename] = useState(''); + + const get = Get.getInstance(); + const auth = Authentication.getInstance(); + const fileManager = FileManager.getInstance(); + + useEffect(() => { + fetchReimbursements(); + }, []); + + const fetchReimbursements = async () => { + try { + setLoading(true); + const pb = auth.getPocketBase(); + const userId = pb.authStore.model?.id; + + if (!userId) { + throw new Error('User not authenticated'); + } + + const records = await pb.collection('reimbursement').getList(1, 50, { + filter: `submitted_by = "${userId}"`, + sort: '-created', + expand: 'submitted_by' + }); + + // Convert PocketBase records to ReimbursementRequest type + const reimbursements = records.items.map(record => ({ + id: record.id, + title: record.title, + total_amount: record.total_amount, + date_of_purchase: record.date_of_purchase, + business_purpose: record.business_purpose || '', // Add fallback since it might not exist in schema + payment_method: record.payment_method, + status: record.status, + expense_items: typeof record.expense_items === 'string' ? JSON.parse(record.expense_items) : record.expense_items, + receipts: record.receipts || [], + submitted_by: record.submitted_by, + submitted_at: record.created, // Use created field for submitted_at + last_updated: record.updated // Use updated field for last_updated + })) as ReimbursementRequest[]; + + setRequests(reimbursements); + } catch (error) { + console.error('Error fetching reimbursements:', error); + setError('Failed to load reimbursement requests'); + } finally { + setLoading(false); + } + }; + + const handlePreviewFile = (request: ReimbursementRequest, filename: string) => { + const url = fileManager.getFileUrl('reimbursement', request.id, filename); + setPreviewUrl(url); + setPreviewFilename(filename); + setShowPreview(true); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ + {error} +
+ ); + } + + return ( +
+ {requests.length === 0 ? ( +
+ +

No reimbursement requests

+

Create a new request to get started

+
+ ) : ( +
+ {requests.map((request) => ( +
+
+
+
+

{request.title}

+
+
+ {STATUS_LABELS[request.status]} +
+
+ ${request.total_amount.toFixed(2)} +
+
+ {formatDate(request.date_of_purchase)} +
+
+
+ +
+
+
+ ))} +
+ )} + + {/* Details Modal */} + {selectedRequest && ( +
+
+

{selectedRequest.title}

+ +
+
+
+ +
+ {STATUS_LABELS[selectedRequest.status]} +
+
+
+ +

${selectedRequest.total_amount.toFixed(2)}

+
+
+ +

{formatDate(selectedRequest.date_of_purchase)}

+
+
+ +

{selectedRequest.payment_method}

+
+
+ +
+ +

{selectedRequest.business_purpose}

+
+ +
+ +
+ {selectedRequest.expense_items.map((item, index) => ( +
+
+
+ +

{item.description}

+
+
+ +

{item.category}

+
+
+ +

${item.amount.toFixed(2)}

+
+
+
+ ))} +
+
+ +
+ +
+ {selectedRequest.receipts.map((filename) => ( + + ))} +
+
+ +
+ +
+
+ +

{formatDate(selectedRequest.submitted_at)}

+
+
+ +

{formatDate(selectedRequest.last_updated)}

+
+
+
+ +
+ +
+
+
+ )} + + {/* File Preview Modal */} + {showPreview && ( +
+
+ +
+ +
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/reimbursement/Reimbursement.astro b/src/components/reimbursement/Reimbursement.astro deleted file mode 100644 index e69de29..0000000