From 09713f5e239ef1b9bdc2ba6c08482811d32b263f Mon Sep 17 00:00:00 2001 From: theorose49 Date: Tue, 30 Jun 2026 16:06:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?'=EB=82=B4=20=EC=9D=B4=EC=8A=88'=20=EB=B3=B4=EB=93=9C(=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=EB=B3=84=20=EC=83=89)=20+=20?= =?UTF-8?q?=EC=BA=98=EB=A6=B0=EB=8D=94=20=ED=8E=98=EC=9D=B4=EC=A7=80=20+?= =?UTF-8?q?=20=EB=A9=94=EC=9D=BC=20AI=EC=9A=94=EC=95=BD=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 대시보드: 내게 배정된 작업을 상태별 JIRA 보드로, 프로젝트별 색상 카드(/my/tasks) - 캘린더 페이지(/calendar): 월 그리드, 분류(프로젝트/기타/개인)별 색 일정 추가·수정·삭제 - 메일 리스트 메모칸 위에 AI 자동 요약 표시 - projectColor() 헬퍼, 사이드바 CalendarDays 아이콘, 라우트 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.tsx | 2 + src/components/Sidebar.tsx | 4 +- src/lib/api.ts | 12 ++- src/lib/format.ts | 13 +++ src/pages/Calendar.tsx | 166 ++++++++++++++++++++++++++++++++++++ src/pages/Dashboard.tsx | 70 ++++++++++++++- src/pages/ProjectDetail.tsx | 7 +- src/types.ts | 20 +++++ 8 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 src/pages/Calendar.tsx diff --git a/src/App.tsx b/src/App.tsx index e709e35..d6e204c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { IncentivePage } from "@/pages/Incentive"; import { ProfilePage } from "@/pages/Profile"; import { AccountSettingsPage } from "@/pages/AccountSettings"; import { InboxPage } from "@/pages/Inbox"; +import { CalendarPage } from "@/pages/Calendar"; import { ApprovalsPage } from "@/pages/admin/Approvals"; import { AttendanceAdminPage } from "@/pages/admin/AttendanceAdmin"; import { ProjectsAdminPage } from "@/pages/admin/ProjectsAdmin"; @@ -45,6 +46,7 @@ function Shell() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 2b98afa..b7745f9 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, Database, + Wallet, Users, Settings, FolderCog, Inbox, UserCircle, ClipboardList, Database, CalendarDays, 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, Database, + Inbox, UserCircle, ClipboardList, Database, CalendarDays, }; export function Sidebar({ collapsed = false, className }: { collapsed?: boolean; className?: string }) { diff --git a/src/lib/api.ts b/src/lib/api.ts index 29a35cd..0eb4aaa 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,7 +1,7 @@ import axios from "axios"; import type { Account, AcctSummary, ApprovalQueue, Attendance, AuditLog, ClientContact, - Company, Contract, ContractFile, Dashboard, Department, DirectoryEntry, IncentiveConfig, + CalendarEvent, Company, Contract, ContractFile, Dashboard, Department, DirectoryEntry, IncentiveConfig, MyTask, LeaveBalance, LeaveRequest, MailFull, MailNote, Me, Member, MyIncentive, NavItem, Notification, OvertimeRequest, PaymentSplit, PaymentStage, Product, Project, ProjectMailsResponse, ProjectMember, ProjectTask, Settlement, SimResult, TaskComment, TaxRecord, Timesheet, Transaction, @@ -147,6 +147,16 @@ export const updateTask = (tId: string, b: Partial) => api.patch(`/tasks/${tId}`, b).then((r) => r.data); export const deleteTask = (tId: string) => api.delete(`/tasks/${tId}`).then((r) => r.data); +export const getMyTasks = () => api.get("/my/tasks").then((r) => r.data); + +/* ---- calendar ---- */ +export const getEvents = () => api.get("/calendar/events").then((r) => r.data); +export const createEvent = (b: Partial) => + api.post("/calendar/events", b).then((r) => r.data); +export const updateEvent = (eId: string, b: Partial) => + api.patch(`/calendar/events/${eId}`, b).then((r) => r.data); +export const deleteEvent = (eId: string) => api.delete(`/calendar/events/${eId}`).then((r) => r.data); + export const getProjectMails = (id: string) => api.get(`/projects/${id}/mails`).then((r) => r.data); export const getMailNotes = (id: string) => diff --git a/src/lib/format.ts b/src/lib/format.ts index 6459b19..cb6a982 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -146,6 +146,19 @@ export const LANE_LABELS: Record = { done: "완료", }; +// 프로젝트별 고정 색상 — 같은 id면 항상 같은 색(대시보드 내 이슈·캘린더 분류용). +const PROJECT_PALETTE = [ + "#175CD3", "#5925DC", "#067647", "#B54708", "#C11574", + "#0E7090", "#4E5BA6", "#B42318", "#7A5AF8", "#1570EF", + "#C4320A", "#3538CD", +]; +export function projectColor(id?: string | null): string { + if (!id) return "#667085"; + let h = 0; + for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0; + return PROJECT_PALETTE[h % PROJECT_PALETTE.length]; +} + // 작업 우선순위 (JIRA형). order로 정렬·뱃지 색 통일. export const PRIORITY_META: Record = { urgent: { label: "긴급", fg: "#B42318", bg: "#FEE4E2", order: 0 }, diff --git a/src/pages/Calendar.tsx b/src/pages/Calendar.tsx new file mode 100644 index 0000000..7669fde --- /dev/null +++ b/src/pages/Calendar.tsx @@ -0,0 +1,166 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react"; +import { getEvents, createEvent, updateEvent, deleteEvent, getProjects } from "@/lib/api"; +import { + Card, Button, PageHeader, Modal, Field, Input, Select, Textarea, LoadingState, +} from "@/components/ui"; +import { projectColor, classNames } from "@/lib/format"; +import type { CalendarEvent } from "@/types"; + +const CATS = [ + { key: "project", label: "프로젝트" }, + { key: "etc", label: "기타" }, + { key: "personal", label: "개인" }, +]; +const CAT_COLOR: Record = { etc: "#475467", personal: "#C11574" }; + +function eventColor(e: { category: string; projectId: string }): string { + return e.category === "project" ? projectColor(e.projectId) : (CAT_COLOR[e.category] ?? "#475467"); +} +const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + +// 전체 캘린더 — 개인 일정을 분류(프로젝트/기타/개인)별 색으로 관리. +export function CalendarPage() { + const now = new Date(); + const [ym, setYm] = useState({ y: now.getFullYear(), m: now.getMonth() }); + const [editing, setEditing] = useState | null>(null); + const evQ = useQuery({ queryKey: ["events"], queryFn: getEvents }); + const events = evQ.data ?? []; + + const first = new Date(ym.y, ym.m, 1); + const startDow = first.getDay(); + const daysInMonth = new Date(ym.y, ym.m + 1, 0).getDate(); + const cells: (number | null)[] = [...Array(startDow).fill(null), ...Array.from({ length: daysInMonth }, (_, i) => i + 1)]; + + const eventsOn = useMemo(() => { + const map = new Map(); + for (const e of events) { + const s = e.start, end = e.end || e.start; + // start..end 사이 모든 날짜에 배치 + let d = new Date(s); + const last = new Date(end); + if (Number.isNaN(d.getTime())) continue; + for (let i = 0; i < 60 && d <= last; i++) { + const key = ymd(d); + (map.get(key) ?? map.set(key, []).get(key)!).push(e); + d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1); + } + } + return map; + }, [events]); + + const prevMonth = () => setYm((s) => ({ y: s.m === 0 ? s.y - 1 : s.y, m: s.m === 0 ? 11 : s.m - 1 })); + const nextMonth = () => setYm((s) => ({ y: s.m === 11 ? s.y + 1 : s.y, m: s.m === 11 ? 0 : s.m + 1 })); + + return ( +
+ } onClick={() => setEditing({ category: "etc", start: ymd(now) })}>일정 추가} /> + + +
+ +
{ym.y}.{String(ym.m + 1).padStart(2, "0")}
+ +
+ {evQ.isLoading ? : ( +
+ {["일", "월", "화", "수", "목", "금", "토"].map((d, i) => ( +
{d}
+ ))} + {cells.map((day, i) => { + const dateStr = day ? ymd(new Date(ym.y, ym.m, day)) : ""; + const dayEvents = day ? (eventsOn.get(dateStr) ?? []) : []; + const isToday = day && dateStr === ymd(now); + return ( +
+ {day && ( + <> + +
+ {dayEvents.slice(0, 4).map((e) => ( + + ))} + {dayEvents.length > 4 &&
+{dayEvents.length - 4}
} +
+ + )} +
+ ); + })} +
+ )} + {/* 범례 */} +
+ 기타 + 개인 + 프로젝트(프로젝트별 색) +
+
+ + {editing && setEditing(null)} />} +
+ ); +} + +function EventModal({ init, onClose }: { init: Partial; onClose: () => void }) { + const qc = useQueryClient(); + const projQ = useQuery({ queryKey: ["projects", "mine"], queryFn: () => getProjects({ scope: "mine" }) }); + const [f, setF] = useState({ + title: init.title ?? "", category: init.category ?? "etc", projectId: init.projectId ?? "", + start: init.start ?? "", end: init.end ?? "", memo: init.memo ?? "", + }); + const isEdit = !!init.id; + const invalidate = () => qc.invalidateQueries({ queryKey: ["events"] }); + const save = useMutation({ + mutationFn: () => { + const body = { ...f, color: f.category === "project" ? projectColor(f.projectId) : (CAT_COLOR[f.category] ?? "") }; + return isEdit ? updateEvent(init.id!, body) : createEvent(body); + }, + onSuccess: () => { invalidate(); onClose(); }, + }); + const del = useMutation({ mutationFn: () => deleteEvent(init.id!), onSuccess: () => { invalidate(); onClose(); } }); + + return ( + + {isEdit && } + + + }> +
+ setF({ ...f, title: e.target.value })} placeholder="예: 고객 미팅" /> +
+ + + + {f.category === "project" ? ( + + + + ) :
} +
+
+ setF({ ...f, start: e.target.value })} /> + setF({ ...f, end: e.target.value })} /> +
+