diff --git a/src/App.tsx b/src/App.tsx index 8935169..e709e35 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { InboxPage } from "@/pages/Inbox"; import { ApprovalsPage } from "@/pages/admin/Approvals"; import { AttendanceAdminPage } from "@/pages/admin/AttendanceAdmin"; import { ProjectsAdminPage } from "@/pages/admin/ProjectsAdmin"; +import { MasterDataPage } from "@/pages/admin/MasterData"; import { IncentiveAdminPage } from "@/pages/admin/IncentiveAdmin"; import { AccountingPage } from "@/pages/admin/Accounting"; import { MembersPage } from "@/pages/admin/Members"; @@ -47,6 +48,7 @@ function Shell() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/IncentiveGauge.tsx b/src/components/IncentiveGauge.tsx new file mode 100644 index 0000000..69780f4 --- /dev/null +++ b/src/components/IncentiveGauge.tsx @@ -0,0 +1,56 @@ +import { Card } from "./ui"; +import { formatPoints } from "@/lib/format"; +import type { FixStatus, MyIncentive } from "@/types"; + +// 4색 세그먼트 게이지: 채우는 순서 지급완료→반영완료→반영중→예정, 할당량 위치는 ▼ 화살표. +export const SEG: { key: FixStatus; label: string; color: string }[] = [ + { key: "paid", label: "지급완료", color: "#F04438" }, + { key: "applied", label: "반영완료", color: "#12B76A" }, + { key: "applying", label: "반영중", color: "#F79009" }, + { key: "planned", label: "예정", color: "#98A2B3" }, +]; + +export function QuotaGauge({ seg, quota }: { seg: { key: string; color: string; pts: number }[]; quota: number }) { + const total = seg.reduce((s, x) => s + x.pts, 0); + const denom = Math.max(quota, total, 1); + const quotaPct = Math.min(100, (quota / denom) * 100); + return ( +
+ {quota > 0 && ( +
+ 할당량 {formatPoints(quota)} + +
+ )} +
+ {seg.map((s) => (s.pts > 0 ?
: null))} +
+ {quota > 0 &&
} +
+ ); +} + +// 할당량 달성률 카드 — 대시보드 상단·인센티브 페이지 공용. +export function IncentiveGaugeCard({ data, className }: { data: MyIncentive; className?: string }) { + const byStatus = (st: FixStatus) => data.items.filter((i) => i.fixStatus === st).reduce((s, i) => s + i.points, 0); + const seg = SEG.map((s) => ({ ...s, pts: byStatus(s.key) })); + const realized = byStatus("paid") + byStatus("applied"); + return ( + +
+

인센티브 할당량 달성률 · {data.year}

