add animation and more information

This commit is contained in:
chark1es 2025-02-19 06:14:51 -08:00
parent 8338b7ccd2
commit cb58d8fd33
5 changed files with 1464 additions and 568 deletions

View file

@ -1,6 +1,8 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import FilePreview from '../universal/FilePreview'; import FilePreview from '../universal/FilePreview';
import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion';
interface ExpenseItem { interface ExpenseItem {
description: string; description: string;
@ -33,6 +35,30 @@ const EXPENSE_CATEGORIES = [
'Other' 'Other'
]; ];
// Add these animation variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) { export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string>(''); const [previewUrl, setPreviewUrl] = useState<string>('');
@ -52,12 +78,14 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
// Validate file type // Validate file type
if (!selectedFile.type.match('image/*') && selectedFile.type !== 'application/pdf') { if (!selectedFile.type.match('image/*') && selectedFile.type !== 'application/pdf') {
toast.error('Only images and PDF files are allowed');
setError('Only images and PDF files are allowed'); setError('Only images and PDF files are allowed');
return; return;
} }
// Validate file size (5MB limit) // Validate file size (5MB limit)
if (selectedFile.size > 5 * 1024 * 1024) { if (selectedFile.size > 5 * 1024 * 1024) {
toast.error('File size must be less than 5MB');
setError('File size must be less than 5MB'); setError('File size must be less than 5MB');
return; return;
} }
@ -65,6 +93,7 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
setFile(selectedFile); setFile(selectedFile);
setPreviewUrl(URL.createObjectURL(selectedFile)); setPreviewUrl(URL.createObjectURL(selectedFile));
setError(''); setError('');
toast.success('File uploaded successfully');
} }
}; };
@ -121,232 +150,298 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
}; };
return ( return (
<div className="grid grid-cols-2 gap-6 h-full"> <motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="grid grid-cols-2 gap-6 h-full"
>
{/* Left side - Form */} {/* Left side - Form */}
<div className="space-y-4 overflow-y-auto max-h-[70vh]"> <motion.div
<form onSubmit={handleSubmit} className="space-y-4"> variants={containerVariants}
{error && ( initial="hidden"
<div className="alert alert-error"> animate="visible"
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" /> className="space-y-4 overflow-y-auto max-h-[70vh] pr-4 custom-scrollbar"
<span>{error}</span> >
</div> <form onSubmit={handleSubmit} className="space-y-6">
)} <AnimatePresence mode="wait">
{error && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
className="alert alert-error shadow-lg"
>
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
<span>{error}</span>
</motion.div>
)}
</AnimatePresence>
{/* File Upload */} {/* File Upload */}
<div className="form-control"> <motion.div variants={itemVariants} className="form-control">
<label className="label"> <label className="label">
<span className="label-text">Upload Receipt</span> <span className="label-text font-medium">Upload Receipt</span>
<span className="label-text-alt text-error">*</span> <span className="label-text-alt text-error">*</span>
</label> </label>
<input <div className="relative">
type="file" <input
className="file-input file-input-bordered w-full" type="file"
onChange={handleFileChange} className="file-input file-input-bordered w-full file-input-primary hover:file-input-ghost transition-all duration-300"
accept="image/*,.pdf" onChange={handleFileChange}
/> accept="image/*,.pdf"
</div> />
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-base-content/50">
<Icon icon="heroicons:cloud-arrow-up" className="h-5 w-5" />
</div>
</div>
</motion.div>
{/* Date */} {/* Date */}
<div className="form-control"> <motion.div variants={itemVariants} className="form-control">
<label className="label"> <label className="label">
<span className="label-text">Date</span> <span className="label-text font-medium">Date</span>
<span className="label-text-alt text-error">*</span> <span className="label-text-alt text-error">*</span>
</label> </label>
<input <input
type="date" type="date"
className="input input-bordered" className="input input-bordered focus:input-primary transition-all duration-300"
value={date} value={date}
onChange={(e) => setDate(e.target.value)} onChange={(e) => setDate(e.target.value)}
required required
/> />
</div> </motion.div>
{/* Location Name */} {/* Location Name */}
<div className="form-control"> <motion.div variants={itemVariants} className="form-control">
<label className="label"> <label className="label">
<span className="label-text">Location Name</span> <span className="label-text font-medium">Location Name</span>
<span className="label-text-alt text-error">*</span> <span className="label-text-alt text-error">*</span>
</label> </label>
<input <input
type="text" type="text"
className="input input-bordered" className="input input-bordered focus:input-primary transition-all duration-300"
value={locationName} value={locationName}
onChange={(e) => setLocationName(e.target.value)} onChange={(e) => setLocationName(e.target.value)}
required required
/> />
</div> </motion.div>
{/* Location Address */} {/* Location Address */}
<div className="form-control"> <motion.div variants={itemVariants} className="form-control">
<label className="label"> <label className="label">
<span className="label-text">Location Address</span> <span className="label-text font-medium">Location Address</span>
<span className="label-text-alt text-error">*</span> <span className="label-text-alt text-error">*</span>
</label> </label>
<input <input
type="text" type="text"
className="input input-bordered" className="input input-bordered focus:input-primary transition-all duration-300"
value={locationAddress} value={locationAddress}
onChange={(e) => setLocationAddress(e.target.value)} onChange={(e) => setLocationAddress(e.target.value)}
required required
/> />
</div> </motion.div>
{/* Notes */} {/* Notes */}
<div className="form-control"> <motion.div variants={itemVariants} className="form-control">
<label className="label"> <label className="label">
<span className="label-text">Notes</span> <span className="label-text font-medium">Notes</span>
</label> </label>
<textarea <textarea
className="textarea textarea-bordered" className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[100px]"
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
rows={3} rows={3}
/> />
</div> </motion.div>
{/* Itemized Expenses */} {/* Itemized Expenses */}
<div className="space-y-4"> <motion.div variants={itemVariants} className="space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<label className="label-text font-medium">Itemized Expenses</label> <label className="text-lg font-medium">Itemized Expenses</label>
<button <motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button" type="button"
className="btn btn-sm btn-primary" className="btn btn-primary btn-sm gap-2 hover:shadow-lg transition-all duration-300"
onClick={addExpenseItem} onClick={addExpenseItem}
> >
<Icon icon="heroicons:plus" className="h-4 w-4" /> <Icon icon="heroicons:plus" className="h-4 w-4" />
Add Item Add Item
</button> </motion.button>
</div> </div>
{itemizedExpenses.map((item, index) => ( <AnimatePresence>
<div key={index} className="card bg-base-200 p-4"> {itemizedExpenses.map((item, index) => (
<div className="grid gap-4"> <motion.div
<div className="form-control"> key={index}
<label className="label"> initial={{ opacity: 0, x: -20 }}
<span className="label-text">Description</span> animate={{ opacity: 1, x: 0 }}
</label> exit={{ opacity: 0, x: 20 }}
<input className="card bg-base-200/50 hover:bg-base-200 transition-colors duration-300 backdrop-blur-sm shadow-sm"
type="text" >
className="input input-bordered" <div className="card-body p-4">
value={item.description} <div className="grid gap-4">
onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)} <div className="form-control">
required <label className="label">
/> <span className="label-text">Description</span>
</div> </label>
<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 <input
type="number" type="text"
className="input input-bordered" className="input input-bordered"
value={item.amount} value={item.description}
onChange={(e) => handleExpenseItemChange(index, 'amount', Number(e.target.value))} onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)}
min="0"
step="0.01"
required required
/> />
{itemizedExpenses.length > 1 && ( </div>
<button
type="button" <div className="grid grid-cols-2 gap-4">
className="btn btn-square btn-sm btn-error" <div className="form-control">
onClick={() => removeExpenseItem(index)} <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
> >
<Icon icon="heroicons:trash" className="h-4 w-4" /> <option value="">Select category</option>
</button> {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>
</div> </div>
</div> </motion.div>
</div> ))}
))} </AnimatePresence>
</div> </motion.div>
{/* Tax */} {/* Tax */}
<div className="form-control"> <motion.div variants={itemVariants} className="form-control">
<label className="label"> <label className="label">
<span className="label-text">Tax Amount ($)</span> <span className="label-text font-medium">Tax Amount ($)</span>
</label> </label>
<input <input
type="number" type="number"
className="input input-bordered" className="input input-bordered focus:input-primary transition-all duration-300"
value={tax} value={tax}
onChange={(e) => setTax(Number(e.target.value))} onChange={(e) => setTax(Number(e.target.value))}
min="0" min="0"
step="0.01" step="0.01"
/> />
</div> </motion.div>
{/* Total */} {/* Total */}
<div className="text-right"> <motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<p className="text-lg font-medium"> <div className="space-y-2">
Subtotal: ${itemizedExpenses.reduce((sum, item) => sum + item.amount, 0).toFixed(2)} <div className="flex justify-between items-center text-base-content/70">
</p> <span>Subtotal:</span>
<p className="text-lg font-medium"> <span className="font-mono">${itemizedExpenses.reduce((sum, item) => sum + item.amount, 0).toFixed(2)}</span>
Tax: ${tax.toFixed(2)} </div>
</p> <div className="flex justify-between items-center text-base-content/70">
<p className="text-lg font-medium"> <span>Tax:</span>
Total: ${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)} <span className="font-mono">${tax.toFixed(2)}</span>
</p> </div>
</div> <div className="divider my-1"></div>
<div className="flex justify-between items-center font-medium text-lg">
<span>Total:</span>
<span className="font-mono text-primary">${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)}</span>
</div>
</div>
</motion.div>
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex justify-end gap-2 mt-6"> <motion.div variants={itemVariants} className="flex justify-end gap-3 mt-8">
<button <motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
type="button" type="button"
className="btn" className="btn btn-ghost hover:btn-error transition-all duration-300"
onClick={onCancel} onClick={onCancel}
> >
Cancel Cancel
</button> </motion.button>
<button <motion.button
whileHover={{ scale: 1.02, boxShadow: "0 4px 20px rgba(0, 0, 0, 0.1)" }}
whileTap={{ scale: 0.98 }}
type="submit" type="submit"
className="btn btn-primary" className="btn btn-primary shadow-md hover:shadow-lg transition-all duration-300"
> >
Add Receipt Add Receipt
</button> </motion.button>
</div> </motion.div>
</form> </form>
</div> </motion.div>
{/* Right side - Preview */} {/* Right side - Preview */}
<div className="border-l border-base-300 pl-6"> <motion.div
{previewUrl ? ( initial={{ opacity: 0, x: 20 }}
<FilePreview animate={{ opacity: 1, x: 0 }}
url={previewUrl} transition={{ delay: 0.2 }}
filename={file?.name || ''} className="border-l border-base-300 pl-6"
isModal={false} >
/> <AnimatePresence mode="wait">
) : ( {previewUrl ? (
<div className="flex items-center justify-center h-full text-base-content/70"> <motion.div
<div className="text-center"> key="preview"
<Icon icon="heroicons:document" className="h-12 w-12 mx-auto mb-2" /> initial={{ opacity: 0, scale: 0.95 }}
<p>Upload a receipt to preview</p> animate={{ opacity: 1, scale: 1 }}
</div> exit={{ opacity: 0, scale: 0.9 }}
</div> transition={{ type: "spring", stiffness: 300, damping: 25 }}
)} className="bg-base-200/50 backdrop-blur-sm rounded-xl p-4 shadow-sm"
</div> >
</div> <FilePreview
url={previewUrl}
filename={file?.name || ''}
isModal={false}
/>
</motion.div>
) : (
<motion.div
key="placeholder"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex items-center justify-center h-full text-base-content/70"
>
<div className="text-center">
<Icon icon="heroicons:document" className="h-16 w-16 mx-auto mb-4 text-base-content/30" />
<p className="text-lg">Upload a receipt to preview</p>
<p className="text-sm text-base-content/50 mt-2">Supported formats: Images, PDF</p>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</motion.div>
); );
} }

