basic reimbursement concept working

This commit is contained in:
chark1es 2025-02-19 01:05:14 -08:00
parent 64e989664c
commit 600146296b
4 changed files with 787 additions and 10 deletions

View file

@ -1,18 +1,56 @@
---
import { Icon } from "astro-icon/components";
import ReimbursementForm from "./reimbursement/ReimbursementForm";
import ReimbursementList from "./reimbursement/ReimbursementList";
---
<div id="" class="">
<div class="space-y-6">
<div class="mb-6">
<h2 class="text-2xl font-bold">Reimbursement</h2>
<p class="opacity-70">Manage your reimbursement requests</p>
</div>
<div
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform"
>
<div class="tabs tabs-boxed">
<button class="tab tab-active" data-tab="list">My Requests</button>
<button class="tab" data-tab="form">New Request</button>
</div>
<div id="reimbursementContent">
<div id="listTab" class="tab-content block">
<ReimbursementList client:load />
</div>
<div id="formTab" class="tab-content hidden">
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h3 class="card-title">Reimbursement Requests</h3>
<!-- Reimbursement content will go here -->
<ReimbursementForm client:load />
</div>
</div>
</div>
</div>
</div>
<script>
// Tab switching logic
const tabs = document.querySelectorAll(".tab");
const tabContents = document.querySelectorAll(".tab-content");
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
const targetTab = tab.getAttribute("data-tab");
// Update tab states
tabs.forEach((t) => t.classList.remove("tab-active"));
tab.classList.add("tab-active");
// Update content visibility
tabContents.forEach((content) => {
if (content.id === `${targetTab}Tab`) {
content.classList.remove("hidden");
content.classList.add("block");
} else {
content.classList.remove("block");
content.classList.add("hidden");
}
});
});
});
</script>

View file

