diff --git a/src/index.css b/src/index.css
index b8087f1..0a0c0f9 100644
--- a/src/index.css
+++ b/src/index.css
@@ -44,8 +44,8 @@ body {
.form-input:focus,
.form-select:focus {
outline: none;
- border-color: #11224f;
- box-shadow: 0 0 0 3px rgba(17, 34, 79, 0.08);
+ border-color: #03143f;
+ box-shadow: 0 0 0 3px rgba(3, 20, 63, 0.12);
}
.form-label {
display: block;
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 229d2f8..e11065d 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -2,9 +2,10 @@ import axios from "axios";
import type {
Account, AcctSummary, ApprovalQueue, Attendance, AuditLog, ClientContact,
Company, Contract, ContractFile, Dashboard, Department, IncentiveConfig,
- LeaveRequest, Me, Member, MyIncentive, NavItem, OvertimeRequest, PaymentSplit,
- PaymentStage, Product, Project, ProjectMember, ProjectTask, Settlement,
- SimResult, TaxRecord, Timesheet, Transaction, UserIncentive, Version, WorkPolicy,
+ LeaveBalance, LeaveRequest, Me, Member, MyIncentive, NavItem, Notification,
+ OvertimeRequest, PaymentSplit, PaymentStage, Product, Project, ProjectMember,
+ ProjectTask, Settlement, SimResult, TaxRecord, Timesheet, Transaction,
+ UserIncentive, Version, WorkPolicy, WorkStatusEvent, WorkStatusKind,
} from "@/types";
export const api = axios.create({
@@ -21,6 +22,26 @@ export const getMe = () => api.get
("/me").then((r) => r.data);
export const getNav = () => api.get("/me/nav").then((r) => r.data);
export const getDashboard = () => api.get("/dashboard").then((r) => r.data);
+/* ---- inbox / notifications ---- */
+export const getNotifications = (unread?: boolean) =>
+ api.get("/notifications", { params: { unread } }).then((r) => r.data);
+export const getUnreadCount = () =>
+ api.get<{ count: number }>("/notifications/unread-count").then((r) => r.data.count);
+export const markNotificationRead = (id: string) =>
+ api.post(`/notifications/${id}/read`).then((r) => r.data);
+export const markAllNotificationsRead = () =>
+ api.post("/notifications/read-all").then((r) => r.data);
+
+/* ---- profile photo ---- */
+export const uploadAvatar = (file: File) => {
+ const form = new FormData();
+ form.append("file", file);
+ return api.post("/me/avatar", form, { headers: { "Content-Type": "multipart/form-data" } }).then((r) => r.data);
+};
+// Avatar image URL (cache-busted by avatarKey). Empty avatarKey → no image.
+export const avatarUrl = (memberId?: string, avatarKey?: string) =>
+ memberId && avatarKey ? `/api/members/${memberId}/avatar?v=${encodeURIComponent(avatarKey)}` : "";
+
/* ---- members / org ---- */
export const getMembers = () => api.get("/members").then((r) => r.data);
export const getMember = (id: string) => api.get(`/members/${id}`).then((r) => r.data);
@@ -40,6 +61,12 @@ export const getAudit = () => api.get("/audit").then((r) => r.data);
export const getAttendance = (params: { month?: string; email?: string }) =>
api.get("/attendance", { params }).then((r) => r.data);
export const punch = () => api.post("/attendance/punch").then((r) => r.data);
+export const setWorkStatus = (status: WorkStatusKind, note?: string) =>
+ api.post("/attendance/status", { status, note }).then((r) => r.data);
+export const getWorkStatusLog = (params?: { email?: string; date?: string }) =>
+ api.get("/attendance/status", { params }).then((r) => r.data);
+export const getLeaveBalance = (params?: { year?: number; email?: string }) =>
+ api.get("/leave/balance", { params }).then((r) => r.data);
export const getTimesheet = (params: { year?: number; month?: number; email?: string }) =>
api.get("/attendance/timesheet", { params }).then((r) => r.data);
export const getLeave = (params?: { status?: string; email?: string }) =>
@@ -70,7 +97,7 @@ export const getVersions = (productId?: string) =>
api.get("/versions", { params: { productId } }).then((r) => r.data);
export const createVersion = (b: Partial) => api.post("/versions", b).then((r) => r.data);
-export const getProjects = (params?: { companyId?: string; status?: string }) =>
+export const getProjects = (params?: { companyId?: string; status?: string; scope?: "mine" }) =>
api.get("/projects", { params }).then((r) => r.data);
export const getProject = (id: string) => api.get(`/projects/${id}`).then((r) => r.data);
export const createProject = (b: Partial) => api.post("/projects", b).then((r) => r.data);
diff --git a/src/pages/Attendance.tsx b/src/pages/Attendance.tsx
index f1c7568..af988bc 100644
--- a/src/pages/Attendance.tsx
+++ b/src/pages/Attendance.tsx
@@ -1,64 +1,44 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { CalendarDays, Plus, LogIn, LogOut } from "lucide-react";
+import { CalendarDays, Plus } from "lucide-react";
import {
- getTimesheet, getAttendance, getLeave, getOvertime, punch, createLeave,
- createOvertime, cancelLeave,
+ getAttendance, getLeave, getOvertime, getLeaveBalance, createLeave, createOvertime, cancelLeave,
} from "@/lib/api";
import {
Card, Button, Badge, Stat, PageHeader, Modal, Field, Input, Select,
- Textarea, Tabs, Progress, EmptyState, LoadingState,
+ Textarea, Tabs, EmptyState, LoadingState,
} from "@/components/ui";
import {
- formatDate, formatTime, minutesToHM, REQ_STATUS_META, LEAVE_LABELS, classNames,
+ formatDate, formatTime, minutesToHM, REQ_STATUS_META, LEAVE_LABELS,
} 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 [tab, setTab] = useState("records");
const [leaveOpen, setLeaveOpen] = useState(false);
const [otOpen, setOtOpen] = useState(false);
- const tsQ = useQuery({ queryKey: ["timesheet"], queryFn: () => getTimesheet({}) });
+ const balQ = useQuery({ queryKey: ["leave-balance"], queryFn: () => getLeaveBalance() });
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;
+ const bal = balQ.data;
return (
-
- } onClick={() => punchM.mutate()}>출근
- } onClick={() => punchM.mutate()}>퇴근
-
- }
- />
+
- {ts && (
-
-
-
-
-
- 월 근로 달성률
- {ts.fulfillmentPct.toFixed(0)}%
-
-
-
- )}
+
+
+ a.workMinutes > 0).length}일`} sub="본인 기록 기준" />
+ l.status === "pending").length + (otQ.data ?? []).filter((o) => o.status === "pending").length} sub="승인 대기" accent="#B54708" />
+
@@ -66,7 +46,7 @@ export function AttendancePage() {
active={tab}
onChange={setTab}
tabs={[
- { key: "timesheet", label: "근무 기록" },
+ { key: "records", 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 },
]}
@@ -76,24 +56,22 @@ export function AttendancePage() {
- {tab === "timesheet" && (
+ {tab === "records" && (
attQ.isLoading ?
: (attQ.data?.length ?? 0) === 0 ?
} /> : (
-
-
- | 날짜 | 출근 | 퇴근 | 근무시간 | 비고 |
-
- {attQ.data!.map((a) => (
-
- | {formatDate(a.date)} |
- {formatTime(a.clockIn)} |
- {formatTime(a.clockOut)} |
- {minutesToHM(a.workMinutes)} |
- {a.note || "—"} |
-
- ))}
-
-
-
+
+ | 날짜 | 출근 | 퇴근 | 근무시간 | 비고 |
+
+ {attQ.data!.map((a) => (
+
+ | {formatDate(a.date)} |
+ {formatTime(a.clockIn)} |
+ {formatTime(a.clockOut)} |
+ {minutesToHM(a.workMinutes)} |
+ {a.note || "—"} |
+
+ ))}
+
+
)
)}
@@ -143,7 +121,7 @@ export function AttendancePage() {
- setLeaveOpen(false)} onDone={() => qc.invalidateQueries({ queryKey: ["leave-mine"] })} />
+ setLeaveOpen(false)} onDone={() => { qc.invalidateQueries({ queryKey: ["leave-mine"] }); qc.invalidateQueries({ queryKey: ["leave-balance"] }); }} />
setOtOpen(false)} onDone={() => qc.invalidateQueries({ queryKey: ["ot-mine"] })} />
);
@@ -195,7 +173,7 @@ function OvertimeModal({ open, onClose, onDone }: { open: boolean; onClose: () =
※ 초과근무는 관리자만 확인·승인합니다.
+ ※ 초과근무는 관리자만 확인·승인합니다.