554 lines
22 KiB
TypeScript
554 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}; |