updates
This commit is contained in:
@@ -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 */}
|
||||
|
||||
69
Frontend/src/features/payments/services/approvalService.ts
Normal file
69
Frontend/src/features/payments/services/approvalService.ts
Normal 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
155
Frontend/src/features/payments/services/glService.ts
Normal file
155
Frontend/src/features/payments/services/glService.ts
Normal 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
280
Frontend/src/pages/accountant/ApprovalManagementPage.tsx
Normal file
280
Frontend/src/pages/accountant/ApprovalManagementPage.tsx
Normal 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;
|
||||
|
||||
257
Frontend/src/pages/accountant/AuditTrailPage.tsx
Normal file
257
Frontend/src/pages/accountant/AuditTrailPage.tsx
Normal 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;
|
||||
|
||||
415
Frontend/src/pages/accountant/FinancialReportsPage.tsx
Normal file
415
Frontend/src/pages/accountant/FinancialReportsPage.tsx
Normal 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;
|
||||
|
||||
281
Frontend/src/pages/accountant/GLManagementPage.tsx
Normal file
281
Frontend/src/pages/accountant/GLManagementPage.tsx
Normal 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;
|
||||
|
||||
372
Frontend/src/pages/accountant/ReconciliationPage.tsx
Normal file
372
Frontend/src/pages/accountant/ReconciliationPage.tsx
Normal 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;
|
||||
|
||||
281
Frontend/src/pages/accountant/SecurityManagementPage.tsx
Normal file
281
Frontend/src/pages/accountant/SecurityManagementPage.tsx
Normal 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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user