feat: 대시보드 '내 이슈' 보드(프로젝트별 색) + 캘린더 페이지 + 메일 AI요약 표시
All checks were successful
build-and-push / build (push) Successful in 32s

- 대시보드: 내게 배정된 작업을 상태별 JIRA 보드로, 프로젝트별 색상 카드(/my/tasks)
- 캘린더 페이지(/calendar): 월 그리드, 분류(프로젝트/기타/개인)별 색 일정 추가·수정·삭제
- 메일 리스트 메모칸 위에 AI 자동 요약 표시
- projectColor() 헬퍼, 사이드바 CalendarDays 아이콘, 라우트

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-30 16:06:56 +09:00
parent a42318fc4c
commit 09713f5e23
8 changed files with 287 additions and 7 deletions

View File

@ -10,6 +10,7 @@ import { IncentivePage } from "@/pages/Incentive";
import { ProfilePage } from "@/pages/Profile"; import { ProfilePage } from "@/pages/Profile";
import { AccountSettingsPage } from "@/pages/AccountSettings"; import { AccountSettingsPage } from "@/pages/AccountSettings";
import { InboxPage } from "@/pages/Inbox"; import { InboxPage } from "@/pages/Inbox";
import { CalendarPage } from "@/pages/Calendar";
import { ApprovalsPage } from "@/pages/admin/Approvals"; import { ApprovalsPage } from "@/pages/admin/Approvals";
import { AttendanceAdminPage } from "@/pages/admin/AttendanceAdmin"; import { AttendanceAdminPage } from "@/pages/admin/AttendanceAdmin";
import { ProjectsAdminPage } from "@/pages/admin/ProjectsAdmin"; import { ProjectsAdminPage } from "@/pages/admin/ProjectsAdmin";
@ -45,6 +46,7 @@ function Shell() {
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
<Route path="/account" element={<AccountSettingsPage />} /> <Route path="/account" element={<AccountSettingsPage />} />
<Route path="/inbox" element={<InboxPage />} /> <Route path="/inbox" element={<InboxPage />} />
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/admin/approvals" element={<RequireAdmin><ApprovalsPage /></RequireAdmin>} /> <Route path="/admin/approvals" element={<RequireAdmin><ApprovalsPage /></RequireAdmin>} />
<Route path="/admin/attendance" element={<RequireAdmin><AttendanceAdminPage /></RequireAdmin>} /> <Route path="/admin/attendance" element={<RequireAdmin><AttendanceAdminPage /></RequireAdmin>} />
<Route path="/admin/projects" element={<RequireAdmin><ProjectsAdminPage /></RequireAdmin>} /> <Route path="/admin/projects" element={<RequireAdmin><ProjectsAdminPage /></RequireAdmin>} />

View File

@ -1,7 +1,7 @@
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { import {
LayoutDashboard, Clock, FolderKanban, Coins, CheckSquare, Calculator, 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, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@ -14,7 +14,7 @@ import type { NavItem } from "@/types";
export const ICONS: Record<string, LucideIcon> = { export const ICONS: Record<string, LucideIcon> = {
LayoutDashboard, Clock, FolderKanban, Coins, CheckSquare, Calculator, Wallet, Users, Settings, FolderCog, 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 }) { export function Sidebar({ collapsed = false, className }: { collapsed?: boolean; className?: string }) {

View File

@ -1,7 +1,7 @@
import axios from "axios"; import axios from "axios";
import type { import type {
Account, AcctSummary, ApprovalQueue, Attendance, AuditLog, ClientContact, 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, LeaveBalance, LeaveRequest, MailFull, MailNote, Me, Member, MyIncentive, NavItem, Notification,
OvertimeRequest, PaymentSplit, PaymentStage, Product, Project, ProjectMailsResponse, OvertimeRequest, PaymentSplit, PaymentStage, Product, Project, ProjectMailsResponse,
ProjectMember, ProjectTask, Settlement, SimResult, TaskComment, TaxRecord, Timesheet, Transaction, ProjectMember, ProjectTask, Settlement, SimResult, TaskComment, TaxRecord, Timesheet, Transaction,
@ -147,6 +147,16 @@ export const updateTask = (tId: string, b: Partial<ProjectTask>) =>
api.patch<ProjectTask>(`/tasks/${tId}`, b).then((r) => r.data); api.patch<ProjectTask>(`/tasks/${tId}`, b).then((r) => r.data);
export const deleteTask = (tId: string) => api.delete(`/tasks/${tId}`).then((r) => r.data); export const deleteTask = (tId: string) => api.delete(`/tasks/${tId}`).then((r) => r.data);
export const getMyTasks = () => api.get<MyTask[]>("/my/tasks").then((r) => r.data);
/* ---- calendar ---- */
export const getEvents = () => api.get<CalendarEvent[]>("/calendar/events").then((r) => r.data);
export const createEvent = (b: Partial<CalendarEvent>) =>
api.post<CalendarEvent>("/calendar/events", b).then((r) => r.data);
export const updateEvent = (eId: string, b: Partial<CalendarEvent>) =>
api.patch<CalendarEvent>(`/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) => export const getProjectMails = (id: string) =>
api.get<ProjectMailsResponse>(`/projects/${id}/mails`).then((r) => r.data); api.get<ProjectMailsResponse>(`/projects/${id}/mails`).then((r) => r.data);
export const getMailNotes = (id: string) => export const getMailNotes = (id: string) =>

View File

@ -146,6 +146,19 @@ export const LANE_LABELS: Record<string, string> = {
done: "완료", 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로 정렬·뱃지 색 통일. // 작업 우선순위 (JIRA형). order로 정렬·뱃지 색 통일.
export const PRIORITY_META: Record<string, { label: string; fg: string; bg: string; order: number }> = { export const PRIORITY_META: Record<string, { label: string; fg: string; bg: string; order: number }> = {
urgent: { label: "긴급", fg: "#B42318", bg: "#FEE4E2", order: 0 }, urgent: { label: "긴급", fg: "#B42318", bg: "#FEE4E2", order: 0 },

166
src/pages/Calendar.tsx Normal file
View File

@ -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<string, string> = { 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<Partial<CalendarEvent> | 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<string, CalendarEvent[]>();
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 (
<div>
<PageHeader title="캘린더" description="내 일정을 분류(프로젝트·기타·개인)별 색으로 관리합니다."
action={<Button icon={<Plus size={16} />} onClick={() => setEditing({ category: "etc", start: ymd(now) })}> </Button>} />
<Card className="p-4">
<div className="flex items-center justify-between mb-3">
<button className="p-1.5 rounded-control hover:bg-canvas text-ink-secondary" onClick={prevMonth}><ChevronLeft size={18} /></button>
<div className="font-bold text-ink">{ym.y}.{String(ym.m + 1).padStart(2, "0")}</div>
<button className="p-1.5 rounded-control hover:bg-canvas text-ink-secondary" onClick={nextMonth}><ChevronRight size={18} /></button>
</div>
{evQ.isLoading ? <LoadingState /> : (
<div className="grid grid-cols-7 gap-px bg-border rounded-card overflow-hidden border border-border">
{["일", "월", "화", "수", "목", "금", "토"].map((d, i) => (
<div key={d} className={classNames("bg-canvas text-center text-xs font-semibold py-2", i === 0 ? "text-[#B42318]" : "text-ink-secondary")}>{d}</div>
))}
{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 (
<div key={i} className="bg-surface min-h-[104px] p-1.5 align-top">
{day && (
<>
<button onClick={() => setEditing({ category: "etc", start: dateStr })}
className={classNames("text-xs mb-1 w-6 h-6 rounded-full inline-flex items-center justify-center hover:bg-canvas", isToday ? "bg-navy text-white" : "text-ink-muted")}>{day}</button>
<div className="space-y-1">
{dayEvents.slice(0, 4).map((e) => (
<button key={e.id + dateStr} onClick={() => setEditing(e)}
className="block w-full text-left text-[10px] leading-tight px-1.5 py-0.5 rounded truncate text-white"
style={{ background: eventColor(e) }} title={e.title}>
{e.title}
</button>
))}
{dayEvents.length > 4 && <div className="text-[10px] text-ink-muted px-1">+{dayEvents.length - 4}</div>}
</div>
</>
)}
</div>
);
})}
</div>
)}
{/* 범례 */}
<div className="flex flex-wrap items-center gap-3 mt-3 text-xs text-ink-muted">
<span className="inline-flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-full" style={{ background: CAT_COLOR.etc }} /> </span>
<span className="inline-flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-full" style={{ background: CAT_COLOR.personal }} /> </span>
<span className="inline-flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-full bg-[#175CD3]" /> ( )</span>
</div>
</Card>
{editing && <EventModal init={editing} onClose={() => setEditing(null)} />}
</div>
);
}
function EventModal({ init, onClose }: { init: Partial<CalendarEvent>; 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 (
<Modal open onClose={onClose} title={isEdit ? "일정 수정" : "일정 추가"}
footer={<>
{isEdit && <Button variant="danger" icon={<Trash2 size={14} />} onClick={() => del.mutate()} className="mr-auto"></Button>}
<Button variant="secondary" onClick={onClose}></Button>
<Button disabled={!f.title || !f.start || save.isPending} onClick={() => save.mutate()}>{isEdit ? "저장" : "추가"}</Button>
</>}>
<div className="space-y-4">
<Field label="제목"><Input value={f.title} autoFocus onChange={(e) => setF({ ...f, title: e.target.value })} placeholder="예: 고객 미팅" /></Field>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="분류">
<Select value={f.category} onChange={(e) => setF({ ...f, category: e.target.value })}>
{CATS.map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
</Select>
</Field>
{f.category === "project" ? (
<Field label="프로젝트">
<Select value={f.projectId} onChange={(e) => setF({ ...f, projectId: e.target.value })}>
<option value=""></option>
{projQ.data?.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</Select>
</Field>
) : <div />}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="시작일"><Input type="date" value={f.start} onChange={(e) => setF({ ...f, start: e.target.value })} /></Field>
<Field label="종료일 (하루면 비움)"><Input type="date" value={f.end} onChange={(e) => setF({ ...f, end: e.target.value })} /></Field>
</div>
<Field label="메모"><Textarea value={f.memo} onChange={(e) => setF({ ...f, memo: e.target.value })} /></Field>
<div className="flex items-center gap-2 text-xs text-ink-muted">
: <span className="w-4 h-4 rounded-full inline-block" style={{ background: f.category === "project" ? projectColor(f.projectId) : (CAT_COLOR[f.category] ?? "#475467") }} />
{f.category === "project" ? "프로젝트별 색 자동" : "분류 색 자동"}
</div>
</div>
</Modal>
);
}

View File

@ -1,11 +1,12 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Clock, FolderKanban, Coins, User } from "lucide-react"; import { Clock, FolderKanban, Coins, User } from "lucide-react";
import { getDashboard, getMyIncentive } from "@/lib/api"; import { getDashboard, getMyIncentive, getMyTasks } from "@/lib/api";
import { useAuth } from "@/context/Auth"; import { useAuth } from "@/context/Auth";
import { Card, CardHeader, Stat, PageHeader, LoadingState } from "@/components/ui"; import { Card, CardHeader, Stat, PageHeader, Badge, LoadingState } from "@/components/ui";
import { IncentiveGaugeCard } from "@/components/IncentiveGauge"; import { IncentiveGaugeCard } from "@/components/IncentiveGauge";
import { formatPoints, rankLabel } from "@/lib/format"; import { formatPoints, rankLabel, projectColor, formatDate, LANE_LABELS, PRIORITY_META } from "@/lib/format";
import type { MyTask } from "@/types";
// 개요(대시보드)는 역할과 무관하게 동일하게 보입니다 — 본인 업무 요약만 표시하고 // 개요(대시보드)는 역할과 무관하게 동일하게 보입니다 — 본인 업무 요약만 표시하고
// 회계·전사 위젯은 넣지 않습니다. (전사 현황은 각 관리자 메뉴에서 확인) // 회계·전사 위젯은 넣지 않습니다. (전사 현황은 각 관리자 메뉴에서 확인)
@ -25,6 +26,10 @@ export function DashboardPage() {
{/* 최상단 인센티브 할당량 게이지 */} {/* 최상단 인센티브 할당량 게이지 */}
{incQ.data && <IncentiveGaugeCard data={incQ.data} className="mb-4" />} {incQ.data && <IncentiveGaugeCard data={incQ.data} className="mb-4" />}
{/* 내 이슈 — 전 프로젝트에서 나에게 배정된 작업을 JIRA형 보드로 */}
<MyIssuesBoard />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<Link to="/projects"><Stat label="참여 프로젝트" value={d.myProjects} sub="내가 속한 프로젝트" /></Link> <Link to="/projects"><Stat label="참여 프로젝트" value={d.myProjects} sub="내가 속한 프로젝트" /></Link>
<Link to="/incentive"><Stat label="올해 인센티브 포인트" value={formatPoints(d.myPoints)} sub="반영완료 기준" accent="#5925DC" /></Link> <Link to="/incentive"><Stat label="올해 인센티브 포인트" value={formatPoints(d.myPoints)} sub="반영완료 기준" accent="#5925DC" /></Link>
@ -55,6 +60,65 @@ export function DashboardPage() {
); );
} }
const LANES = ["todo", "doing", "review", "done"] as const;
const LANE_DOT: Record<string, string> = { todo: "#98A2B3", doing: "#2E90FA", review: "#7A5AF8", done: "#12B76A" };
function MyIssuesBoard() {
const q = useQuery({ queryKey: ["my-tasks"], queryFn: getMyTasks });
const tasks = q.data ?? [];
return (
<Card className="mb-4">
<CardHeader title="내 이슈" subtitle="전 프로젝트에서 나에게 배정된 작업 · 프로젝트별 색상" />
<div className="p-4">
{q.isLoading ? (
<div className="text-sm text-ink-muted py-6 text-center"> </div>
) : tasks.length === 0 ? (
<div className="text-sm text-ink-muted py-6 text-center"> .</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3">
{LANES.map((lane) => {
const items = tasks.filter((t) => t.lane === lane);
return (
<div key={lane} className="rounded-card border border-border bg-canvas/50 min-h-[120px]">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
<span className="w-2 h-2 rounded-full" style={{ background: LANE_DOT[lane] }} />
<span className="text-sm font-semibold text-ink">{LANE_LABELS[lane]}</span>
<span className="ml-auto text-xs text-ink-muted font-num">{items.length}</span>
</div>
<div className="p-2 space-y-2">
{items.map((t) => <IssueCard key={t.id} task={t} />)}
{items.length === 0 && <div className="text-xs text-ink-muted text-center py-3"></div>}
</div>
</div>
);
})}
</div>
)}
</div>
</Card>
);
}
function IssueCard({ task }: { task: MyTask }) {
const color = projectColor(task.projectId);
const pr = task.priority ? PRIORITY_META[task.priority] : undefined;
return (
<Link to={`/projects/${task.projectId}`}
className="block bg-surface border border-border rounded-control p-2.5 shadow-card hover:border-navy transition-colors"
style={{ borderLeft: `3px solid ${color}` }}>
<div className="flex items-center gap-1.5 mb-1">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: color }} />
<span className="text-[11px] text-ink-muted truncate">{task.projectName || "프로젝트"}</span>
</div>
<div className="text-sm font-medium text-ink leading-snug">{task.title}</div>
<div className="flex items-center gap-1.5 mt-1.5">
{pr && <Badge label={pr.label} fg={pr.fg} bg={pr.bg} size="sm" />}
{task.end && <span className="text-[11px] text-ink-muted tabular">~{formatDate(task.end)}</span>}
</div>
</Link>
);
}
function QuickLink({ to, icon, label }: { to: string; icon: React.ReactNode; label: string }) { function QuickLink({ to, icon, label }: { to: string; icon: React.ReactNode; label: string }) {
return ( return (
<Link to={to} className="flex items-center gap-3 p-3 rounded-control border border-border hover:border-navy hover:bg-navy-subtle/40 transition-colors"> <Link to={to} className="flex items-center gap-3 p-3 rounded-control border border-border hover:border-navy hover:bg-navy-subtle/40 transition-colors">

View File

@ -689,8 +689,13 @@ function MailRow({ projectId, mail, nameOf, open, onToggle, siblings, onNavigate
</button> </button>
</div> </div>
</div> </div>
{/* 메모 열 */} {/* 메모 열 (위에 AI 요약) */}
<div className="sm:w-72 shrink-0 border-t sm:border-t-0 sm:border-l border-divider px-3 py-2 flex flex-col bg-canvas/30"> <div className="sm:w-72 shrink-0 border-t sm:border-t-0 sm:border-l border-divider px-3 py-2 flex flex-col bg-canvas/30">
{mail.summary && (
<div className="mb-1.5 text-[11px] leading-snug text-ink-secondary bg-[#EBE9FE]/50 border border-[#D9D6FE] rounded-control px-2 py-1">
<span className="text-[#5925DC] font-semibold">AI</span> {mail.summary}
</div>
)}
<textarea value={memo} onChange={(e) => setMemo(e.target.value)} <textarea value={memo} onChange={(e) => setMemo(e.target.value)}
onBlur={() => { if (memo !== (mail.note ?? "")) save.mutate(); }} onBlur={() => { if (memo !== (mail.note ?? "")) save.mutate(); }}
placeholder="요약 메모 입력…" rows={2} placeholder="요약 메모 입력…" rows={2}

View File

@ -204,6 +204,7 @@ export interface ProjectMail {
subject: string; subject: string;
date: string; date: string;
snippet: string; snippet: string;
summary: string; // AI 자동 요약
mailbox: string; mailbox: string;
ts: number; ts: number;
hidden: boolean; hidden: boolean;
@ -292,6 +293,25 @@ export interface ProjectTask {
color: string; color: string;
} }
export interface MyTask extends ProjectTask {
projectName: string;
}
export interface CalendarEvent {
id: string;
ownerEmail: string;
title: string;
category: string; // project | etc | personal
projectId: string;
color: string;
start: string; // YYYY-MM-DD
end: string;
allDay: boolean;
memo: string;
createdAt: string;
updatedAt: string;
}
export interface TaskComment { export interface TaskComment {
id: string; id: string;
taskId: string; taskId: string;