add animation and more information
This commit is contained in:
parent
8338b7ccd2
commit
cb58d8fd33
5 changed files with 1464 additions and 568 deletions
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
19
src/components/dashboard/reimbursement/ToastProvider.tsx
Normal file
19
src/components/dashboard/reimbursement/ToastProvider.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in a new issue