add reimbursement forms
This commit is contained in:
parent
600146296b
commit
cfd3d21da8
4 changed files with 804 additions and 388 deletions
352
src/components/dashboard/reimbursement/ReceiptForm.tsx
Normal file
352
src/components/dashboard/reimbursement/ReceiptForm.tsx
Normal file
|
@ -0,0 +1,352 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import FilePreview from '../universal/FilePreview';
|
||||||
|
|
||||||
|
interface ExpenseItem {
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReceiptFormData {
|
||||||
|
field: File;
|
||||||
|
itemized_expenses: ExpenseItem[];
|
||||||
|
tax: number;
|
||||||
|
date: string;
|
||||||
|
location_name: string;
|
||||||
|
location_address: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReceiptFormProps {
|
||||||
|
onSubmit: (data: ReceiptFormData) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPENSE_CATEGORIES = [
|
||||||
|
'Travel',
|
||||||
|
'Meals',
|
||||||
|
'Supplies',
|
||||||
|
'Equipment',
|
||||||
|
'Software',
|
||||||
|
'Event Expenses',
|
||||||
|
'Other'
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||||
|
const [itemizedExpenses, setItemizedExpenses] = useState<ExpenseItem[]>([
|
||||||
|
{ description: '', amount: 0, category: '' }
|
||||||
|
]);
|
||||||
|
const [tax, setTax] = useState<number>(0);
|
||||||
|
const [date, setDate] = useState<string>(new Date().toISOString().split('T')[0]);
|
||||||
|
const [locationName, setLocationName] = useState<string>('');
|
||||||
|
const [locationAddress, setLocationAddress] = useState<string>('');
|
||||||
|
const [notes, setNotes] = useState<string>('');
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
const selectedFile = e.target.files[0];
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!selectedFile.type.match('image/*') && selectedFile.type !== 'application/pdf') {
|
||||||
|
setError('Only images and PDF files are allowed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (5MB limit)
|
||||||
|
if (selectedFile.size > 5 * 1024 * 1024) {
|
||||||
|
setError('File size must be less than 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFile(selectedFile);
|
||||||
|
setPreviewUrl(URL.createObjectURL(selectedFile));
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addExpenseItem = () => {
|
||||||
|
setItemizedExpenses([...itemizedExpenses, { description: '', amount: 0, category: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeExpenseItem = (index: number) => {
|
||||||
|
if (itemizedExpenses.length === 1) return;
|
||||||
|
setItemizedExpenses(itemizedExpenses.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExpenseItemChange = (index: number, field: keyof ExpenseItem, value: string | number) => {
|
||||||
|
const newItems = [...itemizedExpenses];
|
||||||
|
newItems[index] = {
|
||||||
|
...newItems[index],
|
||||||
|
[field]: value
|
||||||
|
};
|
||||||
|
setItemizedExpenses(newItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
setError('Please upload a receipt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!locationName.trim()) {
|
||||||
|
setError('Location name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!locationAddress.trim()) {
|
||||||
|
setError('Location address is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemizedExpenses.some(item => !item.description || !item.category || item.amount <= 0)) {
|
||||||
|
setError('All expense items must be filled out completely');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
field: file,
|
||||||
|
itemized_expenses: itemizedExpenses,
|
||||||
|
tax,
|
||||||
|
date,
|
||||||
|
location_name: locationName,
|
||||||
|
location_address: locationAddress,
|
||||||
|
notes
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-6 h-full">
|
||||||
|
{/* Left side - Form */}
|
||||||
|
<div className="space-y-4 overflow-y-auto max-h-[70vh]">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-error">
|
||||||
|
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File Upload */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Upload Receipt</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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Date</span>
|
||||||
|
<span className="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="input input-bordered"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location Name */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Location Name</span>
|
||||||
|
<span className="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-bordered"
|
||||||
|
value={locationName}
|
||||||
|
onChange={(e) => setLocationName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location Address */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Location Address</span>
|
||||||
|
<span className="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-bordered"
|
||||||
|
value={locationAddress}
|
||||||
|
onChange={(e) => setLocationAddress(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Notes</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-bordered"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Itemized Expenses */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="label-text font-medium">Itemized Expenses</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>
|
||||||
|
|
||||||
|
{itemizedExpenses.map((item, index) => (
|
||||||
|
<div key={index} className="card bg-base-200 p-4">
|
||||||
|
<div className="grid 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="grid grid-cols-2 gap-4">
|
||||||
|
<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
|
||||||
|
/>
|
||||||
|
{itemizedExpenses.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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tax */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Tax Amount ($)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input input-bordered"
|
||||||
|
value={tax}
|
||||||
|
onChange={(e) => setTax(Number(e.target.value))}
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-lg font-medium">
|
||||||
|
Subtotal: ${itemizedExpenses.reduce((sum, item) => sum + item.amount, 0).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-medium">
|
||||||
|
Tax: ${tax.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-medium">
|
||||||
|
Total: ${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
Add Receipt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Preview */}
|
||||||
|
<div className="border-l border-base-300 pl-6">
|
||||||
|
{previewUrl ? (
|
||||||
|
<FilePreview
|
||||||
|
url={previewUrl}
|
||||||
|
filename={file?.name || ''}
|
||||||
|
isModal={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-base-content/70">
|
||||||
|
<div className="text-center">
|
||||||
|
<Icon icon="heroicons:document" className="h-12 w-12 mx-auto mb-2" />
|
||||||
|
<p>Upload a receipt to preview</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,9 +1,7 @@
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import FilePreview from '../universal/FilePreview';
|
|
||||||
import { FileManager } from '../../../scripts/pocketbase/FileManager';
|
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
import { Update } from '../../../scripts/pocketbase/Update';
|
import ReceiptForm from './ReceiptForm';
|
||||||
|
|
||||||
interface ExpenseItem {
|
interface ExpenseItem {
|
||||||
description: string;
|
description: string;
|
||||||
|
@ -11,28 +9,29 @@ interface ExpenseItem {
|
||||||
category: string;
|
category: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReceiptFormData {
|
||||||
|
field: File;
|
||||||
|
itemized_expenses: ExpenseItem[];
|
||||||
|
tax: number;
|
||||||
|
date: string;
|
||||||
|
location_name: string;
|
||||||
|
location_address: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ReimbursementRequest {
|
interface ReimbursementRequest {
|
||||||
id?: string;
|
id?: string;
|
||||||
title: string;
|
title: string;
|
||||||
total_amount: number;
|
total_amount: number;
|
||||||
date_of_purchase: string;
|
date_of_purchase: string;
|
||||||
payment_method: string;
|
payment_method: string;
|
||||||
status: 'draft' | 'submitted' | 'under_review' | 'approved' | 'rejected' | 'paid';
|
status: 'submitted' | 'under_review' | 'approved' | 'rejected' | 'paid' | 'in_progress';
|
||||||
expense_items: ExpenseItem[];
|
|
||||||
receipts: string[];
|
|
||||||
submitted_by?: string;
|
submitted_by?: string;
|
||||||
|
additional_info: string;
|
||||||
|
reciepts: string[];
|
||||||
|
department: 'internal' | 'external' | 'projects' | 'events' | 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
const EXPENSE_CATEGORIES = [
|
|
||||||
'Travel',
|
|
||||||
'Meals',
|
|
||||||
'Supplies',
|
|
||||||
'Equipment',
|
|
||||||
'Software',
|
|
||||||
'Event Expenses',
|
|
||||||
'Other'
|
|
||||||
];
|
|
||||||
|
|
||||||
const PAYMENT_METHODS = [
|
const PAYMENT_METHODS = [
|
||||||
'Personal Credit Card',
|
'Personal Credit Card',
|
||||||
'Personal Debit Card',
|
'Personal Debit Card',
|
||||||
|
@ -41,7 +40,21 @@ const PAYMENT_METHODS = [
|
||||||
'Other'
|
'Other'
|
||||||
];
|
];
|
||||||
|
|
||||||
const MAX_REIMBURSEMENT_AMOUNT = 1000; // Maximum amount in dollars
|
const DEPARTMENTS = [
|
||||||
|
'internal',
|
||||||
|
'external',
|
||||||
|
'projects',
|
||||||
|
'events',
|
||||||
|
'other'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const DEPARTMENT_LABELS = {
|
||||||
|
internal: 'Internal',
|
||||||
|
external: 'External',
|
||||||
|
projects: 'Projects',
|
||||||
|
events: 'Events',
|
||||||
|
other: 'Other'
|
||||||
|
};
|
||||||
|
|
||||||
export default function ReimbursementForm() {
|
export default function ReimbursementForm() {
|
||||||
const [request, setRequest] = useState<ReimbursementRequest>({
|
const [request, setRequest] = useState<ReimbursementRequest>({
|
||||||
|
@ -49,121 +62,73 @@ export default function ReimbursementForm() {
|
||||||
total_amount: 0,
|
total_amount: 0,
|
||||||
date_of_purchase: new Date().toISOString().split('T')[0],
|
date_of_purchase: new Date().toISOString().split('T')[0],
|
||||||
payment_method: '',
|
payment_method: '',
|
||||||
status: 'draft',
|
status: 'submitted',
|
||||||
expense_items: [{ description: '', amount: 0, category: '' }],
|
additional_info: '',
|
||||||
receipts: []
|
reciepts: [],
|
||||||
|
department: 'internal'
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedFiles, setSelectedFiles] = useState<Map<string, File>>(new Map());
|
const [receipts, setReceipts] = useState<(ReceiptFormData & { id: string })[]>([]);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
const [showReceiptForm, setShowReceiptForm] = useState(false);
|
||||||
const [previewFilename, setPreviewFilename] = useState<string>('');
|
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
const fileManager = FileManager.getInstance();
|
|
||||||
const auth = Authentication.getInstance();
|
const auth = Authentication.getInstance();
|
||||||
const update = Update.getInstance();
|
|
||||||
|
|
||||||
const handleExpenseItemChange = (index: number, field: keyof ExpenseItem, value: string | number) => {
|
const handleAddReceipt = async (receiptData: ReceiptFormData) => {
|
||||||
const newExpenseItems = [...request.expense_items];
|
try {
|
||||||
newExpenseItems[index] = {
|
const pb = auth.getPocketBase();
|
||||||
...newExpenseItems[index],
|
const userId = pb.authStore.model?.id;
|
||||||
[field]: value
|
|
||||||
};
|
|
||||||
|
|
||||||
// Recalculate total amount
|
if (!userId) {
|
||||||
const newTotalAmount = newExpenseItems.reduce((sum, item) => sum + Number(item.amount), 0);
|
throw new Error('User not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
setRequest(prev => ({
|
// Create receipt record
|
||||||
...prev,
|
const formData = new FormData();
|
||||||
expense_items: newExpenseItems,
|
formData.append('field', receiptData.field);
|
||||||
total_amount: newTotalAmount
|
formData.append('created_by', userId);
|
||||||
}));
|
formData.append('itemized_expenses', JSON.stringify(receiptData.itemized_expenses));
|
||||||
};
|
formData.append('tax', receiptData.tax.toString());
|
||||||
|
formData.append('date', new Date(receiptData.date).toISOString());
|
||||||
|
formData.append('location_name', receiptData.location_name);
|
||||||
|
formData.append('location_address', receiptData.location_address);
|
||||||
|
formData.append('notes', receiptData.notes);
|
||||||
|
|
||||||
const addExpenseItem = () => {
|
const response = await pb.collection('reciepts').create(formData);
|
||||||
setRequest(prev => ({
|
|
||||||
...prev,
|
|
||||||
expense_items: [...prev.expense_items, { description: '', amount: 0, category: '' }]
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeExpenseItem = (index: number) => {
|
// Add receipt to state
|
||||||
if (request.expense_items.length === 1) {
|
setReceipts(prev => [...prev, { ...receiptData, id: response.id }]);
|
||||||
return; // Keep at least one expense item
|
|
||||||
|
// Update total amount
|
||||||
|
const totalAmount = receiptData.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + receiptData.tax;
|
||||||
|
setRequest(prev => ({
|
||||||
|
...prev,
|
||||||
|
total_amount: prev.total_amount + totalAmount,
|
||||||
|
reciepts: [...prev.reciepts, response.id]
|
||||||
|
}));
|
||||||
|
|
||||||
|
setShowReceiptForm(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating receipt:', error);
|
||||||
|
setError('Failed to add receipt. Please try again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
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>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (isSubmitting) return;
|
if (isSubmitting) return;
|
||||||
|
|
||||||
if (!validateForm()) {
|
if (!request.title.trim()) {
|
||||||
|
setError('Title is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!request.payment_method) {
|
||||||
|
setError('Payment method is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (receipts.length === 0) {
|
||||||
|
setError('At least one receipt is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,17 +149,13 @@ export default function ReimbursementForm() {
|
||||||
formData.append('total_amount', request.total_amount.toString());
|
formData.append('total_amount', request.total_amount.toString());
|
||||||
formData.append('date_of_purchase', new Date(request.date_of_purchase).toISOString());
|
formData.append('date_of_purchase', new Date(request.date_of_purchase).toISOString());
|
||||||
formData.append('payment_method', request.payment_method);
|
formData.append('payment_method', request.payment_method);
|
||||||
formData.append('status', 'draft');
|
formData.append('status', 'submitted');
|
||||||
formData.append('expense_items', JSON.stringify(request.expense_items));
|
|
||||||
formData.append('submitted_by', userId);
|
formData.append('submitted_by', userId);
|
||||||
|
formData.append('additional_info', request.additional_info);
|
||||||
|
formData.append('reciepts', JSON.stringify(request.reciepts));
|
||||||
|
formData.append('department', request.department);
|
||||||
|
|
||||||
// Upload files
|
await pb.collection('reimbursement').create(formData);
|
||||||
Array.from(selectedFiles.values()).forEach(file => {
|
|
||||||
formData.append('receipts', file);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Submit the request
|
|
||||||
const response = await pb.collection('reimbursement').create(formData);
|
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
setRequest({
|
setRequest({
|
||||||
|
@ -202,11 +163,12 @@ export default function ReimbursementForm() {
|
||||||
total_amount: 0,
|
total_amount: 0,
|
||||||
date_of_purchase: new Date().toISOString().split('T')[0],
|
date_of_purchase: new Date().toISOString().split('T')[0],
|
||||||
payment_method: '',
|
payment_method: '',
|
||||||
status: 'draft',
|
status: 'submitted',
|
||||||
expense_items: [{ description: '', amount: 0, category: '' }],
|
additional_info: '',
|
||||||
receipts: []
|
reciepts: [],
|
||||||
|
department: 'internal'
|
||||||
});
|
});
|
||||||
setSelectedFiles(new Map());
|
setReceipts([]);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
|
@ -220,218 +182,180 @@ export default function ReimbursementForm() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<div>
|
||||||
{error && (
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="alert alert-error">
|
{error && (
|
||||||
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
|
<div className="alert alert-error">
|
||||||
<span>{error}</span>
|
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
|
||||||
</div>
|
<span>{error}</span>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* Department */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Department</span>
|
||||||
|
<span className="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered"
|
||||||
|
value={request.department}
|
||||||
|
onChange={(e) => setRequest(prev => ({ ...prev, department: e.target.value as typeof DEPARTMENTS[number] }))}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{DEPARTMENTS.map(dept => (
|
||||||
|
<option key={dept} value={dept}>{DEPARTMENT_LABELS[dept]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional Info */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Additional Information</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-bordered"
|
||||||
|
value={request.additional_info}
|
||||||
|
onChange={(e) => setRequest(prev => ({ ...prev, additional_info: e.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Receipts */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="label-text font-medium">Receipts</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={() => setShowReceiptForm(true)}
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:plus" className="h-4 w-4" />
|
||||||
|
Add Receipt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{receipts.length > 0 ? (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{receipts.map((receipt, index) => (
|
||||||
|
<div
|
||||||
|
key={receipt.id}
|
||||||
|
className="card bg-base-200 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">{receipt.location_name}</h3>
|
||||||
|
<p className="text-sm text-base-content/70">{receipt.location_address}</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Total: ${(receipt.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + receipt.tax).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
// Show receipt details in modal
|
||||||
|
// TODO: Implement receipt details view
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 bg-base-200 rounded-lg">
|
||||||
|
<Icon icon="heroicons:receipt" className="h-12 w-12 mx-auto text-base-content/50" />
|
||||||
|
<h3 className="mt-4 text-lg font-medium">No receipts added</h3>
|
||||||
|
<p className="text-base-content/70">Add receipts to continue</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-lg font-medium">
|
<p className="text-lg font-medium">
|
||||||
Total Amount: ${request.total_amount.toFixed(2)}
|
Total Amount: ${request.total_amount.toFixed(2)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Receipt Upload */}
|
{/* Submit Button */}
|
||||||
<div className="form-control">
|
<div className="mt-6">
|
||||||
<label className="label">
|
<button
|
||||||
<span className="label-text">Upload Receipts</span>
|
type="submit"
|
||||||
<span className="label-text-alt text-error">*</span>
|
className={`btn btn-primary w-full ${isSubmitting ? 'loading' : ''}`}
|
||||||
</label>
|
disabled={isSubmitting || receipts.length === 0}
|
||||||
<input
|
>
|
||||||
type="file"
|
{isSubmitting ? 'Submitting...' : 'Submit Reimbursement Request'}
|
||||||
className="file-input file-input-bordered w-full"
|
</button>
|
||||||
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>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Receipt Form Modal */}
|
||||||
<div className="mt-6">
|
{showReceiptForm && (
|
||||||
<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 modal-open">
|
||||||
<div className="modal-box max-w-4xl">
|
<div className="modal-box max-w-5xl">
|
||||||
<FilePreview
|
<h3 className="font-bold text-lg mb-4">Add Receipt</h3>
|
||||||
url={previewUrl}
|
<ReceiptForm
|
||||||
filename={previewFilename}
|
onSubmit={handleAddReceipt}
|
||||||
isModal={true}
|
onCancel={() => setShowReceiptForm(false)}
|
||||||
/>
|
/>
|
||||||
<div className="modal-action">
|
|
||||||
<button
|
|
||||||
className="btn"
|
|
||||||
onClick={() => setShowPreview(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -16,32 +16,55 @@ interface ReimbursementRequest {
|
||||||
title: string;
|
title: string;
|
||||||
total_amount: number;
|
total_amount: number;
|
||||||
date_of_purchase: string;
|
date_of_purchase: string;
|
||||||
business_purpose: string;
|
|
||||||
payment_method: string;
|
payment_method: string;
|
||||||
status: 'draft' | 'submitted' | 'under_review' | 'approved' | 'rejected' | 'paid';
|
status: 'submitted' | 'under_review' | 'approved' | 'rejected' | 'paid' | 'in_progress';
|
||||||
expense_items: ExpenseItem[];
|
|
||||||
receipts: string[];
|
|
||||||
submitted_by: string;
|
submitted_by: string;
|
||||||
submitted_at: string;
|
additional_info: string;
|
||||||
last_updated: string;
|
reciepts: string[];
|
||||||
|
department: 'internal' | 'external' | 'projects' | 'events' | 'other';
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReceiptDetails {
|
||||||
|
id: string;
|
||||||
|
field: string;
|
||||||
|
created_by: string;
|
||||||
|
itemized_expenses: ExpenseItem[];
|
||||||
|
tax: number;
|
||||||
|
date: string;
|
||||||
|
location_name: string;
|
||||||
|
location_address: string;
|
||||||
|
notes: string;
|
||||||
|
audited_by: string[];
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
const STATUS_COLORS = {
|
||||||
draft: 'badge-ghost',
|
|
||||||
submitted: 'badge-primary',
|
submitted: 'badge-primary',
|
||||||
under_review: 'badge-warning',
|
under_review: 'badge-warning',
|
||||||
approved: 'badge-success',
|
approved: 'badge-success',
|
||||||
rejected: 'badge-error',
|
rejected: 'badge-error',
|
||||||
paid: 'badge-success'
|
paid: 'badge-success',
|
||||||
|
in_progress: 'badge-info'
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
const STATUS_LABELS = {
|
||||||
draft: 'Draft',
|
|
||||||
submitted: 'Submitted',
|
submitted: 'Submitted',
|
||||||
under_review: 'Under Review',
|
under_review: 'Under Review',
|
||||||
approved: 'Approved',
|
approved: 'Approved',
|
||||||
rejected: 'Rejected',
|
rejected: 'Rejected',
|
||||||
paid: 'Paid'
|
paid: 'Paid',
|
||||||
|
in_progress: 'In Progress'
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEPARTMENT_LABELS = {
|
||||||
|
internal: 'Internal',
|
||||||
|
external: 'External',
|
||||||
|
projects: 'Projects',
|
||||||
|
events: 'Events',
|
||||||
|
other: 'Other'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ReimbursementList() {
|
export default function ReimbursementList() {
|
||||||
|
@ -52,6 +75,7 @@ export default function ReimbursementList() {
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const [previewUrl, setPreviewUrl] = useState('');
|
const [previewUrl, setPreviewUrl] = useState('');
|
||||||
const [previewFilename, setPreviewFilename] = useState('');
|
const [previewFilename, setPreviewFilename] = useState('');
|
||||||
|
const [selectedReceipt, setSelectedReceipt] = useState<ReceiptDetails | null>(null);
|
||||||
|
|
||||||
const get = Get.getInstance();
|
const get = Get.getInstance();
|
||||||
const auth = Authentication.getInstance();
|
const auth = Authentication.getInstance();
|
||||||
|
@ -83,14 +107,14 @@ export default function ReimbursementList() {
|
||||||
title: record.title,
|
title: record.title,
|
||||||
total_amount: record.total_amount,
|
total_amount: record.total_amount,
|
||||||
date_of_purchase: record.date_of_purchase,
|
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,
|
payment_method: record.payment_method,
|
||||||
status: record.status,
|
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_by: record.submitted_by,
|
||||||
submitted_at: record.created, // Use created field for submitted_at
|
additional_info: record.additional_info || '',
|
||||||
last_updated: record.updated // Use updated field for last_updated
|
reciepts: record.reciepts || [],
|
||||||
|
department: record.department,
|
||||||
|
created: record.created,
|
||||||
|
updated: record.updated
|
||||||
})) as ReimbursementRequest[];
|
})) as ReimbursementRequest[];
|
||||||
|
|
||||||
setRequests(reimbursements);
|
setRequests(reimbursements);
|
||||||
|
@ -102,11 +126,48 @@ export default function ReimbursementList() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreviewFile = (request: ReimbursementRequest, filename: string) => {
|
const handlePreviewFile = async (request: ReimbursementRequest, receiptId: string) => {
|
||||||
const url = fileManager.getFileUrl('reimbursement', request.id, filename);
|
try {
|
||||||
setPreviewUrl(url);
|
const pb = auth.getPocketBase();
|
||||||
setPreviewFilename(filename);
|
|
||||||
setShowPreview(true);
|
// Get the receipt record using its ID
|
||||||
|
const receiptRecord = await pb.collection('reciepts').getOne(receiptId, {
|
||||||
|
$autoCancel: false
|
||||||
|
});
|
||||||
|
|
||||||
|
if (receiptRecord) {
|
||||||
|
// Parse the itemized expenses if it's a string
|
||||||
|
const itemizedExpenses = typeof receiptRecord.itemized_expenses === 'string'
|
||||||
|
? JSON.parse(receiptRecord.itemized_expenses)
|
||||||
|
: receiptRecord.itemized_expenses;
|
||||||
|
|
||||||
|
setSelectedReceipt({
|
||||||
|
id: receiptRecord.id,
|
||||||
|
field: receiptRecord.field,
|
||||||
|
created_by: receiptRecord.created_by,
|
||||||
|
itemized_expenses: itemizedExpenses,
|
||||||
|
tax: receiptRecord.tax,
|
||||||
|
date: receiptRecord.date,
|
||||||
|
location_name: receiptRecord.location_name,
|
||||||
|
location_address: receiptRecord.location_address,
|
||||||
|
notes: receiptRecord.notes || '',
|
||||||
|
audited_by: receiptRecord.audited_by || [],
|
||||||
|
created: receiptRecord.created,
|
||||||
|
updated: receiptRecord.updated
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the file URL using the PocketBase URL and collection info
|
||||||
|
const url = `${pb.baseUrl}/api/files/reciepts/${receiptRecord.id}/${receiptRecord.field}`;
|
||||||
|
setPreviewUrl(url);
|
||||||
|
setPreviewFilename(receiptRecord.field);
|
||||||
|
setShowPreview(true);
|
||||||
|
} else {
|
||||||
|
throw new Error('Receipt not found');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading receipt:', error);
|
||||||
|
alert('Failed to load receipt. Please try again.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
|
@ -188,10 +249,16 @@ export default function ReimbursementList() {
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">Status</label>
|
<label className="text-sm font-medium">Status</label>
|
||||||
<div className={`badge ${STATUS_COLORS[selectedRequest.status]} mt-1`}>
|
<div className={`badge ${STATUS_COLORS[selectedRequest.status]} mt-1 block`}>
|
||||||
{STATUS_LABELS[selectedRequest.status]}
|
{STATUS_LABELS[selectedRequest.status]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Department</label>
|
||||||
|
<div className="badge badge-outline mt-1 block">
|
||||||
|
{DEPARTMENT_LABELS[selectedRequest.department]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">Total Amount</label>
|
<label className="text-sm font-medium">Total Amount</label>
|
||||||
<p className="mt-1">${selectedRequest.total_amount.toFixed(2)}</p>
|
<p className="mt-1">${selectedRequest.total_amount.toFixed(2)}</p>
|
||||||
|
@ -206,46 +273,24 @@ export default function ReimbursementList() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{selectedRequest.additional_info && (
|
||||||
<label className="text-sm font-medium">Business Purpose</label>
|
<div>
|
||||||
<p className="mt-1">{selectedRequest.business_purpose}</p>
|
<label className="text-sm font-medium">Additional Information</label>
|
||||||
</div>
|
<p className="mt-1">{selectedRequest.additional_info}</p>
|
||||||
|
|
||||||
<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>
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">Receipts</label>
|
<label className="text-sm font-medium">Receipts</label>
|
||||||
<div className="mt-2 grid grid-cols-2 md:grid-cols-3 gap-2">
|
<div className="mt-2 grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
{selectedRequest.receipts.map((filename) => (
|
{(selectedRequest.reciepts || []).map((receiptId, index) => (
|
||||||
<button
|
<button
|
||||||
key={filename}
|
key={receiptId || index}
|
||||||
className="btn btn-outline btn-sm normal-case"
|
className="btn btn-outline btn-sm normal-case"
|
||||||
onClick={() => handlePreviewFile(selectedRequest, filename)}
|
onClick={() => handlePreviewFile(selectedRequest, receiptId)}
|
||||||
>
|
>
|
||||||
<Icon icon="heroicons:document" className="h-4 w-4 mr-2" />
|
<Icon icon="heroicons:document" className="h-4 w-4 mr-2" />
|
||||||
{filename}
|
Receipt #{index + 1}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -256,11 +301,11 @@ export default function ReimbursementList() {
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium">Submitted At</label>
|
<label className="font-medium">Submitted At</label>
|
||||||
<p className="mt-1">{formatDate(selectedRequest.submitted_at)}</p>
|
<p className="mt-1">{formatDate(selectedRequest.created)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium">Last Updated</label>
|
<label className="font-medium">Last Updated</label>
|
||||||
<p className="mt-1">{formatDate(selectedRequest.last_updated)}</p>
|
<p className="mt-1">{formatDate(selectedRequest.updated)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -278,18 +323,99 @@ export default function ReimbursementList() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File Preview Modal */}
|
{/* File Preview Modal */}
|
||||||
{showPreview && (
|
{showPreview && selectedReceipt && (
|
||||||
<div className="modal modal-open">
|
<div className="modal modal-open">
|
||||||
<div className="modal-box max-w-4xl">
|
<div className="modal-box max-w-7xl">
|
||||||
<FilePreview
|
<div className="grid grid-cols-5 gap-6">
|
||||||
url={previewUrl}
|
{/* Receipt Details */}
|
||||||
filename={previewFilename}
|
<div className="col-span-2 space-y-4">
|
||||||
isModal={true}
|
<h3 className="font-bold text-lg">Receipt Details</h3>
|
||||||
/>
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Location</label>
|
||||||
|
<p className="mt-1">{selectedReceipt.location_name}</p>
|
||||||
|
<p className="text-sm text-base-content/70">{selectedReceipt.location_address}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Date</label>
|
||||||
|
<p className="mt-1">{formatDate(selectedReceipt.date)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedReceipt.notes && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Notes</label>
|
||||||
|
<p className="mt-1">{selectedReceipt.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Itemized Expenses</label>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{selectedReceipt.itemized_expenses.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 className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Tax</label>
|
||||||
|
<p className="mt-1">${selectedReceipt.tax.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Total</label>
|
||||||
|
<p className="mt-1">${(selectedReceipt.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + selectedReceipt.tax).toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Preview */}
|
||||||
|
<div className="col-span-3 border-l border-base-300 pl-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="font-bold text-lg">Receipt Image</h3>
|
||||||
|
<a
|
||||||
|
href={previewUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn btn-sm btn-outline gap-2"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:arrow-top-right-on-square" className="h-4 w-4" />
|
||||||
|
View Full Size
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="bg-base-200 rounded-lg p-4">
|
||||||
|
<FilePreview
|
||||||
|
url={previewUrl}
|
||||||
|
filename={previewFilename}
|
||||||
|
isModal={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="modal-action">
|
<div className="modal-action">
|
||||||
<button
|
<button
|
||||||
className="btn"
|
className="btn"
|
||||||
onClick={() => setShowPreview(false)}
|
onClick={() => {
|
||||||
|
setShowPreview(false);
|
||||||
|
setSelectedReceipt(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
|
14
src/config/reimbursement.yaml
Normal file
14
src/config/reimbursement.yaml
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
status:
|
||||||
|
- "submitted"
|
||||||
|
- "under_review"
|
||||||
|
- "approved"
|
||||||
|
- "rejected"
|
||||||
|
- "paid"
|
||||||
|
- "in_progress"
|
||||||
|
|
||||||
|
department:
|
||||||
|
- "internal"
|
||||||
|
- "external"
|
||||||
|
- "projects"
|
||||||
|
- "events"
|
||||||
|
- "other"
|
Loading…
Reference in a new issue