@ -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<ReimbursementRequest>({
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<Map<string, File>>(new Map());
const [previewUrl, setPreviewUrl] = useState<string>('');
const [previewFilename, setPreviewFilename] = useState<string>('');
const [showPreview, setShowPreview] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string>('');
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<HTMLInputElement>) => {
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<HTMLFormElement>) => {
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 (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="alert alert-error">
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
<span>{error}</span>
</div>
)}
{/* Title */}
<div className="form-control">
<label className="label">
<span className="label-text">Title</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
className="input input-bordered"
value={request.title}
onChange={(e) => setRequest(prev => ({ ...prev, title: e.target.value }))}
required
/>
</div>
{/* Date of Purchase */}
<div className="form-control">
<label className="label">
<span className="label-text">Date of Purchase</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="date"
className="input input-bordered"
value={request.date_of_purchase}
onChange={(e) => setRequest(prev => ({ ...prev, date_of_purchase: e.target.value }))}
required
/>
</div>
{/* Payment Method */}
<div className="form-control">
<label className="label">
<span className="label-text">Payment Method</span>
<span className="label-text-alt text-error">*</span>
</label>
<select
className="select select-bordered"
value={request.payment_method}
onChange={(e) => setRequest(prev => ({ ...prev, payment_method: e.target.value }))}
required
>
<option value="">Select payment method</option>
{PAYMENT_METHODS.map(method => (
<option key={method} value={method}>{method}</option>
))}
</select>
</div>
{/* Expense Items */}
<div className="space-y-4">
<div className="flex justify-between items-center">
<label className="label-text font-medium">Expense Items</label>
<button
type="button"
className="btn btn-sm btn-primary"
onClick={addExpenseItem}
>
<Icon icon="heroicons:plus" className="h-4 w-4" />
Add Item
</button>
</div>
{request.expense_items.map((item, index) => (
<div key={index} className="card bg-base-200 p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="form-control">
<label className="label">
<span className="label-text">Description</span>
</label>
<input
type="text"
className="input input-bordered"
value={item.description}
onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)}
required
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Category</span>
</label>
<select
className="select select-bordered"
value={item.category}
onChange={(e) => handleExpenseItemChange(index, 'category', e.target.value)}
required
>
<option value="">Select category</option>
{EXPENSE_CATEGORIES.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Amount ($)</span>
</label>
<div className="flex items-center space-x-2">
<input
type="number"
className="input input-bordered"
value={item.amount}
onChange={(e) => handleExpenseItemChange(index, 'amount', Number(e.target.value))}
min="0"
step="0.01"
required
/>
{request.expense_items.length > 1 && (
<button
type="button"
className="btn btn-square btn-sm btn-error"
onClick={() => removeExpenseItem(index)}
>
<Icon icon="heroicons:trash" className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
</div>
))}
<div className="text-right">
<p className="text-lg font-medium">
Total Amount: ${request.total_amount.toFixed(2)}
</p>
</div>
</div>
{/* Receipt Upload */}
<div className="form-control">
<label className="label">
<span className="label-text">Upload Receipts</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="file"
className="file-input file-input-bordered w-full"
onChange={handleFileChange}
accept="image/*,.pdf"
multiple
/>
<label className="label">
<span className="label-text-alt">Accepted formats: Images and PDF files (max 5MB each)</span>
</label>
{/* Selected Files Preview */}
<div className="mt-4 space-y-2">
{Array.from(selectedFiles.entries()).map(([name, file]) => (
<div key={name} className="flex items-center justify-between p-2 bg-base-200 rounded-lg">
<span className="truncate">{name}</span>
<div className="flex gap-2">
<div className="badge badge-primary">New</div>
<button
type="button"
className="btn btn-ghost btn-xs text-error"
onClick={() => {
const updatedFiles = new Map(selectedFiles);
updatedFiles.delete(name);
setSelectedFiles(updatedFiles);
}}
>
<Icon icon="heroicons:x-circle" className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
</div>
{/* Submit Button */}
<div className="mt-6">
<button
type="submit"
className={`btn btn-primary w-full ${isSubmitting ? 'loading' : ''}`}
disabled={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit Reimbursement Request'}
</button>
</div>
{/* File Preview Modal */}
{showPreview && (
<div className="modal modal-open">
<div className="modal-box max-w-4xl">
<FilePreview
url={previewUrl}
filename={previewFilename}
isModal={true}
/>
<div className="modal-action">
<button
className="btn"
onClick={() => setShowPreview(false)}
>
Close
</button>
</div>
</div>
</div>
)}
</form>
);
}

View file

@ -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<ReimbursementRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>('');
const [selectedRequest, setSelectedRequest] = useState<ReimbursementRequest | null>(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 (
<div className="flex justify-center items-center p-8">
<div className="loading loading-spinner loading-lg text-primary"></div>
</div>
);
}
if (error) {
return (
<div className="alert alert-error">
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
<span>{error}</span>
</div>
);
}
return (
<div className="space-y-6">
{requests.length === 0 ? (
<div className="text-center py-8">
<Icon icon="heroicons:document" className="h-12 w-12 mx-auto text-base-content/50" />
<h3 className="mt-4 text-lg font-medium">No reimbursement requests</h3>
<p className="text-base-content/70">Create a new request to get started</p>
</div>
) : (
<div className="grid gap-4">
{requests.map((request) => (
<div
key={request.id}
className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300"
>
<div className="card-body">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h3 className="card-title">{request.title}</h3>
<div className="flex flex-wrap gap-2 mt-2">
<div className={`badge ${STATUS_COLORS[request.status]}`}>
{STATUS_LABELS[request.status]}
</div>
<div className="badge badge-outline">
${request.total_amount.toFixed(2)}
</div>
<div className="badge badge-outline">
{formatDate(request.date_of_purchase)}
</div>
</div>
</div>
<button
className="btn btn-sm btn-primary"
onClick={() => setSelectedRequest(request)}
>
View Details
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Details Modal */}
{selectedRequest && (
<div className="modal modal-open">
<div className="modal-box max-w-3xl">
<h3 className="font-bold text-lg mb-4">{selectedRequest.title}</h3>
<div className="grid gap-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Status</label>
<div className={`badge ${STATUS_COLORS[selectedRequest.status]} mt-1`}>
{STATUS_LABELS[selectedRequest.status]}
</div>
</div>
<div>
<label className="text-sm font-medium">Total Amount</label>
<p className="mt-1">${selectedRequest.total_amount.toFixed(2)}</p>
</div>
<div>
<label className="text-sm font-medium">Date of Purchase</label>
<p className="mt-1">{formatDate(selectedRequest.date_of_purchase)}</p>
</div>
<div>
<label className="text-sm font-medium">Payment Method</label>
<p className="mt-1">{selectedRequest.payment_method}</p>
</div>
</div>
<div>
<label className="text-sm font-medium">Business Purpose</label>
<p className="mt-1">{selectedRequest.business_purpose}</p>
</div>
<div>
<label className="text-sm font-medium">Expense Items</label>
<div className="mt-2 space-y-2">
{selectedRequest.expense_items.map((item, index) => (
<div key={index} className="card bg-base-200 p-3">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-xs font-medium">Description</label>
<p>{item.description}</p>
</div>
<div>
<label className="text-xs font-medium">Category</label>
<p>{item.category}</p>
</div>
<div>
<label className="text-xs font-medium">Amount</label>
<p>${item.amount.toFixed(2)}</p>
</div>
</div>
</div>
))}
</div>
</div>
<div>
<label className="text-sm font-medium">Receipts</label>
<div className="mt-2 grid grid-cols-2 md:grid-cols-3 gap-2">
{selectedRequest.receipts.map((filename) => (
<button
key={filename}
className="btn btn-outline btn-sm normal-case"
onClick={() => handlePreviewFile(selectedRequest, filename)}
>
<Icon icon="heroicons:document" className="h-4 w-4 mr-2" />
{filename}
</button>
))}
</div>
</div>
<div className="divider"></div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<label className="font-medium">Submitted At</label>
<p className="mt-1">{formatDate(selectedRequest.submitted_at)}</p>
</div>
<div>
<label className="font-medium">Last Updated</label>
<p className="mt-1">{formatDate(selectedRequest.last_updated)}</p>
</div>
</div>
</div>
<div className="modal-action">
<button
className="btn"
onClick={() => setSelectedRequest(null)}
>
Close
</button>
</div>
</div>
</div>
)}
{/* File Preview Modal */}
{showPreview && (
<div className="modal modal-open">
<div className="modal-box max-w-4xl">
<FilePreview
url={previewUrl}
filename={previewFilename}
isModal={true}
/>
<div className="modal-action">
<button
className="btn"
onClick={() => setShowPreview(false)}
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
}