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 { Icon } from '@iconify/react';
import FilePreview from '../universal/FilePreview';
import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion';
interface ExpenseItem {
description: string;
@ -33,6 +35,30 @@ const EXPENSE_CATEGORIES = [
'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) {
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string>('');
@ -52,12 +78,14 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
// Validate file type
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');
return;
}
// Validate file size (5MB limit)
if (selectedFile.size > 5 * 1024 * 1024) {
toast.error('File size must be less than 5MB');
setError('File size must be less than 5MB');
return;
}
@ -65,6 +93,7 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
setFile(selectedFile);
setPreviewUrl(URL.createObjectURL(selectedFile));
setError('');
toast.success('File uploaded successfully');
}
};
@ -121,105 +150,136 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
};
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 */}
<div className="space-y-4 overflow-y-auto max-h-[70vh]">
<form onSubmit={handleSubmit} className="space-y-4">
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-4 overflow-y-auto max-h-[70vh] pr-4 custom-scrollbar"
>
<form onSubmit={handleSubmit} className="space-y-6">
<AnimatePresence mode="wait">
{error && (
<div className="alert alert-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>
</div>
</motion.div>
)}
</AnimatePresence>
{/* File Upload */}
<div className="form-control">
<motion.div variants={itemVariants} className="form-control">
<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>
</label>
<div className="relative">
<input
type="file"
className="file-input file-input-bordered w-full"
className="file-input file-input-bordered w-full file-input-primary hover:file-input-ghost transition-all duration-300"
onChange={handleFileChange}
accept="image/*,.pdf"
/>
<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 */}
<div className="form-control">
<motion.div variants={itemVariants} className="form-control">
<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>
</label>
<input
type="date"
className="input input-bordered"
className="input input-bordered focus:input-primary transition-all duration-300"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</div>
</motion.div>
{/* Location Name */}
<div className="form-control">
<motion.div variants={itemVariants} className="form-control">
<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>
</label>
<input
type="text"
className="input input-bordered"
className="input input-bordered focus:input-primary transition-all duration-300"
value={locationName}
onChange={(e) => setLocationName(e.target.value)}
required
/>
</div>
</motion.div>
{/* Location Address */}
<div className="form-control">
<motion.div variants={itemVariants} className="form-control">
<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>
</label>
<input
type="text"
className="input input-bordered"
className="input input-bordered focus:input-primary transition-all duration-300"
value={locationAddress}
onChange={(e) => setLocationAddress(e.target.value)}
required
/>
</div>
</motion.div>
{/* Notes */}
<div className="form-control">
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text">Notes</span>
<span className="label-text font-medium">Notes</span>
</label>
<textarea
className="textarea textarea-bordered"
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[100px]"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
/>
</div>
</motion.div>
{/* Itemized Expenses */}
<div className="space-y-4">
<motion.div variants={itemVariants} className="space-y-4">
<div className="flex justify-between items-center">
<label className="label-text font-medium">Itemized Expenses</label>
<button
<label className="text-lg font-medium">Itemized Expenses</label>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
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}
>
<Icon icon="heroicons:plus" className="h-4 w-4" />
Add Item
</button>
</motion.button>
</div>
<AnimatePresence>
{itemizedExpenses.map((item, index) => (
<div key={index} className="card bg-base-200 p-4">
<motion.div
key={index}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="card bg-base-200/50 hover:bg-base-200 transition-colors duration-300 backdrop-blur-sm shadow-sm"
>
<div className="card-body p-4">
<div className="grid gap-4">
<div className="form-control">
<label className="label">
@ -280,73 +340,108 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
</div>
</div>
</div>
</motion.div>
))}
</div>
</AnimatePresence>
</motion.div>
{/* Tax */}
<div className="form-control">
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text">Tax Amount ($)</span>
<span className="label-text font-medium">Tax Amount ($)</span>
</label>
<input
type="number"
className="input input-bordered"
className="input input-bordered focus:input-primary transition-all duration-300"
value={tax}
onChange={(e) => setTax(Number(e.target.value))}
min="0"
step="0.01"
/>
</div>
</motion.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>
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<div className="space-y-2">
<div className="flex justify-between items-center text-base-content/70">
<span>Subtotal:</span>
<span className="font-mono">${itemizedExpenses.reduce((sum, item) => sum + item.amount, 0).toFixed(2)}</span>
</div>
<div className="flex justify-between items-center text-base-content/70">
<span>Tax:</span>
<span className="font-mono">${tax.toFixed(2)}</span>
</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 */}
<div className="flex justify-end gap-2 mt-6">
<button
<motion.div variants={itemVariants} className="flex justify-end gap-3 mt-8">
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
type="button"
className="btn"
className="btn btn-ghost hover:btn-error transition-all duration-300"
onClick={onCancel}
>
Cancel
</button>
<button
</motion.button>
<motion.button
whileHover={{ scale: 1.02, boxShadow: "0 4px 20px rgba(0, 0, 0, 0.1)" }}
whileTap={{ scale: 0.98 }}
type="submit"
className="btn btn-primary"
className="btn btn-primary shadow-md hover:shadow-lg transition-all duration-300"
>
Add Receipt
</button>
</div>
</motion.button>
</motion.div>
</form>
</div>
</motion.div>
{/* Right side - Preview */}
<div className="border-l border-base-300 pl-6">
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="border-l border-base-300 pl-6"
>
<AnimatePresence mode="wait">
{previewUrl ? (
<motion.div
key="preview"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="bg-base-200/50 backdrop-blur-sm rounded-xl p-4 shadow-sm"
>
<FilePreview
url={previewUrl}
filename={file?.name || ''}
isModal={false}
/>
</motion.div>
) : (
<div className="flex items-center justify-center h-full text-base-content/70">
<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-12 w-12 mx-auto mb-2" />
<p>Upload a receipt to preview</p>
</div>
<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>
)}
</div>
</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 { Authentication } from '../../../scripts/pocketbase/Authentication';
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 {
description: string;
@ -56,6 +60,34 @@ const DEPARTMENT_LABELS = {
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() {
const [request, setRequest] = useState<ReimbursementRequest>({
title: '',
@ -72,18 +104,89 @@ export default function ReimbursementForm() {
const [showReceiptForm, setShowReceiptForm] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
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();
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) => {
try {
const pb = auth.getPocketBase();
const userId = pb.authStore.model?.id;
if (!userId) {
toast.error('User not authenticated');
throw new Error('User not authenticated');
}
toast.loading('Adding receipt...');
// Create receipt record
const formData = new FormData();
formData.append('field', receiptData.field);
@ -109,25 +212,32 @@ export default function ReimbursementForm() {
}));
setShowReceiptForm(false);
toast.dismiss();
toast.success('Receipt added successfully');
} catch (error) {
console.error('Error creating receipt:', error);
toast.dismiss();
toast.error('Failed to add receipt');
setError('Failed to add receipt. Please try again.');
}
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting) return;
if (!request.title.trim()) {
toast.error('Title is required');
setError('Title is required');
return;
}
if (!request.payment_method) {
toast.error('Payment method is required');
setError('Payment method is required');
return;
}
if (receipts.length === 0) {
toast.error('At least one receipt is required');
setError('At least one receipt is required');
return;
}
@ -143,6 +253,8 @@ export default function ReimbursementForm() {
throw new Error('User not authenticated');
}
const loadingToast = toast.loading('Submitting reimbursement request...');
// Create reimbursement record
const formData = new FormData();
formData.append('title', request.title);
@ -171,35 +283,61 @@ export default function ReimbursementForm() {
setReceipts([]);
setError('');
// Show success message
alert('Reimbursement request submitted successfully!');
// Dismiss loading toast and show success
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) {
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 {
setIsSubmitting(false);
}
};
return (
<div>
<form onSubmit={handleSubmit} className="space-y-6">
<>
<ToastProvider />
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="max-w-4xl mx-auto"
>
<form onSubmit={handleSubmit} className="space-y-8">
<AnimatePresence mode="wait">
{error && (
<div className="alert alert-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>
</div>
</motion.div>
)}
</AnimatePresence>
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Title */}
<div className="form-control">
<div className="form-control md:col-span-2">
<label className="label">
<span className="label-text">Title</span>
<span className="label-text font-medium">Title</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
className="input input-bordered"
className="input input-bordered focus:input-primary transition-all duration-300"
value={request.title}
onChange={(e) => setRequest(prev => ({ ...prev, title: e.target.value }))}
required
@ -209,12 +347,12 @@ export default function ReimbursementForm() {
{/* Date of Purchase */}
<div className="form-control">
<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>
</label>
<input
type="date"
className="input input-bordered"
className="input input-bordered focus:input-primary transition-all duration-300"
value={request.date_of_purchase}
onChange={(e) => setRequest(prev => ({ ...prev, date_of_purchase: e.target.value }))}
required
@ -224,11 +362,11 @@ export default function ReimbursementForm() {
{/* Payment Method */}
<div className="form-control">
<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>
</label>
<select
className="select select-bordered"
className="select select-bordered focus:select-primary transition-all duration-300"
value={request.payment_method}
onChange={(e) => setRequest(prev => ({ ...prev, payment_method: e.target.value }))}
required
@ -243,11 +381,11 @@ export default function ReimbursementForm() {
{/* Department */}
<div className="form-control">
<label className="label">
<span className="label-text">Department</span>
<span className="label-text font-medium">Department</span>
<span className="label-text-alt text-error">*</span>
</label>
<select
className="select select-bordered"
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
@ -259,103 +397,263 @@ export default function ReimbursementForm() {
</div>
{/* Additional Info */}
<div className="form-control">
<div className="form-control md:col-span-2">
<label className="label">
<span className="label-text">Additional Information</span>
<span className="label-text font-medium">Additional Information</span>
</label>
<textarea
className="textarea textarea-bordered"
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 */}
<div className="space-y-4">
<div className="flex justify-between items-center">
<label className="label-text font-medium">Receipts</label>
<button
<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-sm btn-primary"
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
</button>
</motion.button>
</div>
{receipts.length > 0 ? (
<div className="grid gap-4">
<motion.div layout className="grid gap-4">
<AnimatePresence>
{receipts.map((receipt, index) => (
<div
<motion.div
key={receipt.id}
className="card bg-base-200 p-4"
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">{receipt.location_name}</h3>
<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">
Total: ${(receipt.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + receipt.tax).toFixed(2)}
<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>
<div className="flex gap-2">
<button
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
className="btn btn-sm"
className="btn btn-ghost btn-sm gap-2"
onClick={() => {
// Show receipt details in modal
// TODO: Implement receipt details view
setSelectedReceiptDetails(receipt);
setShowReceiptDetails(true);
}}
>
<Icon icon="heroicons:eye" className="h-4 w-4" />
View Details
</button>
</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"
>
<motion.button
whileHover={{ scale: 1.01, boxShadow: "0 4px 20px rgba(0, 0, 0, 0.1)" }}
whileTap={{ scale: 0.99 }}
type="submit"
className="btn btn-primary w-full h-12 shadow-md hover:shadow-lg transition-all duration-300 text-lg"
disabled={isSubmitting || receipts.length === 0}
>
{isSubmitting ? (
<span className="loading loading-spinner loading-md"></span>
) : (
<>
<Icon icon="heroicons:paper-airplane" className="h-5 w-5" />
Submit Reimbursement Request
</>
)}
</motion.button>
</motion.div>
</form>
{/* Receipt Form Modal */}
<AnimatePresence>
{showReceiptForm && (
<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-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>
<label className="text-sm font-medium">Location</label>
<p className="mt-1">{selectedReceiptDetails.location_name}</p>
<p className="text-sm text-base-content/70">{selectedReceiptDetails.location_address}</p>
</div>
<div>
<label className="text-sm font-medium">Date</label>
<p className="mt-1">{new Date(selectedReceiptDetails.date).toLocaleDateString()}</p>
</div>
{selectedReceiptDetails.notes && (
<div>
<label className="text-sm font-medium">Notes</label>
<p className="mt-1">{selectedReceiptDetails.notes}</p>
</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 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">
<p className="text-lg font-medium">
Total Amount: ${request.total_amount.toFixed(2)}
</p>
<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>
{/* 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 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>
</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 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>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</>
);
}

View file

@ -4,6 +4,9 @@ import { Get } from '../../../scripts/pocketbase/Get';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { FileManager } from '../../../scripts/pocketbase/FileManager';
import FilePreview from '../universal/FilePreview';
import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion';
import ToastProvider from './ToastProvider';
interface ExpenseItem {
description: string;
@ -11,6 +14,13 @@ interface ExpenseItem {
category: string;
}
interface AuditNote {
note: string;
auditor_id: string;
timestamp: string;
is_private: boolean;
}
interface ReimbursementRequest {
id: string;
title: string;
@ -24,6 +34,7 @@ interface ReimbursementRequest {
department: 'internal' | 'external' | 'projects' | 'events' | 'other';
created: string;
updated: string;
audit_notes: AuditNote[] | null;
}
interface ReceiptDetails {
@ -67,6 +78,45 @@ const DEPARTMENT_LABELS = {
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() {
const [requests, setRequests] = useState<ReimbursementRequest[]>([]);
const [loading, setLoading] = useState(true);
@ -82,27 +132,69 @@ export default function ReimbursementList() {
const fileManager = FileManager.getInstance();
useEffect(() => {
console.log('Component mounted');
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 () => {
try {
setLoading(true);
const pb = auth.getPocketBase();
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) {
toast.error('User not authenticated');
throw new Error('User not authenticated');
}
const loadingToast = toast.loading('Loading reimbursements...');
// First try to get all reimbursements to see if the collection is accessible
try {
const allRecords = await pb.collection('reimbursement').getList(1, 50);
console.log('All reimbursements (no filter):', allRecords);
} catch (e) {
console.error('Error getting all reimbursements:', e);
}
// Now try with the filter
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',
expand: 'submitted_by'
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 = records.items.map(record => ({
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,
@ -114,13 +206,36 @@ export default function ReimbursementList() {
receipts: record.receipts || [],
department: record.department,
created: record.created,
updated: record.updated
})) as ReimbursementRequest[];
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);
} catch (error) {
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);
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 {
setLoading(false);
}
@ -128,6 +243,7 @@ export default function ReimbursementList() {
const handlePreviewFile = async (request: ReimbursementRequest, receiptId: string) => {
try {
const loadingToast = toast.loading('Loading receipt...');
const pb = auth.getPocketBase();
// Get the receipt record using its ID
@ -161,12 +277,14 @@ export default function ReimbursementList() {
setPreviewUrl(url);
setPreviewFilename(receiptRecord.field);
setShowPreview(true);
toast.dismiss(loadingToast);
toast.success('Receipt loaded successfully');
} else {
throw new Error('Receipt not found');
}
} catch (error) {
console.error('Error loading receipt:', error);
alert('Failed to load receipt. Please try again.');
toast.error('Failed to load receipt');
}
};
@ -179,158 +297,314 @@ export default function ReimbursementList() {
};
if (loading) {
console.log('Rendering loading state');
return (
<div className="flex justify-center items-center p-8">
<div className="loading loading-spinner loading-lg text-primary"></div>
</div>
<motion.div
initial={{ opacity: 0 }}
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) {
console.log('Rendering error state:', error);
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" />
<span>{error}</span>
</div>
</motion.div>
);
}
console.log('Rendering main component. Requests:', requests);
console.log('Requests length:', requests.length);
return (
<div className="space-y-6">
{requests.length === 0 ? (
<div className="text-center py-8">
<Icon icon="heroicons:document" className="h-12 w-12 mx-auto text-base-content/50" />
<h3 className="mt-4 text-lg font-medium">No reimbursement requests</h3>
<p className="text-base-content/70">Create a new request to get started</p>
</div>
) : (
<div className="grid gap-4">
{requests.map((request) => (
<div
key={request.id}
className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300"
<>
<ToastProvider />
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-6"
>
<div className="card-body">
{requests.length === 0 ? (
<motion.div
variants={itemVariants}
initial="hidden"
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>
<h3 className="card-title">{request.title}</h3>
<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 ${STATUS_COLORS[request.status]}`}>
{STATUS_LABELS[request.status]}
</div>
<div className="badge badge-outline">
<div className="badge badge-outline badge-lg font-mono">
${request.total_amount.toFixed(2)}
</div>
<div className="badge badge-outline">
<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>
<button
className="btn btn-sm btn-primary"
<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
</button>
</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>
))}
</div>
</motion.div>
);
})}
</AnimatePresence>
</motion.div>
)}
{/* Details Modal */}
<AnimatePresence>
{selectedRequest && (
<div className="modal modal-open">
<div className="modal-box max-w-3xl">
<h3 className="font-bold text-lg mb-4">{selectedRequest.title}</h3>
<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-3xl 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">
{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-4">
<div className="grid gap-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Status</label>
<div className={`badge ${STATUS_COLORS[selectedRequest.status]} mt-1 block`}>
<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>
<label className="text-sm font-medium">Department</label>
<div className="badge badge-outline mt-1 block">
<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>
<label className="text-sm font-medium">Total Amount</label>
<p className="mt-1">${selectedRequest.total_amount.toFixed(2)}</p>
<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>
<label className="text-sm font-medium">Date of Purchase</label>
<p className="mt-1">{formatDate(selectedRequest.date_of_purchase)}</p>
<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>
<label className="text-sm font-medium">Payment Method</label>
<p className="mt-1">{selectedRequest.payment_method}</p>
<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>
<label className="text-sm font-medium">Additional Information</label>
<p className="mt-1">{selectedRequest.additional_info}</p>
<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>
)}
<div>
<label className="text-sm font-medium">Receipts</label>
<div className="mt-2 grid grid-cols-2 md:grid-cols-3 gap-2">
{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) => (
<button
<motion.button
key={receiptId || index}
className="btn btn-outline btn-sm normal-case"
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 mr-2" />
<Icon icon="heroicons:document" className="h-4 w-4" />
Receipt #{index + 1}
</button>
</motion.button>
))}
</div>
</div>
<div className="divider"></div>
<div className="divider before:bg-base-300 after:bg-base-300"></div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<label className="font-medium">Submitted At</label>
<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>
<label className="font-medium">Last Updated</label>
<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>
<div className="modal-action">
<button
className="btn"
onClick={() => setSelectedRequest(null)}
>
Close
</button>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* File Preview Modal */}
<AnimatePresence>
{showPreview && selectedReceipt && (
<div className="modal modal-open">
<div className="modal-box max-w-7xl">
<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">
<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>
@ -388,18 +662,20 @@ export default function ReimbursementList() {
{/* 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
<h3 className="text-lg font-medium">Receipt Image</h3>
<motion.a
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
href={previewUrl}
target="_blank"
rel="noopener noreferrer"
className="btn btn-sm btn-outline gap-2"
className="btn btn-sm btn-outline gap-2 hover:shadow-md transition-all duration-300"
>
<Icon icon="heroicons:arrow-top-right-on-square" className="h-4 w-4" />
View Full Size
</a>
</motion.a>
</div>
<div className="bg-base-200 rounded-lg p-4">
<div className="bg-base-200/50 backdrop-blur-sm rounded-lg p-4 shadow-sm">
<FilePreview
url={previewUrl}
filename={previewFilename}
@ -408,21 +684,11 @@ export default function ReimbursementList() {
</div>
</div>
</div>
<div className="modal-action">
<button
className="btn"
onClick={() => {
setShowPreview(false);
setSelectedReceipt(null);
}}
>
Close
</button>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</div>
</AnimatePresence>
</motion.div>
</>
);
}

View file

@ -28,6 +28,7 @@ interface User {
name: string;
email: string;
avatar: string;
zelle_information: string;
created: string;
updated: string;
}
@ -68,6 +69,8 @@ export default function ReimbursementManagementPortal() {
const [reimbursements, setReimbursements] = useState<Reimbursement[]>([]);
const [receipts, setReceipts] = useState<Record<string, Receipt>>({});
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 [error, setError] = useState<string | null>(null);
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>
<Icon icon="heroicons:chevron-down" className="h-4 w-4 flex-shrink-0" />
</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>
@ -1054,6 +1089,17 @@ export default function ReimbursementManagementPortal() {
</p>
</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 className="divider before:bg-base-300 after:bg-base-300">
@ -1112,6 +1158,118 @@ export default function ReimbursementManagementPortal() {
</motion.div>
</button>
</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>
</motion.div>
);
@ -1161,22 +1319,24 @@ export default function ReimbursementManagementPortal() {
{(() => {
if (!selectedReimbursement.audit_logs) return null;
let logs = [];
let currentLogs = [];
try {
if (selectedReimbursement.audit_logs) {
if (typeof selectedReimbursement.audit_logs === 'string') {
logs = JSON.parse(selectedReimbursement.audit_logs);
currentLogs = JSON.parse(selectedReimbursement.audit_logs);
} else {
logs = selectedReimbursement.audit_logs;
currentLogs = selectedReimbursement.audit_logs;
}
if (!Array.isArray(currentLogs)) {
currentLogs = [];
}
if (!Array.isArray(logs)) {
logs = [];
}
} catch (error) {
console.error('Error parsing audit logs:', error);
logs = [];
console.error('Error parsing existing audit logs:', error);
currentLogs = [];
}
if (logs.length === 0) {
if (currentLogs.length === 0) {
return (
<div className="bg-base-200 p-4 rounded-lg text-base-content/70 text-center">
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 endIndex = startIndex + logsPerPage;
const currentLogs = logs.slice(startIndex, endIndex);
const currentPageLogs = currentLogs.slice(startIndex, endIndex);
return (
<>
{currentLogs.map((log: { action: string; from?: string; to?: string; receipt_id?: string; receipt_name?: string; receipt_date?: string; receipt_amount?: number; auditor_id: string; timestamp: string }, index: number) => (
{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 className="flex items-center justify-between flex-wrap 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 startIndex = (currentNotePage - 1) * notesPerPage;
const endIndex = startIndex + notesPerPage;
const currentNotes = notes.slice(startIndex, endIndex);
const currentPageNotes = notes.slice(startIndex, endIndex);
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 className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
@ -1477,6 +1643,58 @@ export default function ReimbursementManagementPortal() {
</motion.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>
);
}

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',
},
}}
/>
);
}