improve reimbursement submissions
This commit is contained in:
parent
216f48a572
commit
52504aeb21
2 changed files with 555 additions and 152 deletions
|
@ -4,6 +4,7 @@ import FilePreview from '../universal/FilePreview';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import type { ItemizedExpense } from '../../../schemas/pocketbase';
|
import type { ItemizedExpense } from '../../../schemas/pocketbase';
|
||||||
|
// import ZoomablePreview from '../universal/ZoomablePreview';
|
||||||
|
|
||||||
interface ReceiptFormData {
|
interface ReceiptFormData {
|
||||||
file: File;
|
file: File;
|
||||||
|
@ -66,6 +67,35 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
const [locationAddress, setLocationAddress] = useState<string>('');
|
const [locationAddress, setLocationAddress] = useState<string>('');
|
||||||
const [notes, setNotes] = useState<string>('');
|
const [notes, setNotes] = useState<string>('');
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
|
const [jsonInput, setJsonInput] = useState<string>('');
|
||||||
|
const [showJsonInput, setShowJsonInput] = useState<boolean>(false);
|
||||||
|
const [zoomLevel, setZoomLevel] = useState<number>(1);
|
||||||
|
|
||||||
|
// Sample JSON data for users to copy
|
||||||
|
const sampleJsonData = {
|
||||||
|
itemized_expenses: [
|
||||||
|
{
|
||||||
|
description: "Presentation supplies for IEEE workshop",
|
||||||
|
category: "Supplies",
|
||||||
|
amount: 45.99
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Team lunch during planning meeting",
|
||||||
|
category: "Meals",
|
||||||
|
amount: 82.50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Transportation to conference venue",
|
||||||
|
category: "Travel",
|
||||||
|
amount: 28.75
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tax: 12.65,
|
||||||
|
date: "2024-01-15",
|
||||||
|
location_name: "Office Depot & Local Restaurant",
|
||||||
|
location_address: "1234 Campus Drive, San Diego, CA 92093",
|
||||||
|
notes: "Expenses for January IEEE workshop preparation and team coordination meeting"
|
||||||
|
};
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files[0]) {
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
@ -144,6 +174,69 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseJsonData = () => {
|
||||||
|
try {
|
||||||
|
if (!jsonInput.trim()) {
|
||||||
|
toast.error('Please enter JSON data to parse');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(jsonInput);
|
||||||
|
|
||||||
|
// Validate the structure
|
||||||
|
if (!parsed.itemized_expenses || !Array.isArray(parsed.itemized_expenses)) {
|
||||||
|
throw new Error('itemized_expenses must be an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each expense item
|
||||||
|
for (const item of parsed.itemized_expenses) {
|
||||||
|
if (!item.description || !item.category || typeof item.amount !== 'number') {
|
||||||
|
throw new Error('Each expense item must have description, category, and amount');
|
||||||
|
}
|
||||||
|
if (!EXPENSE_CATEGORIES.includes(item.category)) {
|
||||||
|
throw new Error(`Invalid category: ${item.category}. Must be one of: ${EXPENSE_CATEGORIES.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the form fields
|
||||||
|
setItemizedExpenses(parsed.itemized_expenses);
|
||||||
|
if (parsed.tax !== undefined) setTax(Number(parsed.tax) || 0);
|
||||||
|
if (parsed.date) setDate(parsed.date);
|
||||||
|
if (parsed.location_name) setLocationName(parsed.location_name);
|
||||||
|
if (parsed.location_address) setLocationAddress(parsed.location_address);
|
||||||
|
if (parsed.notes) setNotes(parsed.notes);
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
toast.success(`Successfully imported ${parsed.itemized_expenses.length} expense items`);
|
||||||
|
setShowJsonInput(false);
|
||||||
|
setJsonInput('');
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Invalid JSON format';
|
||||||
|
setError(`JSON Parse Error: ${errorMessage}`);
|
||||||
|
toast.error(`Failed to parse JSON: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
toast.success('Sample data copied to clipboard!');
|
||||||
|
}).catch(() => {
|
||||||
|
toast.error('Failed to copy to clipboard');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomIn = () => {
|
||||||
|
setZoomLevel(prev => Math.min(prev + 0.25, 3));
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomOut = () => {
|
||||||
|
setZoomLevel(prev => Math.max(prev - 0.25, 0.5));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetZoom = () => {
|
||||||
|
setZoomLevel(1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
|
@ -195,68 +288,270 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Date */}
|
{/* Date and Location in Grid */}
|
||||||
<motion.div variants={itemVariants} className="form-control">
|
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<label className="label">
|
<div className="form-control">
|
||||||
<span className="label-text font-medium">Date</span>
|
<label className="label">
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text font-medium">Date</span>
|
||||||
</label>
|
<span className="label-text-alt text-error">*</span>
|
||||||
<input
|
</label>
|
||||||
type="date"
|
<input
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
type="date"
|
||||||
value={date}
|
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||||
onChange={(e) => setDate(e.target.value)}
|
value={date}
|
||||||
required
|
onChange={(e) => setDate(e.target.value)}
|
||||||
/>
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-medium">Tax Amount ($)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||||
|
value={tax === 0 ? '' : tax}
|
||||||
|
onChange={(e) => setTax(Number(e.target.value))}
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Location Name */}
|
{/* Location Fields */}
|
||||||
<motion.div variants={itemVariants} className="form-control">
|
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<label className="label">
|
<div className="form-control">
|
||||||
<span className="label-text font-medium">Location Name</span>
|
<label className="label">
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text font-medium">Location Name</span>
|
||||||
</label>
|
<span className="label-text-alt text-error">*</span>
|
||||||
<input
|
</label>
|
||||||
type="text"
|
<input
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
type="text"
|
||||||
value={locationName}
|
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||||
onChange={(e) => setLocationName(e.target.value)}
|
value={locationName}
|
||||||
required
|
onChange={(e) => setLocationName(e.target.value)}
|
||||||
/>
|
placeholder="Store/vendor name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-medium">Location Address</span>
|
||||||
|
<span className="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||||
|
value={locationAddress}
|
||||||
|
onChange={(e) => setLocationAddress(e.target.value)}
|
||||||
|
placeholder="Full address"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Location Address */}
|
{/* Notes - Reduced height */}
|
||||||
<motion.div variants={itemVariants} className="form-control">
|
|
||||||
<label className="label">
|
|
||||||
<span className="label-text font-medium">Location Address</span>
|
|
||||||
<span className="label-text-alt text-error">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
|
||||||
value={locationAddress}
|
|
||||||
onChange={(e) => setLocationAddress(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
<motion.div variants={itemVariants} className="form-control">
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="label-text font-medium">Notes</span>
|
<span className="label-text font-medium">Notes</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[100px]"
|
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300"
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
rows={3}
|
rows={2}
|
||||||
|
placeholder="Additional notes..."
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* JSON Import Section */}
|
||||||
|
<motion.div variants={itemVariants} className="space-y-4">
|
||||||
|
<div className="card bg-base-200/30 border border-primary/20 shadow-sm">
|
||||||
|
<div className="card-body p-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-primary">Quick Import from JSON</h3>
|
||||||
|
<p className="text-sm text-base-content/70">Paste receipt data in JSON format to auto-populate fields</p>
|
||||||
|
</div>
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm gap-2"
|
||||||
|
onClick={() => setShowJsonInput(!showJsonInput)}
|
||||||
|
>
|
||||||
|
<Icon icon={showJsonInput ? "heroicons:chevron-up" : "heroicons:chevron-down"} className="h-4 w-4" />
|
||||||
|
{showJsonInput ? 'Hide' : 'Show'} JSON Import
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showJsonInput && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="space-y-4 mt-4 overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Sample Data Section */}
|
||||||
|
<div className="bg-base-100/50 rounded-lg p-4 border border-base-300/50">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h4 className="font-medium text-sm">Sample JSON Format:</h4>
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-xs btn-ghost gap-1"
|
||||||
|
onClick={() => copyToClipboard(JSON.stringify(sampleJsonData, null, 2))}
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:clipboard-document" className="h-3 w-3" />
|
||||||
|
Copy Sample
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs bg-base-200/50 p-3 rounded border overflow-x-auto">
|
||||||
|
<code>{JSON.stringify(sampleJsonData, null, 2)}</code>
|
||||||
|
</pre>
|
||||||
|
<div className="mt-2 text-xs text-base-content/60">
|
||||||
|
<p><strong>Required fields:</strong> itemized_expenses (array)</p>
|
||||||
|
<p><strong>Optional fields:</strong> tax, date, location_name, location_address, notes</p>
|
||||||
|
<p><strong>Valid categories:</strong> {EXPENSE_CATEGORIES.join(', ')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* JSON Input Area */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-medium">Paste your JSON data:</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-bordered w-full min-h-[150px] font-mono text-sm"
|
||||||
|
value={jsonInput}
|
||||||
|
onChange={(e) => setJsonInput(e.target.value)}
|
||||||
|
placeholder="Paste your JSON data here..."
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => setJsonInput('')}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</motion.button>
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm gap-2"
|
||||||
|
onClick={parseJsonData}
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:arrow-down-tray" className="h-4 w-4" />
|
||||||
|
Import Data
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Itemized Expenses */}
|
{/* Itemized Expenses */}
|
||||||
<motion.div variants={itemVariants} 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="text-lg font-medium">Itemized Expenses</label>
|
<label className="text-lg font-medium">Itemized Expenses</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{itemizedExpenses.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 20 }}
|
||||||
|
className="card bg-base-200/50 hover:bg-base-200 transition-colors duration-300 backdrop-blur-sm shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="card-body p-3">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<h4 className="text-sm font-medium">Item #{index + 1}</h4>
|
||||||
|
{itemizedExpenses.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-xs btn-ghost text-error hover:bg-error/10"
|
||||||
|
onClick={() => removeExpenseItem(index)}
|
||||||
|
aria-label="Remove item"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:trash" className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label py-1">
|
||||||
|
<span className="label-text text-xs">Description</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-bordered input-sm"
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)}
|
||||||
|
placeholder="What was purchased?"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label py-1">
|
||||||
|
<span className="label-text text-xs">Category</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered select-sm w-full"
|
||||||
|
value={item.category}
|
||||||
|
onChange={(e) => handleExpenseItemChange(index, 'category', e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select...</option>
|
||||||
|
{EXPENSE_CATEGORIES.map(category => (
|
||||||
|
<option key={category} value={category}>{category}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label py-1">
|
||||||
|
<span className="label-text text-xs">Amount ($)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input input-bordered input-sm w-full"
|
||||||
|
value={item.amount === 0 ? '' : item.amount}
|
||||||
|
onChange={(e) => handleExpenseItemChange(index, 'amount', Number(e.target.value))}
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Add Item Button - Moved to bottom */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex justify-center pt-2"
|
||||||
|
>
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
|
@ -267,119 +562,24 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
<Icon icon="heroicons:plus" className="h-4 w-4" />
|
<Icon icon="heroicons:plus" className="h-4 w-4" />
|
||||||
Add Item
|
Add Item
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{itemizedExpenses.map((item, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={index}
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: 20 }}
|
|
||||||
className="card bg-base-200/50 hover:bg-base-200 transition-colors duration-300 backdrop-blur-sm shadow-sm overflow-visible"
|
|
||||||
>
|
|
||||||
<div className="card-body p-4">
|
|
||||||
<div className="grid gap-4 overflow-visible">
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label">
|
|
||||||
<span className="label-text">Description</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="input input-bordered"
|
|
||||||
value={item.description}
|
|
||||||
onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h4 className="text-sm font-medium">Item #{index + 1}</h4>
|
|
||||||
{itemizedExpenses.length > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-sm btn-ghost text-error hover:bg-error/10"
|
|
||||||
onClick={() => removeExpenseItem(index)}
|
|
||||||
aria-label="Remove item"
|
|
||||||
>
|
|
||||||
<Icon icon="heroicons:trash" className="h-4 w-4" />
|
|
||||||
<span className="text-xs">Remove</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label">
|
|
||||||
<span className="label-text">Category</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="select select-bordered w-full"
|
|
||||||
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>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input input-bordered w-full"
|
|
||||||
value={item.amount === 0 ? '' : item.amount}
|
|
||||||
onChange={(e) => handleExpenseItemChange(index, 'amount', Number(e.target.value))}
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Tax */}
|
|
||||||
<motion.div variants={itemVariants} className="form-control">
|
|
||||||
<label className="label">
|
|
||||||
<span className="label-text font-medium">Tax Amount ($)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
|
||||||
value={tax === 0 ? '' : tax}
|
|
||||||
onChange={(e) => setTax(Number(e.target.value))}
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Total */}
|
{/* Total */}
|
||||||
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-3 shadow-sm">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<div className="flex justify-between items-center text-base-content/70">
|
<div className="flex justify-between items-center text-sm text-base-content/70">
|
||||||
<span>Subtotal:</span>
|
<span>Subtotal:</span>
|
||||||
<span className="font-mono">${itemizedExpenses.reduce((sum, item) => sum + item.amount, 0).toFixed(2)}</span>
|
<span className="font-mono">${itemizedExpenses.reduce((sum, item) => sum + item.amount, 0).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-base-content/70">
|
<div className="flex justify-between items-center text-sm text-base-content/70">
|
||||||
<span>Tax:</span>
|
<span>Tax:</span>
|
||||||
<span className="font-mono">${tax.toFixed(2)}</span>
|
<span className="font-mono">${tax.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="divider my-1"></div>
|
<div className="divider my-1"></div>
|
||||||
<div className="flex justify-between items-center font-medium text-lg">
|
<div className="flex justify-between items-center font-medium">
|
||||||
<span>Total:</span>
|
<span>Total:</span>
|
||||||
<span className="font-mono text-primary">${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)}</span>
|
<span className="font-mono text-primary text-lg">${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
@ -422,13 +622,60 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.9 }}
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||||
className="bg-base-200/50 backdrop-blur-sm rounded-xl p-4 shadow-sm"
|
className="bg-base-200/50 backdrop-blur-sm rounded-xl shadow-sm relative"
|
||||||
>
|
>
|
||||||
<FilePreview
|
{/* Zoom Controls */}
|
||||||
url={previewUrl}
|
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2 bg-base-100/90 backdrop-blur-sm rounded-lg p-2 shadow-lg">
|
||||||
filename={file?.name || ''}
|
<motion.button
|
||||||
isModal={false}
|
whileHover={{ scale: 1.1 }}
|
||||||
/>
|
whileTap={{ scale: 0.9 }}
|
||||||
|
className="btn btn-xs btn-ghost"
|
||||||
|
onClick={zoomIn}
|
||||||
|
disabled={zoomLevel >= 3}
|
||||||
|
title="Zoom In"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:plus" className="h-3 w-3" />
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<div className="text-xs text-center font-mono px-1">
|
||||||
|
{Math.round(zoomLevel * 100)}%
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
className="btn btn-xs btn-ghost"
|
||||||
|
onClick={zoomOut}
|
||||||
|
disabled={zoomLevel <= 0.5}
|
||||||
|
title="Zoom Out"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:minus" className="h-3 w-3" />
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
className="btn btn-xs btn-ghost"
|
||||||
|
onClick={resetZoom}
|
||||||
|
disabled={zoomLevel === 1}
|
||||||
|
title="Reset Zoom"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:arrows-pointing-out" className="h-3 w-3" />
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview with Zoom */}
|
||||||
|
<div
|
||||||
|
className="overflow-auto h-full rounded-xl"
|
||||||
|
style={{
|
||||||
|
transform: `scale(${zoomLevel})`,
|
||||||
|
transformOrigin: 'top left',
|
||||||
|
height: zoomLevel > 1 ? `${100 / zoomLevel}%` : '100%',
|
||||||
|
width: zoomLevel > 1 ? `${100 / zoomLevel}%` : '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilePreview url={previewUrl} filename={file?.name || ''} />
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
156
src/components/dashboard/universal/ZoomablePreview.tsx
Normal file
156
src/components/dashboard/universal/ZoomablePreview.tsx
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import FilePreview from './FilePreview';
|
||||||
|
|
||||||
|
interface ZoomablePreviewProps {
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ZoomablePreview({ url, filename }: ZoomablePreviewProps) {
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const zoomLevels = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
||||||
|
const currentZoomIndex = zoomLevels.findIndex(level => Math.abs(level - zoom) < 0.01);
|
||||||
|
|
||||||
|
const handleZoomIn = useCallback(() => {
|
||||||
|
const nextIndex = Math.min(currentZoomIndex + 1, zoomLevels.length - 1);
|
||||||
|
setZoom(zoomLevels[nextIndex]);
|
||||||
|
}, [currentZoomIndex]);
|
||||||
|
|
||||||
|
const handleZoomOut = useCallback(() => {
|
||||||
|
const prevIndex = Math.max(currentZoomIndex - 1, 0);
|
||||||
|
setZoom(zoomLevels[prevIndex]);
|
||||||
|
}, [currentZoomIndex]);
|
||||||
|
|
||||||
|
const handleZoomReset = useCallback(() => {
|
||||||
|
setZoom(1);
|
||||||
|
setPosition({ x: 0, y: 0 });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (zoom > 1) {
|
||||||
|
setIsDragging(true);
|
||||||
|
setDragStart({
|
||||||
|
x: e.clientX - position.x,
|
||||||
|
y: e.clientY - position.y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [zoom, position]);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (isDragging && zoom > 1) {
|
||||||
|
setPosition({
|
||||||
|
x: e.clientX - dragStart.x,
|
||||||
|
y: e.clientY - dragStart.y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isDragging, dragStart, zoom]);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? -1 : 1;
|
||||||
|
const newZoomIndex = Math.max(0, Math.min(zoomLevels.length - 1, currentZoomIndex + delta));
|
||||||
|
setZoom(zoomLevels[newZoomIndex]);
|
||||||
|
|
||||||
|
// Reset position when zooming out to 100% or less
|
||||||
|
if (zoomLevels[newZoomIndex] <= 1) {
|
||||||
|
setPosition({ x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
}, [currentZoomIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full">
|
||||||
|
{/* Zoom Controls */}
|
||||||
|
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2 bg-base-100/90 backdrop-blur-sm rounded-lg p-2 shadow-lg">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
className="btn btn-xs btn-ghost"
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
disabled={currentZoomIndex >= zoomLevels.length - 1}
|
||||||
|
title="Zoom In"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:plus" className="h-3 w-3" />
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<div className="text-xs text-center font-mono px-1">
|
||||||
|
{Math.round(zoom * 100)}%
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
className="btn btn-xs btn-ghost"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
disabled={currentZoomIndex <= 0}
|
||||||
|
title="Zoom Out"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:minus" className="h-3 w-3" />
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
className="btn btn-xs btn-ghost"
|
||||||
|
onClick={handleZoomReset}
|
||||||
|
disabled={zoom === 1 && position.x === 0 && position.y === 0}
|
||||||
|
title="Reset Zoom"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:arrows-pointing-out" className="h-3 w-3" />
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zoom Indicator */}
|
||||||
|
{zoom !== 1 && (
|
||||||
|
<div className="absolute top-4 left-4 z-10 bg-primary/90 backdrop-blur-sm text-primary-content text-xs px-2 py-1 rounded">
|
||||||
|
{zoom > 1 ? 'Click and drag to pan' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preview Container */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative h-full overflow-hidden rounded-lg cursor-move"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
style={{
|
||||||
|
cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full transition-transform duration-100"
|
||||||
|
style={{
|
||||||
|
transform: `scale(${zoom}) translate(${position.x / zoom}px, ${position.y / zoom}px)`,
|
||||||
|
transformOrigin: 'center center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="p-4 h-full">
|
||||||
|
<FilePreview
|
||||||
|
url={url}
|
||||||
|
filename={filename}
|
||||||
|
isModal={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage Hint */}
|
||||||
|
<div className="absolute bottom-4 left-4 z-10 text-xs text-base-content/50 bg-base-100/80 backdrop-blur-sm px-2 py-1 rounded">
|
||||||
|
Scroll to zoom • Click and drag to pan
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in a new issue