LMS-BGN/src/features/payroll-reward-system/components/PayrollManagement.tsx

554 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
import { useService } from '../../../core/di/ServiceContainer';
import { IPayrollCalculator } from '../services/interfaces/IPayrollCalculator';
import {
PayrollBatch,
PayrollEntry,
PayrollStatus,
PayrollPeriod,
PayrollCalculationInput,
PayrollSummary
} from '../types/payroll.types';
interface PayrollManagementProps {
className?: string;
userRole?: 'admin' | 'hr' | 'manager';
}
export const PayrollManagement: React.FC<PayrollManagementProps> = ({
className = '',
userRole = 'admin'
}) => {
const payrollCalculator = useService<IPayrollCalculator>('payroll-calculator');
const [batches, setBatches] = useState<PayrollBatch[]>([]);
const [selectedBatch, setSelectedBatch] = useState<PayrollBatch | null>(null);
const [batchEntries, setBatchEntries] = useState<PayrollEntry[]>([]);
const [summary, setSummary] = useState<PayrollSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'batches' | 'calculate' | 'reports'>('batches');
const [calculationForm, setCalculationForm] = useState<PayrollCalculationInput>({
period: PayrollPeriod.MONTHLY,
startDate: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
endDate: new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0)
});
useEffect(() => {
if (payrollCalculator) {
loadPayrollData();
}
}, [payrollCalculator]);
const loadPayrollData = async () => {
try {
setLoading(true);
setError(null);
if (!payrollCalculator) {
throw new Error('Payroll calculator service not available');
}
const allBatches = await payrollCalculator.getPayrollBatches({
limit: 50,
offset: 0
});
setBatches(allBatches);
// Load summary for current month
const currentMonth = new Date();
const startOfMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), 1);
const endOfMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0);
if (!payrollCalculator) {
throw new Error('Payroll calculator service not available');
}
const monthlySummary = await payrollCalculator.getPayrollSummary(startOfMonth, endOfMonth);
setSummary(monthlySummary);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load payroll data');
} finally {
setLoading(false);
}
};
const handleCalculatePayroll = async () => {
try {
setLoading(true);
if (!payrollCalculator) {
throw new Error('Payroll calculator service not available');
}
const result = await payrollCalculator.calculatePayroll(calculationForm);
// Refresh batches list
await loadPayrollData();
// Select the newly created batch
const newBatch = await payrollCalculator.getPayrollBatchById(result.batchId);
if (newBatch) {
setSelectedBatch(newBatch);
setBatchEntries(newBatch.entries);
}
alert(`Payroll berhasil dihitung! Batch ID: ${result.batchId}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to calculate payroll');
} finally {
setLoading(false);
}
};
const handleBatchAction = async (batchId: string, action: 'approve' | 'process' | 'cancel') => {
try {
setLoading(true);
if (!payrollCalculator) {
throw new Error('Payroll calculator service not available');
}
let updatedBatch: PayrollBatch;
switch (action) {
case 'approve':
updatedBatch = await payrollCalculator.approvePayrollBatch(batchId, 'current-user');
break;
case 'process':
updatedBatch = await payrollCalculator.processPayrollBatch(batchId);
break;
case 'cancel':
const reason = prompt('Alasan pembatalan:');
if (!reason) return;
updatedBatch = await payrollCalculator.cancelPayrollBatch(batchId, reason, 'current-user');
break;
default:
return;
}
// Update local state
setBatches(prev => prev.map(b => b.id === batchId ? updatedBatch : b));
if (selectedBatch?.id === batchId) {
setSelectedBatch(updatedBatch);
}
alert(`Batch ${action} berhasil!`);
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to ${action} batch`);
} finally {
setLoading(false);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR'
}).format(amount);
};
const getStatusBadge = (status: PayrollStatus) => {
const statusConfig = {
[PayrollStatus.DRAFT]: { label: 'Draft', className: 'bg-gray-100 text-gray-800' },
[PayrollStatus.CALCULATING]: { label: 'Menghitung', className: 'bg-blue-100 text-blue-800' },
[PayrollStatus.PENDING_APPROVAL]: { label: 'Menunggu Persetujuan', className: 'bg-yellow-100 text-yellow-800' },
[PayrollStatus.APPROVED]: { label: 'Disetujui', className: 'bg-green-100 text-green-800' },
[PayrollStatus.PROCESSING]: { label: 'Memproses', className: 'bg-purple-100 text-purple-800' },
[PayrollStatus.COMPLETED]: { label: 'Selesai', className: 'bg-blue-100 text-blue-800' },
[PayrollStatus.FAILED]: { label: 'Gagal', className: 'bg-red-100 text-red-800' }
};
const config = statusConfig[status];
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${config.className}`}>
{config.label}
</span>
);
};
const getPeriodLabel = (period: PayrollPeriod) => {
const labels = {
[PayrollPeriod.WEEKLY]: 'Mingguan',
[PayrollPeriod.BIWEEKLY]: 'Dua Mingguan',
[PayrollPeriod.MONTHLY]: 'Bulanan',
[PayrollPeriod.QUARTERLY]: 'Kuartalan'
};
return labels[period] || period;
};
if (loading && batches.length === 0) {
return (
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
<div className="animate-pulse">
<div className="h-6 bg-gray-200 rounded mb-4"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
</div>
);
}
return (
<div className={`bg-white rounded-lg shadow-md ${className}`}>
{/* Header */}
<div className="border-b border-gray-200 px-6 py-4">
<h2 className="text-2xl font-bold text-gray-900">Manajemen Payroll</h2>
<p className="text-gray-600 mt-1">Kelola perhitungan dan pembayaran payroll</p>
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="flex space-x-8 px-6">
{[
{ id: 'batches', label: 'Batch Payroll', icon: '📊' },
{ id: 'calculate', label: 'Hitung Payroll', icon: '🧮' },
{ id: 'reports', label: 'Laporan', icon: '📈' }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab.icon} {tab.label}
</button>
))}
</nav>
</div>
<div className="p-6">
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-md p-4">
<div className="flex">
<div className="text-red-400"></div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error</h3>
<div className="mt-2 text-sm text-red-700">{error}</div>
</div>
</div>
</div>
)}
{/* Batches Tab */}
{activeTab === 'batches' && (
<div>
{/* Summary Cards */}
{summary && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-blue-50 rounded-lg p-4">
<div className="flex items-center">
<div className="text-2xl mr-3">💰</div>
<div>
<p className="text-sm text-gray-600">Total Amount</p>
<p className="text-lg font-semibold text-blue-600">
{formatCurrency(summary.totalAmount)}
</p>
</div>
</div>
</div>
<div className="bg-green-50 rounded-lg p-4">
<div className="flex items-center">
<div className="text-2xl mr-3">👥</div>
<div>
<p className="text-sm text-gray-600">Participants</p>
<p className="text-lg font-semibold text-green-600">
{summary.participantCount}
</p>
</div>
</div>
</div>
<div className="bg-yellow-50 rounded-lg p-4">
<div className="flex items-center">
<div className="text-2xl mr-3">📊</div>
<div>
<p className="text-sm text-gray-600">Average</p>
<p className="text-lg font-semibold text-yellow-600">
{formatCurrency(summary.averageAmount)}
</p>
</div>
</div>
</div>
<div className="bg-purple-50 rounded-lg p-4">
<div className="flex items-center">
<div className="text-2xl mr-3">🏆</div>
<div>
<p className="text-sm text-gray-600">Top Earner</p>
<p className="text-lg font-semibold text-purple-600">
{summary.topEarners[0] ? formatCurrency(summary.topEarners[0].amount) : '-'}
</p>
</div>
</div>
</div>
</div>
)}
{/* Batches List */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Batch ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Periode
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tanggal
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Participants
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Total Amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{batches.map((batch) => (
<tr key={batch.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
{batch.id.substring(0, 8)}...
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{getPeriodLabel(batch.period)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{batch.startDate.toLocaleDateString('id-ID')} - {batch.endDate.toLocaleDateString('id-ID')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{batch.totalParticipants}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{formatCurrency(batch.totalAmount)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(batch.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
<button
onClick={() => {
setSelectedBatch(batch);
setBatchEntries(batch.entries);
}}
className="text-blue-600 hover:text-blue-900"
>
View
</button>
{batch.status === PayrollStatus.PENDING_APPROVAL && userRole === 'admin' && (
<button
onClick={() => handleBatchAction(batch.id, 'approve')}
className="text-green-600 hover:text-green-900"
>
Approve
</button>
)}
{batch.status === PayrollStatus.APPROVED && (
<button
onClick={() => handleBatchAction(batch.id, 'process')}
className="text-purple-600 hover:text-purple-900"
>
Process
</button>
)}
{[PayrollStatus.DRAFT, PayrollStatus.PENDING_APPROVAL].includes(batch.status) && (
<button
onClick={() => handleBatchAction(batch.id, 'cancel')}
className="text-red-600 hover:text-red-900"
>
Cancel
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{batches.length === 0 && (
<div className="text-center py-8 text-gray-500">
<div className="text-4xl mb-2">📊</div>
<p>Belum ada batch payroll</p>
<p className="text-sm">Buat perhitungan payroll pertama Anda</p>
</div>
)}
</div>
)}
{/* Calculate Tab */}
{activeTab === 'calculate' && (
<div className="max-w-2xl">
<h3 className="text-lg font-semibold mb-4">Hitung Payroll Baru</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Periode
</label>
<select
value={calculationForm.period}
onChange={(e) => setCalculationForm(prev => ({
...prev,
period: e.target.value as PayrollPeriod
}))}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{Object.values(PayrollPeriod).map(period => (
<option key={period} value={period}>
{getPeriodLabel(period)}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tanggal Mulai
</label>
<input
type="date"
value={calculationForm.startDate.toISOString().split('T')[0]}
onChange={(e) => setCalculationForm(prev => ({
...prev,
startDate: new Date(e.target.value)
}))}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tanggal Selesai
</label>
<input
type="date"
value={calculationForm.endDate.toISOString().split('T')[0]}
onChange={(e) => setCalculationForm(prev => ({
...prev,
endDate: new Date(e.target.value)
}))}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<button
onClick={handleCalculatePayroll}
disabled={loading}
className="w-full bg-blue-600 text-white py-3 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 font-medium"
>
{loading ? 'Menghitung...' : '🧮 Hitung Payroll'}
</button>
</div>
</div>
)}
{/* Reports Tab */}
{activeTab === 'reports' && (
<div>
<h3 className="text-lg font-semibold mb-4">Laporan Payroll</h3>
<div className="text-center py-8 text-gray-500">
<div className="text-4xl mb-2">📈</div>
<p>Fitur laporan akan segera hadir</p>
</div>
</div>
)}
</div>
{/* Batch Detail Modal */}
{selectedBatch && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">
Batch Detail: {selectedBatch.id.substring(0, 8)}...
</h3>
<button
onClick={() => setSelectedBatch(null)}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
</div>
<div className="p-6">
<div className="grid grid-cols-2 gap-4 mb-6">
<div>
<p className="text-sm text-gray-600">Periode</p>
<p className="font-medium">{getPeriodLabel(selectedBatch.period)}</p>
</div>
<div>
<p className="text-sm text-gray-600">Status</p>
<div className="mt-1">{getStatusBadge(selectedBatch.status)}</div>
</div>
<div>
<p className="text-sm text-gray-600">Total Amount</p>
<p className="font-medium">{formatCurrency(selectedBatch.totalAmount)}</p>
</div>
<div>
<p className="text-sm text-gray-600">Participants</p>
<p className="font-medium">{selectedBatch.totalParticipants}</p>
</div>
</div>
<h4 className="font-semibold mb-3">Entries</h4>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
User ID
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Amount
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Rewards
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{batchEntries.map((entry) => (
<tr key={entry.id}>
<td className="px-4 py-2 text-sm text-gray-900">
{entry.userId}
</td>
<td className="px-4 py-2 text-sm font-medium text-gray-900">
{formatCurrency(entry.totalAmount)}
</td>
<td className="px-4 py-2 text-sm text-gray-900">
{entry.rewardBreakdown.length} rewards
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
</div>
);
};