spin-frontend/src/pages/Attendance.tsx
theorose49 7cab590fe2
All checks were successful
build-and-push / build (push) Successful in 36s
feat: spin 프론트엔드 전체 구현 (React+TS+Vite+Tailwind)
- AppShell·사이드바(역할별 네비)·탑바·UI킷, react-query·axios·recharts·dnd-kit
- SP 디자인 토큰 재사용(navy/canvas/Noto Sans KR) + 회계용 고밀도 확장
- 페이지: 대시보드, 근무(타임시트·휴가/초과 신청), 프로젝트 목록/상세
  (간트·칸반·캘린더·작업자portion·업체담당자·계약/분할입금 admin),
  인센티브(유저 대시보드), 인센티브 관리 콘솔(단계 stepper·시뮬레이터·오버라이드),
  회계(현금-인센티브 갭·원장·세금), 구성원·설정·승인·프로필
- 권한 가드: 관리자 전용 라우트, ?as=user 로 구성원 시점 미리보기

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:57:50 +09:00

203 lines
10 KiB
TypeScript

import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CalendarDays, Plus, LogIn, LogOut } from "lucide-react";
import {
getTimesheet, getAttendance, getLeave, getOvertime, punch, createLeave,
createOvertime, cancelLeave,
} from "@/lib/api";
import {
Card, Button, Badge, Stat, PageHeader, Modal, Field, Input, Select,
Textarea, Tabs, Progress, EmptyState, LoadingState,
} from "@/components/ui";
import {
formatDate, formatTime, minutesToHM, REQ_STATUS_META, LEAVE_LABELS, classNames,
} from "@/lib/format";
import type { LeaveType } from "@/types";
const THIS_MONTH = new Date().toISOString().slice(0, 7);
export function AttendancePage() {
const qc = useQueryClient();
const [tab, setTab] = useState("timesheet");
const [leaveOpen, setLeaveOpen] = useState(false);
const [otOpen, setOtOpen] = useState(false);
const tsQ = useQuery({ queryKey: ["timesheet"], queryFn: () => getTimesheet({}) });
const attQ = useQuery({ queryKey: ["attendance", THIS_MONTH], queryFn: () => getAttendance({ month: THIS_MONTH }) });
const leaveQ = useQuery({ queryKey: ["leave-mine"], queryFn: () => getLeave() });
const otQ = useQuery({ queryKey: ["ot-mine"], queryFn: () => getOvertime() });
const punchM = useMutation({
mutationFn: punch,
onSuccess: () => { qc.invalidateQueries({ queryKey: ["attendance", THIS_MONTH] }); qc.invalidateQueries({ queryKey: ["timesheet"] }); },
});
const ts = tsQ.data;
return (
<div>
<PageHeader
title="근무"
description="출퇴근 체크와 휴가·초과근무 신청을 관리합니다. 본인 기록만 표시됩니다."
action={
<div className="flex gap-2">
<Button variant="secondary" icon={<LogIn size={16} />} onClick={() => punchM.mutate()}></Button>
<Button icon={<LogOut size={16} />} onClick={() => punchM.mutate()}></Button>
</div>
}
/>
{ts && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<Stat label={`${ts.year}.${String(ts.month).padStart(2, "0")} 인정 근무`} value={minutesToHM(ts.recognizedTotal)} sub={`근무 ${minutesToHM(ts.workedMinutes)} + 인정휴가 ${minutesToHM(ts.leaveMinutes)}`} />
<Stat label="월 소정근로" value={minutesToHM(ts.standardMinutes)} sub={`영업일 ${ts.businessDays}`} />
<Stat label="출근일" value={`${ts.daysPresent}`} sub={`초과근무 ${minutesToHM(ts.overtimeMinutes)}`} />
<Card className="p-5">
<div className="text-xs font-medium text-ink-secondary"> </div>
<div className="mt-2 text-2xl font-bold font-num text-navy">{ts.fulfillmentPct.toFixed(0)}%</div>
<div className="mt-2"><Progress pct={ts.fulfillmentPct} color={ts.fulfillmentPct >= 100 ? "#12B76A" : "#11224F"} /></div>
</Card>
</div>
)}
<Card>
<div className="px-3 pt-2 flex items-center justify-between">
<Tabs
active={tab}
onChange={setTab}
tabs={[
{ key: "timesheet", label: "근무 기록" },
{ key: "leave", label: "휴가/공가", badge: leaveQ.data?.filter((l) => l.status === "pending").length },
{ key: "overtime", label: "초과근무", badge: otQ.data?.filter((o) => o.status === "pending").length },
]}
/>
{tab === "leave" && <Button size="sm" icon={<Plus size={14} />} onClick={() => setLeaveOpen(true)}> </Button>}
{tab === "overtime" && <Button size="sm" icon={<Plus size={14} />} onClick={() => setOtOpen(true)}> </Button>}
</div>
<div className="p-4">
{tab === "timesheet" && (
attQ.isLoading ? <LoadingState /> : (attQ.data?.length ?? 0) === 0 ? <EmptyState title="이번 달 근무 기록이 없습니다" icon={<CalendarDays size={28} />} /> : (
<div className="overflow-x-auto">
<table className="dense-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{attQ.data!.map((a) => (
<tr key={a.id}>
<td className="tabular">{formatDate(a.date)}</td>
<td className="tabular">{formatTime(a.clockIn)}</td>
<td className="tabular">{formatTime(a.clockOut)}</td>
<td className="tabular">{minutesToHM(a.workMinutes)}</td>
<td className="text-ink-muted">{a.note || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
{tab === "leave" && (
leaveQ.isLoading ? <LoadingState /> : (leaveQ.data?.length ?? 0) === 0 ? <EmptyState title="신청 내역이 없습니다" /> : (
<table className="dense-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{leaveQ.data!.map((l) => {
const m = REQ_STATUS_META[l.status];
return (
<tr key={l.id}>
<td>{LEAVE_LABELS[l.type]}</td>
<td className="tabular">{formatDate(l.startDate)}{l.endDate && l.endDate !== l.startDate ? ` ~ ${formatDate(l.endDate)}` : ""}</td>
<td className="tabular">{l.days}</td>
<td className="text-ink-secondary max-w-[240px] truncate">{l.reason}</td>
<td><Badge label={m.label} fg={m.fg} bg={m.bg} dot /></td>
<td className="text-right">{l.status === "pending" && <button className="text-xs text-[#B42318]" onClick={() => cancelLeave(l.id).then(() => qc.invalidateQueries({ queryKey: ["leave-mine"] }))}></button>}</td>
</tr>
);
})}
</tbody>
</table>
)
)}
{tab === "overtime" && (
otQ.isLoading ? <LoadingState /> : (otQ.data?.length ?? 0) === 0 ? <EmptyState title="신청 내역이 없습니다" /> : (
<table className="dense-table">
<thead><tr><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{otQ.data!.map((o) => {
const m = REQ_STATUS_META[o.status];
return (
<tr key={o.id}>
<td className="tabular">{formatDate(o.date)}</td>
<td className="tabular">{minutesToHM(o.minutes)}</td>
<td className="text-ink-secondary">{o.reason}</td>
<td><Badge label={m.label} fg={m.fg} bg={m.bg} dot /></td>
</tr>
);
})}
</tbody>
</table>
)
)}
</div>
</Card>
<LeaveModal open={leaveOpen} onClose={() => setLeaveOpen(false)} onDone={() => qc.invalidateQueries({ queryKey: ["leave-mine"] })} />
<OvertimeModal open={otOpen} onClose={() => setOtOpen(false)} onDone={() => qc.invalidateQueries({ queryKey: ["ot-mine"] })} />
</div>
);
}
function LeaveModal({ open, onClose, onDone }: { open: boolean; onClose: () => void; onDone: () => void }) {
const [type, setType] = useState<LeaveType>("annual");
const [startDate, setStart] = useState("");
const [endDate, setEnd] = useState("");
const [reason, setReason] = useState("");
const m = useMutation({
mutationFn: () => createLeave({ type, startDate, endDate: endDate || startDate, reason }),
onSuccess: () => { onDone(); onClose(); setReason(""); },
});
return (
<Modal
open={open} onClose={onClose} title="휴가 / 공가 신청"
footer={<><Button variant="secondary" onClick={onClose}></Button><Button disabled={!startDate || m.isPending} onClick={() => m.mutate()}></Button></>}
>
<div className="space-y-4">
<Field label="종류"><Select value={type} onChange={(e) => setType(e.target.value as LeaveType)}>
{Object.entries(LEAVE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select></Field>
<div className="grid grid-cols-2 gap-3">
<Field label="시작일"><Input type="date" value={startDate} onChange={(e) => setStart(e.target.value)} /></Field>
<Field label="종료일"><Input type="date" value={endDate} onChange={(e) => setEnd(e.target.value)} /></Field>
</div>
<Field label="사유"><Textarea value={reason} onChange={(e) => setReason(e.target.value)} placeholder="사유를 입력하세요" /></Field>
<p className="text-xs text-ink-muted"> .</p>
</div>
</Modal>
);
}
function OvertimeModal({ open, onClose, onDone }: { open: boolean; onClose: () => void; onDone: () => void }) {
const [date, setDate] = useState("");
const [hours, setHours] = useState("2");
const [reason, setReason] = useState("");
const m = useMutation({
mutationFn: () => createOvertime({ date, minutes: Math.round(parseFloat(hours) * 60), reason }),
onSuccess: () => { onDone(); onClose(); setReason(""); },
});
return (
<Modal
open={open} onClose={onClose} title="초과근무 신청"
footer={<><Button variant="secondary" onClick={onClose}></Button><Button disabled={!date || m.isPending} onClick={() => m.mutate()}></Button></>}
>
<div className="space-y-4">
<Field label="날짜"><Input type="date" value={date} onChange={(e) => setDate(e.target.value)} /></Field>
<Field label="시간(시간 단위)"><Input type="number" step="0.5" value={hours} onChange={(e) => setHours(e.target.value)} /></Field>
<Field label="사유"><Textarea value={reason} onChange={(e) => setReason(e.target.value)} /></Field>
<p className={classNames("text-xs text-ink-muted")}> ·.</p>
</div>
</Modal>
);
}