Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/bot/src/services/fine.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
138 changes: 123 additions & 15 deletions packages/web/src/app/(admin)/admin/fines/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -110,6 +110,7 @@ export default function AdminFinesPage() {
const [statusFilter, setStatusFilter] = useState<string>('all');
const [updatingId, setUpdatingId] = useState<string | null>(null);
const [waiveTarget, setWaiveTarget] = useState<string | null>(null);
const [revertTarget, setRevertTarget] = useState<string | null>(null);

const fetchFines = useCallback(async () => {
try {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -391,15 +441,34 @@ export default function AdminFinesPage() {
<XCircle className="h-3 w-3 mr-1" />
면제
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleType(fine)}
disabled={updatingId === fine.id}
>
<ArrowRightLeft className="h-3 w-3 mr-1" />
{fine.type === 'late' ? '결석으로' : '지각으로'}
</Button>
</div>
)}
{fine.status === 'paid' && fine.paidAt && (
<p className="text-xs text-muted-foreground pt-1">
{new Date(fine.paidAt).toLocaleDateString('ko-KR')} 납부
</p>
)}
{fine.status === 'waived' && (
<p className="text-xs text-muted-foreground pt-1">면제됨</p>
{(fine.status === 'PAID' || fine.status === 'WAIVED') && (
<div className="flex items-center gap-2 pt-1">
<span className="text-xs text-muted-foreground">
{fine.status === 'PAID' && fine.paidAt
? `${new Date(fine.paidAt).toLocaleDateString('ko-KR')} 납부`
: '면제됨'}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setRevertTarget(fine.id)}
disabled={updatingId === fine.id}
>
<AlertCircle className="h-3 w-3 mr-1" />
미납 되돌리기
</Button>
</div>
)}
</div>
))
Expand Down Expand Up @@ -472,15 +541,34 @@ export default function AdminFinesPage() {
<XCircle className="h-3 w-3 mr-1" />
면제
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleType(fine)}
disabled={updatingId === fine.id}
>
<ArrowRightLeft className="h-3 w-3 mr-1" />
{fine.type === 'late' ? '결석으로' : '지각으로'}
</Button>
</div>
)}
{fine.status === 'paid' && fine.paidAt && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(fine.paidAt).toLocaleDateString('ko-KR')} 납부
</span>
)}
{fine.status === 'waived' && (
<span className="text-xs text-muted-foreground">면제됨</span>
{(fine.status === 'PAID' || fine.status === 'WAIVED') && (
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{fine.status === 'PAID' && fine.paidAt
? `${new Date(fine.paidAt).toLocaleDateString('ko-KR')} 납부`
: '면제됨'}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setRevertTarget(fine.id)}
disabled={updatingId === fine.id}
>
<AlertCircle className="h-3 w-3 mr-1" />
미납 되돌리기
</Button>
</div>
)}
</TableCell>
</TableRow>
Expand Down Expand Up @@ -517,6 +605,26 @@ export default function AdminFinesPage() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Revert to Unpaid Confirmation Dialog */}
<AlertDialog open={!!revertTarget} onOpenChange={(open) => !open && setRevertTarget(null)}>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle className="text-base">미납으로 되돌리시겠습니까?</AlertDialogTitle>
<AlertDialogDescription className="text-sm text-muted-foreground">
벌금이 다시 미납 상태로 전환됩니다.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="h-9 text-sm">취소</AlertDialogCancel>
<AlertDialogAction
onClick={() => revertTarget && handleRevertToUnpaid(revertTarget)}
className="h-9 text-sm"
>
되돌리기
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
5 changes: 3 additions & 2 deletions packages/web/src/app/api/admin/attendance/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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));
}
Expand Down
58 changes: 48 additions & 10 deletions packages/web/src/app/api/admin/fines/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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();
Expand All @@ -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<string, unknown> = {};

// 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<string, number> = {
[FineType.LATE]: 3000,
[FineType.ABSENT]: 5000,
};
updateData.type = type;
updateData.amount = fineAmounts[type];
}

const [updatedFine] = await database
Expand All @@ -56,10 +78,26 @@ export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => {
return Errors.internalError('벌금 업데이트에 실패했습니다.').toResponse();
}

let message = '벌금이 수정되었습니다.';
if (status && !type) {
const messageMap: Record<string, string> = {
[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(),
},
Expand Down
Loading