1717 lines
No EOL
105 KiB
TypeScript
1717 lines
No EOL
105 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Icon } from '@iconify/react';
|
|
import FilePreview from '../universal/FilePreview';
|
|
import { Get } from '../../../scripts/pocketbase/Get';
|
|
import { Update } from '../../../scripts/pocketbase/Update';
|
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
|
import { FileManager } from '../../../scripts/pocketbase/FileManager';
|
|
import { toast } from 'react-hot-toast';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import type { Receipt as SchemaReceipt, User, Reimbursement } from '../../../schemas/pocketbase';
|
|
|
|
// Extended Receipt interface with additional properties needed for this component
|
|
interface ExtendedReceipt extends Omit<SchemaReceipt, 'audited_by'> {
|
|
audited_by: string[]; // In schema it's a string, but in this component it's used as string[]
|
|
auditor_names?: string[]; // Names of auditors
|
|
}
|
|
|
|
// Extended User interface with additional properties needed for this component
|
|
interface ExtendedUser extends User {
|
|
avatar: string;
|
|
zelle_information: string;
|
|
}
|
|
|
|
// Extended Reimbursement interface with additional properties needed for this component
|
|
interface ExtendedReimbursement extends Reimbursement {
|
|
submitter?: ExtendedUser;
|
|
}
|
|
|
|
interface FilterOptions {
|
|
status: string[];
|
|
department: string[];
|
|
dateRange: 'all' | 'week' | 'month' | 'year';
|
|
sortBy: 'date_of_purchase' | 'total_amount' | 'status';
|
|
sortOrder: 'asc' | 'desc';
|
|
}
|
|
|
|
interface ItemizedExpense {
|
|
description: string;
|
|
category: string;
|
|
amount: number;
|
|
}
|
|
|
|
export default function ReimbursementManagementPortal() {
|
|
const [reimbursements, setReimbursements] = useState<ExtendedReimbursement[]>([]);
|
|
const [receipts, setReceipts] = useState<Record<string, ExtendedReceipt>>({});
|
|
const [selectedReimbursement, setSelectedReimbursement] = useState<ExtendedReimbursement | null>(null);
|
|
const [selectedReceipt, setSelectedReceipt] = useState<ExtendedReceipt | null>(null);
|
|
const [showReceiptModal, setShowReceiptModal] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [filters, setFilters] = useState<FilterOptions>({
|
|
status: [],
|
|
department: [],
|
|
dateRange: 'all',
|
|
sortBy: 'date_of_purchase',
|
|
sortOrder: 'desc'
|
|
});
|
|
const [auditNote, setAuditNote] = useState('');
|
|
const [loadingStatus, setLoadingStatus] = useState(false);
|
|
const [expandedReceipts, setExpandedReceipts] = useState<Set<string>>(new Set());
|
|
const [auditingReceipt, setAuditingReceipt] = useState<string | null>(null);
|
|
const [users, setUsers] = useState<Record<string, ExtendedUser>>({});
|
|
const [showUserProfile, setShowUserProfile] = useState<string | null>(null);
|
|
const [auditNotes, setAuditNotes] = useState<string[]>([]);
|
|
const userDropdownRef = React.useRef<HTMLDivElement>(null);
|
|
const [currentLogPage, setCurrentLogPage] = useState(1);
|
|
const [currentNotePage, setCurrentNotePage] = useState(1);
|
|
const logsPerPage = 5;
|
|
const [notesPerPage, setNotesPerPage] = useState(5);
|
|
const [isPrivateNote, setIsPrivateNote] = useState(true);
|
|
const [showRejectModal, setShowRejectModal] = useState(false);
|
|
const [rejectReason, setRejectReason] = useState('');
|
|
const [rejectingId, setRejectingId] = useState<string | null>(null);
|
|
const [receiptUrl, setReceiptUrl] = useState<string>('');
|
|
|
|
useEffect(() => {
|
|
const auth = Authentication.getInstance();
|
|
if (!auth.isAuthenticated()) {
|
|
setError('You must be logged in to view reimbursements');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
loadReimbursements();
|
|
}, [filters]);
|
|
|
|
// Add click outside handler
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (userDropdownRef.current && !userDropdownRef.current.contains(event.target as Node)) {
|
|
setShowUserProfile(null);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
};
|
|
}, []);
|
|
|
|
const loadReimbursements = async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const get = Get.getInstance();
|
|
const sort = `${filters.sortOrder === 'desc' ? '-' : ''}${filters.sortBy}`;
|
|
|
|
let filter = '';
|
|
if (filters.status.length > 0) {
|
|
const statusFilter = filters.status.map(s => `status = "${s}"`).join(' || ');
|
|
filter = `(${statusFilter})`;
|
|
}
|
|
|
|
if (filters.department.length > 0) {
|
|
const departmentFilter = filters.department.map(d => `department = "${d}"`).join(' || ');
|
|
filter = filter ? `${filter} && (${departmentFilter})` : `(${departmentFilter})`;
|
|
}
|
|
|
|
if (filters.dateRange !== 'all') {
|
|
const now = new Date();
|
|
const cutoff = new Date();
|
|
switch (filters.dateRange) {
|
|
case 'week':
|
|
cutoff.setDate(now.getDate() - 7);
|
|
break;
|
|
case 'month':
|
|
cutoff.setMonth(now.getMonth() - 1);
|
|
break;
|
|
case 'year':
|
|
cutoff.setFullYear(now.getFullYear() - 1);
|
|
break;
|
|
}
|
|
const dateFilter = `created >= "${cutoff.toISOString()}"`;
|
|
filter = filter ? `${filter} && ${dateFilter}` : dateFilter;
|
|
}
|
|
|
|
const records = await get.getAll<ExtendedReimbursement>('reimbursement', filter, sort);
|
|
console.log('Loaded reimbursements:', records);
|
|
|
|
// Load user data for submitters
|
|
const userIds = new Set(records.map(r => r.submitted_by));
|
|
const userRecords = await Promise.all(
|
|
Array.from(userIds).map(async id => {
|
|
try {
|
|
return await get.getOne<ExtendedUser>('users', id);
|
|
} catch (error) {
|
|
console.error(`Failed to load user ${id}:`, error);
|
|
return null;
|
|
}
|
|
})
|
|
);
|
|
|
|
const validUsers = userRecords.filter((u): u is ExtendedUser => u !== null);
|
|
const userMap = Object.fromEntries(
|
|
validUsers.map(user => [user.id, user])
|
|
);
|
|
setUsers(userMap);
|
|
|
|
// Attach user data to reimbursements
|
|
const enrichedRecords = records.map(record => ({
|
|
...record,
|
|
submitter: userMap[record.submitted_by]
|
|
}));
|
|
|
|
setReimbursements(enrichedRecords);
|
|
|
|
// Load associated receipts
|
|
const receiptIds = enrichedRecords.flatMap(r => r.receipts || []);
|
|
console.log('Extracted receipt IDs:', receiptIds, 'from reimbursements:', enrichedRecords.map(r => ({ id: r.id, receipts: r.receipts })));
|
|
|
|
if (receiptIds.length > 0) {
|
|
try {
|
|
console.log('Attempting to load receipts with IDs:', receiptIds);
|
|
const receiptRecords = await Promise.all(
|
|
receiptIds.map(async id => {
|
|
try {
|
|
const receipt = await get.getOne<ExtendedReceipt>('receipts', id);
|
|
// Get auditor names from the users collection
|
|
if (receipt.audited_by) {
|
|
// Convert audited_by to array if it's a string
|
|
const auditorIds = Array.isArray(receipt.audited_by)
|
|
? receipt.audited_by
|
|
: receipt.audited_by ? [receipt.audited_by] : [];
|
|
|
|
if (auditorIds.length > 0) {
|
|
const auditorUsers = await Promise.all(
|
|
auditorIds.map(auditorId =>
|
|
get.getOne('users', auditorId)
|
|
.catch(() => ({ name: 'Unknown User' }))
|
|
)
|
|
);
|
|
receipt.auditor_names = auditorUsers.map(user => user.name);
|
|
// Ensure audited_by is always an array for consistency
|
|
receipt.audited_by = auditorIds;
|
|
}
|
|
}
|
|
return receipt;
|
|
} catch (error) {
|
|
console.error(`Failed to load receipt ${id}:`, error);
|
|
return null;
|
|
}
|
|
})
|
|
);
|
|
|
|
const validReceipts = receiptRecords.filter((r): r is ExtendedReceipt => r !== null);
|
|
console.log('Successfully loaded receipt records:', validReceipts);
|
|
|
|
const receiptMap = Object.fromEntries(
|
|
validReceipts.map(receipt => [receipt.id, receipt])
|
|
);
|
|
console.log('Created receipt map:', receiptMap);
|
|
setReceipts(receiptMap);
|
|
} catch (error: any) {
|
|
console.error('Error loading receipts:', error);
|
|
console.error('Error details:', {
|
|
status: error?.status,
|
|
message: error?.message,
|
|
data: error?.data
|
|
});
|
|
toast.error('Failed to load receipts: ' + (error?.message || 'Unknown error'));
|
|
}
|
|
} else {
|
|
console.log('No receipt IDs found in reimbursements');
|
|
setReceipts({});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading reimbursements:', error);
|
|
toast.error('Failed to load reimbursements. Please try again later.');
|
|
setError('Failed to load reimbursements. Please try again later.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Add helper function to add audit log
|
|
const addAuditLog = async (reimbursementId: string, action: string, details: Record<string, any> = {}) => {
|
|
try {
|
|
const update = Update.getInstance();
|
|
const auth = Authentication.getInstance();
|
|
const userId = auth.getUserId();
|
|
|
|
if (!userId) throw new Error('User not authenticated');
|
|
|
|
const reimbursement = reimbursements.find(r => r.id === reimbursementId);
|
|
if (!reimbursement) throw new Error('Reimbursement not found');
|
|
|
|
// Get current logs
|
|
let currentLogs = [];
|
|
try {
|
|
if (reimbursement.audit_logs) {
|
|
if (typeof reimbursement.audit_logs === 'string') {
|
|
currentLogs = JSON.parse(reimbursement.audit_logs);
|
|
} else {
|
|
currentLogs = reimbursement.audit_logs;
|
|
}
|
|
if (!Array.isArray(currentLogs)) {
|
|
currentLogs = [];
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error parsing existing audit logs:', error);
|
|
currentLogs = [];
|
|
}
|
|
|
|
// Add new log entry
|
|
currentLogs.push({
|
|
action,
|
|
...details,
|
|
auditor_id: userId,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
await update.updateFields('reimbursement', reimbursementId, {
|
|
audit_logs: JSON.stringify(currentLogs)
|
|
});
|
|
} catch (error) {
|
|
console.error('Error adding audit log:', error);
|
|
}
|
|
};
|
|
|
|
const refreshAuditData = async (reimbursementId: string) => {
|
|
try {
|
|
const get = Get.getInstance();
|
|
const updatedReimbursement = await get.getOne<ExtendedReimbursement>('reimbursement', reimbursementId);
|
|
|
|
// Get updated user data if needed
|
|
if (!users[updatedReimbursement.submitted_by]) {
|
|
const user = await get.getOne<ExtendedUser>('users', updatedReimbursement.submitted_by);
|
|
setUsers(prev => ({
|
|
...prev,
|
|
[user.id]: user
|
|
}));
|
|
}
|
|
|
|
// Get updated receipt data
|
|
const updatedReceipts = await Promise.all(
|
|
updatedReimbursement.receipts.map(async id => {
|
|
try {
|
|
const receipt = await get.getOne<ExtendedReceipt>('receipts', id);
|
|
// Get updated auditor names
|
|
if (receipt.audited_by) {
|
|
// Convert audited_by to array if it's a string
|
|
const auditorIds = Array.isArray(receipt.audited_by)
|
|
? receipt.audited_by
|
|
: receipt.audited_by ? [receipt.audited_by] : [];
|
|
|
|
if (auditorIds.length > 0) {
|
|
const auditorUsers = await Promise.all(
|
|
auditorIds.map(async auditorId => {
|
|
try {
|
|
const user = await get.getOne<ExtendedUser>('users', auditorId);
|
|
// Update users state with any new auditors
|
|
setUsers(prev => ({
|
|
...prev,
|
|
[user.id]: user
|
|
}));
|
|
return user;
|
|
} catch {
|
|
return { name: 'Unknown User' } as ExtendedUser;
|
|
}
|
|
})
|
|
);
|
|
receipt.auditor_names = auditorUsers.map(user => user.name);
|
|
// Ensure audited_by is always an array for consistency
|
|
receipt.audited_by = auditorIds;
|
|
}
|
|
}
|
|
return receipt;
|
|
} catch (error) {
|
|
console.error(`Failed to load receipt ${id}:`, error);
|
|
return null;
|
|
}
|
|
})
|
|
);
|
|
|
|
const validReceipts = updatedReceipts.filter((r): r is ExtendedReceipt => r !== null);
|
|
const receiptMap = Object.fromEntries(
|
|
validReceipts.map(receipt => [receipt.id, receipt])
|
|
);
|
|
|
|
// Update all states
|
|
setReceipts(prev => ({
|
|
...prev,
|
|
...receiptMap
|
|
}));
|
|
|
|
// Update the reimbursement in the list
|
|
setReimbursements(prev => prev.map(r =>
|
|
r.id === reimbursementId ? {
|
|
...r,
|
|
...updatedReimbursement,
|
|
submitter: users[updatedReimbursement.submitted_by]
|
|
} : r
|
|
));
|
|
|
|
// Update selected reimbursement if it's the one being viewed
|
|
if (selectedReimbursement?.id === reimbursementId) {
|
|
setSelectedReimbursement({
|
|
...selectedReimbursement,
|
|
...updatedReimbursement,
|
|
submitter: users[updatedReimbursement.submitted_by]
|
|
});
|
|
}
|
|
|
|
// Reset pagination to first page when data is refreshed
|
|
setCurrentLogPage(1);
|
|
setCurrentNotePage(1);
|
|
} catch (error) {
|
|
console.error('Error refreshing audit data:', error);
|
|
toast.error('Failed to refresh audit data');
|
|
}
|
|
};
|
|
|
|
// Update the updateStatus function
|
|
const updateStatus = async (id: string, status: 'submitted' | 'under_review' | 'approved' | 'rejected' | 'in_progress' | 'paid') => {
|
|
try {
|
|
setLoadingStatus(true);
|
|
const update = Update.getInstance();
|
|
const auth = Authentication.getInstance();
|
|
const userId = auth.getUserId();
|
|
|
|
if (!userId) throw new Error('User not authenticated');
|
|
|
|
await update.updateFields('reimbursement', id, { status });
|
|
|
|
// Add audit log for status change
|
|
await addAuditLog(id, 'status_change', {
|
|
from: selectedReimbursement?.status,
|
|
to: status
|
|
});
|
|
|
|
toast.success(`Reimbursement ${status} successfully`);
|
|
await refreshAuditData(id);
|
|
} catch (error) {
|
|
console.error('Error updating status:', error);
|
|
toast.error('Failed to update reimbursement status');
|
|
setError('Failed to update reimbursement status. Please try again.');
|
|
} finally {
|
|
setLoadingStatus(false);
|
|
}
|
|
};
|
|
|
|
const toggleReceipt = async (receiptId: string) => {
|
|
if (expandedReceipts.has(receiptId)) {
|
|
// If already expanded, collapse it
|
|
const newSet = new Set(expandedReceipts);
|
|
newSet.delete(receiptId);
|
|
setExpandedReceipts(newSet);
|
|
setSelectedReceipt(null);
|
|
} else {
|
|
// If not expanded, expand it
|
|
const newSet = new Set(expandedReceipts);
|
|
newSet.add(receiptId);
|
|
setExpandedReceipts(newSet);
|
|
|
|
// Set the selected receipt
|
|
const receipt = receipts[receiptId];
|
|
if (receipt) {
|
|
setSelectedReceipt(receipt);
|
|
|
|
// Get the receipt URL and update the state
|
|
try {
|
|
const url = await getReceiptUrl(receipt);
|
|
setReceiptUrl(url);
|
|
} catch (error) {
|
|
console.error('Error getting receipt URL:', error);
|
|
setReceiptUrl('');
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Update the auditReceipt function
|
|
const auditReceipt = async (receiptId: string) => {
|
|
try {
|
|
setAuditingReceipt(receiptId);
|
|
const update = Update.getInstance();
|
|
const auth = Authentication.getInstance();
|
|
const userId = auth.getUserId();
|
|
|
|
if (!userId) throw new Error('User not authenticated');
|
|
|
|
const receipt = receipts[receiptId];
|
|
if (!receipt) throw new Error('Receipt not found');
|
|
|
|
// Get the receipt URL and update the state
|
|
try {
|
|
const url = await getReceiptUrl(receipt);
|
|
setReceiptUrl(url);
|
|
} catch (error) {
|
|
console.error('Error getting receipt URL:', error);
|
|
setReceiptUrl('');
|
|
}
|
|
|
|
const updatedAuditors = [...new Set([...receipt.audited_by, userId])];
|
|
|
|
await update.updateFields('receipts', receiptId, {
|
|
audited_by: updatedAuditors
|
|
});
|
|
|
|
// Add audit log for receipt audit
|
|
if (selectedReimbursement) {
|
|
let totalAmount = 0;
|
|
try {
|
|
const expenses: ItemizedExpense[] = typeof receipt.itemized_expenses === 'string'
|
|
? JSON.parse(receipt.itemized_expenses)
|
|
: receipt.itemized_expenses;
|
|
totalAmount = expenses.reduce((sum, item) => sum + item.amount, 0) + receipt.tax;
|
|
} catch (error) {
|
|
console.error('Error calculating total amount:', error);
|
|
}
|
|
|
|
await addAuditLog(selectedReimbursement.id, 'receipt_audit', {
|
|
receipt_id: receiptId,
|
|
receipt_name: receipt.location_name,
|
|
receipt_date: receipt.date,
|
|
receipt_amount: totalAmount
|
|
});
|
|
|
|
await refreshAuditData(selectedReimbursement.id);
|
|
}
|
|
|
|
// Update local state
|
|
setReceipts(prev => ({
|
|
...prev,
|
|
[receiptId]: {
|
|
...prev[receiptId],
|
|
audited_by: updatedAuditors
|
|
}
|
|
}));
|
|
|
|
setSelectedReceipt(receipt);
|
|
setShowReceiptModal(true);
|
|
toast.success('Receipt audited successfully');
|
|
} catch (error) {
|
|
console.error('Error auditing receipt:', error);
|
|
toast.error('Failed to audit receipt');
|
|
} finally {
|
|
setAuditingReceipt(null);
|
|
}
|
|
};
|
|
|
|
const canApproveOrReject = (reimbursement: ExtendedReimbursement): boolean => {
|
|
const auth = Authentication.getInstance();
|
|
const userId = auth.getUserId();
|
|
|
|
if (!userId) return false;
|
|
|
|
// Check if all receipts have been audited by the current user
|
|
return reimbursement.receipts.every(receiptId => {
|
|
const receipt = receipts[receiptId];
|
|
return receipt && receipt.audited_by.includes(userId);
|
|
});
|
|
};
|
|
|
|
const getReceiptUrl = async (receipt: ExtendedReceipt): Promise<string> => {
|
|
try {
|
|
const fileManager = FileManager.getInstance();
|
|
return await fileManager.getFileUrlWithToken(
|
|
'receipts',
|
|
receipt.id,
|
|
receipt.file,
|
|
true // Use token for protected files
|
|
);
|
|
} catch (error) {
|
|
console.error('Error getting receipt URL:', error);
|
|
return '';
|
|
}
|
|
};
|
|
|
|
// Add this function to get the user avatar URL
|
|
const getUserAvatarUrl = (user: ExtendedUser): string => {
|
|
const auth = Authentication.getInstance();
|
|
const pb = auth.getPocketBase();
|
|
return pb.files.getURL(user, user.avatar);
|
|
};
|
|
|
|
// Update the saveAuditNote function
|
|
const saveAuditNote = async () => {
|
|
try {
|
|
if (!auditNote.trim()) {
|
|
toast.error('Please enter a note before saving');
|
|
return;
|
|
}
|
|
|
|
if (!selectedReimbursement) return;
|
|
|
|
setLoadingStatus(true);
|
|
const update = Update.getInstance();
|
|
const auth = Authentication.getInstance();
|
|
const userId = auth.getUserId();
|
|
|
|
if (!userId) throw new Error('User not authenticated');
|
|
|
|
// Parse existing notes or initialize empty array
|
|
let currentNotes = [];
|
|
try {
|
|
if (selectedReimbursement.audit_notes) {
|
|
if (typeof selectedReimbursement.audit_notes === 'string') {
|
|
currentNotes = JSON.parse(selectedReimbursement.audit_notes);
|
|
} else {
|
|
currentNotes = selectedReimbursement.audit_notes;
|
|
}
|
|
if (!Array.isArray(currentNotes)) {
|
|
currentNotes = [];
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error parsing existing audit notes:', error);
|
|
currentNotes = [];
|
|
}
|
|
|
|
// Add new note with privacy setting
|
|
currentNotes.push({
|
|
note: auditNote.trim(),
|
|
auditor_id: userId,
|
|
timestamp: new Date().toISOString(),
|
|
is_private: isPrivateNote
|
|
});
|
|
|
|
await update.updateFields('reimbursement', selectedReimbursement.id, {
|
|
audit_notes: JSON.stringify(currentNotes)
|
|
});
|
|
|
|
// Add audit log for note addition
|
|
await addAuditLog(selectedReimbursement.id, 'note_added', {
|
|
note_preview: auditNote.length > 50 ? `${auditNote.substring(0, 50)}...` : auditNote,
|
|
is_private: isPrivateNote
|
|
});
|
|
|
|
toast.success('Audit note saved successfully');
|
|
setAuditNote('');
|
|
setIsPrivateNote(true);
|
|
await refreshAuditData(selectedReimbursement.id);
|
|
} catch (error) {
|
|
console.error('Error saving audit note:', error);
|
|
toast.error('Failed to save audit note');
|
|
} finally {
|
|
setLoadingStatus(false);
|
|
}
|
|
};
|
|
|
|
// Add this function to get auditor name
|
|
const getAuditorName = (auditorId: string): string => {
|
|
return users[auditorId]?.name || 'Unknown User';
|
|
};
|
|
|
|
// Add handleReject function
|
|
const handleReject = (id: string) => {
|
|
setRejectingId(id);
|
|
setRejectReason('');
|
|
setShowRejectModal(true);
|
|
};
|
|
|
|
// Add submitRejection function
|
|
const submitRejection = async () => {
|
|
if (!rejectReason.trim() || !rejectingId) return;
|
|
|
|
try {
|
|
setLoadingStatus(true);
|
|
|
|
// First update the status
|
|
await updateStatus(rejectingId, 'rejected');
|
|
|
|
// Then add the rejection reason as a public note
|
|
const auth = Authentication.getInstance();
|
|
const userId = auth.getUserId();
|
|
|
|
if (!userId) throw new Error('User not authenticated');
|
|
|
|
// Get current notes
|
|
let currentNotes = [];
|
|
try {
|
|
const reimbursement = reimbursements.find(r => r.id === rejectingId);
|
|
if (reimbursement?.audit_notes) {
|
|
if (typeof reimbursement.audit_notes === 'string') {
|
|
currentNotes = JSON.parse(reimbursement.audit_notes);
|
|
} else {
|
|
currentNotes = reimbursement.audit_notes;
|
|
}
|
|
if (!Array.isArray(currentNotes)) {
|
|
currentNotes = [];
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error parsing existing audit notes:', error);
|
|
currentNotes = [];
|
|
}
|
|
|
|
// Add rejection reason as a public note
|
|
currentNotes.push({
|
|
note: `Rejection Reason: ${rejectReason.trim()}`,
|
|
auditor_id: userId,
|
|
timestamp: new Date().toISOString(),
|
|
is_private: false
|
|
});
|
|
|
|
const update = Update.getInstance();
|
|
await update.updateFields('reimbursement', rejectingId, {
|
|
audit_notes: JSON.stringify(currentNotes)
|
|
});
|
|
|
|
await refreshAuditData(rejectingId);
|
|
setShowRejectModal(false);
|
|
setRejectingId(null);
|
|
setRejectReason('');
|
|
toast.success('Reimbursement rejected successfully');
|
|
} catch (error) {
|
|
console.error('Error rejecting reimbursement:', error);
|
|
toast.error('Failed to reject reimbursement');
|
|
} finally {
|
|
setLoadingStatus(false);
|
|
}
|
|
};
|
|
|
|
if (error) {
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="alert alert-error shadow-lg max-w-2xl mx-auto mt-8"
|
|
>
|
|
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
|
|
<span>{error}</span>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,0.8fr),minmax(0,1fr)] gap-4 p-2 sm:p-4 max-w-[1600px] mx-auto">
|
|
{/* Left side - List of reimbursements */}
|
|
<div className="space-y-3 sm:space-y-4">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="sticky top-0 lg:top-4 z-10 bg-base-100 p-3 sm:p-5 rounded-xl shadow-lg border border-base-300"
|
|
>
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4 mb-4">
|
|
<h2 className="text-lg sm:text-xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
|
Reimbursement Requests
|
|
</h2>
|
|
<span className="badge badge-primary badge-md font-medium">
|
|
{reimbursements.length} Total
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3">
|
|
<div className="form-control">
|
|
<div className="join h-9 relative">
|
|
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
|
<Icon icon="heroicons:funnel" className="h-4 w-4" />
|
|
</div>
|
|
<select
|
|
className={`select select-bordered select-sm w-full focus:outline-none h-full join-item rounded-l-none ${filters.status.length > 0 ? 'pr-16' : 'pr-8'}`}
|
|
value="placeholder"
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
if (value === 'placeholder') return;
|
|
setFilters(prev => ({
|
|
...prev,
|
|
status: prev.status.includes(value)
|
|
? prev.status.filter(s => s !== value)
|
|
: [...prev.status, value]
|
|
}));
|
|
}}
|
|
>
|
|
<option value="placeholder">
|
|
{filters.status.length === 0
|
|
? 'Filter by Status'
|
|
: `${filters.status.length} Status${filters.status.length > 1 ? 'es' : ''} Selected`}
|
|
</option>
|
|
<option value="submitted" className={filters.status.includes('submitted') ? 'bg-base-200' : ''}>
|
|
Submitted {filters.status.includes('submitted') ? '✓' : ''}
|
|
</option>
|
|
<option value="under_review" className={filters.status.includes('under_review') ? 'bg-base-200' : ''}>
|
|
Under Review {filters.status.includes('under_review') ? '✓' : ''}
|
|
</option>
|
|
<option value="approved" className={filters.status.includes('approved') ? 'bg-base-200' : ''}>
|
|
Approved {filters.status.includes('approved') ? '✓' : ''}
|
|
</option>
|
|
<option value="rejected" className={filters.status.includes('rejected') ? 'bg-base-200' : ''}>
|
|
Rejected {filters.status.includes('rejected') ? '✓' : ''}
|
|
</option>
|
|
<option value="in_progress" className={filters.status.includes('in_progress') ? 'bg-base-200' : ''}>
|
|
In Progress {filters.status.includes('in_progress') ? '✓' : ''}
|
|
</option>
|
|
<option value="paid" className={filters.status.includes('paid') ? 'bg-base-200' : ''}>
|
|
Paid {filters.status.includes('paid') ? '✓' : ''}
|
|
</option>
|
|
</select>
|
|
|
|
{filters.status.length > 0 && (
|
|
<button
|
|
className="btn btn-ghost btn-sm absolute right-6 top-0 h-full px-2"
|
|
onClick={() => setFilters(prev => ({ ...prev, status: [] }))}
|
|
>
|
|
<Icon icon="heroicons:x-mark" className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-control">
|
|
<div className="join h-9 relative">
|
|
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
|
<Icon icon="heroicons:building-office" className="h-4 w-4" />
|
|
</div>
|
|
<select
|
|
className={`select select-bordered select-sm w-full focus:outline-none h-full join-item rounded-l-none ${filters.department.length > 0 ? 'pr-16' : 'pr-8'}`}
|
|
value="placeholder"
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
if (value === 'placeholder') return;
|
|
setFilters(prev => ({
|
|
...prev,
|
|
department: prev.department.includes(value)
|
|
? prev.department.filter(d => d !== value)
|
|
: [...prev.department, value]
|
|
}));
|
|
}}
|
|
>
|
|
<option value="placeholder">
|
|
{filters.department.length === 0
|
|
? 'Filter by Department'
|
|
: `${filters.department.length} Department${filters.department.length > 1 ? 's' : ''} Selected`}
|
|
</option>
|
|
<option value="internal" className={filters.department.includes('internal') ? 'bg-base-200' : ''}>
|
|
Internal {filters.department.includes('internal') ? '✓' : ''}
|
|
</option>
|
|
<option value="external" className={filters.department.includes('external') ? 'bg-base-200' : ''}>
|
|
External {filters.department.includes('external') ? '✓' : ''}
|
|
</option>
|
|
<option value="projects" className={filters.department.includes('projects') ? 'bg-base-200' : ''}>
|
|
Projects {filters.department.includes('projects') ? '✓' : ''}
|
|
</option>
|
|
<option value="events" className={filters.department.includes('events') ? 'bg-base-200' : ''}>
|
|
Events {filters.department.includes('events') ? '✓' : ''}
|
|
</option>
|
|
<option value="other" className={filters.department.includes('other') ? 'bg-base-200' : ''}>
|
|
Other {filters.department.includes('other') ? '✓' : ''}
|
|
</option>
|
|
</select>
|
|
|
|
{filters.department.length > 0 && (
|
|
<button
|
|
className="btn btn-ghost btn-sm absolute right-6 top-0 h-full px-2"
|
|
onClick={() => setFilters(prev => ({ ...prev, department: [] }))}
|
|
>
|
|
<Icon icon="heroicons:x-mark" className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-control">
|
|
<div className="join h-9">
|
|
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
|
<Icon icon="heroicons:calendar" className="h-4 w-4" />
|
|
</div>
|
|
<select
|
|
className="select select-bordered select-sm w-full focus:outline-none h-full join-item rounded-l-none"
|
|
value={filters.dateRange}
|
|
onChange={(e) => setFilters({ ...filters, dateRange: e.target.value as FilterOptions['dateRange'] })}
|
|
>
|
|
<option value="all">All Time</option>
|
|
<option value="week">Last Week</option>
|
|
<option value="month">Last Month</option>
|
|
<option value="year">Last Year</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-control md:col-span-2">
|
|
<div className="join h-9">
|
|
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
|
<Icon icon="heroicons:arrows-up-down" className="h-4 w-4" />
|
|
</div>
|
|
<select
|
|
className="select select-bordered select-sm w-full focus:outline-none h-full join-item rounded-none"
|
|
value={filters.sortBy}
|
|
onChange={(e) => setFilters({ ...filters, sortBy: e.target.value as FilterOptions['sortBy'] })}
|
|
>
|
|
<option value="date_of_purchase">Sort by Date</option>
|
|
<option value="total_amount">Sort by Amount</option>
|
|
<option value="status">Sort by Status</option>
|
|
</select>
|
|
<button
|
|
className="btn btn-sm btn-ghost border border-base-300 rounded-l-none join-item w-9 h-full p-0 min-h-0"
|
|
onClick={() => setFilters({ ...filters, sortOrder: filters.sortOrder === 'asc' ? 'desc' : 'asc' })}
|
|
>
|
|
<Icon
|
|
icon={filters.sortOrder === 'asc' ? 'heroicons:arrow-up' : 'heroicons:arrow-down'}
|
|
className="h-4 w-4"
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{loading ? (
|
|
<div className="flex flex-col items-center justify-center p-12 space-y-4">
|
|
<div className="relative">
|
|
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<Icon icon="heroicons:document-text" className="h-6 w-6 text-primary" />
|
|
</div>
|
|
</div>
|
|
<p className="text-base-content/70 animate-pulse">Loading reimbursements...</p>
|
|
</div>
|
|
) : reimbursements.length === 0 ? (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className="flex flex-col items-center justify-center p-12 space-y-4 bg-base-200 rounded-2xl border-2 border-dashed border-base-300"
|
|
>
|
|
<Icon icon="heroicons:document-text" className="h-16 w-16 text-base-content/40" />
|
|
<p className="text-base-content/70">No reimbursements found</p>
|
|
</motion.div>
|
|
) : (
|
|
<AnimatePresence>
|
|
<div className="space-y-4">
|
|
{reimbursements.map((reimbursement, index) => (
|
|
<motion.div
|
|
key={reimbursement.id}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: index * 0.05 }}
|
|
className={`group card bg-base-100 hover:bg-base-200 cursor-pointer transition-all duration-200 border border-base-300
|
|
${selectedReimbursement?.id === reimbursement.id ? 'ring-2 ring-primary shadow-lg scale-[1.02]' : 'hover:scale-[1.01] hover:shadow-md'}`}
|
|
onClick={() => setSelectedReimbursement(reimbursement)}
|
|
>
|
|
<div className="card-body p-5">
|
|
<div className="flex justify-between items-start gap-4">
|
|
<div className="space-y-2 flex-1 min-w-0">
|
|
<h3 className="font-bold text-lg group-hover:text-primary transition-colors truncate">
|
|
{reimbursement.title}
|
|
</h3>
|
|
<div className="flex flex-wrap gap-3 text-sm">
|
|
<div className="flex items-center gap-1.5 text-base-content/70">
|
|
<Icon icon="heroicons:calendar" className="h-4 w-4 flex-shrink-0" />
|
|
<span>{new Date(reimbursement.date_of_purchase).toLocaleDateString()}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-base-content/70">
|
|
<Icon icon="heroicons:building-office" className="h-4 w-4 flex-shrink-0" />
|
|
<span className="truncate">{reimbursement.department}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
|
<span className="font-mono font-bold text-lg text-primary whitespace-nowrap">
|
|
${reimbursement.total_amount.toFixed(2)}
|
|
</span>
|
|
<span className={`badge ${reimbursement.status === 'approved' ? 'badge-success' :
|
|
reimbursement.status === 'rejected' ? 'badge-error' :
|
|
reimbursement.status === 'under_review' ? 'badge-info' :
|
|
reimbursement.status === 'in_progress' ? 'badge-warning' :
|
|
reimbursement.status === 'paid' ? 'badge-success' :
|
|
'badge-ghost'
|
|
} gap-1.5 px-3 py-2.5 capitalize font-medium`}>
|
|
<Icon icon={
|
|
reimbursement.status === 'approved' ? 'heroicons:check-circle' :
|
|
reimbursement.status === 'rejected' ? 'heroicons:x-circle' :
|
|
reimbursement.status === 'under_review' ? 'heroicons:eye' :
|
|
reimbursement.status === 'in_progress' ? 'heroicons:currency-dollar' :
|
|
reimbursement.status === 'paid' ? 'heroicons:banknotes' :
|
|
'heroicons:clock'
|
|
} className="h-4 w-4 flex-shrink-0" />
|
|
{reimbursement.status.replace('_', ' ')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</AnimatePresence>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right side - Selected reimbursement details */}
|
|
<AnimatePresence mode="wait">
|
|
{selectedReimbursement ? (
|
|
<motion.div
|
|
key={selectedReimbursement.id}
|
|
initial={{ opacity: 0, x: 20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: 20 }}
|
|
className="card bg-base-100 lg:sticky lg:top-4 h-fit max-h-[calc(100vh-1rem)] overflow-y-auto border border-base-300 shadow-xl"
|
|
>
|
|
<div className="card-body p-3 sm:p-6 space-y-4 sm:space-y-6">
|
|
<div className="flex flex-col sm:flex-row justify-between items-start gap-3 sm:gap-4">
|
|
<div className="space-y-2 flex-1 min-w-0">
|
|
<h2 className="text-xl sm:text-2xl font-bold break-words">{selectedReimbursement.title}</h2>
|
|
<div className="relative" ref={userDropdownRef}>
|
|
<div className="flex flex-wrap items-center gap-2 text-base-content/70">
|
|
<span>Submitted by:</span>
|
|
<button
|
|
className="btn btn-ghost btn-sm gap-2 hover:bg-base-200 -ml-1 truncate"
|
|
onClick={() => setShowUserProfile(prev => prev === selectedReimbursement.submitted_by ? null : selectedReimbursement.submitted_by)}
|
|
>
|
|
{selectedReimbursement.submitter?.avatar && (
|
|
<img
|
|
src={getUserAvatarUrl(selectedReimbursement.submitter)}
|
|
alt=""
|
|
className="w-6 h-6 rounded-full flex-shrink-0"
|
|
/>
|
|
)}
|
|
<span className="font-medium text-base-content truncate">{selectedReimbursement.submitter?.name || 'Unknown User'}</span>
|
|
<Icon icon="heroicons:chevron-down" className="h-4 w-4 flex-shrink-0" />
|
|
</button>
|
|
{showUserProfile === selectedReimbursement.submitted_by && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="absolute top-full left-0 mt-2 w-72 bg-base-100 rounded-lg shadow-xl border border-base-300 z-50"
|
|
>
|
|
<div className="p-4 space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
{selectedReimbursement.submitter?.avatar && (
|
|
<img
|
|
src={getUserAvatarUrl(selectedReimbursement.submitter)}
|
|
alt=""
|
|
className="w-12 h-12 rounded-full"
|
|
/>
|
|
)}
|
|
<div>
|
|
<h3 className="font-semibold">{selectedReimbursement.submitter?.name}</h3>
|
|
<p className="text-sm text-base-content/70">{selectedReimbursement.submitter?.email}</p>
|
|
</div>
|
|
</div>
|
|
{selectedReimbursement.submitter?.zelle_information && (
|
|
<div className="pt-2 border-t border-base-300">
|
|
<h4 className="text-sm font-medium text-base-content/70 mb-1">Zelle Information</h4>
|
|
<p className="text-sm flex items-center gap-2">
|
|
<Icon icon="heroicons:banknotes" className="h-4 w-4 text-primary flex-shrink-0" />
|
|
{selectedReimbursement.submitter.zelle_information}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2 w-full sm:w-auto">
|
|
{selectedReimbursement.status === 'submitted' && (
|
|
<button
|
|
className="btn btn-sm btn-info gap-2 flex-1 sm:flex-initial"
|
|
onClick={() => updateStatus(selectedReimbursement.id, 'under_review')}
|
|
disabled={loadingStatus}
|
|
>
|
|
{loadingStatus ? (
|
|
<span className="loading loading-spinner loading-sm" />
|
|
) : (
|
|
<Icon icon="heroicons:eye" className="h-4 w-4 flex-shrink-0" />
|
|
)}
|
|
<span className="font-medium">Review</span>
|
|
</button>
|
|
)}
|
|
{selectedReimbursement.status === 'under_review' && (
|
|
<button
|
|
className="btn btn-sm btn-success gap-2 flex-1 sm:flex-initial px-6"
|
|
onClick={() => updateStatus(selectedReimbursement.id, 'approved')}
|
|
disabled={loadingStatus || !canApproveOrReject(selectedReimbursement)}
|
|
title={!canApproveOrReject(selectedReimbursement) ? 'All receipts must be audited first' : ''}
|
|
>
|
|
{loadingStatus ? (
|
|
<span className="loading loading-spinner loading-sm" />
|
|
) : (
|
|
<Icon icon="heroicons:check" className="h-4 w-4 flex-shrink-0" />
|
|
)}
|
|
<span className="font-medium">Approve</span>
|
|
</button>
|
|
)}
|
|
{selectedReimbursement.status === 'approved' && (
|
|
<button
|
|
className="btn btn-sm btn-primary gap-2 flex-1 sm:flex-initial px-6"
|
|
onClick={() => updateStatus(selectedReimbursement.id, 'in_progress')}
|
|
disabled={loadingStatus}
|
|
>
|
|
{loadingStatus ? (
|
|
<span className="loading loading-spinner loading-sm" />
|
|
) : (
|
|
<Icon icon="heroicons:currency-dollar" className="h-4 w-4 flex-shrink-0" />
|
|
)}
|
|
<span className="font-medium">Mark as in progress</span>
|
|
</button>
|
|
)}
|
|
{selectedReimbursement.status === 'in_progress' && (
|
|
<button
|
|
className="btn btn-sm btn-success gap-2 flex-1 sm:flex-initial px-6"
|
|
onClick={() => updateStatus(selectedReimbursement.id, 'paid')}
|
|
disabled={loadingStatus}
|
|
>
|
|
{loadingStatus ? (
|
|
<span className="loading loading-spinner loading-sm" />
|
|
) : (
|
|
<Icon icon="heroicons:check-circle" className="h-4 w-4 flex-shrink-0" />
|
|
)}
|
|
<span className="font-medium">Mark as Paid</span>
|
|
</button>
|
|
)}
|
|
{selectedReimbursement.status !== 'rejected' && selectedReimbursement.status !== 'paid' && (
|
|
<button
|
|
className="btn btn-sm btn-error gap-2 flex-1 sm:flex-initial px-6"
|
|
onClick={() => handleReject(selectedReimbursement.id)}
|
|
disabled={loadingStatus}
|
|
>
|
|
{loadingStatus ? (
|
|
<span className="loading loading-spinner loading-sm" />
|
|
) : (
|
|
<Icon icon="heroicons:x-mark" className="h-4 w-4 flex-shrink-0" />
|
|
)}
|
|
<span className="font-medium">Reject</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
className="btn btn-ghost btn-sm hover:bg-base-200"
|
|
onClick={() => setSelectedReimbursement(null)}
|
|
>
|
|
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 xs:grid-cols-2 gap-2 sm:gap-3">
|
|
<div className="card bg-base-200 hover:bg-base-300 transition-colors">
|
|
<div className="card-body !p-3">
|
|
<h3 className="text-sm font-medium text-base-content/70">Date of Purchase</h3>
|
|
<p className="flex items-center gap-2 font-medium mt-1">
|
|
<Icon icon="heroicons:calendar" className="h-4 w-4 text-primary flex-shrink-0" />
|
|
{new Date(selectedReimbursement.date_of_purchase).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="card bg-base-200 hover:bg-base-300 transition-colors">
|
|
<div className="card-body !p-3">
|
|
<h3 className="text-sm font-medium text-base-content/70">Payment Method</h3>
|
|
<p className="flex items-center gap-2 font-medium mt-1">
|
|
<Icon icon="heroicons:credit-card" className="h-4 w-4 text-primary flex-shrink-0" />
|
|
{selectedReimbursement.payment_method}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="card bg-base-200 hover:bg-base-300 transition-colors">
|
|
<div className="card-body !p-3">
|
|
<h3 className="text-sm font-medium text-base-content/70">Department</h3>
|
|
<p className="flex items-center gap-2 font-medium mt-1">
|
|
<Icon icon="heroicons:building-office" className="h-4 w-4 text-primary flex-shrink-0" />
|
|
<span className="capitalize">{selectedReimbursement.department.replace('_', ' ')}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="card bg-base-200 hover:bg-base-300 transition-colors">
|
|
<div className="card-body !p-3">
|
|
<h3 className="text-sm font-medium text-base-content/70">Total Amount</h3>
|
|
<p className="font-mono font-bold text-xl text-primary">
|
|
${selectedReimbursement.total_amount.toFixed(2)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{selectedReimbursement.submitter?.zelle_information && (
|
|
<div className="card bg-base-200 hover:bg-base-300 transition-colors xs:col-span-2">
|
|
<div className="card-body !p-3">
|
|
<h3 className="text-sm font-medium text-base-content/70">Zelle Information</h3>
|
|
<p className="flex items-center gap-2 font-medium mt-1">
|
|
<Icon icon="heroicons:banknotes" className="h-4 w-4 text-primary flex-shrink-0" />
|
|
{selectedReimbursement.submitter.zelle_information}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="divider before:bg-base-300 after:bg-base-300">
|
|
Receipts ({selectedReimbursement.receipts?.length || 0})
|
|
</div>
|
|
|
|
<div className="space-y-3 sm:space-y-4">
|
|
{selectedReimbursement.receipts?.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center p-4 sm:p-8 space-y-3 bg-base-200 rounded-lg border-2 border-dashed border-base-300">
|
|
<Icon icon="heroicons:receipt-percent" className="h-10 sm:h-12 w-10 sm:w-12 text-base-content/30" />
|
|
<p className="text-base-content/70 text-sm">No receipts attached</p>
|
|
</div>
|
|
) : (
|
|
selectedReimbursement.receipts?.map(receiptId => {
|
|
const receipt = receipts[receiptId];
|
|
if (!receipt) return null;
|
|
|
|
const isExpanded = expandedReceipts.has(receipt.id);
|
|
|
|
return (
|
|
<motion.div
|
|
key={receipt.id}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="card bg-base-200 hover:bg-base-300 transition-all duration-200"
|
|
>
|
|
<div className="card-body !p-3 sm:!p-4">
|
|
<div className="flex flex-col sm:flex-row justify-between items-start gap-2 sm:gap-4">
|
|
<div className="space-y-1 flex-1 min-w-0">
|
|
<h3 className="font-semibold text-base sm:text-lg truncate">
|
|
{receipt.location_name}
|
|
</h3>
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-base-content/70">
|
|
<div className="flex items-center gap-1.5">
|
|
<Icon icon="heroicons:map-pin" className="h-4 w-4 flex-shrink-0" />
|
|
<span className="truncate">{receipt.location_address}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<Icon icon="heroicons:calendar" className="h-4 w-4 flex-shrink-0" />
|
|
<span>{new Date(receipt.date).toLocaleDateString()}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
className="btn btn-ghost btn-sm p-1"
|
|
onClick={() => toggleReceipt(receipt.id)}
|
|
>
|
|
<motion.div
|
|
animate={{ rotate: isExpanded ? 180 : 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<Icon
|
|
icon="heroicons:chevron-down"
|
|
className="h-5 w-5 text-base-content/70"
|
|
/>
|
|
</motion.div>
|
|
</button>
|
|
</div>
|
|
{isExpanded && (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: "auto" }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
className="border-t border-base-300"
|
|
>
|
|
<div className="p-3 sm:p-4 space-y-4">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{receipt.itemized_expenses && (
|
|
<div className="space-y-2">
|
|
<h4 className="text-sm font-medium text-base-content/70">Itemized Expenses</h4>
|
|
<div className="space-y-2">
|
|
{(() => {
|
|
try {
|
|
const expenses: ItemizedExpense[] = typeof receipt.itemized_expenses === 'string'
|
|
? JSON.parse(receipt.itemized_expenses)
|
|
: receipt.itemized_expenses;
|
|
return expenses.map((expense, index) => (
|
|
<div key={index} className="flex justify-between items-start gap-2 text-sm">
|
|
<div>
|
|
<p className="font-medium">{expense.description}</p>
|
|
<p className="text-base-content/70">{expense.category}</p>
|
|
</div>
|
|
<span className="font-mono font-medium whitespace-nowrap">
|
|
${expense.amount.toFixed(2)}
|
|
</span>
|
|
</div>
|
|
));
|
|
} catch (error) {
|
|
console.error('Error parsing itemized expenses:', error);
|
|
return <p className="text-error text-sm">Error loading expenses</p>;
|
|
}
|
|
})()}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="space-y-2">
|
|
<h4 className="text-sm font-medium text-base-content/70">Receipt Details</h4>
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-center text-sm">
|
|
<span className="text-base-content/70">Tax</span>
|
|
<span className="font-mono font-medium">${receipt.tax.toFixed(2)}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-sm">
|
|
<span className="text-base-content/70">Total</span>
|
|
<span className="font-mono font-medium">
|
|
${(() => {
|
|
try {
|
|
const expenses: ItemizedExpense[] = typeof receipt.itemized_expenses === 'string'
|
|
? JSON.parse(receipt.itemized_expenses)
|
|
: receipt.itemized_expenses;
|
|
const subtotal = expenses.reduce((sum, item) => sum + item.amount, 0);
|
|
return (subtotal + receipt.tax).toFixed(2);
|
|
} catch (error) {
|
|
return '0.00';
|
|
}
|
|
})()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{receipt.notes && (
|
|
<div className="space-y-2">
|
|
<h4 className="text-sm font-medium text-base-content/70">Notes</h4>
|
|
<p className="text-sm whitespace-pre-wrap">{receipt.notes}</p>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-center flex-col items-center gap-2">
|
|
<button
|
|
className="btn btn-primary btn-sm gap-2 w-full"
|
|
onClick={() => {
|
|
setSelectedReceipt(receipt);
|
|
setShowReceiptModal(true);
|
|
}}
|
|
>
|
|
<Icon icon="heroicons:photo" className="h-4 w-4" />
|
|
View Receipt
|
|
</button>
|
|
|
|
</div>
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h4 className="text-sm font-medium text-base-content/70">Audited by:</h4>
|
|
{receipt.auditor_names?.length ? (
|
|
<div className="flex flex-wrap gap-1">
|
|
{receipt.auditor_names.map((name, index) => (
|
|
<span key={index} className="badge badge-ghost">{name}</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span className="text-sm text-base-content/70">Not audited yet</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
className="btn btn-primary btn-sm gap-2"
|
|
onClick={() => auditReceipt(receipt.id)}
|
|
disabled={auditingReceipt === receipt.id || receipt.audited_by.includes(Authentication.getInstance().getUserId() || '')}
|
|
>
|
|
{auditingReceipt === receipt.id ? (
|
|
<span className="loading loading-spinner loading-sm" />
|
|
) : (
|
|
<Icon icon="heroicons:check-circle" className="h-4 w-4" />
|
|
)}
|
|
{receipt.audited_by.includes(Authentication.getInstance().getUserId() || '') ? 'Audited' : 'Mark as Audited'}
|
|
</button>
|
|
</div>
|
|
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{selectedReimbursement.additional_info && (
|
|
<>
|
|
<div className="divider before:bg-base-300 after:bg-base-300">Additional Information from Member</div>
|
|
<div className="bg-base-200 p-4 rounded-lg">
|
|
<p className="whitespace-pre-wrap text-sm">{selectedReimbursement.additional_info}</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{selectedReimbursement.audit_logs && (
|
|
<>
|
|
<div
|
|
className="divider before:bg-base-300 after:bg-base-300 cursor-pointer hover:text-primary transition-colors"
|
|
onClick={() => setExpandedReceipts(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has('audit_logs')) {
|
|
next.delete('audit_logs');
|
|
} else {
|
|
next.add('audit_logs');
|
|
}
|
|
return next;
|
|
})}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
System Audit Logs
|
|
<Icon
|
|
icon={expandedReceipts.has('audit_logs') ? 'heroicons:chevron-up' : 'heroicons:chevron-down'}
|
|
className="h-4 w-4"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<AnimatePresence>
|
|
{expandedReceipts.has('audit_logs') && (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: "auto" }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
className="space-y-3 overflow-hidden"
|
|
>
|
|
{(() => {
|
|
if (!selectedReimbursement.audit_logs) return null;
|
|
|
|
let currentLogs = [];
|
|
try {
|
|
if (selectedReimbursement.audit_logs) {
|
|
if (typeof selectedReimbursement.audit_logs === 'string') {
|
|
currentLogs = JSON.parse(selectedReimbursement.audit_logs);
|
|
} else {
|
|
currentLogs = selectedReimbursement.audit_logs;
|
|
}
|
|
if (!Array.isArray(currentLogs)) {
|
|
currentLogs = [];
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error parsing existing audit logs:', error);
|
|
currentLogs = [];
|
|
}
|
|
|
|
if (currentLogs.length === 0) {
|
|
return (
|
|
<div className="bg-base-200 p-4 rounded-lg text-base-content/70 text-center">
|
|
No audit logs yet
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Sort logs by timestamp in descending order (most recent first)
|
|
currentLogs.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
|
|
const totalPages = Math.ceil(currentLogs.length / logsPerPage);
|
|
const startIndex = (currentLogPage - 1) * logsPerPage;
|
|
const endIndex = startIndex + logsPerPage;
|
|
const currentPageLogs = currentLogs.slice(startIndex, endIndex);
|
|
|
|
return (
|
|
<>
|
|
{currentPageLogs.map((log: { action: string; from?: string; to?: string; receipt_id?: string; receipt_name?: string; receipt_date?: string; receipt_amount?: number; auditor_id: string; timestamp: string }, index: number) => (
|
|
<div key={index} className="bg-base-200 p-4 rounded-lg space-y-2">
|
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`badge ${log.to === 'approved' ? 'badge-success' :
|
|
log.to === 'rejected' ? 'badge-error' :
|
|
log.to === 'under_review' ? 'badge-info' :
|
|
log.to === 'in_progress' ? 'badge-warning' :
|
|
log.to === 'paid' ? 'badge-success' :
|
|
'badge-ghost'
|
|
} capitalize`}>
|
|
{log.action === 'status_change' ? `Changed to ${log.to?.replace('_', ' ')}` :
|
|
log.action === 'receipt_audit' ? 'Audited Receipt' : log.action}
|
|
</span>
|
|
<span className="text-sm">
|
|
by <span className="font-medium">{getAuditorName(log.auditor_id)}</span>
|
|
</span>
|
|
</div>
|
|
<span className="text-sm text-base-content/70">
|
|
{new Date(log.timestamp).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
{log.action === 'status_change' && log.from && (
|
|
<p className="text-sm text-base-content/70">
|
|
Previous status: <span className="capitalize">{log.from.replace('_', ' ')}</span>
|
|
</p>
|
|
)}
|
|
{log.action === 'receipt_audit' && log.receipt_name && (
|
|
<p className="text-sm text-base-content/70">
|
|
Receipt from <span className="font-medium">{log.receipt_name}</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{totalPages > 1 && (
|
|
<div className="flex justify-center items-center gap-2 mt-4">
|
|
<button
|
|
className="btn btn-sm btn-ghost"
|
|
onClick={() => setCurrentLogPage(prev => Math.max(1, prev - 1))}
|
|
disabled={currentLogPage === 1}
|
|
>
|
|
<Icon icon="heroicons:chevron-left" className="h-4 w-4" />
|
|
</button>
|
|
<span className="text-sm">
|
|
Page {currentLogPage} of {totalPages}
|
|
</span>
|
|
<button
|
|
className="btn btn-sm btn-ghost"
|
|
onClick={() => setCurrentLogPage(prev => Math.min(totalPages, prev + 1))}
|
|
disabled={currentLogPage === totalPages}
|
|
>
|
|
<Icon icon="heroicons:chevron-right" className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
)}
|
|
|
|
{selectedReimbursement.audit_notes && (
|
|
<>
|
|
<div
|
|
className="divider before:bg-base-300 after:bg-base-300 cursor-pointer hover:text-primary transition-colors"
|
|
onClick={() => setExpandedReceipts(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has('audit_notes')) {
|
|
next.delete('audit_notes');
|
|
} else {
|
|
next.add('audit_notes');
|
|
}
|
|
return next;
|
|
})}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
Auditor Notes
|
|
<Icon
|
|
icon={expandedReceipts.has('audit_notes') ? 'heroicons:chevron-up' : 'heroicons:chevron-down'}
|
|
className="h-4 w-4"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<AnimatePresence>
|
|
{expandedReceipts.has('audit_notes') && (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: "auto" }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
className="space-y-3 overflow-hidden"
|
|
>
|
|
{(() => {
|
|
if (!selectedReimbursement.audit_notes) return null;
|
|
|
|
let notes = [];
|
|
try {
|
|
if (typeof selectedReimbursement.audit_notes === 'string') {
|
|
notes = JSON.parse(selectedReimbursement.audit_notes);
|
|
} else {
|
|
notes = selectedReimbursement.audit_notes;
|
|
}
|
|
if (!Array.isArray(notes)) {
|
|
notes = [];
|
|
}
|
|
} catch (error) {
|
|
console.error('Error parsing audit notes:', error);
|
|
notes = [];
|
|
}
|
|
|
|
if (notes.length === 0) {
|
|
return (
|
|
<div className="bg-base-200 p-4 rounded-lg text-base-content/70 text-center">
|
|
No audit notes yet
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Sort notes by timestamp in descending order (most recent first)
|
|
notes.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
|
|
const totalPages = Math.ceil(notes.length / notesPerPage);
|
|
const startIndex = (currentNotePage - 1) * notesPerPage;
|
|
const endIndex = startIndex + notesPerPage;
|
|
const currentPageNotes = notes.slice(startIndex, endIndex);
|
|
|
|
return (
|
|
<>
|
|
{currentPageNotes.map((note, index) => (
|
|
<div key={index} className="bg-base-200 p-4 rounded-lg space-y-2">
|
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm">
|
|
Note by <span className="font-medium">{getAuditorName(note.auditor_id)}</span>
|
|
</span>
|
|
{note.is_private && (
|
|
<span className="badge badge-ghost gap-1">
|
|
<Icon icon="heroicons:eye-slash" className="h-3 w-3" />
|
|
Private
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-sm text-base-content/70">
|
|
{new Date(note.timestamp).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm whitespace-pre-wrap">{note.note}</p>
|
|
</div>
|
|
))}
|
|
|
|
{totalPages > 1 && (
|
|
<div className="flex justify-center items-center gap-2 mt-4">
|
|
<button
|
|
className="btn btn-sm btn-ghost"
|
|
onClick={() => setCurrentNotePage(prev => Math.max(1, prev - 1))}
|
|
disabled={currentNotePage === 1}
|
|
>
|
|
<Icon icon="heroicons:chevron-left" className="h-4 w-4" />
|
|
</button>
|
|
<span className="text-sm">
|
|
Page {currentNotePage} of {totalPages}
|
|
</span>
|
|
<button
|
|
className="btn btn-sm btn-ghost"
|
|
onClick={() => setCurrentNotePage(prev => Math.min(totalPages, prev + 1))}
|
|
disabled={currentNotePage === totalPages}
|
|
>
|
|
<Icon icon="heroicons:chevron-right" className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
)}
|
|
|
|
<div className="form-control">
|
|
<label className="label justify-between">
|
|
<span className="label-text font-medium">New Audit Note</span>
|
|
<span className="label-text-alt text-base-content/70">
|
|
{auditNote.length} / 500 characters
|
|
</span>
|
|
</label>
|
|
<div className="space-y-2">
|
|
<textarea
|
|
className="textarea textarea-bordered w-full min-h-[120px] resize-y font-medium"
|
|
value={auditNote}
|
|
onChange={(e) => setAuditNote(e.target.value)}
|
|
placeholder="Enter your audit note here..."
|
|
rows={3}
|
|
maxLength={500}
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<label className="label cursor-pointer gap-2">
|
|
<input
|
|
type="checkbox"
|
|
className="checkbox checkbox-sm"
|
|
checked={!isPrivateNote}
|
|
onChange={(e) => setIsPrivateNote(!e.target.checked)}
|
|
/>
|
|
<span className="label-text flex items-center gap-1">
|
|
<Icon icon="heroicons:eye" className="h-4 w-4" />
|
|
Make note visible to member
|
|
</span>
|
|
</label>
|
|
</div>
|
|
<button
|
|
className="btn btn-primary w-full gap-2"
|
|
onClick={saveAuditNote}
|
|
disabled={loadingStatus || !auditNote.trim()}
|
|
>
|
|
{loadingStatus ? (
|
|
<span className="loading loading-spinner loading-sm" />
|
|
) : (
|
|
<Icon icon="heroicons:document-text" className="h-4 w-4" />
|
|
)}
|
|
Save Audit Note
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className="flex flex-col items-center justify-center p-12 space-y-4 text-base-content/70 h-[50vh] bg-base-200 rounded-2xl border-2 border-dashed border-base-300"
|
|
>
|
|
<Icon icon="heroicons:document-text" className="h-16 w-16 text-base-content/40" />
|
|
<p>Select a reimbursement to view details</p>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Add Rejection Modal */}
|
|
{showRejectModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="modal-box relative max-w-lg w-full"
|
|
>
|
|
<h3 className="font-bold text-lg mb-4">Reject Reimbursement</h3>
|
|
<p className="text-base-content/70 mb-4">
|
|
Please provide a reason for rejecting this reimbursement. This will be added as a public note.
|
|
</p>
|
|
<div className="form-control">
|
|
<textarea
|
|
className="textarea textarea-bordered w-full min-h-[120px] resize-y font-medium"
|
|
value={rejectReason}
|
|
onChange={(e) => setRejectReason(e.target.value)}
|
|
placeholder="Enter rejection reason..."
|
|
rows={3}
|
|
maxLength={500}
|
|
/>
|
|
</div>
|
|
<div className="modal-action mt-6">
|
|
<button
|
|
className="btn btn-ghost"
|
|
onClick={() => {
|
|
setShowRejectModal(false);
|
|
setRejectingId(null);
|
|
setRejectReason('');
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="btn btn-error"
|
|
onClick={submitRejection}
|
|
disabled={!rejectReason.trim() || loadingStatus}
|
|
>
|
|
{loadingStatus ? (
|
|
<span className="loading loading-spinner loading-sm" />
|
|
) : (
|
|
'Confirm Rejection'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Receipt Preview Modal */}
|
|
{showReceiptModal && selectedReceipt && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="modal-box relative max-w-4xl w-full bg-base-100 p-0 overflow-hidden"
|
|
>
|
|
<div className="sticky top-0 flex items-center justify-between p-4 bg-base-100 border-b border-base-300 z-10">
|
|
<h3 className="font-bold text-lg pl-2">
|
|
File Preview
|
|
</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="px-4 py-0">
|
|
<FilePreview
|
|
url={receiptUrl}
|
|
filename={`Receipt_${selectedReceipt.location_name.replace(/\s+/g, '_')}.txt`}
|
|
/>
|
|
</div>
|
|
<div className="sticky bottom-0 flex justify-end gap-2 p-4 bg-base-100 border-t border-base-300">
|
|
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|