This commit is contained in:
Iliyan Angelov
2025-12-04 15:10:07 +02:00
parent 3d634b4fce
commit 9f1aeb32da
78 changed files with 7204 additions and 114 deletions

View File

@@ -129,6 +129,12 @@ const AccountantDashboardPage = lazy(() => import('./pages/accountant/DashboardP
const AccountantPaymentManagementPage = lazy(() => import('./pages/accountant/PaymentManagementPage'));
const AccountantInvoiceManagementPage = lazy(() => import('./pages/accountant/InvoiceManagementPage'));
const AccountantAnalyticsDashboardPage = lazy(() => import('./pages/accountant/AnalyticsDashboardPage'));
const GLManagementPage = lazy(() => import('./pages/accountant/GLManagementPage'));
const AccountantApprovalManagementPage = lazy(() => import('./pages/accountant/ApprovalManagementPage'));
const FinancialReportsPage = lazy(() => import('./pages/accountant/FinancialReportsPage'));
const ReconciliationPage = lazy(() => import('./pages/accountant/ReconciliationPage'));
const AuditTrailPage = lazy(() => import('./pages/accountant/AuditTrailPage'));
const AccountantSecurityManagementPage = lazy(() => import('./pages/accountant/SecurityManagementPage'));
const AccountantLayout = lazy(() => import('./pages/AccountantLayout'));
const HousekeepingDashboardPage = lazy(() => import('./pages/housekeeping/DashboardPage'));
@@ -745,6 +751,10 @@ function App() {
path="upsells"
element={<UpsellManagementPage />}
/>
<Route
path="invoices/:id"
element={<InvoicePage />}
/>
<Route
path="profile"
element={<StaffProfilePage />}
@@ -775,6 +785,34 @@ function App() {
path="invoices"
element={<AccountantInvoiceManagementPage />}
/>
<Route
path="gl"
element={<GLManagementPage />}
/>
<Route
path="approvals"
element={<AccountantApprovalManagementPage />}
/>
<Route
path="reports"
element={<FinancialReportsPage />}
/>
<Route
path="reconciliation"
element={<ReconciliationPage />}
/>
<Route
path="audit-trail"
element={<AuditTrailPage />}
/>
<Route
path="security"
element={<AccountantSecurityManagementPage />}
/>
<Route
path="profile"
element={<AccountantProfilePage />}
/>
<Route
path="invoices/:id/edit"
element={<InvoiceEditPage />}
@@ -783,14 +821,6 @@ function App() {
path="invoices/:id"
element={<InvoicePage />}
/>
<Route
path="reports"
element={<AccountantAnalyticsDashboardPage />}
/>
<Route
path="profile"
element={<AccountantProfilePage />}
/>
</Route>
{/* Housekeeping Routes */}

View File

@@ -0,0 +1,69 @@
import apiClient from '../../../shared/services/apiClient';
export type ApprovalActionType =
| 'large_refund'
| 'manual_payment_status_override'
| 'invoice_write_off'
| 'significant_discount'
| 'tax_rate_change'
| 'fiscal_period_close'
| 'gl_manual_entry';
export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'cancelled';
export interface FinancialApproval {
id: number;
action_type: ApprovalActionType;
action_description: string;
status: ApprovalStatus;
requested_by: number;
requested_by_email?: string;
approved_by?: number;
approved_by_email?: string;
requested_at: string;
responded_at?: string;
payment_id?: number;
invoice_id?: number;
booking_id?: number;
gl_entry_id?: number;
amount?: number;
previous_value?: any;
new_value?: any;
currency?: string;
request_reason?: string;
response_notes?: string;
metadata?: any;
}
export interface RespondToApprovalRequest {
status: 'approved' | 'rejected';
response_notes?: string;
}
class ApprovalService {
async getApprovals(params?: {
status?: ApprovalStatus;
action_type?: ApprovalActionType;
requested_by?: number;
approved_by?: number;
page?: number;
limit?: number;
}): Promise<{ status: string; data: FinancialApproval[] }> {
const response = await apiClient.get('/financial/approvals', { params });
return response.data;
}
async getApprovalById(approvalId: number): Promise<{ status: string; data: FinancialApproval }> {
const response = await apiClient.get(`/financial/approvals/${approvalId}`);
return response.data;
}
async respondToApproval(approvalId: number, data: RespondToApprovalRequest): Promise<{ status: string; data: FinancialApproval }> {
const response = await apiClient.post(`/financial/approvals/${approvalId}/respond`, data);
return response.data;
}
}
const approvalService = new ApprovalService();
export default approvalService;

View File

@@ -59,6 +59,26 @@ const financialAuditService = {
const response = await apiClient.get(`/financial/audit-trail/${recordId}`);
return response.data;
},
/**
* Export audit trail to CSV or JSON
*/
async exportAuditTrail(filters: FinancialAuditFilters = {}, format: 'csv' | 'json' = 'csv'): Promise<Blob> {
const params = new URLSearchParams();
if (filters.payment_id) params.append('payment_id', filters.payment_id.toString());
if (filters.invoice_id) params.append('invoice_id', filters.invoice_id.toString());
if (filters.booking_id) params.append('booking_id', filters.booking_id.toString());
if (filters.action_type) params.append('action_type', filters.action_type);
if (filters.user_id) params.append('user_id', filters.user_id.toString());
if (filters.start_date) params.append('start_date', filters.start_date);
if (filters.end_date) params.append('end_date', filters.end_date);
params.append('format', format);
const response = await apiClient.get(`/financial/audit-trail/export?${params.toString()}`, {
responseType: 'blob'
});
return response.data;
},
};
export default financialAuditService;

View File

@@ -0,0 +1,98 @@
import apiClient from '../../../shared/services/apiClient';
export interface ProfitLossReport {
period: {
start_date: string;
end_date: string;
fiscal_period_id?: number;
};
revenue: {
total_revenue: number;
revenue_by_account: Record<string, number>;
};
costs: {
total_cogs: number;
gross_profit: number;
};
expenses: {
total_operating_expenses: number;
expenses_by_account: Record<string, number>;
};
profit: {
net_profit: number;
profit_margin: number;
};
}
export interface BalanceSheetReport {
as_of_date: string;
fiscal_period_id?: number;
assets: {
breakdown: Record<string, number>;
total_assets: number;
};
liabilities: {
breakdown: Record<string, number>;
total_liabilities: number;
};
equity: {
breakdown: Record<string, number>;
total_equity: number;
};
balance: {
total_liabilities_and_equity: number;
is_balanced: boolean;
};
}
export interface TaxReport {
period: {
start_date: string;
end_date: string;
};
total_tax_collected: number;
total_taxable_amount: number;
transactions: Array<{
invoice_number: string;
customer_name: string;
taxable_amount: number;
tax_amount: number;
tax_rate: number;
transaction_date: string;
}>;
}
class FinancialReportService {
async getProfitLoss(params?: {
start_date?: string;
end_date?: string;
fiscal_period_id?: number;
}): Promise<{ status: string; data: ProfitLossReport }> {
const response = await apiClient.get('/financial/profit-loss', { params });
return response.data;
}
async getBalanceSheet(params?: {
as_of_date?: string;
fiscal_period_id?: number;
}): Promise<{ status: string; data: BalanceSheetReport }> {
const response = await apiClient.get('/financial/balance-sheet', { params });
return response.data;
}
async getTaxReport(params?: {
start_date?: string;
end_date?: string;
format?: 'json' | 'csv';
}): Promise<{ status: string; data: TaxReport } | Blob> {
const response = await apiClient.get('/financial/tax-report', {
params,
responseType: params?.format === 'csv' ? 'blob' : 'json'
});
return response.data;
}
}
const financialReportService = new FinancialReportService();
export default financialReportService;

View File

@@ -0,0 +1,155 @@
import apiClient from '../../../shared/services/apiClient';
// Chart of Accounts
export interface Account {
id: number;
account_number: string;
account_name: string;
account_type: string;
account_category: string;
normal_balance: string;
description?: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface CreateAccountRequest {
account_number: string;
account_name: string;
account_type: string;
account_category: string;
normal_balance: string;
description?: string;
is_active?: boolean;
}
// Fiscal Periods
export interface FiscalPeriod {
id: number;
name: string;
start_date: string;
end_date: string;
status: 'Open' | 'Closed' | 'Locked';
is_current: boolean;
created_at: string;
updated_at: string;
}
export interface CreateFiscalPeriodRequest {
name: string;
start_date: string;
end_date: string;
set_as_current?: boolean;
}
// Journal Entries
export interface JournalLine {
account_id: number;
debit: number;
credit: number;
description?: string;
}
export interface JournalEntry {
id: number;
entry_date: string;
description: string;
entry_type: string;
reference_id?: string;
fiscal_period_id: number;
created_at: string;
updated_at: string;
journal_lines: Array<JournalLine & { id: number; account: Account }>;
}
export interface CreateJournalEntryRequest {
entry_date: string;
description: string;
entry_type: string;
fiscal_period_id: number;
lines: JournalLine[];
reference_id?: string;
requires_approval?: boolean;
request_reason?: string;
}
// Trial Balance
export interface TrialBalance {
accounts: Array<{
account_number: string;
account_name: string;
account_type: string;
normal_balance: string;
debit: number;
credit: number;
}>;
total_debits: number;
total_credits: number;
is_balanced: boolean;
}
class GLService {
// Chart of Accounts
async getAccounts(): Promise<{ status: string; data: { accounts: Account[] } }> {
const response = await apiClient.get('/financial/gl/accounts');
return response.data;
}
async createAccount(data: CreateAccountRequest): Promise<{ status: string; data: Account }> {
const response = await apiClient.post('/financial/gl/accounts', data);
return response.data;
}
// Fiscal Periods
async getFiscalPeriods(): Promise<{ status: string; data: { fiscal_periods: FiscalPeriod[] } }> {
const response = await apiClient.get('/financial/gl/fiscal-periods');
return response.data;
}
async createFiscalPeriod(data: CreateFiscalPeriodRequest): Promise<{ status: string; data: FiscalPeriod }> {
const response = await apiClient.post('/financial/gl/fiscal-periods', data);
return response.data;
}
async closeFiscalPeriod(periodId: number, lockPeriod: boolean = false, reason?: string): Promise<{ status: string; data: FiscalPeriod }> {
const response = await apiClient.put(`/financial/gl/fiscal-periods/${periodId}/close`, {
lock_period: lockPeriod,
request_reason: reason
});
return response.data;
}
// Journal Entries
async getJournalEntries(params?: {
fiscal_period_id?: number;
start_date?: string;
end_date?: string;
entry_type?: string;
account_id?: number;
page?: number;
limit?: number;
}): Promise<{ status: string; data: { journal_entries: JournalEntry[] } }> {
const response = await apiClient.get('/financial/gl/journal-entries', { params });
return response.data;
}
async createJournalEntry(data: CreateJournalEntryRequest): Promise<{ status: string; data: JournalEntry }> {
const response = await apiClient.post('/financial/gl/journal-entries', data);
return response.data;
}
// Trial Balance
async getTrialBalance(fiscalPeriodId?: number, asOfDate?: string): Promise<{ status: string; data: TrialBalance }> {
const params: any = {};
if (fiscalPeriodId) params.fiscal_period_id = fiscalPeriodId;
if (asOfDate) params.as_of_date = asOfDate;
const response = await apiClient.get('/financial/gl/trial-balance', { params });
return response.data;
}
}
const glService = new GLService();
export default glService;

View File

@@ -0,0 +1,96 @@
import apiClient from '../../../shared/services/apiClient';
export type ExceptionType =
| 'missing_invoice'
| 'missing_payment'
| 'amount_mismatch'
| 'duplicate_payment'
| 'orphaned_payment'
| 'date_mismatch';
export type ExceptionStatus = 'open' | 'assigned' | 'in_review' | 'resolved' | 'closed';
export interface ReconciliationException {
id: number;
exception_type: ExceptionType;
status: ExceptionStatus;
severity: 'low' | 'medium' | 'high' | 'critical';
payment_id?: number;
invoice_id?: number;
booking_id?: number;
description: string;
expected_amount?: number;
actual_amount?: number;
difference?: number;
assigned_to?: number;
assigned_at?: string;
resolved_by?: number;
resolved_at?: string;
resolution_notes?: string;
comments?: Array<{
user_id: number;
comment: string;
created_at: string;
}>;
created_at: string;
updated_at: string;
}
export interface ExceptionStats {
total: number;
by_status: Record<ExceptionStatus, number>;
by_type: Record<ExceptionType, number>;
by_severity: Record<string, number>;
}
class ReconciliationService {
async runReconciliation(params?: {
start_date?: string;
end_date?: string;
}): Promise<{ status: string; data: { exceptions_created: number; exceptions: any[] } }> {
const response = await apiClient.post('/financial/reconciliation/run', null, { params });
return response.data;
}
async getExceptions(params?: {
status?: ExceptionStatus;
exception_type?: ExceptionType;
assigned_to?: number;
severity?: string;
page?: number;
limit?: number;
}): Promise<{ status: string; data: { exceptions: ReconciliationException[]; pagination: any } }> {
const response = await apiClient.get('/financial/reconciliation/exceptions', { params });
return response.data;
}
async assignException(exceptionId: number, assignedTo: number): Promise<{ status: string; data: any }> {
const response = await apiClient.post(`/financial/reconciliation/exceptions/${exceptionId}/assign`, {
assigned_to: assignedTo
});
return response.data;
}
async resolveException(exceptionId: number, notes: string): Promise<{ status: string; data: any }> {
const response = await apiClient.post(`/financial/reconciliation/exceptions/${exceptionId}/resolve`, {
notes
});
return response.data;
}
async addComment(exceptionId: number, comment: string): Promise<{ status: string; data: any }> {
const response = await apiClient.post(`/financial/reconciliation/exceptions/${exceptionId}/comment`, {
comment
});
return response.data;
}
async getExceptionStats(): Promise<{ status: string; data: ExceptionStats }> {
const response = await apiClient.get('/financial/reconciliation/exceptions/stats');
return response.data;
}
}
const reconciliationService = new ReconciliationService();
export default reconciliationService;

View File

@@ -0,0 +1,81 @@
import apiClient from '../../../shared/services/apiClient';
export interface AccountantSession {
id: number;
ip_address?: string;
user_agent?: string;
country?: string;
city?: string;
last_activity: string;
step_up_authenticated: boolean;
step_up_expires_at?: string;
created_at: string;
expires_at: string;
}
export interface AccountantActivityLog {
id: number;
user_id: number;
activity_type: string;
activity_description: string;
ip_address?: string;
country?: string;
city?: string;
risk_level: 'low' | 'medium' | 'high' | 'critical';
is_unusual: boolean;
metadata?: any;
created_at: string;
}
export interface MFAStatus {
requires_mfa: boolean;
mfa_enabled: boolean;
is_enforced: boolean;
enforcement_reason?: string;
backup_codes_count: number;
}
class AccountantSecurityService {
async verifyStepUp(data: {
mfa_token?: string;
password?: string;
session_token?: string;
}): Promise<{ status: string; data: { step_up_completed: boolean } }> {
const response = await apiClient.post('/accountant/security/step-up/verify', data);
return response.data;
}
async getSessions(): Promise<{ status: string; data: { sessions: AccountantSession[] } }> {
const response = await apiClient.get('/accountant/security/sessions');
return response.data;
}
async revokeSession(sessionId: number): Promise<{ status: string; message: string }> {
const response = await apiClient.post(`/accountant/security/sessions/${sessionId}/revoke`);
return response.data;
}
async revokeAllSessions(): Promise<{ status: string; data: { revoked_count: number } }> {
const response = await apiClient.post('/accountant/security/sessions/revoke-all');
return response.data;
}
async getActivityLogs(params?: {
page?: number;
limit?: number;
risk_level?: string;
is_unusual?: boolean;
}): Promise<{ status: string; data: { logs: AccountantActivityLog[]; pagination: any } }> {
const response = await apiClient.get('/accountant/security/activity-logs', { params });
return response.data;
}
async getMFAStatus(): Promise<{ status: string; data: MFAStatus }> {
const response = await apiClient.get('/accountant/security/mfa-status');
return response.data;
}
}
const accountantSecurityService = new AccountantSecurityService();
export default accountantSecurityService;

View File

@@ -0,0 +1,280 @@
import React, { useState, useEffect } from 'react';
import { CheckCircle2, XCircle, Clock, AlertCircle, Eye } from 'lucide-react';
import { toast } from 'react-toastify';
import approvalService, { FinancialApproval, ApprovalStatus } from '../../features/payments/services/approvalService';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { formatDate } from '../../shared/utils/format';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
const ApprovalManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [loading, setLoading] = useState(true);
const [approvals, setApprovals] = useState<FinancialApproval[]>([]);
const [selectedApproval, setSelectedApproval] = useState<FinancialApproval | null>(null);
const [filter, setFilter] = useState<ApprovalStatus | 'all'>('all');
const [responseNotes, setResponseNotes] = useState('');
useEffect(() => {
fetchApprovals();
}, [filter]);
const fetchApprovals = async () => {
try {
setLoading(true);
const params: any = {};
if (filter !== 'all') params.status = filter;
const response = await approvalService.getApprovals(params);
setApprovals(response.data || []);
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load approvals');
} finally {
setLoading(false);
}
};
const handleRespond = async (approvalId: number, status: 'approved' | 'rejected') => {
if (!responseNotes.trim() && status === 'rejected') {
toast.error('Please provide notes for rejection');
return;
}
try {
await approvalService.respondToApproval(approvalId, {
status,
response_notes: responseNotes || undefined
});
toast.success(`Approval ${status} successfully`);
setSelectedApproval(null);
setResponseNotes('');
fetchApprovals();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to respond to approval');
}
};
const getStatusColor = (status: ApprovalStatus) => {
switch (status) {
case 'approved':
return 'bg-green-100 text-green-800 border-green-200';
case 'rejected':
return 'bg-red-100 text-red-800 border-red-200';
case 'pending':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const getActionTypeLabel = (type: string) => {
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
};
if (loading && approvals.length === 0) {
return <Loading fullScreen text="Loading approvals..." />;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* Header */}
<div className="mb-6 sm:mb-8 md:mb-10">
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-emerald-400 via-green-500 to-emerald-600 rounded-full"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent">
Approval Management
</h1>
</div>
<p className="text-slate-600 text-sm sm:text-base md:text-lg font-light">
Review and respond to financial approval requests
</p>
</div>
{/* Filters */}
<div className="flex gap-2 mb-6 flex-wrap">
<button
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded-lg font-semibold transition-colors ${
filter === 'all' ? 'bg-emerald-500 text-white' : 'bg-white text-slate-700 hover:bg-slate-100'
}`}
>
All
</button>
<button
onClick={() => setFilter('pending')}
className={`px-4 py-2 rounded-lg font-semibold transition-colors ${
filter === 'pending' ? 'bg-emerald-500 text-white' : 'bg-white text-slate-700 hover:bg-slate-100'
}`}
>
<Clock className="inline w-4 h-4 mr-2" />
Pending
</button>
<button
onClick={() => setFilter('approved')}
className={`px-4 py-2 rounded-lg font-semibold transition-colors ${
filter === 'approved' ? 'bg-emerald-500 text-white' : 'bg-white text-slate-700 hover:bg-slate-100'
}`}
>
<CheckCircle2 className="inline w-4 h-4 mr-2" />
Approved
</button>
<button
onClick={() => setFilter('rejected')}
className={`px-4 py-2 rounded-lg font-semibold transition-colors ${
filter === 'rejected' ? 'bg-emerald-500 text-white' : 'bg-white text-slate-700 hover:bg-slate-100'
}`}
>
<XCircle className="inline w-4 h-4 mr-2" />
Rejected
</button>
</div>
{/* Approvals List */}
<div className="bg-white rounded-xl shadow-lg p-6">
{approvals.length === 0 ? (
<EmptyState title="No Approvals" description="Approval requests will appear here" />
) : (
<div className="space-y-4">
{approvals.map((approval) => (
<div
key={approval.id}
className="border border-slate-200 rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => setSelectedApproval(approval)}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-bold text-lg">{getActionTypeLabel(approval.action_type)}</h3>
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getStatusColor(approval.status)}`}>
{approval.status.charAt(0).toUpperCase() + approval.status.slice(1)}
</span>
</div>
<p className="text-slate-600 mb-2">{approval.action_description}</p>
<div className="flex flex-wrap gap-4 text-sm text-slate-500">
<span>Requested by: {approval.requested_by_email || `User #${approval.requested_by}`}</span>
<span></span>
<span>{formatDate(approval.requested_at)}</span>
{approval.amount && (
<>
<span></span>
<span className="font-semibold text-slate-700">{formatCurrency(approval.amount)}</span>
</>
)}
</div>
{approval.request_reason && (
<p className="mt-2 text-sm text-slate-600 bg-slate-50 p-2 rounded">
<strong>Reason:</strong> {approval.request_reason}
</p>
)}
</div>
{approval.status === 'pending' && (
<button
onClick={(e) => {
e.stopPropagation();
setSelectedApproval(approval);
}}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 flex items-center gap-2"
>
<Eye className="w-4 h-4" />
Review
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Approval Detail Modal */}
{selectedApproval && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<div className="flex justify-between items-start">
<div>
<h2 className="text-2xl font-bold mb-2">{getActionTypeLabel(selectedApproval.action_type)}</h2>
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getStatusColor(selectedApproval.status)}`}>
{selectedApproval.status.charAt(0).toUpperCase() + selectedApproval.status.slice(1)}
</span>
</div>
<button
onClick={() => {
setSelectedApproval(null);
setResponseNotes('');
}}
className="text-slate-500 hover:text-slate-700"
>
<XCircle className="w-6 h-6" />
</button>
</div>
</div>
<div className="p-6 space-y-4">
<div>
<h3 className="font-semibold mb-2">Description</h3>
<p className="text-slate-600">{selectedApproval.action_description}</p>
</div>
{selectedApproval.request_reason && (
<div>
<h3 className="font-semibold mb-2">Request Reason</h3>
<p className="text-slate-600">{selectedApproval.request_reason}</p>
</div>
)}
{selectedApproval.amount && (
<div>
<h3 className="font-semibold mb-2">Amount</h3>
<p className="text-lg font-bold text-emerald-600">{formatCurrency(selectedApproval.amount)}</p>
</div>
)}
{selectedApproval.previous_value && (
<div>
<h3 className="font-semibold mb-2">Previous Value</h3>
<pre className="bg-slate-50 p-3 rounded text-sm overflow-x-auto">
{JSON.stringify(selectedApproval.previous_value, null, 2)}
</pre>
</div>
)}
{selectedApproval.new_value && (
<div>
<h3 className="font-semibold mb-2">New Value</h3>
<pre className="bg-slate-50 p-3 rounded text-sm overflow-x-auto">
{JSON.stringify(selectedApproval.new_value, null, 2)}
</pre>
</div>
)}
{selectedApproval.status === 'pending' && (
<div>
<h3 className="font-semibold mb-2">Response Notes</h3>
<textarea
value={responseNotes}
onChange={(e) => setResponseNotes(e.target.value)}
className="w-full p-3 border border-slate-300 rounded-lg"
rows={4}
placeholder="Add notes for your response..."
/>
<div className="flex gap-3 mt-4">
<button
onClick={() => handleRespond(selectedApproval.id, 'approved')}
className="flex-1 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 flex items-center justify-center gap-2"
>
<CheckCircle2 className="w-4 h-4" />
Approve
</button>
<button
onClick={() => handleRespond(selectedApproval.id, 'rejected')}
className="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 flex items-center justify-center gap-2"
>
<XCircle className="w-4 h-4" />
Reject
</button>
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default ApprovalManagementPage;

View File

@@ -0,0 +1,257 @@
import React, { useState, useEffect } from 'react';
import { FileText, Download, Filter, Search, Calendar } from 'lucide-react';
import { toast } from 'react-toastify';
import financialAuditService, { FinancialAuditRecord } from '../../features/payments/services/financialAuditService';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { formatDate } from '../../shared/utils/format';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
const AuditTrailPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [loading, setLoading] = useState(true);
const [records, setRecords] = useState<FinancialAuditRecord[]>([]);
const [pagination, setPagination] = useState<any>(null);
const [filters, setFilters] = useState({
action_type: '',
user_id: '',
start_date: '',
end_date: '',
payment_id: '',
invoice_id: '',
booking_id: '',
page: 1,
limit: 50,
});
const [showFilters, setShowFilters] = useState(false);
useEffect(() => {
fetchAuditTrail();
}, [filters.page, filters.action_type, filters.user_id, filters.start_date, filters.end_date]);
const fetchAuditTrail = async () => {
try {
setLoading(true);
const response = await financialAuditService.getAuditTrail(filters);
if (response.status === 'success' && response.data) {
setRecords(response.data.audit_trail || []);
setPagination(response.data.pagination);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load audit trail');
} finally {
setLoading(false);
}
};
const handleExport = async (format: 'csv' | 'json') => {
try {
const blob = await financialAuditService.exportAuditTrail(filters, format);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit-trail-${new Date().toISOString().split('T')[0]}.${format}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Audit trail exported successfully');
} catch (error: any) {
toast.error('Failed to export audit trail');
}
};
const getActionTypeColor = (type: string) => {
if (type.includes('create') || type.includes('approve')) return 'bg-green-100 text-green-800';
if (type.includes('update') || type.includes('modify')) return 'bg-blue-100 text-blue-800';
if (type.includes('delete') || type.includes('reject')) return 'bg-red-100 text-red-800';
if (type.includes('export')) return 'bg-purple-100 text-purple-800';
return 'bg-gray-100 text-gray-800';
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* Header */}
<div className="mb-6 sm:mb-8 md:mb-10">
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-emerald-400 via-green-500 to-emerald-600 rounded-full"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent">
Financial Audit Trail
</h1>
</div>
<p className="text-slate-600 text-sm sm:text-base md:text-lg font-light">
Complete audit log of all financial actions
</p>
</div>
{/* Actions */}
<div className="bg-white rounded-xl shadow-lg p-4 mb-6 flex justify-between items-center flex-wrap gap-4">
<div className="flex gap-2">
<button
onClick={() => setShowFilters(!showFilters)}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 flex items-center gap-2"
>
<Filter className="w-4 h-4" />
Filters
</button>
</div>
<div className="flex gap-2">
<button
onClick={() => handleExport('csv')}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 flex items-center gap-2"
>
<Download className="w-4 h-4" />
Export CSV
</button>
<button
onClick={() => handleExport('json')}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 flex items-center gap-2"
>
<Download className="w-4 h-4" />
Export JSON
</button>
</div>
</div>
{/* Filters */}
{showFilters && (
<div className="bg-white rounded-xl shadow-lg p-4 mb-6">
<div className="grid md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-semibold mb-2">Action Type</label>
<input
type="text"
value={filters.action_type}
onChange={(e) => setFilters({ ...filters, action_type: e.target.value, page: 1 })}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
placeholder="Filter by action type"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2">Start Date</label>
<input
type="date"
value={filters.start_date}
onChange={(e) => setFilters({ ...filters, start_date: e.target.value, page: 1 })}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2">End Date</label>
<input
type="date"
value={filters.end_date}
onChange={(e) => setFilters({ ...filters, end_date: e.target.value, page: 1 })}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2">Payment ID</label>
<input
type="number"
value={filters.payment_id}
onChange={(e) => setFilters({ ...filters, payment_id: e.target.value, page: 1 })}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
placeholder="Filter by payment ID"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2">Invoice ID</label>
<input
type="number"
value={filters.invoice_id}
onChange={(e) => setFilters({ ...filters, invoice_id: e.target.value, page: 1 })}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
placeholder="Filter by invoice ID"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2">User ID</label>
<input
type="number"
value={filters.user_id}
onChange={(e) => setFilters({ ...filters, user_id: e.target.value, page: 1 })}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
placeholder="Filter by user ID"
/>
</div>
</div>
</div>
)}
{/* Audit Trail */}
<div className="bg-white rounded-xl shadow-lg p-6">
{loading && records.length === 0 ? (
<Loading text="Loading audit trail..." />
) : records.length === 0 ? (
<EmptyState title="No Audit Records" description="Audit trail records will appear here" />
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left p-3">Date</th>
<th className="text-left p-3">Action Type</th>
<th className="text-left p-3">Description</th>
<th className="text-left p-3">Performed By</th>
<th className="text-right p-3">Amount</th>
<th className="text-left p-3">Related IDs</th>
</tr>
</thead>
<tbody>
{records.map((record) => (
<tr key={record.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="p-3 text-sm">{formatDate(record.created_at)}</td>
<td className="p-3">
<span className={`px-2 py-1 rounded-full text-xs font-semibold ${getActionTypeColor(record.action_type)}`}>
{record.action_type.replace(/_/g, ' ')}
</span>
</td>
<td className="p-3">{record.action_description}</td>
<td className="p-3 text-sm">{record.performed_by_email || `User #${record.performed_by}`}</td>
<td className="p-3 text-right">
{record.amount ? formatCurrency(record.amount) : '-'}
</td>
<td className="p-3 text-sm text-slate-500">
{record.payment_id && `Payment: ${record.payment_id} `}
{record.invoice_id && `Invoice: ${record.invoice_id} `}
{record.booking_id && `Booking: ${record.booking_id}`}
</td>
</tr>
))}
</tbody>
</table>
</div>
{pagination && (
<div className="flex justify-between items-center mt-4 pt-4 border-t border-slate-200">
<div className="text-sm text-slate-600">
Showing {((pagination.page - 1) * pagination.limit) + 1} to {Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total} records
</div>
<div className="flex gap-2">
<button
onClick={() => setFilters({ ...filters, page: filters.page - 1 })}
disabled={pagination.page === 1}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 disabled:opacity-50"
>
Previous
</button>
<button
onClick={() => setFilters({ ...filters, page: filters.page + 1 })}
disabled={pagination.page >= pagination.total_pages}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 disabled:opacity-50"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
};
export default AuditTrailPage;

View File

@@ -0,0 +1,415 @@
import React, { useState, useEffect } from 'react';
import { TrendingUp, FileText, Download } from 'lucide-react';
import { toast } from 'react-toastify';
import financialReportService, { ProfitLossReport, BalanceSheetReport, TaxReport } from '../../features/payments/services/financialReportService';
import glService from '../../features/payments/services/glService';
import Loading from '../../shared/components/Loading';
import { formatDate } from '../../shared/utils/format';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
const FinancialReportsPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [activeTab, setActiveTab] = useState<'pl' | 'balance' | 'tax'>('pl');
const [loading, setLoading] = useState(false);
const [plReport, setPlReport] = useState<ProfitLossReport | null>(null);
const [balanceReport, setBalanceReport] = useState<BalanceSheetReport | null>(null);
const [taxReport, setTaxReport] = useState<TaxReport | null>(null);
const [periods, setPeriods] = useState<any[]>([]);
const [selectedPeriod, setSelectedPeriod] = useState<number | null>(null);
const [dateRange, setDateRange] = useState({
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0],
});
useEffect(() => {
fetchPeriods();
}, []);
useEffect(() => {
if (activeTab === 'pl') fetchPLReport();
else if (activeTab === 'balance') fetchBalanceSheet();
else if (activeTab === 'tax') fetchTaxReport();
}, [activeTab, selectedPeriod, dateRange]);
const fetchPeriods = async () => {
try {
const response = await glService.getFiscalPeriods();
setPeriods(response.data.fiscal_periods || []);
} catch (error) {
// Silently fail
}
};
const fetchPLReport = async () => {
try {
setLoading(true);
const params: any = {};
if (selectedPeriod) {
params.fiscal_period_id = selectedPeriod;
} else {
params.start_date = dateRange.start;
params.end_date = dateRange.end;
}
const response = await financialReportService.getProfitLoss(params);
setPlReport(response.data);
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load P&L report');
} finally {
setLoading(false);
}
};
const fetchBalanceSheet = async () => {
try {
setLoading(true);
const params: any = {};
if (selectedPeriod) {
params.fiscal_period_id = selectedPeriod;
} else {
params.as_of_date = dateRange.end;
}
const response = await financialReportService.getBalanceSheet(params);
setBalanceReport(response.data);
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load balance sheet');
} finally {
setLoading(false);
}
};
const fetchTaxReport = async () => {
try {
setLoading(true);
const params: any = {
start_date: dateRange.start,
end_date: dateRange.end,
};
const response = await financialReportService.getTaxReport(params);
if (response && 'data' in response && !(response instanceof Blob)) {
setTaxReport(response.data);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load tax report');
} finally {
setLoading(false);
}
};
const handleExport = async (format: 'csv' | 'json') => {
try {
const response = await financialReportService.getTaxReport({
start_date: dateRange.start,
end_date: dateRange.end,
format
});
if (response instanceof Blob) {
const url = window.URL.createObjectURL(response);
const a = document.createElement('a');
a.href = url;
a.download = `tax-report-${dateRange.start}-${dateRange.end}.${format}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Report exported successfully');
}
} catch (error: any) {
toast.error('Failed to export report');
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* Header */}
<div className="mb-6 sm:mb-8 md:mb-10">
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-emerald-400 via-green-500 to-emerald-600 rounded-full"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent">
Financial Reports
</h1>
</div>
<p className="text-slate-600 text-sm sm:text-base md:text-lg font-light">
Profit & Loss, Balance Sheet, and Tax Reports
</p>
</div>
{/* Filters */}
<div className="bg-white rounded-xl shadow-lg p-4 mb-6">
<div className="flex flex-wrap gap-4 items-end">
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-semibold mb-2">Fiscal Period</label>
<select
value={selectedPeriod || ''}
onChange={(e) => setSelectedPeriod(e.target.value ? parseInt(e.target.value) : null)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
>
<option value="">Custom Date Range</option>
{periods.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{!selectedPeriod && (
<>
<div className="flex-1 min-w-[150px]">
<label className="block text-sm font-semibold mb-2">Start Date</label>
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
/>
</div>
<div className="flex-1 min-w-[150px]">
<label className="block text-sm font-semibold mb-2">End Date</label>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })}
className="w-full px-4 py-2 border border-slate-300 rounded-lg"
/>
</div>
</>
)}
</div>
</div>
{/* Tabs */}
<div className="flex flex-wrap gap-2 mb-6 border-b border-slate-200">
<button
onClick={() => setActiveTab('pl')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'pl'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<TrendingUp className="inline w-4 h-4 mr-2" />
Profit & Loss
</button>
<button
onClick={() => setActiveTab('balance')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'balance'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<FileText className="inline w-4 h-4 mr-2" />
Balance Sheet
</button>
<button
onClick={() => setActiveTab('tax')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'tax'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<FileText className="inline w-4 h-4 mr-2" />
Tax Report
</button>
</div>
{/* Content */}
{loading ? (
<Loading text="Loading report..." />
) : (
<>
{activeTab === 'pl' && plReport && (
<div className="bg-white rounded-xl shadow-lg p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">Profit & Loss Statement</h2>
<button
onClick={() => handleExport('csv')}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 flex items-center gap-2"
>
<Download className="w-4 h-4" />
Export CSV
</button>
</div>
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3">Revenue</h3>
<div className="space-y-2">
{plReport.revenue?.revenue_by_account && Object.entries(plReport.revenue.revenue_by_account).map(([account, amount]) => (
<div key={account} className="flex justify-between p-2 bg-slate-50 rounded">
<span>{account}</span>
<span className="font-semibold">{formatCurrency(amount)}</span>
</div>
))}
<div className="flex justify-between p-3 bg-emerald-50 rounded border-t-2 border-emerald-500 font-bold">
<span>Total Revenue</span>
<span>{formatCurrency(plReport.revenue?.total_revenue || 0)}</span>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Cost of Goods Sold</h3>
<div className="flex justify-between p-3 bg-slate-50 rounded">
<span>Total COGS</span>
<span className="font-semibold">{formatCurrency(plReport.costs?.total_cogs || 0)}</span>
</div>
<div className="flex justify-between p-3 bg-blue-50 rounded border-t-2 border-blue-500 font-bold mt-2">
<span>Gross Profit</span>
<span>{formatCurrency(plReport.costs?.gross_profit || 0)}</span>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Operating Expenses</h3>
<div className="space-y-2">
{plReport.expenses?.expenses_by_account && Object.entries(plReport.expenses.expenses_by_account).map(([account, amount]) => (
<div key={account} className="flex justify-between p-2 bg-slate-50 rounded">
<span>{account}</span>
<span className="font-semibold">{formatCurrency(amount)}</span>
</div>
))}
<div className="flex justify-between p-3 bg-slate-100 rounded border-t-2 border-slate-400 font-semibold">
<span>Total Operating Expenses</span>
<span>{formatCurrency(plReport.expenses?.total_operating_expenses || 0)}</span>
</div>
</div>
</div>
<div className="flex justify-between p-4 bg-gradient-to-r from-emerald-100 to-green-100 rounded border-t-4 border-emerald-500">
<span className="text-xl font-bold">Net Profit</span>
<span className="text-xl font-bold">{formatCurrency(plReport.profit?.net_profit || 0)}</span>
</div>
<div className="text-sm text-slate-600">
Profit Margin: {plReport.profit?.profit_margin ? plReport.profit.profit_margin.toFixed(2) : '0.00'}%
</div>
</div>
</div>
)}
{activeTab === 'balance' && balanceReport && (
<div className="bg-white rounded-xl shadow-lg p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">Balance Sheet</h2>
<div className="text-sm text-slate-600">
As of: {balanceReport.as_of_date ? formatDate(balanceReport.as_of_date) : 'N/A'}
</div>
</div>
<div className="grid md:grid-cols-2 gap-6">
<div>
<h3 className="text-lg font-semibold mb-3">Assets</h3>
<div className="space-y-2">
{balanceReport.assets?.breakdown && Object.entries(balanceReport.assets.breakdown).map(([account, amount]) => (
<div key={account} className="flex justify-between p-2 bg-slate-50 rounded">
<span>{account}</span>
<span className="font-semibold">{formatCurrency(amount)}</span>
</div>
))}
<div className="flex justify-between p-3 bg-emerald-50 rounded border-t-2 border-emerald-500 font-bold">
<span>Total Assets</span>
<span>{formatCurrency(balanceReport.assets?.total_assets || 0)}</span>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Liabilities & Equity</h3>
<div className="space-y-2">
{balanceReport.liabilities?.breakdown && Object.entries(balanceReport.liabilities.breakdown).map(([account, amount]) => (
<div key={account} className="flex justify-between p-2 bg-slate-50 rounded">
<span>{account}</span>
<span className="font-semibold">{formatCurrency(amount)}</span>
</div>
))}
<div className="flex justify-between p-3 bg-slate-100 rounded border-t-2 border-slate-400 font-semibold">
<span>Total Liabilities</span>
<span>{formatCurrency(balanceReport.liabilities?.total_liabilities || 0)}</span>
</div>
{balanceReport.equity?.breakdown && Object.entries(balanceReport.equity.breakdown).map(([account, amount]) => (
<div key={account} className="flex justify-between p-2 bg-slate-50 rounded">
<span>{account}</span>
<span className="font-semibold">{formatCurrency(amount)}</span>
</div>
))}
<div className="flex justify-between p-3 bg-blue-50 rounded border-t-2 border-blue-500 font-semibold">
<span>Total Equity</span>
<span>{formatCurrency(balanceReport.equity?.total_equity || 0)}</span>
</div>
<div className={`flex justify-between p-3 rounded border-t-2 font-bold ${
balanceReport.balance?.is_balanced
? 'bg-green-50 border-green-500'
: 'bg-red-50 border-red-500'
}`}>
<span>Total Liabilities & Equity</span>
<span>{formatCurrency(balanceReport.balance?.total_liabilities_and_equity || 0)}</span>
</div>
</div>
</div>
</div>
<div className={`mt-4 p-3 rounded ${
balanceReport.balance?.is_balanced ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{balanceReport.balance?.is_balanced ? '✓ Balance Sheet is balanced' : '✗ Balance Sheet is not balanced'}
</div>
</div>
)}
{activeTab === 'tax' && taxReport && (
<div className="bg-white rounded-xl shadow-lg p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">Tax Report</h2>
<div className="flex gap-2">
<button
onClick={() => handleExport('csv')}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 flex items-center gap-2"
>
<Download className="w-4 h-4" />
Export CSV
</button>
</div>
</div>
<div className="mb-6 p-4 bg-slate-50 rounded-lg">
<div className="grid md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-slate-600">Total Tax Collected</p>
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(taxReport.total_tax_collected || 0)}</p>
</div>
<div>
<p className="text-sm text-slate-600">Total Taxable Amount</p>
<p className="text-2xl font-bold">{formatCurrency(taxReport.total_taxable_amount || 0)}</p>
</div>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left p-3">Invoice #</th>
<th className="text-left p-3">Customer</th>
<th className="text-right p-3">Taxable Amount</th>
<th className="text-right p-3">Tax Amount</th>
<th className="text-right p-3">Tax Rate</th>
<th className="text-left p-3">Date</th>
</tr>
</thead>
<tbody>
{taxReport.transactions?.map((tx, idx) => (
<tr key={idx} className="border-b border-slate-100 hover:bg-slate-50">
<td className="p-3">{tx.invoice_number}</td>
<td className="p-3">{tx.customer_name}</td>
<td className="p-3 text-right">{formatCurrency(tx.taxable_amount)}</td>
<td className="p-3 text-right font-semibold">{formatCurrency(tx.tax_amount)}</td>
<td className="p-3 text-right">{(tx.tax_rate * 100).toFixed(2)}%</td>
<td className="p-3">{formatDate(tx.transaction_date)}</td>
</tr>
)) || (
<tr>
<td colSpan={6} className="p-3 text-center text-slate-500">No transactions found</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
);
};
export default FinancialReportsPage;

View File

@@ -0,0 +1,281 @@
import React, { useState, useEffect } from 'react';
import { BookOpen, Plus, Calendar, FileText, TrendingUp, Lock, Unlock } from 'lucide-react';
import { toast } from 'react-toastify';
import glService, { Account, FiscalPeriod, JournalEntry } from '../../features/payments/services/glService';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { formatDate } from '../../shared/utils/format';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
const GLManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [activeTab, setActiveTab] = useState<'accounts' | 'periods' | 'entries'>('accounts');
const [loading, setLoading] = useState(true);
const [accounts, setAccounts] = useState<Account[]>([]);
const [periods, setPeriods] = useState<FiscalPeriod[]>([]);
const [entries, setEntries] = useState<JournalEntry[]>([]);
const [selectedPeriod, setSelectedPeriod] = useState<number | null>(null);
useEffect(() => {
fetchData();
}, [activeTab, selectedPeriod]);
const fetchData = async () => {
try {
setLoading(true);
if (activeTab === 'accounts') {
const response = await glService.getAccounts();
setAccounts(response.data.accounts || []);
} else if (activeTab === 'periods') {
const response = await glService.getFiscalPeriods();
setPeriods(response.data.fiscal_periods || []);
} else if (activeTab === 'entries') {
const params: any = { limit: 50 };
if (selectedPeriod) params.fiscal_period_id = selectedPeriod;
const response = await glService.getJournalEntries(params);
setEntries(response.data.journal_entries || []);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load data');
} finally {
setLoading(false);
}
};
const handleClosePeriod = async (periodId: number) => {
if (!confirm('Are you sure you want to close this fiscal period? This action may require approval.')) {
return;
}
try {
await glService.closeFiscalPeriod(periodId, false);
toast.success('Fiscal period closed successfully');
fetchData();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to close period');
}
};
if (loading && accounts.length === 0 && periods.length === 0 && entries.length === 0) {
return <Loading fullScreen text="Loading General Ledger..." />;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* Header */}
<div className="mb-6 sm:mb-8 md:mb-10">
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-emerald-400 via-green-500 to-emerald-600 rounded-full"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent">
General Ledger
</h1>
</div>
<p className="text-slate-600 text-sm sm:text-base md:text-lg font-light">
Manage chart of accounts, fiscal periods, and journal entries
</p>
</div>
{/* Tabs */}
<div className="flex flex-wrap gap-2 mb-6 border-b border-slate-200">
<button
onClick={() => setActiveTab('accounts')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'accounts'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<BookOpen className="inline w-4 h-4 mr-2" />
Chart of Accounts
</button>
<button
onClick={() => setActiveTab('periods')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'periods'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<Calendar className="inline w-4 h-4 mr-2" />
Fiscal Periods
</button>
<button
onClick={() => setActiveTab('entries')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'entries'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<FileText className="inline w-4 h-4 mr-2" />
Journal Entries
</button>
</div>
{/* Content */}
{activeTab === 'accounts' && (
<div className="bg-white rounded-xl shadow-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Chart of Accounts</h2>
<button className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 flex items-center gap-2">
<Plus className="w-4 h-4" />
Add Account
</button>
</div>
{accounts.length === 0 ? (
<EmptyState title="No Accounts" description="Chart of accounts will appear here" />
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left p-3">Account #</th>
<th className="text-left p-3">Account Name</th>
<th className="text-left p-3">Type</th>
<th className="text-left p-3">Category</th>
<th className="text-left p-3">Normal Balance</th>
<th className="text-left p-3">Status</th>
</tr>
</thead>
<tbody>
{accounts.map((account) => (
<tr key={account.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="p-3 font-mono">{account.account_number}</td>
<td className="p-3 font-semibold">{account.account_name}</td>
<td className="p-3">{account.account_type}</td>
<td className="p-3">{account.account_category}</td>
<td className="p-3">{account.normal_balance}</td>
<td className="p-3">
<span className={`px-2 py-1 rounded-full text-xs ${
account.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}>
{account.is_active ? 'Active' : 'Inactive'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{activeTab === 'periods' && (
<div className="bg-white rounded-xl shadow-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Fiscal Periods</h2>
<button className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 flex items-center gap-2">
<Plus className="w-4 h-4" />
Create Period
</button>
</div>
{periods.length === 0 ? (
<EmptyState title="No Fiscal Periods" description="Fiscal periods will appear here" />
) : (
<div className="space-y-4">
{periods.map((period) => (
<div key={period.id} className="border border-slate-200 rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex justify-between items-start">
<div>
<h3 className="font-bold text-lg">{period.name}</h3>
<p className="text-sm text-slate-600">
{formatDate(period.start_date)} - {formatDate(period.end_date)}
</p>
<div className="flex gap-2 mt-2">
<span className={`px-2 py-1 rounded-full text-xs ${
period.status === 'Open' ? 'bg-green-100 text-green-800' :
period.status === 'Closed' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{period.status}
</span>
{period.is_current && (
<span className="px-2 py-1 rounded-full text-xs bg-emerald-100 text-emerald-800">
Current
</span>
)}
</div>
</div>
{period.status === 'Open' && (
<button
onClick={() => handleClosePeriod(period.id)}
className="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 flex items-center gap-2"
>
<Lock className="w-4 h-4" />
Close Period
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'entries' && (
<div className="bg-white rounded-xl shadow-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Journal Entries</h2>
<div className="flex gap-2">
<select
value={selectedPeriod || ''}
onChange={(e) => setSelectedPeriod(e.target.value ? parseInt(e.target.value) : null)}
className="px-4 py-2 border border-slate-300 rounded-lg"
>
<option value="">All Periods</option>
{periods.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<button className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 flex items-center gap-2">
<Plus className="w-4 h-4" />
New Entry
</button>
</div>
</div>
{entries.length === 0 ? (
<EmptyState title="No Journal Entries" description="Journal entries will appear here" />
) : (
<div className="space-y-4">
{entries.map((entry) => (
<div key={entry.id} className="border border-slate-200 rounded-lg p-4">
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="font-bold">{entry.description}</h3>
<p className="text-sm text-slate-600">{formatDate(entry.entry_date)}</p>
<p className="text-xs text-slate-500">{entry.entry_type}</p>
</div>
</div>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left p-2">Account</th>
<th className="text-right p-2">Debit</th>
<th className="text-right p-2">Credit</th>
</tr>
</thead>
<tbody>
{entry.journal_lines?.map((line) => (
<tr key={line.id} className="border-b border-slate-100">
<td className="p-2">{line.account?.account_name}</td>
<td className="p-2 text-right">{line.debit > 0 ? formatCurrency(line.debit) : '-'}</td>
<td className="p-2 text-right">{line.credit > 0 ? formatCurrency(line.credit) : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
};
export default GLManagementPage;

View File

@@ -0,0 +1,372 @@
import React, { useState, useEffect } from 'react';
import { AlertTriangle, CheckCircle2, Clock, User, MessageSquare, Play, Filter } from 'lucide-react';
import { toast } from 'react-toastify';
import reconciliationService, { ReconciliationException, ExceptionStatus } from '../../features/payments/services/reconciliationService';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { formatDate } from '../../shared/utils/format';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
const ReconciliationPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [loading, setLoading] = useState(false);
const [exceptions, setExceptions] = useState<ReconciliationException[]>([]);
const [stats, setStats] = useState<any>(null);
const [selectedException, setSelectedException] = useState<ReconciliationException | null>(null);
const [filter, setFilter] = useState<ExceptionStatus | 'all'>('all');
const [comment, setComment] = useState('');
const [resolutionNotes, setResolutionNotes] = useState('');
useEffect(() => {
fetchExceptions();
fetchStats();
}, [filter]);
const fetchExceptions = async () => {
try {
setLoading(true);
const params: any = { limit: 50 };
if (filter !== 'all') params.status = filter;
const response = await reconciliationService.getExceptions(params);
setExceptions(response.data.exceptions || []);
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load exceptions');
} finally {
setLoading(false);
}
};
const fetchStats = async () => {
try {
const response = await reconciliationService.getExceptionStats();
setStats(response.data);
} catch (error) {
// Silently fail
}
};
const handleRunReconciliation = async () => {
try {
setLoading(true);
const response = await reconciliationService.runReconciliation();
toast.success(`Reconciliation completed. ${response.data.exceptions_created} new exceptions created.`);
fetchExceptions();
fetchStats();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to run reconciliation');
} finally {
setLoading(false);
}
};
const handleResolve = async (exceptionId: number) => {
if (!resolutionNotes.trim()) {
toast.error('Please provide resolution notes');
return;
}
try {
await reconciliationService.resolveException(exceptionId, resolutionNotes);
toast.success('Exception resolved successfully');
setSelectedException(null);
setResolutionNotes('');
fetchExceptions();
fetchStats();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to resolve exception');
}
};
const handleAddComment = async (exceptionId: number) => {
if (!comment.trim()) {
toast.error('Please enter a comment');
return;
}
try {
await reconciliationService.addComment(exceptionId, comment);
toast.success('Comment added successfully');
setComment('');
fetchExceptions();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to add comment');
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'critical':
return 'bg-red-100 text-red-800 border-red-200';
case 'high':
return 'bg-orange-100 text-orange-800 border-orange-200';
case 'medium':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
default:
return 'bg-blue-100 text-blue-800 border-blue-200';
}
};
const getStatusColor = (status: ExceptionStatus) => {
switch (status) {
case 'resolved':
return 'bg-green-100 text-green-800 border-green-200';
case 'closed':
return 'bg-gray-100 text-gray-800 border-gray-200';
case 'in_review':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'assigned':
return 'bg-purple-100 text-purple-800 border-purple-200';
default:
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* Header */}
<div className="mb-6 sm:mb-8 md:mb-10">
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-emerald-400 via-green-500 to-emerald-600 rounded-full"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent">
Reconciliation
</h1>
</div>
<p className="text-slate-600 text-sm sm:text-base md:text-lg font-light">
Manage payment and invoice reconciliation exceptions
</p>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl shadow-lg p-4 border-l-4 border-blue-500">
<p className="text-sm text-slate-600 mb-1">Total Exceptions</p>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
<div className="bg-white rounded-xl shadow-lg p-4 border-l-4 border-yellow-500">
<p className="text-sm text-slate-600 mb-1">Open</p>
<p className="text-2xl font-bold">{stats.by_status?.open || 0}</p>
</div>
<div className="bg-white rounded-xl shadow-lg p-4 border-l-4 border-green-500">
<p className="text-sm text-slate-600 mb-1">Resolved</p>
<p className="text-2xl font-bold">{stats.by_status?.resolved || 0}</p>
</div>
<div className="bg-white rounded-xl shadow-lg p-4 border-l-4 border-red-500">
<p className="text-sm text-slate-600 mb-1">Critical</p>
<p className="text-2xl font-bold">{stats.by_severity?.critical || 0}</p>
</div>
</div>
)}
{/* Actions */}
<div className="bg-white rounded-xl shadow-lg p-4 mb-6 flex justify-between items-center">
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded-lg font-semibold transition-colors ${
filter === 'all' ? 'bg-emerald-500 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
All
</button>
<button
onClick={() => setFilter('open')}
className={`px-4 py-2 rounded-lg font-semibold transition-colors ${
filter === 'open' ? 'bg-emerald-500 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Open
</button>
<button
onClick={() => setFilter('resolved')}
className={`px-4 py-2 rounded-lg font-semibold transition-colors ${
filter === 'resolved' ? 'bg-emerald-500 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Resolved
</button>
</div>
<button
onClick={handleRunReconciliation}
disabled={loading}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 flex items-center gap-2 disabled:opacity-50"
>
<Play className="w-4 h-4" />
Run Reconciliation
</button>
</div>
{/* Exceptions List */}
<div className="bg-white rounded-xl shadow-lg p-6">
{loading && exceptions.length === 0 ? (
<Loading text="Loading exceptions..." />
) : exceptions.length === 0 ? (
<EmptyState title="No Exceptions" description="Reconciliation exceptions will appear here" />
) : (
<div className="space-y-4">
{exceptions.map((exception) => (
<div
key={exception.id}
className="border border-slate-200 rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => setSelectedException(exception)}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<AlertTriangle className={`w-5 h-5 ${
exception.severity === 'critical' ? 'text-red-500' :
exception.severity === 'high' ? 'text-orange-500' :
'text-yellow-500'
}`} />
<h3 className="font-bold text-lg">{exception.exception_type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}</h3>
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getSeverityColor(exception.severity)}`}>
{exception.severity}
</span>
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getStatusColor(exception.status)}`}>
{exception.status}
</span>
</div>
<p className="text-slate-600 mb-2">{exception.description}</p>
<div className="flex flex-wrap gap-4 text-sm text-slate-500">
{exception.expected_amount && (
<span>Expected: {formatCurrency(exception.expected_amount)}</span>
)}
{exception.actual_amount && (
<span>Actual: {formatCurrency(exception.actual_amount)}</span>
)}
{exception.difference && (
<span className="font-semibold text-red-600">
Difference: {formatCurrency(Math.abs(exception.difference))}
</span>
)}
<span></span>
<span>{formatDate(exception.created_at)}</span>
</div>
</div>
{exception.status === 'open' && (
<button
onClick={(e) => {
e.stopPropagation();
setSelectedException(exception);
}}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600"
>
Review
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Exception Detail Modal */}
{selectedException && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<div className="flex justify-between items-start">
<div>
<h2 className="text-2xl font-bold mb-2">
{selectedException.exception_type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</h2>
<div className="flex gap-2">
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getSeverityColor(selectedException.severity)}`}>
{selectedException.severity}
</span>
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getStatusColor(selectedException.status)}`}>
{selectedException.status}
</span>
</div>
</div>
<button
onClick={() => {
setSelectedException(null);
setComment('');
setResolutionNotes('');
}}
className="text-slate-500 hover:text-slate-700"
>
<AlertTriangle className="w-6 h-6" />
</button>
</div>
</div>
<div className="p-6 space-y-4">
<div>
<h3 className="font-semibold mb-2">Description</h3>
<p className="text-slate-600">{selectedException.description}</p>
</div>
{selectedException.expected_amount && (
<div>
<h3 className="font-semibold mb-2">Expected Amount</h3>
<p className="text-lg font-bold">{formatCurrency(selectedException.expected_amount)}</p>
</div>
)}
{selectedException.actual_amount && (
<div>
<h3 className="font-semibold mb-2">Actual Amount</h3>
<p className="text-lg font-bold">{formatCurrency(selectedException.actual_amount)}</p>
</div>
)}
{selectedException.difference && (
<div>
<h3 className="font-semibold mb-2">Difference</h3>
<p className="text-lg font-bold text-red-600">{formatCurrency(Math.abs(selectedException.difference))}</p>
</div>
)}
{selectedException.comments && selectedException.comments.length > 0 && (
<div>
<h3 className="font-semibold mb-2">Comments</h3>
<div className="space-y-2">
{selectedException.comments.map((c, idx) => (
<div key={idx} className="bg-slate-50 p-3 rounded">
<p className="text-sm text-slate-600">{c.comment}</p>
<p className="text-xs text-slate-400 mt-1">{formatDate(c.created_at)}</p>
</div>
))}
</div>
</div>
)}
<div>
<h3 className="font-semibold mb-2">Add Comment</h3>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
className="w-full p-3 border border-slate-300 rounded-lg"
rows={3}
placeholder="Add a comment..."
/>
<button
onClick={() => handleAddComment(selectedException.id)}
className="mt-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Add Comment
</button>
</div>
{selectedException.status === 'open' && (
<div>
<h3 className="font-semibold mb-2">Resolution Notes</h3>
<textarea
value={resolutionNotes}
onChange={(e) => setResolutionNotes(e.target.value)}
className="w-full p-3 border border-slate-300 rounded-lg"
rows={4}
placeholder="Enter resolution notes..."
/>
<button
onClick={() => handleResolve(selectedException.id)}
className="mt-2 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600"
>
Resolve Exception
</button>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default ReconciliationPage;

View File

@@ -0,0 +1,281 @@
import React, { useState, useEffect } from 'react';
import { Shield, Activity, LogOut, AlertTriangle, CheckCircle2, Clock } from 'lucide-react';
import { toast } from 'react-toastify';
import accountantSecurityService, { AccountantSession, AccountantActivityLog, MFAStatus } from '../../features/security/services/accountantSecurityService';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { formatDate } from '../../shared/utils/format';
const SecurityManagementPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'sessions' | 'activity' | 'mfa'>('sessions');
const [loading, setLoading] = useState(true);
const [sessions, setSessions] = useState<AccountantSession[]>([]);
const [activityLogs, setActivityLogs] = useState<AccountantActivityLog[]>([]);
const [mfaStatus, setMfaStatus] = useState<MFAStatus | null>(null);
const [pagination, setPagination] = useState<any>(null);
useEffect(() => {
if (activeTab === 'sessions') fetchSessions();
else if (activeTab === 'activity') fetchActivityLogs();
else if (activeTab === 'mfa') fetchMFAStatus();
}, [activeTab]);
const fetchSessions = async () => {
try {
setLoading(true);
const response = await accountantSecurityService.getSessions();
setSessions(response.data.sessions || []);
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load sessions');
} finally {
setLoading(false);
}
};
const fetchActivityLogs = async () => {
try {
setLoading(true);
const response = await accountantSecurityService.getActivityLogs({ limit: 50 });
setActivityLogs(response.data.logs || []);
setPagination(response.data.pagination);
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load activity logs');
} finally {
setLoading(false);
}
};
const fetchMFAStatus = async () => {
try {
setLoading(true);
const response = await accountantSecurityService.getMFAStatus();
setMfaStatus(response.data);
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load MFA status');
} finally {
setLoading(false);
}
};
const handleRevokeSession = async (sessionId: number) => {
if (!confirm('Are you sure you want to revoke this session?')) return;
try {
await accountantSecurityService.revokeSession(sessionId);
toast.success('Session revoked successfully');
fetchSessions();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to revoke session');
}
};
const handleRevokeAllSessions = async () => {
if (!confirm('Are you sure you want to revoke all sessions? You will be logged out.')) return;
try {
await accountantSecurityService.revokeAllSessions();
toast.success('All sessions revoked successfully');
fetchSessions();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to revoke sessions');
}
};
const getRiskColor = (risk: string) => {
switch (risk) {
case 'critical':
return 'bg-red-100 text-red-800 border-red-200';
case 'high':
return 'bg-orange-100 text-orange-800 border-orange-200';
case 'medium':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
default:
return 'bg-blue-100 text-blue-800 border-blue-200';
}
};
if (loading && sessions.length === 0 && activityLogs.length === 0 && !mfaStatus) {
return <Loading fullScreen text="Loading security information..." />;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* Header */}
<div className="mb-6 sm:mb-8 md:mb-10">
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-emerald-400 via-green-500 to-emerald-600 rounded-full"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent">
Security Management
</h1>
</div>
<p className="text-slate-600 text-sm sm:text-base md:text-lg font-light">
Manage sessions, view activity logs, and MFA settings
</p>
</div>
{/* Tabs */}
<div className="flex flex-wrap gap-2 mb-6 border-b border-slate-200">
<button
onClick={() => setActiveTab('sessions')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'sessions'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<LogOut className="inline w-4 h-4 mr-2" />
Active Sessions
</button>
<button
onClick={() => setActiveTab('activity')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'activity'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<Activity className="inline w-4 h-4 mr-2" />
Activity Logs
</button>
<button
onClick={() => setActiveTab('mfa')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors ${
activeTab === 'mfa'
? 'bg-emerald-500 text-white border-b-2 border-emerald-600'
: 'text-slate-600 hover:text-emerald-600'
}`}
>
<Shield className="inline w-4 h-4 mr-2" />
MFA Status
</button>
</div>
{/* Content */}
{activeTab === 'sessions' && (
<div className="bg-white rounded-xl shadow-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Active Sessions</h2>
<button
onClick={handleRevokeAllSessions}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Revoke All Sessions
</button>
</div>
{sessions.length === 0 ? (
<EmptyState title="No Active Sessions" description="Active sessions will appear here" />
) : (
<div className="space-y-4">
{sessions.map((session) => (
<div key={session.id} className="border border-slate-200 rounded-lg p-4">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-bold">Session #{session.id}</h3>
{session.step_up_authenticated && (
<span className="px-2 py-1 bg-green-100 text-green-800 rounded-full text-xs">
Step-up Authenticated
</span>
)}
</div>
<div className="space-y-1 text-sm text-slate-600">
<p>IP Address: {session.ip_address || 'N/A'}</p>
<p>Location: {session.city && session.country ? `${session.city}, ${session.country}` : 'N/A'}</p>
<p>Last Activity: {formatDate(session.last_activity)}</p>
<p>Expires: {formatDate(session.expires_at)}</p>
</div>
</div>
<button
onClick={() => handleRevokeSession(session.id)}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Revoke
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'activity' && (
<div className="bg-white rounded-xl shadow-lg p-6">
<h2 className="text-xl font-bold mb-4">Activity Logs</h2>
{activityLogs.length === 0 ? (
<EmptyState title="No Activity Logs" description="Activity logs will appear here" />
) : (
<div className="space-y-4">
{activityLogs.map((log) => (
<div key={log.id} className="border border-slate-200 rounded-lg p-4">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-bold">{log.activity_type.replace(/_/g, ' ')}</h3>
<span className={`px-2 py-1 rounded-full text-xs font-semibold border ${getRiskColor(log.risk_level)}`}>
{log.risk_level}
</span>
{log.is_unusual && (
<span className="px-2 py-1 bg-orange-100 text-orange-800 rounded-full text-xs">
Unusual
</span>
)}
</div>
<p className="text-slate-600 mb-2">{log.activity_description}</p>
<div className="flex flex-wrap gap-4 text-sm text-slate-500">
<span>IP: {log.ip_address || 'N/A'}</span>
{log.country && <span>Location: {log.country}</span>}
<span></span>
<span>{formatDate(log.created_at)}</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'mfa' && mfaStatus && (
<div className="bg-white rounded-xl shadow-lg p-6">
<h2 className="text-xl font-bold mb-4">MFA Status</h2>
<div className="space-y-4">
<div className="p-4 bg-slate-50 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold">MFA Required</span>
<span className={mfaStatus.requires_mfa ? 'text-green-600' : 'text-gray-600'}>
{mfaStatus.requires_mfa ? 'Yes' : 'No'}
</span>
</div>
<div className="flex items-center justify-between mb-2">
<span className="font-semibold">MFA Enabled</span>
<span className={mfaStatus.mfa_enabled ? 'text-green-600' : 'text-red-600'}>
{mfaStatus.mfa_enabled ? 'Yes' : 'No'}
</span>
</div>
<div className="flex items-center justify-between mb-2">
<span className="font-semibold">MFA Enforced</span>
<span className={mfaStatus.is_enforced ? 'text-green-600' : 'text-red-600'}>
{mfaStatus.is_enforced ? 'Yes' : 'No'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="font-semibold">Backup Codes</span>
<span>{mfaStatus.backup_codes_count} remaining</span>
</div>
</div>
{mfaStatus.enforcement_reason && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-yellow-800">
<strong>Note:</strong> {mfaStatus.enforcement_reason}
</p>
</div>
)}
</div>
</div>
)}
</div>
);
};
export default SecurityManagementPage;

View File

@@ -25,8 +25,12 @@ const InvoicePage: React.FC = () => {
if (!invoiceId) {
setLoading(false);
// Redirect based on user role
if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
if (userInfo?.role === 'admin') {
navigate('/admin/bookings?createInvoice=true');
} else if (userInfo?.role === 'staff') {
navigate('/staff/bookings?createInvoice=true');
} else if (userInfo?.role === 'accountant') {
navigate('/accountant/invoices');
} else {
navigate('/bookings');
}

View File

@@ -11,6 +11,13 @@ const AccountantDashboardPage = lazy(() => import('../pages/accountant/Dashboard
const PaymentManagementPage = lazy(() => import('../pages/accountant/PaymentManagementPage'));
const InvoiceManagementPage = lazy(() => import('../pages/accountant/InvoiceManagementPage'));
const AnalyticsDashboardPage = lazy(() => import('../pages/accountant/AnalyticsDashboardPage'));
const GLManagementPage = lazy(() => import('../pages/accountant/GLManagementPage'));
const ApprovalManagementPage = lazy(() => import('../pages/accountant/ApprovalManagementPage'));
const FinancialReportsPage = lazy(() => import('../pages/accountant/FinancialReportsPage'));
const ReconciliationPage = lazy(() => import('../pages/accountant/ReconciliationPage'));
const AuditTrailPage = lazy(() => import('../pages/accountant/AuditTrailPage'));
const SecurityManagementPage = lazy(() => import('../pages/accountant/SecurityManagementPage'));
const ProfilePage = lazy(() => import('../pages/accountant/ProfilePage'));
const accountantRoutes: RouteObject[] = [
{
@@ -20,7 +27,13 @@ const accountantRoutes: RouteObject[] = [
{ path: 'dashboard', element: <AccountantDashboardPage /> },
{ path: 'payments', element: <PaymentManagementPage /> },
{ path: 'invoices', element: <InvoiceManagementPage /> },
{ path: 'reports', element: <AnalyticsDashboardPage /> },
{ path: 'gl', element: <GLManagementPage /> },
{ path: 'approvals', element: <ApprovalManagementPage /> },
{ path: 'reports', element: <FinancialReportsPage /> },
{ path: 'reconciliation', element: <ReconciliationPage /> },
{ path: 'audit-trail', element: <AuditTrailPage /> },
{ path: 'security', element: <SecurityManagementPage /> },
{ path: 'profile', element: <ProfilePage /> },
],
},
];

View File

@@ -10,7 +10,15 @@ import {
X,
CreditCard,
Receipt,
User
User,
BookOpen,
CheckCircle2,
FileText,
AlertTriangle,
Shield,
Activity,
TrendingUp,
FileCheck
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import { useResponsive } from '../../hooks';
@@ -91,11 +99,36 @@ const SidebarAccountant: React.FC<SidebarAccountantProps> = ({
icon: Receipt,
label: 'Invoices'
},
{
path: '/accountant/gl',
icon: BookOpen,
label: 'General Ledger'
},
{
path: '/accountant/approvals',
icon: CheckCircle2,
label: 'Approvals'
},
{
path: '/accountant/reports',
icon: BarChart3,
label: 'Financial Reports'
},
{
path: '/accountant/reconciliation',
icon: FileCheck,
label: 'Reconciliation'
},
{
path: '/accountant/audit-trail',
icon: FileText,
label: 'Audit Trail'
},
{
path: '/accountant/security',
icon: Shield,
label: 'Security'
},
{
path: '/accountant/profile',
icon: User,