View file

@ -1,7 +1,11 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { Authentication } from '../../../scripts/pocketbase/Authentication';
import ReceiptForm from './ReceiptForm'; import ReceiptForm from './ReceiptForm';
import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion';
import FilePreview from '../universal/FilePreview';
import ToastProvider from './ToastProvider';
interface ExpenseItem { interface ExpenseItem {
description: string; description: string;
@ -56,6 +60,34 @@ const DEPARTMENT_LABELS = {
other: 'Other' other: 'Other'
}; };
// Add these animation variants
const containerVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 30,
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
export default function ReimbursementForm() { export default function ReimbursementForm() {
const [request, setRequest] = useState<ReimbursementRequest>({ const [request, setRequest] = useState<ReimbursementRequest>({
title: '', title: '',
@ -72,18 +104,89 @@ export default function ReimbursementForm() {
const [showReceiptForm, setShowReceiptForm] = useState(false); const [showReceiptForm, setShowReceiptForm] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [showReceiptDetails, setShowReceiptDetails] = useState(false);
const [selectedReceiptDetails, setSelectedReceiptDetails] = useState<ReceiptFormData | null>(null);
const [hasZelleInfo, setHasZelleInfo] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(true);
const auth = Authentication.getInstance(); const auth = Authentication.getInstance();
useEffect(() => {
checkZelleInformation();
}, []);
const checkZelleInformation = async () => {
try {
setIsLoading(true);
const pb = auth.getPocketBase();
const userId = pb.authStore.model?.id;
if (!userId) {
throw new Error('User not authenticated');
}
const user = await pb.collection('users').getOne(userId);
setHasZelleInfo(!!user.zelle_information);
} catch (error) {
console.error('Error checking Zelle information:', error);
toast.error('Failed to verify Zelle information');
} finally {
setIsLoading(false);
}
};
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px]">
<div className="loading loading-spinner loading-lg text-primary"></div>
<p className="mt-4 text-base-content/70">Loading...</p>
</div>
);
}
if (hasZelleInfo === false) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="max-w-2xl mx-auto text-center py-12"
>
<div className="card bg-base-200 p-8">
<Icon icon="heroicons:exclamation-triangle" className="h-16 w-16 mx-auto text-warning" />
<h2 className="text-2xl font-bold mt-6">Zelle Information Required</h2>
<p className="mt-4 text-base-content/70">
Before submitting a reimbursement request, you need to provide your Zelle information.
This is required for processing your reimbursement payments.
</p>
<div className="mt-8">
<button
className="btn btn-primary gap-2"
onClick={() => {
const profileBtn = document.querySelector('[data-section="settings"]') as HTMLButtonElement;
if (profileBtn) profileBtn.click();
}}
>
<Icon icon="heroicons:user-circle" className="h-5 w-5" />
Update Profile
</button>
</div>
</div>
</motion.div>
);
}
const handleAddReceipt = async (receiptData: ReceiptFormData) => { const handleAddReceipt = async (receiptData: ReceiptFormData) => {
try { try {
const pb = auth.getPocketBase(); const pb = auth.getPocketBase();
const userId = pb.authStore.model?.id; const userId = pb.authStore.model?.id;
if (!userId) { if (!userId) {
toast.error('User not authenticated');
throw new Error('User not authenticated'); throw new Error('User not authenticated');
} }
toast.loading('Adding receipt...');
// Create receipt record // Create receipt record
const formData = new FormData(); const formData = new FormData();
formData.append('field', receiptData.field); formData.append('field', receiptData.field);
@ -109,25 +212,32 @@ export default function ReimbursementForm() {
})); }));
setShowReceiptForm(false); setShowReceiptForm(false);
toast.dismiss();
toast.success('Receipt added successfully');
} catch (error) { } catch (error) {
console.error('Error creating receipt:', error); console.error('Error creating receipt:', error);
toast.dismiss();
toast.error('Failed to add receipt');
setError('Failed to add receipt. Please try again.'); setError('Failed to add receipt. Please try again.');
} }
}; };
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (isSubmitting) return; if (isSubmitting) return;
if (!request.title.trim()) { if (!request.title.trim()) {
toast.error('Title is required');
setError('Title is required'); setError('Title is required');
return; return;
} }
if (!request.payment_method) { if (!request.payment_method) {
toast.error('Payment method is required');
setError('Payment method is required'); setError('Payment method is required');
return; return;
} }
if (receipts.length === 0) { if (receipts.length === 0) {
toast.error('At least one receipt is required');
setError('At least one receipt is required'); setError('At least one receipt is required');
return; return;
} }
@ -143,6 +253,8 @@ export default function ReimbursementForm() {
throw new Error('User not authenticated'); throw new Error('User not authenticated');
} }
const loadingToast = toast.loading('Submitting reimbursement request...');
// Create reimbursement record // Create reimbursement record
const formData = new FormData(); const formData = new FormData();
formData.append('title', request.title); formData.append('title', request.title);
@ -171,191 +283,377 @@ export default function ReimbursementForm() {
setReceipts([]); setReceipts([]);
setError(''); setError('');
// Show success message // Dismiss loading toast and show success
alert('Reimbursement request submitted successfully!'); toast.dismiss(loadingToast);
toast.success('🎉 Reimbursement request submitted successfully! Check "My Requests" to view it.', {
duration: 5000,
position: 'top-center',
style: {
background: '#10B981',
color: '#FFFFFF',
padding: '16px',
borderRadius: '8px',
}
});
} catch (error) { } catch (error) {
console.error('Error submitting reimbursement request:', error); console.error('Error submitting reimbursement request:', error);
setError('Failed to submit reimbursement request. Please try again.'); toast.error('Failed to submit reimbursement request. Please try again.');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
return ( return (
<div> <>
<form onSubmit={handleSubmit} className="space-y-6"> <ToastProvider />
{error && ( <motion.div
<div className="alert alert-error"> variants={containerVariants}
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" /> initial="hidden"
<span>{error}</span> animate="visible"
</div> className="max-w-4xl mx-auto"
)} >
<form onSubmit={handleSubmit} className="space-y-8">
<AnimatePresence mode="wait">
{error && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
className="alert alert-error shadow-lg"
>
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
<span>{error}</span>
</motion.div>
)}
</AnimatePresence>
{/* Title */} <motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="form-control"> {/* Title */}
<label className="label"> <div className="form-control md:col-span-2">
<span className="label-text">Title</span> <label className="label">
<span className="label-text-alt text-error">*</span> <span className="label-text font-medium">Title</span>
</label> <span className="label-text-alt text-error">*</span>
<input </label>
type="text" <input
className="input input-bordered" type="text"
value={request.title} className="input input-bordered focus:input-primary transition-all duration-300"
onChange={(e) => setRequest(prev => ({ ...prev, title: e.target.value }))} value={request.title}
required onChange={(e) => setRequest(prev => ({ ...prev, title: e.target.value }))}
/> required
</div> />
</div>
{/* Date of Purchase */} {/* Date of Purchase */}
<div className="form-control"> <div className="form-control">
<label className="label"> <label className="label">
<span className="label-text">Date of Purchase</span> <span className="label-text font-medium">Date of Purchase</span>
<span className="label-text-alt text-error">*</span> <span className="label-text-alt text-error">*</span>
</label> </label>
<input <input
type="date" type="date"
className="input input-bordered" className="input input-bordered focus:input-primary transition-all duration-300"
value={request.date_of_purchase} value={request.date_of_purchase}
onChange={(e) => setRequest(prev => ({ ...prev, date_of_purchase: e.target.value }))} onChange={(e) => setRequest(prev => ({ ...prev, date_of_purchase: e.target.value }))}
required required
/> />
</div> </div>
{/* Payment Method */} {/* Payment Method */}
<div className="form-control"> <div className="form-control">
<label className="label"> <label className="label">
<span className="label-text">Payment Method</span> <span className="label-text font-medium">Payment Method</span>
<span className="label-text-alt text-error">*</span> <span className="label-text-alt text-error">*</span>
</label> </label>
<select <select
className="select select-bordered" className="select select-bordered focus:select-primary transition-all duration-300"
value={request.payment_method} value={request.payment_method}
onChange={(e) => setRequest(prev => ({ ...prev, payment_method: e.target.value }))} onChange={(e) => setRequest(prev => ({ ...prev, payment_method: e.target.value }))}
required 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 font-medium">Department</span>
<span className="label-text-alt text-error">*</span>
</label>
<select
className="select select-bordered focus:select-primary transition-all duration-300"
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 md:col-span-2">
<label className="label">
<span className="label-text font-medium">Additional Information</span>
</label>
<textarea
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[120px]"
value={request.additional_info}
onChange={(e) => setRequest(prev => ({ ...prev, additional_info: e.target.value }))}
rows={3}
/>
</div>
</motion.div>
{/* Receipts */}
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-6 shadow-sm">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Receipts</h3>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
className="btn btn-primary btn-sm gap-2 hover:shadow-lg transition-all duration-300"
onClick={() => setShowReceiptForm(true)}
>
<Icon icon="heroicons:plus" className="h-4 w-4" />
Add Receipt
</motion.button>
</div>
{receipts.length > 0 ? (
<motion.div layout className="grid gap-4">
<AnimatePresence>
{receipts.map((receipt, index) => (
<motion.div
key={receipt.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="card bg-base-100 hover:bg-base-200 transition-all duration-300 shadow-sm"
>
<div className="card-body p-4">
<div className="flex justify-between items-start">
<div>
<h3 className="font-medium text-lg">{receipt.location_name}</h3>
<p className="text-sm text-base-content/70">{receipt.location_address}</p>
<p className="text-sm mt-2">
Total: <span className="font-mono font-medium text-primary">${(receipt.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + receipt.tax).toFixed(2)}</span>
</p>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
className="btn btn-ghost btn-sm gap-2"
onClick={() => {
setSelectedReceiptDetails(receipt);
setShowReceiptDetails(true);
}}
>
<Icon icon="heroicons:eye" className="h-4 w-4" />
View Details
</motion.button>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</motion.div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center py-12 bg-base-100 rounded-lg"
>
<Icon icon="heroicons:receipt" className="h-16 w-16 mx-auto text-base-content/30" />
<h3 className="mt-4 text-lg font-medium">No receipts added</h3>
<p className="text-base-content/70 mt-2">Add receipts to continue</p>
</motion.div>
)}
{receipts.length > 0 && (
<div className="mt-4 p-4 bg-base-100 rounded-lg">
<div className="flex justify-between items-center text-lg font-medium">
<span>Total Amount:</span>
<span className="font-mono text-primary">${request.total_amount.toFixed(2)}</span>
</div>
</div>
)}
</motion.div>
{/* Submit Button */}
<motion.div
variants={itemVariants}
className="mt-8"
> >
<option value="">Select payment method</option> <motion.button
{PAYMENT_METHODS.map(method => ( whileHover={{ scale: 1.01, boxShadow: "0 4px 20px rgba(0, 0, 0, 0.1)" }}
<option key={method} value={method}>{method}</option> whileTap={{ scale: 0.99 }}
))} type="submit"
</select> className="btn btn-primary w-full h-12 shadow-md hover:shadow-lg transition-all duration-300 text-lg"
</div> disabled={isSubmitting || receipts.length === 0}
{/* 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" /> {isSubmitting ? (
Add Receipt <span className="loading loading-spinner loading-md"></span>
</button> ) : (
</div> <>
<Icon icon="heroicons:paper-airplane" className="h-5 w-5" />
Submit Reimbursement Request
</>
)}
</motion.button>
</motion.div>
</form>
{receipts.length > 0 ? ( {/* Receipt Form Modal */}
<div className="grid gap-4"> <AnimatePresence>
{receipts.map((receipt, index) => ( {showReceiptForm && (
<div <motion.div
key={receipt.id} initial={{ opacity: 0 }}
className="card bg-base-200 p-4" animate={{ opacity: 1 }}
> exit={{ opacity: 0 }}
<div className="flex justify-between items-start"> className="modal modal-open"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="modal-box max-w-5xl bg-base-100/95 backdrop-blur-md"
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">Add Receipt</h3>
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
whileTap={{ scale: 0.9 }}
className="btn btn-ghost btn-sm btn-circle"
onClick={() => setShowReceiptForm(false)}
>
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
</motion.button>
</div>
<ReceiptForm
onSubmit={handleAddReceipt}
onCancel={() => setShowReceiptForm(false)}
/>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Receipt Details Modal */}
<AnimatePresence>
{showReceiptDetails && selectedReceiptDetails && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="modal modal-open"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="modal-box max-w-4xl bg-base-100/95 backdrop-blur-md"
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
Receipt Details
</h3>
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
whileTap={{ scale: 0.9 }}
className="btn btn-ghost btn-sm btn-circle"
onClick={() => {
setShowReceiptDetails(false);
setSelectedReceiptDetails(null);
}}
>
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
</motion.button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div> <div>
<h3 className="font-medium">{receipt.location_name}</h3> <label className="text-sm font-medium">Location</label>
<p className="text-sm text-base-content/70">{receipt.location_address}</p> <p className="mt-1">{selectedReceiptDetails.location_name}</p>
<p className="text-sm"> <p className="text-sm text-base-content/70">{selectedReceiptDetails.location_address}</p>
Total: ${(receipt.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + receipt.tax).toFixed(2)}
</p>
</div> </div>
<div className="flex gap-2">
<button <div>
type="button" <label className="text-sm font-medium">Date</label>
className="btn btn-sm" <p className="mt-1">{new Date(selectedReceiptDetails.date).toLocaleDateString()}</p>
onClick={() => { </div>
// Show receipt details in modal
// TODO: Implement receipt details view {selectedReceiptDetails.notes && (
}} <div>
> <label className="text-sm font-medium">Notes</label>
View Details <p className="mt-1">{selectedReceiptDetails.notes}</p>
</button> </div>
)}
<div>
<label className="text-sm font-medium">Itemized Expenses</label>
<div className="mt-2 space-y-2">
{selectedReceiptDetails.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">${selectedReceiptDetails.tax.toFixed(2)}</p>
</div>
<div>
<label className="text-sm font-medium">Total</label>
<p className="mt-1">${(selectedReceiptDetails.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + selectedReceiptDetails.tax).toFixed(2)}</p>
</div>
</div>
</div>
<div className="border-l border-base-300 pl-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Receipt Image</h3>
</div>
<div className="bg-base-200/50 backdrop-blur-sm rounded-lg p-4 shadow-sm">
<FilePreview
url={URL.createObjectURL(selectedReceiptDetails.field)}
filename={selectedReceiptDetails.field.name}
isModal={false}
/>
</div> </div>
</div> </div>
</div> </div>
))} </motion.div>
</div> </motion.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> </AnimatePresence>
</motion.div>
{/* Total */} </>
<div className="text-right">
<p className="text-lg font-medium">
Total Amount: ${request.total_amount.toFixed(2)}
</p>
</div>
{/* Submit Button */}
<div className="mt-6">
<button
type="submit"
className={`btn btn-primary w-full ${isSubmitting ? 'loading' : ''}`}
disabled={isSubmitting || receipts.length === 0}
>
{isSubmitting ? 'Submitting...' : 'Submit Reimbursement Request'}
</button>
</div>
</form>
{/* Receipt Form Modal */}
{showReceiptForm && (
<div className="modal modal-open">
<div className="modal-box max-w-5xl">
<h3 className="font-bold text-lg mb-4">Add Receipt</h3>
<ReceiptForm
onSubmit={handleAddReceipt}
onCancel={() => setShowReceiptForm(false)}
/>
</div>
</div>
)}
</div>
); );
} }

View file

@ -4,6 +4,9 @@ import { Get } from '../../../scripts/pocketbase/Get';
import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { FileManager } from '../../../scripts/pocketbase/FileManager'; import { FileManager } from '../../../scripts/pocketbase/FileManager';
import FilePreview from '../universal/FilePreview'; import FilePreview from '../universal/FilePreview';
import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion';
import ToastProvider from './ToastProvider';
interface ExpenseItem { interface ExpenseItem {
description: string; description: string;
@ -11,6 +14,13 @@ interface ExpenseItem {
category: string; category: string;
} }
interface AuditNote {
note: string;
auditor_id: string;
timestamp: string;
is_private: boolean;
}
interface ReimbursementRequest { interface ReimbursementRequest {
id: string; id: string;
title: string; title: string;
@ -24,6 +34,7 @@ interface ReimbursementRequest {
department: 'internal' | 'external' | 'projects' | 'events' | 'other'; department: 'internal' | 'external' | 'projects' | 'events' | 'other';
created: string; created: string;
updated: string; updated: string;
audit_notes: AuditNote[] | null;
} }
interface ReceiptDetails { interface ReceiptDetails {
@ -67,6 +78,45 @@ const DEPARTMENT_LABELS = {
other: 'Other' other: 'Other'
}; };
// Add this after the STATUS_LABELS constant
const STATUS_ORDER = ['submitted', 'under_review', 'approved', 'rejected', 'in_progress', 'paid'] as const;
const STATUS_ICONS = {
submitted: 'heroicons:paper-airplane',
under_review: 'heroicons:eye',
approved: 'heroicons:check-circle',
rejected: 'heroicons:x-circle',
in_progress: 'heroicons:clock',
paid: 'heroicons:banknotes'
} as const;
// Add these animation variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
duration: 0.3,
when: "beforeChildren",
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.3,
type: "spring",
stiffness: 100,
damping: 15
}
}
};
export default function ReimbursementList() { export default function ReimbursementList() {
const [requests, setRequests] = useState<ReimbursementRequest[]>([]); const [requests, setRequests] = useState<ReimbursementRequest[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -82,45 +132,110 @@ export default function ReimbursementList() {
const fileManager = FileManager.getInstance(); const fileManager = FileManager.getInstance();
useEffect(() => { useEffect(() => {
console.log('Component mounted');
fetchReimbursements(); fetchReimbursements();
}, []); }, []);
// Add effect to monitor requests state
useEffect(() => {
console.log('Requests state updated:', requests);
console.log('Number of requests:', requests.length);
}, [requests]);
const fetchReimbursements = async () => { const fetchReimbursements = async () => {
try { try {
setLoading(true); setLoading(true);
const pb = auth.getPocketBase(); const pb = auth.getPocketBase();
const userId = pb.authStore.model?.id; const userId = pb.authStore.model?.id;
console.log('Current user ID:', userId);
console.log('Auth store state:', {
isValid: pb.authStore.isValid,
token: pb.authStore.token,
model: pb.authStore.model
});
if (!userId) { if (!userId) {
toast.error('User not authenticated');
throw new Error('User not authenticated'); throw new Error('User not authenticated');
} }
const records = await pb.collection('reimbursement').getList(1, 50, { const loadingToast = toast.loading('Loading reimbursements...');
filter: `submitted_by = "${userId}"`,
sort: '-created',
expand: 'submitted_by'
});
// Convert PocketBase records to ReimbursementRequest type // First try to get all reimbursements to see if the collection is accessible
const reimbursements = records.items.map(record => ({ try {
id: record.id, const allRecords = await pb.collection('reimbursement').getList(1, 50);
title: record.title, console.log('All reimbursements (no filter):', allRecords);
total_amount: record.total_amount, } catch (e) {
date_of_purchase: record.date_of_purchase, console.error('Error getting all reimbursements:', e);
payment_method: record.payment_method, }
status: record.status,
submitted_by: record.submitted_by,
additional_info: record.additional_info || '',
receipts: record.receipts || [],
department: record.department,
created: record.created,
updated: record.updated
})) as ReimbursementRequest[];
setRequests(reimbursements); // Now try with the filter
} catch (error) { console.log('Attempting to fetch with filter:', `submitted_by = "${userId}"`);
try {
const records = await pb.collection('reimbursement').getList(1, 50, {
filter: `submitted_by = "${userId}"`,
sort: '-created'
});
console.log('Filtered records response:', records);
console.log('Total items:', records.totalItems);
console.log('Items:', records.items);
if (records.items.length === 0) {
console.log('No records found for user');
setRequests([]);
toast.dismiss(loadingToast);
setLoading(false);
return;
}
// Convert PocketBase records to ReimbursementRequest type
const reimbursements: ReimbursementRequest[] = records.items.map(record => {
console.log('Processing record:', record);
const processedRequest = {
id: record.id,
title: record.title,
total_amount: record.total_amount,
date_of_purchase: record.date_of_purchase,
payment_method: record.payment_method,
status: record.status,
submitted_by: record.submitted_by,
additional_info: record.additional_info || '',
receipts: record.receipts || [],
department: record.department,
created: record.created,
updated: record.updated,
audit_notes: record.audit_notes || null
};
console.log('Processed request:', processedRequest);
return processedRequest;
});
console.log('All processed reimbursements:', reimbursements);
console.log('Setting requests state with:', reimbursements.length, 'items');
// Update state with the new reimbursements
setRequests(reimbursements);
console.log('State updated with reimbursements');
toast.dismiss(loadingToast);
toast.success(`Loaded ${reimbursements.length} reimbursement${reimbursements.length === 1 ? '' : 's'}`);
} catch (e) {
console.error('Error with filtered query:', e);
throw e;
}
} catch (error: any) {
console.error('Error fetching reimbursements:', error); console.error('Error fetching reimbursements:', error);
setError('Failed to load reimbursement requests'); console.error('Error details:', {
message: error?.message,
data: error?.data,
url: error?.url,
status: error?.status
});
toast.error('Failed to load reimbursement requests');
setError('Failed to load reimbursement requests. ' + (error?.message || ''));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -128,6 +243,7 @@ export default function ReimbursementList() {
const handlePreviewFile = async (request: ReimbursementRequest, receiptId: string) => { const handlePreviewFile = async (request: ReimbursementRequest, receiptId: string) => {
try { try {
const loadingToast = toast.loading('Loading receipt...');
const pb = auth.getPocketBase(); const pb = auth.getPocketBase();
// Get the receipt record using its ID // Get the receipt record using its ID
@ -161,12 +277,14 @@ export default function ReimbursementList() {
setPreviewUrl(url); setPreviewUrl(url);
setPreviewFilename(receiptRecord.field); setPreviewFilename(receiptRecord.field);
setShowPreview(true); setShowPreview(true);
toast.dismiss(loadingToast);
toast.success('Receipt loaded successfully');
} else { } else {
throw new Error('Receipt not found'); throw new Error('Receipt not found');
} }
} catch (error) { } catch (error) {
console.error('Error loading receipt:', error); console.error('Error loading receipt:', error);
alert('Failed to load receipt. Please try again.'); toast.error('Failed to load receipt');
} }
}; };
@ -179,250 +297,398 @@ export default function ReimbursementList() {
}; };
if (loading) { if (loading) {
console.log('Rendering loading state');
return ( return (
<div className="flex justify-center items-center p-8"> <motion.div
<div className="loading loading-spinner loading-lg text-primary"></div> initial={{ opacity: 0 }}
</div> animate={{ opacity: 1 }}
className="flex flex-col items-center justify-center min-h-[400px] p-8"
>
<div className="loading loading-spinner loading-lg text-primary mb-4"></div>
<p className="text-base-content/70 animate-pulse">Loading your reimbursements...</p>
</motion.div>
); );
} }
if (error) { if (error) {
console.log('Rendering error state:', error);
return ( return (
<div className="alert alert-error"> <motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="alert alert-error shadow-lg max-w-2xl mx-auto"
>
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" /> <Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
<span>{error}</span> <span>{error}</span>
</div> </motion.div>
); );
} }
console.log('Rendering main component. Requests:', requests);
console.log('Requests length:', requests.length);
return ( return (
<div className="space-y-6"> <>
{requests.length === 0 ? ( <ToastProvider />
<div className="text-center py-8"> <motion.div
<Icon icon="heroicons:document" className="h-12 w-12 mx-auto text-base-content/50" /> variants={containerVariants}
<h3 className="mt-4 text-lg font-medium">No reimbursement requests</h3> initial="hidden"
<p className="text-base-content/70">Create a new request to get started</p> animate="visible"
</div> className="space-y-6"
) : ( >
<div className="grid gap-4"> {requests.length === 0 ? (
{requests.map((request) => ( <motion.div
<div variants={itemVariants}
key={request.id} initial="hidden"
className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300" animate="visible"
className="text-center py-16 bg-base-200/50 backdrop-blur-sm rounded-2xl border-2 border-dashed border-base-300"
>
<Icon icon="heroicons:document" className="h-16 w-16 mx-auto text-base-content/30" />
<h3 className="mt-6 text-xl font-medium">No reimbursement requests</h3>
<p className="text-base-content/70 mt-2">Create a new request to get started</p>
</motion.div>
) : (
<motion.div
layout
initial="hidden"
animate="visible"
variants={containerVariants}
className="grid gap-4"
>
<AnimatePresence mode="popLayout">
{requests.map((request, index) => {
console.log('Rendering request:', request);
return (
<motion.div
key={request.id}
variants={itemVariants}
initial="hidden"
animate="visible"
layout
className="card bg-base-100 hover:bg-base-200 transition-all duration-300 border border-base-200 hover:border-primary shadow-sm hover:shadow-md"
>
<div className="card-body p-5">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div className="flex-1 min-w-0">
<h3 className="card-title text-lg font-bold truncate">{request.title}</h3>
<div className="flex flex-wrap gap-2 mt-2">
<div className="badge badge-outline badge-lg font-mono">
${request.total_amount.toFixed(2)}
</div>
<div className="badge badge-ghost badge-lg gap-1">
<Icon icon="heroicons:calendar" className="h-4 w-4" />
{formatDate(request.date_of_purchase)}
</div>
{request.audit_notes && request.audit_notes.filter(note => !note.is_private).length > 0 && (
<div className="badge badge-ghost badge-lg gap-1">
<Icon icon="heroicons:chat-bubble-left-right" className="h-4 w-4" />
{request.audit_notes.filter(note => !note.is_private).length} Notes
</div>
)}
</div>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="btn btn-primary btn-sm gap-2 shadow-sm hover:shadow-md transition-all duration-300"
onClick={() => setSelectedRequest(request)}
>
<Icon icon="heroicons:eye" className="h-4 w-4" />
View Details
</motion.button>
</div>
<div className="mt-4 card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<div className="flex items-center justify-between w-full relative py-2">
<div className="absolute left-0 right-0 top-1/2 h-0.5 bg-base-300 -translate-y-[1.0rem]" />
{STATUS_ORDER.map((status, index) => {
if (status === 'rejected' && request.status !== 'rejected') return null;
if (status === 'approved' && request.status === 'rejected') return null;
const isActive = STATUS_ORDER.indexOf(request.status) >= STATUS_ORDER.indexOf(status);
const isCurrent = request.status === status;
return (
<div key={status} className="relative flex flex-col items-center gap-2 z-10">
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${isCurrent
? 'bg-primary text-primary-content ring-2 ring-primary/20'
: isActive
? 'bg-primary/20 text-primary'
: 'bg-base-300 text-base-content/40'
}`}>
<Icon icon={STATUS_ICONS[status]} className="h-3.5 w-3.5" />
</div>
<span className={`text-[10px] font-medium whitespace-nowrap mt-1 ${isCurrent
? 'text-primary'
: isActive
? 'text-base-content'
: 'text-base-content/40'
}`}>
{STATUS_LABELS[status]}
</span>
</div>
);
})}
</div>
</div>
</div>
</motion.div>
);
})}
</AnimatePresence>
</motion.div>
)}
{/* Details Modal */}
<AnimatePresence>
{selectedRequest && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="modal modal-open"
> >
<div className="card-body"> <motion.div
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4"> initial={{ scale: 0.9, opacity: 0 }}
<div> animate={{ scale: 1, opacity: 1 }}
<h3 className="card-title">{request.title}</h3> exit={{ scale: 0.9, opacity: 0 }}
<div className="flex flex-wrap gap-2 mt-2"> transition={{ type: "spring", stiffness: 300, damping: 25 }}
<div className={`badge ${STATUS_COLORS[request.status]}`}> className="modal-box max-w-3xl bg-base-100/95 backdrop-blur-md"
{STATUS_LABELS[request.status]} >
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
{selectedRequest.title}
</h3>
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
whileTap={{ scale: 0.9 }}
className="btn btn-ghost btn-sm btn-circle"
onClick={() => setSelectedRequest(null)}
>
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
</motion.button>
</div>
<div className="grid gap-6">
<div className="grid grid-cols-2 gap-4">
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Status</label>
<div className={`badge ${STATUS_COLORS[selectedRequest.status]} badge-lg gap-1 mt-1`}>
<Icon icon={STATUS_ICONS[selectedRequest.status]} className="h-4 w-4" />
{STATUS_LABELS[selectedRequest.status]}
</div> </div>
<div className="badge badge-outline"> </div>
${request.total_amount.toFixed(2)} <div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Department</label>
<div className="badge badge-outline badge-lg mt-1">
{DEPARTMENT_LABELS[selectedRequest.department]}
</div> </div>
<div className="badge badge-outline"> </div>
{formatDate(request.date_of_purchase)} <div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Total Amount</label>
<p className="mt-1 text-xl font-mono font-bold text-primary">
${selectedRequest.total_amount.toFixed(2)}
</p>
</div>
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Date of Purchase</label>
<p className="mt-1 font-medium">{formatDate(selectedRequest.date_of_purchase)}</p>
</div>
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm col-span-2">
<label className="text-sm font-medium text-base-content/70">Payment Method</label>
<p className="mt-1 font-medium">{selectedRequest.payment_method}</p>
</div>
</div>
{selectedRequest.additional_info && (
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Additional Information</label>
<p className="mt-2 whitespace-pre-wrap">{selectedRequest.additional_info}</p>
</div>
)}
{selectedRequest.audit_notes && selectedRequest.audit_notes.filter(note => !note.is_private).length > 0 && (
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm border-l-4 border-primary">
<div className="flex items-center gap-2 mb-3">
<Icon icon="heroicons:chat-bubble-left-right" className="h-5 w-5 text-primary" />
<label className="text-base font-medium">Public Notes</label>
</div>
<div className="space-y-3">
{selectedRequest.audit_notes
.filter(note => !note.is_private)
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.map((note, index) => (
<div key={index} className="card bg-base-100 p-4 hover:bg-base-200 transition-colors duration-200">
<p className="whitespace-pre-wrap text-base">{note.note}</p>
<div className="flex justify-between items-center mt-3 text-sm text-base-content/70">
<span className="flex items-center gap-1">
<Icon icon="heroicons:clock" className="h-4 w-4" />
{formatDate(note.timestamp)}
</span>
</div>
</div>
))
}
</div>
</motion.div>
)}
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70 mb-2">Receipts</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{(selectedRequest.receipts || []).map((receiptId, index) => (
<motion.button
key={receiptId || index}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="btn btn-outline btn-sm normal-case gap-2 hover:shadow-md transition-all duration-300"
onClick={() => handlePreviewFile(selectedRequest, receiptId)}
>
<Icon icon="heroicons:document" className="h-4 w-4" />
Receipt #{index + 1}
</motion.button>
))}
</div>
</div>
<div className="divider before:bg-base-300 after:bg-base-300"></div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Submitted At</label>
<p className="mt-1">{formatDate(selectedRequest.created)}</p>
</div>
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Last Updated</label>
<p className="mt-1">{formatDate(selectedRequest.updated)}</p>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* File Preview Modal */}
<AnimatePresence>
{showPreview && selectedReceipt && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="modal modal-open"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="modal-box max-w-7xl bg-base-100/95 backdrop-blur-md"
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
Receipt Details
</h3>
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
whileTap={{ scale: 0.9 }}
className="btn btn-ghost btn-sm btn-circle"
onClick={() => {
setShowPreview(false);
setSelectedReceipt(null);
}}
>
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
</motion.button>
</div>
<div className="grid grid-cols-5 gap-6">
{/* Receipt Details */}
<div className="col-span-2 space-y-4">
<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> </div>
</div> </div>
<button
className="btn btn-sm btn-primary"
onClick={() => setSelectedRequest(request)}
>
View Details
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Details Modal */} {/* File Preview */}
{selectedRequest && ( <div className="col-span-3 border-l border-base-300 pl-6">
<div className="modal modal-open"> <div className="flex justify-between items-center mb-4">
<div className="modal-box max-w-3xl"> <h3 className="text-lg font-medium">Receipt Image</h3>
<h3 className="font-bold text-lg mb-4">{selectedRequest.title}</h3> <motion.a
whileHover={{ scale: 1.05 }}
<div className="grid gap-4"> whileTap={{ scale: 0.95 }}
<div className="grid grid-cols-2 gap-4"> href={previewUrl}
<div> target="_blank"
<label className="text-sm font-medium">Status</label> rel="noopener noreferrer"
<div className={`badge ${STATUS_COLORS[selectedRequest.status]} mt-1 block`}> className="btn btn-sm btn-outline gap-2 hover:shadow-md transition-all duration-300"
{STATUS_LABELS[selectedRequest.status]} >
<Icon icon="heroicons:arrow-top-right-on-square" className="h-4 w-4" />
View Full Size
</motion.a>
</div>
<div className="bg-base-200/50 backdrop-blur-sm rounded-lg p-4 shadow-sm">
<FilePreview
url={previewUrl}
filename={previewFilename}
isModal={false}
/>
</div>
</div> </div>
</div> </div>
<div> </motion.div>
<label className="text-sm font-medium">Department</label> </motion.div>
<div className="badge badge-outline mt-1 block"> )}
{DEPARTMENT_LABELS[selectedRequest.department]} </AnimatePresence>
</div> </motion.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>
{selectedRequest.additional_info && (
<div>
<label className="text-sm font-medium">Additional Information</label>
<p className="mt-1">{selectedRequest.additional_info}</p>
</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((receiptId, index) => (
<button
key={receiptId || index}
className="btn btn-outline btn-sm normal-case"
onClick={() => handlePreviewFile(selectedRequest, receiptId)}
>
<Icon icon="heroicons:document" className="h-4 w-4 mr-2" />
Receipt #{index + 1}
</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.created)}</p>
</div>
<div>
<label className="font-medium">Last Updated</label>
<p className="mt-1">{formatDate(selectedRequest.updated)}</p>
</div>
</div>
</div>
<div className="modal-action">
<button
className="btn"
onClick={() => setSelectedRequest(null)}
>
Close
</button>
</div>
</div>
</div>
)}
{/* File Preview Modal */}
{showPreview && selectedReceipt && (
<div className="modal modal-open">
<div className="modal-box max-w-7xl">
<div className="grid grid-cols-5 gap-6">
{/* Receipt Details */}
<div className="col-span-2 space-y-4">
<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">
<button
className="btn"
onClick={() => {
setShowPreview(false);
setSelectedReceipt(null);
}}
>
Close
</button>
</div>
</div>
</div>
)}
</div>
); );
} }

View file

@ -28,6 +28,7 @@ interface User {
name: string; name: string;
email: string; email: string;
avatar: string; avatar: string;
zelle_information: string;
created: string; created: string;
updated: string; updated: string;
} }
@ -68,6 +69,8 @@ export default function ReimbursementManagementPortal() {
const [reimbursements, setReimbursements] = useState<Reimbursement[]>([]); const [reimbursements, setReimbursements] = useState<Reimbursement[]>([]);
const [receipts, setReceipts] = useState<Record<string, Receipt>>({}); const [receipts, setReceipts] = useState<Record<string, Receipt>>({});
const [selectedReimbursement, setSelectedReimbursement] = useState<Reimbursement | null>(null); const [selectedReimbursement, setSelectedReimbursement] = useState<Reimbursement | null>(null);
const [selectedReceipt, setSelectedReceipt] = useState<Receipt | null>(null);
const [showReceiptModal, setShowReceiptModal] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [filters, setFilters] = useState<FilterOptions>({ const [filters, setFilters] = useState<FilterOptions>({
@ -933,6 +936,38 @@ export default function ReimbursementManagementPortal() {
<span className="font-medium text-base-content truncate">{selectedReimbursement.submitter?.name || 'Unknown User'}</span> <span className="font-medium text-base-content truncate">{selectedReimbursement.submitter?.name || 'Unknown User'}</span>
<Icon icon="heroicons:chevron-down" className="h-4 w-4 flex-shrink-0" /> <Icon icon="heroicons:chevron-down" className="h-4 w-4 flex-shrink-0" />
</button> </button>
{showUserProfile === selectedReimbursement.submitted_by && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="absolute top-full left-0 mt-2 w-72 bg-base-100 rounded-lg shadow-xl border border-base-300 z-50"
>
<div className="p-4 space-y-3">
<div className="flex items-center gap-3">
{selectedReimbursement.submitter?.avatar && (
<img
src={getUserAvatarUrl(selectedReimbursement.submitter)}
alt=""
className="w-12 h-12 rounded-full"
/>
)}
<div>
<h3 className="font-semibold">{selectedReimbursement.submitter?.name}</h3>
<p className="text-sm text-base-content/70">{selectedReimbursement.submitter?.email}</p>
</div>
</div>
{selectedReimbursement.submitter?.zelle_information && (
<div className="pt-2 border-t border-base-300">
<h4 className="text-sm font-medium text-base-content/70 mb-1">Zelle Information</h4>
<p className="text-sm flex items-center gap-2">
<Icon icon="heroicons:banknotes" className="h-4 w-4 text-primary flex-shrink-0" />
{selectedReimbursement.submitter.zelle_information}
</p>
</div>
)}
</div>
</motion.div>
)}
</div> </div>
</div> </div>
</div> </div>
@ -1054,6 +1089,17 @@ export default function ReimbursementManagementPortal() {
</p> </p>
</div> </div>
</div> </div>
{selectedReimbursement.submitter?.zelle_information && (
<div className="card bg-base-200 hover:bg-base-300 transition-colors xs:col-span-2">
<div className="card-body !p-3">
<h3 className="text-sm font-medium text-base-content/70">Zelle Information</h3>
<p className="flex items-center gap-2 font-medium mt-1">
<Icon icon="heroicons:banknotes" className="h-4 w-4 text-primary flex-shrink-0" />
{selectedReimbursement.submitter.zelle_information}
</p>
</div>
</div>
)}
</div> </div>
<div className="divider before:bg-base-300 after:bg-base-300"> <div className="divider before:bg-base-300 after:bg-base-300">
@ -1112,6 +1158,118 @@ export default function ReimbursementManagementPortal() {
</motion.div> </motion.div>
</button> </button>
</div> </div>
{isExpanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="border-t border-base-300"
>
<div className="p-3 sm:p-4 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{receipt.itemized_expenses && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-base-content/70">Itemized Expenses</h4>
<div className="space-y-2">
{(() => {
try {
const expenses: ItemizedExpense[] = typeof receipt.itemized_expenses === 'string'
? JSON.parse(receipt.itemized_expenses)
: receipt.itemized_expenses;
return expenses.map((expense, index) => (
<div key={index} className="flex justify-between items-start gap-2 text-sm">
<div>
<p className="font-medium">{expense.description}</p>
<p className="text-base-content/70">{expense.category}</p>
</div>
<span className="font-mono font-medium whitespace-nowrap">
${expense.amount.toFixed(2)}
</span>
</div>
));
} catch (error) {
console.error('Error parsing itemized expenses:', error);
return <p className="text-error text-sm">Error loading expenses</p>;
}
})()}
</div>
</div>
)}
<div className="space-y-2">
<h4 className="text-sm font-medium text-base-content/70">Receipt Details</h4>
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-base-content/70">Tax</span>
<span className="font-mono font-medium">${receipt.tax.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-base-content/70">Total</span>
<span className="font-mono font-medium">
${(() => {
try {
const expenses: ItemizedExpense[] = typeof receipt.itemized_expenses === 'string'
? JSON.parse(receipt.itemized_expenses)
: receipt.itemized_expenses;
const subtotal = expenses.reduce((sum, item) => sum + item.amount, 0);
return (subtotal + receipt.tax).toFixed(2);
} catch (error) {
return '0.00';
}
})()}
</span>
</div>
</div>
</div>
</div>
{receipt.notes && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-base-content/70">Notes</h4>
<p className="text-sm whitespace-pre-wrap">{receipt.notes}</p>
</div>
)}
<div className="flex justify-center flex-col items-center gap-2">
<button
className="btn btn-primary btn-sm gap-2 w-full"
onClick={() => {
setSelectedReceipt(receipt);
setShowReceiptModal(true);
}}
>
<Icon icon="heroicons:photo" className="h-4 w-4" />
View Receipt
</button>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-medium text-base-content/70">Audited by:</h4>
{receipt.auditor_names?.length ? (
<div className="flex flex-wrap gap-1">
{receipt.auditor_names.map((name, index) => (
<span key={index} className="badge badge-ghost">{name}</span>
))}
</div>
) : (
<span className="text-sm text-base-content/70">Not audited yet</span>
)}
</div>
<button
className="btn btn-primary btn-sm gap-2"
onClick={() => auditReceipt(receipt.id)}
disabled={auditingReceipt === receipt.id || receipt.audited_by.includes(Authentication.getInstance().getUserId() || '')}
>
{auditingReceipt === receipt.id ? (
<span className="loading loading-spinner loading-sm" />
) : (
<Icon icon="heroicons:check-circle" className="h-4 w-4" />
)}
{receipt.audited_by.includes(Authentication.getInstance().getUserId() || '') ? 'Audited' : 'Mark as Audited'}
</button>
</div>
</div>
</motion.div>
)}
</div> </div>
</motion.div> </motion.div>
); );
@ -1161,22 +1319,24 @@ export default function ReimbursementManagementPortal() {
{(() => { {(() => {
if (!selectedReimbursement.audit_logs) return null; if (!selectedReimbursement.audit_logs) return null;
let logs = []; let currentLogs = [];
try { try {
if (typeof selectedReimbursement.audit_logs === 'string') { if (selectedReimbursement.audit_logs) {
logs = JSON.parse(selectedReimbursement.audit_logs); if (typeof selectedReimbursement.audit_logs === 'string') {
} else { currentLogs = JSON.parse(selectedReimbursement.audit_logs);
logs = selectedReimbursement.audit_logs; } else {
} currentLogs = selectedReimbursement.audit_logs;
if (!Array.isArray(logs)) { }
logs = []; if (!Array.isArray(currentLogs)) {
currentLogs = [];
}
} }
} catch (error) { } catch (error) {
console.error('Error parsing audit logs:', error); console.error('Error parsing existing audit logs:', error);
logs = []; currentLogs = [];
} }
if (logs.length === 0) { if (currentLogs.length === 0) {
return ( return (
<div className="bg-base-200 p-4 rounded-lg text-base-content/70 text-center"> <div className="bg-base-200 p-4 rounded-lg text-base-content/70 text-center">
No audit logs yet No audit logs yet
@ -1184,14 +1344,17 @@ export default function ReimbursementManagementPortal() {
); );
} }
const totalPages = Math.ceil(logs.length / logsPerPage); // Sort logs by timestamp in descending order (most recent first)
currentLogs.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
const totalPages = Math.ceil(currentLogs.length / logsPerPage);
const startIndex = (currentLogPage - 1) * logsPerPage; const startIndex = (currentLogPage - 1) * logsPerPage;
const endIndex = startIndex + logsPerPage; const endIndex = startIndex + logsPerPage;
const currentLogs = logs.slice(startIndex, endIndex); const currentPageLogs = currentLogs.slice(startIndex, endIndex);
return ( 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) => ( {currentPageLogs.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) => (
<div key={index} className="bg-base-200 p-4 rounded-lg space-y-2"> <div key={index} className="bg-base-200 p-4 rounded-lg space-y-2">
<div className="flex items-center justify-between flex-wrap gap-2"> <div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -1312,14 +1475,17 @@ export default function ReimbursementManagementPortal() {
); );
} }
// Sort notes by timestamp in descending order (most recent first)
notes.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
const totalPages = Math.ceil(notes.length / notesPerPage); const totalPages = Math.ceil(notes.length / notesPerPage);
const startIndex = (currentNotePage - 1) * notesPerPage; const startIndex = (currentNotePage - 1) * notesPerPage;
const endIndex = startIndex + notesPerPage; const endIndex = startIndex + notesPerPage;
const currentNotes = notes.slice(startIndex, endIndex); const currentPageNotes = notes.slice(startIndex, endIndex);
return ( return (
<> <>
{currentNotes.map((note: { note: string; auditor_id: string; timestamp: string; is_private: boolean }, index: number) => ( {currentPageNotes.map((note, index) => (
<div key={index} className="bg-base-200 p-4 rounded-lg space-y-2"> <div key={index} className="bg-base-200 p-4 rounded-lg space-y-2">
<div className="flex items-center justify-between flex-wrap gap-2"> <div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -1477,6 +1643,58 @@ export default function ReimbursementManagementPortal() {
</motion.div> </motion.div>
</div> </div>
)} )}
{/* Receipt Preview Modal */}
{showReceiptModal && selectedReceipt && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="modal-box relative max-w-4xl w-full bg-base-100 p-0 overflow-hidden"
>
<div className="sticky top-0 flex items-center justify-between p-4 bg-base-100 border-b border-base-300 z-10">
<h3 className="font-bold text-lg">
Receipt from {selectedReceipt.location_name}
</h3>
<button
className="btn btn-sm btn-ghost"
onClick={() => {
setShowReceiptModal(false);
setSelectedReceipt(null);
}}
>
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
</button>
</div>
<div className="p-4">
<FilePreview
url={getReceiptUrl(selectedReceipt)}
filename={`Receipt from ${selectedReceipt.location_name}`}
/>
</div>
<div className="sticky bottom-0 flex justify-end gap-2 p-4 bg-base-100 border-t border-base-300">
<a
href={getReceiptUrl(selectedReceipt)}
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost gap-2"
>
<Icon icon="heroicons:arrow-top-right-on-square" className="h-4 w-4" />
Open in new tab
</a>
<button
className="btn btn-primary gap-2"
onClick={() => {
setShowReceiptModal(false);
setSelectedReceipt(null);
}}
>
Close
</button>
</div>
</motion.div>
</div>
)}
</div> </div>
); );
} }

View file

@ -0,0 +1,19 @@
import { Toaster } from 'react-hot-toast';
export default function ToastProvider() {
return (
<Toaster
position="top-center"
reverseOrder={false}
toastOptions={{
duration: 5000,
style: {
background: '#333',
color: '#fff',
padding: '16px',
borderRadius: '8px',
},
}}
/>
);
}