+ {data.quota > 0 ? Math.round((realized / data.quota) * 100) : 0}% +
+ +
+ {seg.map((s) => ( + + + {s.label} {formatPoints(s.pts)} + + ))} + 할당량 초과 {formatPoints(data.excessPoints)} +
+
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index fb98cf3..2b98afa 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,7 +1,7 @@ import { NavLink } from "react-router-dom"; import { LayoutDashboard, Clock, FolderKanban, Coins, CheckSquare, Calculator, - Wallet, Users, Settings, FolderCog, Inbox, UserCircle, ClipboardList, + Wallet, Users, Settings, FolderCog, Inbox, UserCircle, ClipboardList, Database, type LucideIcon, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; @@ -14,7 +14,7 @@ import type { NavItem } from "@/types"; export const ICONS: Record = { LayoutDashboard, Clock, FolderKanban, Coins, CheckSquare, Calculator, Wallet, Users, Settings, FolderCog, - Inbox, UserCircle, ClipboardList, + Inbox, UserCircle, ClipboardList, Database, }; export function Sidebar({ collapsed = false, className }: { collapsed?: boolean; className?: string }) { diff --git a/src/components/WorkStatusMenu.tsx b/src/components/WorkStatusMenu.tsx index 6071b06..7746c6d 100644 --- a/src/components/WorkStatusMenu.tsx +++ b/src/components/WorkStatusMenu.tsx @@ -7,7 +7,7 @@ import type { WorkStatusKind } from "@/types"; // 근무 상태 빠른 설정. 출근/퇴근은 실제 출퇴근 punch API를 호출하고, 휴식/미팅/이동은 // 현재 상태 표시(프레즌스)로 사용합니다. 선택값은 localStorage에 보관됩니다. -type StatusKey = "off" | "in" | "break" | "meeting" | "move" | "out"; +type StatusKey = "in" | "break" | "meeting" | "move" | "out"; const OPTIONS: { key: StatusKey; label: string; color: string; icon: typeof LogIn }[] = [ { key: "in", label: "출근", color: "#12B76A", icon: LogIn }, @@ -17,7 +17,6 @@ const OPTIONS: { key: StatusKey; label: string; color: string; icon: typeof LogI { key: "out", label: "퇴근", color: "#98A2B3", icon: LogOut }, ]; const META: Record = { - off: { label: "미출근", color: "#667085" }, in: { label: "근무중", color: "#12B76A" }, break: { label: "휴식", color: "#F79009" }, meeting: { label: "미팅", color: "#2E90FA" }, @@ -29,7 +28,7 @@ export function WorkStatusMenu({ collapsed = false }: { collapsed?: boolean }) { const qc = useQueryClient(); const [open, setOpen] = useState(false); const [status, setStatus] = useState( - () => (localStorage.getItem("spin.workStatus") as StatusKey) || "off" + () => (localStorage.getItem("spin.workStatus") as StatusKey) || "out" ); const ref = useRef(null); @@ -55,7 +54,7 @@ export function WorkStatusMenu({ collapsed = false }: { collapsed?: boolean }) { setStatus(key); localStorage.setItem("spin.workStatus", key); setOpen(false); - if (key !== "off") statusM.mutate(key as WorkStatusKind); // record every presence change + statusM.mutate(key as WorkStatusKind); // record every presence change }; const cur = META[status]; diff --git a/src/lib/api.ts b/src/lib/api.ts index 34e76c1..6375041 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -99,12 +99,18 @@ export const getApprovals = () => api.get("/approvals").then((r) /* ---- projects ---- */ export const getCompanies = () => api.get("/companies").then((r) => r.data); export const createCompany = (b: Partial) => api.post("/companies", b).then((r) => r.data); +export const updateCompany = (id: string, b: Partial) => api.patch(`/companies/${id}`, b).then((r) => r.data); +export const deleteCompany = (id: string) => api.delete(`/companies/${id}`).then((r) => r.data); export const getProducts = (companyId?: string) => api.get("/products", { params: { companyId } }).then((r) => r.data); export const createProduct = (b: Partial) => api.post("/products", b).then((r) => r.data); +export const updateProduct = (id: string, b: Partial) => api.patch(`/products/${id}`, b).then((r) => r.data); +export const deleteProduct = (id: string) => api.delete(`/products/${id}`).then((r) => r.data); 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 updateVersion = (id: string, b: Partial) => api.patch(`/versions/${id}`, b).then((r) => r.data); +export const deleteVersion = (id: string) => api.delete(`/versions/${id}`).then((r) => r.data); export const getProjects = (params?: { companyId?: string; status?: string; scope?: "mine" }) => api.get("/projects", { params }).then((r) => r.data); @@ -205,5 +211,6 @@ export const getTaxes = () => api.get("/taxes").then((r) => r.data) export const createTax = (b: Partial) => api.post("/taxes", b).then((r) => r.data); export const updateTax = (taxId: string, b: Partial) => api.patch(`/taxes/${taxId}`, b).then((r) => r.data); +export const deleteTax = (taxId: string) => api.delete(`/taxes/${taxId}`).then((r) => r.data); export const getAccountingSummary = (year?: number) => api.get("/accounting/summary", { params: { year } }).then((r) => r.data); diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 7b3809c..7454ad3 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,9 +1,10 @@ import { useQuery } from "@tanstack/react-query"; import { Link } from "react-router-dom"; import { Clock, FolderKanban, Coins, User } from "lucide-react"; -import { getDashboard } from "@/lib/api"; +import { getDashboard, getMyIncentive } from "@/lib/api"; import { useAuth } from "@/context/Auth"; import { Card, CardHeader, Stat, PageHeader, LoadingState } from "@/components/ui"; +import { IncentiveGaugeCard } from "@/components/IncentiveGauge"; import { formatPoints } from "@/lib/format"; // 개요(대시보드)는 역할과 무관하게 동일하게 보입니다 — 본인 업무 요약만 표시하고 @@ -11,6 +12,7 @@ import { formatPoints } from "@/lib/format"; export function DashboardPage() { const { me } = useAuth(); const q = useQuery({ queryKey: ["dashboard"], queryFn: getDashboard }); + const incQ = useQuery({ queryKey: ["my-incentive", "dash"], queryFn: () => getMyIncentive() }); if (q.isLoading) return ; const d = q.data!; @@ -20,10 +22,13 @@ export function DashboardPage() {
+ {/* 최상단 인센티브 할당량 게이지 */} + {incQ.data && } +
- +
diff --git a/src/pages/Inbox.tsx b/src/pages/Inbox.tsx index bd083b4..71c15d6 100644 --- a/src/pages/Inbox.tsx +++ b/src/pages/Inbox.tsx @@ -35,7 +35,7 @@ export function InboxPage() { const unread = items.filter((n) => !n.read).length; return ( -
+
new Date().getFullYear() - i); +// 유저 인센티브: BE/non-BE·환율 비노출. 연도 선택으로 과거 연도도 조회. export function IncentivePage() { - const q = useQuery({ queryKey: ["my-incentive"], queryFn: () => getMyIncentive() }); + const [year, setYear] = useState(YEARS[0]); + const q = useQuery({ queryKey: ["my-incentive", year], queryFn: () => getMyIncentive({ year }) }); if (q.isLoading) return ; const d = q.data!; - const byStatus = (st: FixStatus) => d.items.filter((i) => i.fixStatus === st).reduce((s, i) => s + i.points, 0); - const seg = SEG.map((s) => ({ ...s, pts: byStatus(s.key) })); - const realized = byStatus("paid") + byStatus("applied"); // 달성(반영완료 이상) - const byProject = Object.entries(d.byProject).map(([pid, pts]) => ({ name: pid.slice(0, 6), points: Math.round(pts * 10) / 10 })); return (
- + setYear(Number(e.target.value))} className="w-28"> + {YEARS.map((y) => )} + + } + /> + +
@@ -43,25 +40,6 @@ export function IncentivePage() {
- -
-

할당량 달성률

- - {d.quota > 0 ? Math.round((realized / d.quota) * 100) : 0}% - -
- -
- {seg.map((s) => ( - - - {s.label} {formatPoints(s.pts)} - - ))} - 할당량 초과 {formatPoints(d.excessPoints)} -
-
-
@@ -104,34 +82,3 @@ export function IncentivePage() {
); } - -// Segmented gauge filled paid→applied→applying→planned, with a ▼ arrow at the quota. -function QuotaGauge({ seg, quota }: { seg: { key: string; label: string; color: string; pts: number }[]; quota: number }) { - const total = seg.reduce((s, x) => s + x.pts, 0); - const denom = Math.max(quota, total, 1); - const quotaPct = Math.min(100, (quota / denom) * 100); - return ( -
- {/* quota arrow marker */} - {quota > 0 && ( -
- 할당량 {formatPointsShort(quota)} - -
- )} -
- {seg.map((s) => - s.pts > 0 ? ( -
- ) : null - )} -
- {/* quota tick line */} - {quota > 0 &&
} -
- ); -} - -function formatPointsShort(n: number) { - return `${Math.round(n * 10) / 10}P`; -} diff --git a/src/pages/ProjectDetail.tsx b/src/pages/ProjectDetail.tsx index 49fab99..2299a0a 100644 --- a/src/pages/ProjectDetail.tsx +++ b/src/pages/ProjectDetail.tsx @@ -2,17 +2,18 @@ import { useState } from "react"; import { useParams, Link } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - ArrowLeft, Plus, GanttChartSquare, Columns3, CalendarDays, Trash2, Upload, Download, Lock, + ArrowLeft, Plus, GanttChartSquare, Columns3, CalendarDays, Trash2, Upload, Download, Lock, Pencil, } from "lucide-react"; import { getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles, getPayments, upsertProjectMember, deleteProjectMember, createTask, updateTask, upsertContact, deleteContact, putContract, uploadContractFile, getFileDownloadUrl, deleteContractFile, createPayment, updatePayment, deletePayment, recomputeProject, + updateProject, } from "@/lib/api"; import { useAuth } from "@/context/Auth"; import { - Card, CardHeader, Button, Badge, Tabs, Modal, Field, Input, Select, + Card, CardHeader, Button, Badge, Tabs, Modal, Field, Input, Select, Textarea, PageHeader, EmptyState, LoadingState, } from "@/components/ui"; import { Gantt } from "@/components/Gantt"; @@ -26,6 +27,7 @@ export function ProjectDetailPage() { const { id = "" } = useParams(); const { isAdmin } = useAuth(); const [tab, setTab] = useState("overview"); + const [editOpen, setEditOpen] = useState(false); const projQ = useQuery({ queryKey: ["project", id], queryFn: () => getProject(id) }); if (projQ.isLoading) return ; @@ -47,7 +49,9 @@ export function ProjectDetailPage() { {p.name} {m && }} description={`${p.companyName} · ${p.productName} ${p.versionName} · ${p.consultingType} · ${p.country}`} + action={isAdmin && } /> + {editOpen && setEditOpen(false)} />}
@@ -389,3 +393,42 @@ function Payments({ projectId, payments, onChange }: { projectId: string; paymen ); } + +/* ---- edit project basic info (admin) ---- */ +function EditProjectModal({ project, onClose }: { project: Project; onClose: () => void }) { + const qc = useQueryClient(); + const [f, setF] = useState({ + name: project.name, consultingType: project.consultingType, country: project.country, + scopeText: project.scopeText, scopeGraphic: project.scopeGraphic, pmEmail: project.pmEmail, + cautions: project.cautions, status: project.status, startDate: project.startDate, dueDate: project.dueDate, + }); + const save = useMutation({ + mutationFn: () => updateProject(project.id, f as Partial), + onSuccess: () => { qc.invalidateQueries({ queryKey: ["project", project.id] }); qc.invalidateQueries({ queryKey: ["projects"] }); onClose(); }, + }); + return ( + }> +
+ setF({ ...f, name: e.target.value })} /> +
+ setF({ ...f, consultingType: e.target.value })} /> + setF({ ...f, country: e.target.value })} /> + +
+
+