This commit is contained in:
@@ -813,6 +813,55 @@ export const apiPushAttendanceQLDT = async (rkId: number, date: string, period:
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export interface LeaveRequestItem {
|
||||
id: number;
|
||||
date: string;
|
||||
note: string;
|
||||
reasonImage?: string;
|
||||
rejectReason?: string | null;
|
||||
period: number;
|
||||
status: string; // 'Đang chờ', 'Phê duyệt', 'Từ chối'
|
||||
approverId?: number | null;
|
||||
createdAt: string;
|
||||
student: {
|
||||
id: number;
|
||||
studentCode: string;
|
||||
fullName: string;
|
||||
phone?: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const apiFetchLeaveRequests = async (
|
||||
classId: number,
|
||||
courseId: number,
|
||||
date: string
|
||||
): Promise<LeaveRequestItem[]> => {
|
||||
const res = await staffFetch(`/classes/${classId}/leave-requests?courseId=${courseId}&date=${date}`);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Failed to fetch leave requests');
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const apiUpdateLeaveStatus = async (
|
||||
classId: number,
|
||||
leaveId: number,
|
||||
payload: { status: string; studentRkId: number; date: string; period: number }
|
||||
): Promise<{ ok: boolean; message: string }> => {
|
||||
const res = await staffFetch(`/classes/${classId}/leave-requests/${leaveId}/status`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Failed to update leave status');
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
async function staffUpload(path: string, form: FormData): Promise<Response> {
|
||||
const headers = new Headers();
|
||||
const token = getToken();
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
attendanceStatusClass,
|
||||
type AttendanceRow,
|
||||
type AttendanceShiftInfo,
|
||||
apiFetchLeaveRequests,
|
||||
apiUpdateLeaveStatus,
|
||||
type LeaveRequestItem,
|
||||
} from '../api';
|
||||
|
||||
interface AttendancePanelProps {
|
||||
@@ -37,6 +40,8 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedStudentRkIds, setSelectedStudentRkIds] = useState<number[]>([]);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [leaveRequests, setLeaveRequests] = useState<LeaveRequestItem[]>([]);
|
||||
const [loadingLeave, setLoadingLeave] = useState(false);
|
||||
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const scrollTop = e.currentTarget.scrollTop;
|
||||
@@ -74,8 +79,46 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
}
|
||||
}, [classId, date, period]);
|
||||
|
||||
const currentShift = useMemo(() => {
|
||||
return shifts.find(s => s.period === period);
|
||||
}, [shifts, period]);
|
||||
|
||||
const courseId = currentShift?.courseId;
|
||||
|
||||
const loadLeaveRequests = useCallback(async () => {
|
||||
if (!courseId) {
|
||||
setLeaveRequests([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoadingLeave(true);
|
||||
const res = await apiFetchLeaveRequests(classId, courseId, date);
|
||||
setLeaveRequests(res || []);
|
||||
} catch {
|
||||
setLeaveRequests([]);
|
||||
} finally {
|
||||
setLoadingLeave(false);
|
||||
}
|
||||
}, [classId, courseId, date]);
|
||||
|
||||
useEffect(() => { loadShifts(); }, [loadShifts]);
|
||||
useEffect(() => { loadAttendance(); }, [loadAttendance]);
|
||||
useEffect(() => { loadLeaveRequests(); }, [loadLeaveRequests]);
|
||||
|
||||
const handleUpdateLeaveStatus = async (leaveId: number, status: string, studentRkId: number) => {
|
||||
try {
|
||||
await apiUpdateLeaveStatus(classId, leaveId, {
|
||||
status,
|
||||
studentRkId,
|
||||
date,
|
||||
period,
|
||||
});
|
||||
await loadAttendance();
|
||||
await loadLeaveRequests();
|
||||
} catch (e: any) {
|
||||
alert(e.message || 'Cập nhật đơn xin nghỉ thất bại');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
@@ -183,7 +226,6 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
}
|
||||
};
|
||||
|
||||
const currentShift = shifts.find(s => s.period === period);
|
||||
const qldtSynced = Boolean(shiftInfo?.pushedToQldtAt);
|
||||
const qldtDirty = Boolean(shiftInfo?.qldtDirty);
|
||||
|
||||
@@ -300,6 +342,133 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leave Requests Approval Section */}
|
||||
{courseId && (
|
||||
<div
|
||||
className="leave-requests-section"
|
||||
style={{
|
||||
margin: '10px 0',
|
||||
padding: '1rem',
|
||||
background: 'rgba(255, 255, 255, 0.8)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '12px',
|
||||
boxShadow: 'var(--shadow-sm)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<h4 style={{ margin: 0, fontSize: '0.95rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--text-primary)' }}>
|
||||
<span>✉️ Đơn xin nghỉ phép trong ca</span>
|
||||
<span className="badge" style={{ background: 'var(--accent-light)', color: 'var(--accent)', fontSize: '0.75rem', padding: '0.15rem 0.4rem', borderRadius: '10px' }}>
|
||||
{leaveRequests.length} đơn
|
||||
</span>
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={loadLeaveRequests}
|
||||
disabled={loadingLeave}
|
||||
style={{ padding: '0.2rem 0.5rem', fontSize: '0.75rem' }}
|
||||
>
|
||||
{loadingLeave ? 'Đang tải...' : '🔄 Làm mới đơn phép'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingLeave ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '1rem 0' }}>
|
||||
<div className="sync-spinner" style={{ width: 20, height: 20 }} />
|
||||
</div>
|
||||
) : leaveRequests.length === 0 ? (
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.8rem', fontStyle: 'italic' }}>
|
||||
Không có đơn xin nghỉ phép nào cho ca học này trong ngày hôm nay.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', maxHeight: '250px', overflowY: 'auto', paddingRight: '4px' }}>
|
||||
{leaveRequests.map((req) => (
|
||||
<div
|
||||
key={req.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#ffffff'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.85rem' }}>{req.student.fullName}</div>
|
||||
<code style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{req.student.studentCode}</code>
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
backgroundColor: req.status === 'Đang chờ' ? '#fef3c7' : req.status === 'Phê duyệt' ? '#d1fae5' : '#fee2e2',
|
||||
color: req.status === 'Đang chờ' ? '#b45309' : req.status === 'Phê duyệt' ? '#065f46' : '#991b1b',
|
||||
padding: '0.1rem 0.4rem',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
{req.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{req.status === 'Đang chờ' && (
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ padding: '0.2rem 0.5rem', fontSize: '0.75rem', backgroundColor: 'var(--success)', borderColor: 'var(--success)' }}
|
||||
onClick={() => {
|
||||
if (confirm(`Phê duyệt đơn xin nghỉ phép của ${req.student.fullName}? Trạng thái điểm danh ca sẽ chuyển thành Nghỉ có phép.`)) {
|
||||
handleUpdateLeaveStatus(req.id, 'Phê duyệt', req.student.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
✓ Phê duyệt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm text-danger"
|
||||
style={{ padding: '0.2rem 0.5rem', fontSize: '0.75rem', borderColor: '#fca5a5' }}
|
||||
onClick={() => {
|
||||
if (confirm(`Từ chối đơn xin nghỉ phép của ${req.student.fullName}?`)) {
|
||||
handleUpdateLeaveStatus(req.id, 'Từ chối', req.student.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
✗ Từ chối
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-primary)', background: '#f9fafb', padding: '6px 8px', borderRadius: '4px', borderLeft: '3px solid #cbd5e1' }}>
|
||||
<strong>Lý do nghỉ:</strong> {req.note || 'Không có ghi chú'}
|
||||
</div>
|
||||
|
||||
{req.reasonImage && (
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<a
|
||||
href={req.reasonImage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: '0.72rem', color: 'var(--accent)', textDecoration: 'underline', display: 'flex', alignItems: 'center', gap: '3px' }}
|
||||
>
|
||||
🖼️ Xem ảnh minh chứng phép
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedStudentRkIds.length > 0 && (
|
||||
<div className="attendance-bulk-actions">
|
||||
<span className="attendance-bulk-title">
|
||||
|
||||
Reference in New Issue
Block a user