diff --git a/packages/bot/src/services/fine.service.ts b/packages/bot/src/services/fine.service.ts index 57a17fe..5b3a70c 100644 --- a/packages/bot/src/services/fine.service.ts +++ b/packages/bot/src/services/fine.service.ts @@ -130,6 +130,16 @@ export class FineService { // Check if fine already exists for this member and round const existing = await this.getByMemberAndRound(memberId, roundId); if (existing) { + if (existing.status === FineStatus.WAIVED) { + // WAIVED 벌금은 UNPAID로 복원 (타입/금액도 갱신) + const amount = getFineAmount(type); + const [restored] = await this.db + .update(fines) + .set({ type, amount, status: FineStatus.UNPAID }) + .where(eq(fines.id, existing.id)) + .returning(); + return restored!; + } // Return existing fine instead of creating duplicate return existing; } diff --git a/packages/web/src/app/(admin)/admin/fines/page.tsx b/packages/web/src/app/(admin)/admin/fines/page.tsx index 8f109dd..8454cb7 100644 --- a/packages/web/src/app/(admin)/admin/fines/page.tsx +++ b/packages/web/src/app/(admin)/admin/fines/page.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { toast } from 'sonner'; -import { AlertCircle, Ban, CheckCircle, CreditCard, Search, XCircle } from 'lucide-react'; +import { AlertCircle, ArrowRightLeft, Ban, CheckCircle, CreditCard, Search, XCircle } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -110,6 +110,7 @@ export default function AdminFinesPage() { const [statusFilter, setStatusFilter] = useState('all'); const [updatingId, setUpdatingId] = useState(null); const [waiveTarget, setWaiveTarget] = useState(null); + const [revertTarget, setRevertTarget] = useState(null); const fetchFines = useCallback(async () => { try { @@ -156,6 +157,55 @@ export default function AdminFinesPage() { } }; + const handleRevertToUnpaid = async (fineId: string) => { + try { + setUpdatingId(fineId); + setRevertTarget(null); + const response = await fetch(`/api/admin/fines/${fineId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: STATUS_FILTERS.pending }), + }); + + if (!response.ok) { + throw new Error('Failed to revert fine'); + } + + await fetchFines(); + toast.success('미납으로 되돌렸습니다.'); + } catch (err) { + console.error('Error reverting fine:', err); + toast.error('미납 되돌리기에 실패했습니다.'); + } finally { + setUpdatingId(null); + } + }; + + const handleToggleType = async (fine: Fine) => { + const newType = fine.type === 'late' ? 'absent' : 'late'; + const label = newType === 'late' ? '지각' : '결석'; + try { + setUpdatingId(fine.id); + const response = await fetch(`/api/admin/fines/${fine.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: newType }), + }); + + if (!response.ok) { + throw new Error('Failed to toggle fine type'); + } + + await fetchFines(); + toast.success(`${label}으로 변경되었습니다.`); + } catch (err) { + console.error('Error toggling fine type:', err); + toast.error('유형 변경에 실패했습니다.'); + } finally { + setUpdatingId(null); + } + }; + const handleWaive = async (fineId: string) => { try { setUpdatingId(fineId); @@ -391,15 +441,34 @@ export default function AdminFinesPage() { 면제 + )} - {fine.status === 'paid' && fine.paidAt && ( -

- {new Date(fine.paidAt).toLocaleDateString('ko-KR')} 납부 -

- )} - {fine.status === 'waived' && ( -

면제됨

+ {(fine.status === 'PAID' || fine.status === 'WAIVED') && ( +
+ + {fine.status === 'PAID' && fine.paidAt + ? `${new Date(fine.paidAt).toLocaleDateString('ko-KR')} 납부` + : '면제됨'} + + +
)} )) @@ -472,15 +541,34 @@ export default function AdminFinesPage() { 면제 + )} - {fine.status === 'paid' && fine.paidAt && ( - - {new Date(fine.paidAt).toLocaleDateString('ko-KR')} 납부 - - )} - {fine.status === 'waived' && ( - 면제됨 + {(fine.status === 'PAID' || fine.status === 'WAIVED') && ( +
+ + {fine.status === 'PAID' && fine.paidAt + ? `${new Date(fine.paidAt).toLocaleDateString('ko-KR')} 납부` + : '면제됨'} + + +
)} @@ -517,6 +605,26 @@ export default function AdminFinesPage() { + {/* Revert to Unpaid Confirmation Dialog */} + !open && setRevertTarget(null)}> + + + 미납으로 되돌리시겠습니까? + + 벌금이 다시 미납 상태로 전환됩니다. + + + + 취소 + revertTarget && handleRevertToUnpaid(revertTarget)} + className="h-9 text-sm" + > + 되돌리기 + + + + ); } diff --git a/packages/web/src/app/api/admin/attendance/[id]/route.ts b/packages/web/src/app/api/admin/attendance/[id]/route.ts index d16ca58..39986f6 100644 --- a/packages/web/src/app/api/admin/attendance/[id]/route.ts +++ b/packages/web/src/app/api/admin/attendance/[id]/route.ts @@ -153,8 +153,8 @@ export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => { amount: fineAmount, status: FineStatus.UNPAID, }); - } else if (existingFine.status === FineStatus.UNPAID) { - // Update existing fine type and amount if unpaid + } else if (existingFine.status === FineStatus.UNPAID || existingFine.status === FineStatus.WAIVED) { + // Update existing fine type/amount + WAIVED면 UNPAID로 복원 const fineAmount = status === AttendanceStatus.LATE ? 3000 : 5000; const fineType = status === AttendanceStatus.LATE ? FineType.LATE : FineType.ABSENT; @@ -163,6 +163,7 @@ export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => { .set({ type: fineType, amount: fineAmount, + status: FineStatus.UNPAID, }) .where(eq(fines.id, existingFine.id)); } diff --git a/packages/web/src/app/api/admin/fines/[id]/route.ts b/packages/web/src/app/api/admin/fines/[id]/route.ts index 7261760..0b10749 100644 --- a/packages/web/src/app/api/admin/fines/[id]/route.ts +++ b/packages/web/src/app/api/admin/fines/[id]/route.ts @@ -5,7 +5,7 @@ import { db as sharedDb } from '@blog-study/shared'; import { withAdminAuth } from '@/lib/admin'; import { errorResponse, Errors } from '@/lib/api-error'; -const { fines, FineStatus } = sharedDb; +const { fines, FineStatus, FineType } = sharedDb; /** * PATCH /api/admin/fines/[id] @@ -22,11 +22,22 @@ export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => { } const body = await request.json(); - const { status } = body; + const { status, type } = body; + + // status 또는 type 중 하나는 있어야 함 + if (!status && !type) { + return Errors.badRequest('status 또는 type이 필요합니다.').toResponse(); + } // Validate status - if (!status || ![FineStatus.PAID, FineStatus.WAIVED].includes(status)) { - return Errors.badRequest('유효하지 않은 상태입니다. (paid 또는 waived만 가능)').toResponse(); + if (status && ![FineStatus.UNPAID, FineStatus.PAID, FineStatus.WAIVED].includes(status)) { + return Errors.badRequest('유효하지 않은 상태입니다. (PENDING, PAID, WAIVED 중 하나여야 합니다.)').toResponse(); + } + + // Validate type + const validTypes = [FineType.LATE, FineType.ABSENT]; + if (type && !validTypes.includes(type)) { + return Errors.badRequest('유효하지 않은 유형입니다. (late, absent 중 하나여야 합니다.)').toResponse(); } const database = db(); @@ -38,12 +49,23 @@ export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => { return Errors.notFound('벌금을 찾을 수 없습니다.').toResponse(); } - // Update fine status - const updateData: { status: string; paidAt?: Date } = { status }; + // Build update data + const updateData: Record = {}; - // Set paidAt timestamp if marking as paid - if (status === FineStatus.PAID) { - updateData.paidAt = new Date(); + if (status) { + updateData.status = status; + if (status === FineStatus.PAID) { + updateData.paidAt = new Date(); + } + } + + if (type) { + const fineAmounts: Record = { + [FineType.LATE]: 3000, + [FineType.ABSENT]: 5000, + }; + updateData.type = type; + updateData.amount = fineAmounts[type]; } const [updatedFine] = await database @@ -56,10 +78,26 @@ export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => { return Errors.internalError('벌금 업데이트에 실패했습니다.').toResponse(); } + let message = '벌금이 수정되었습니다.'; + if (status && !type) { + const messageMap: Record = { + [FineStatus.PAID]: '납부 처리되었습니다.', + [FineStatus.WAIVED]: '면제 처리되었습니다.', + [FineStatus.UNPAID]: '미납으로 되돌렸습니다.', + }; + message = messageMap[status] ?? message; + } + if (type) { + const typeLabel = type === FineType.LATE ? '지각' : '결석'; + message = `${typeLabel}(${updatedFine.amount.toLocaleString()}원)으로 변경되었습니다.`; + } + return NextResponse.json({ - message: status === FineStatus.PAID ? '납부 처리되었습니다.' : '면제 처리되었습니다.', + message, fine: { id: updatedFine.id, + type: updatedFine.type, + amount: updatedFine.amount, status: updatedFine.status, paidAt: updatedFine.paidAt?.toISOString(), },