All checks were successful
build-and-push / build (push) Successful in 36s
- 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>
203 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|