feat: 대시보드 '내 이슈' 보드(프로젝트별 색) + 캘린더 페이지 + 메일 AI요약 표시
All checks were successful
build-and-push / build (push) Successful in 32s
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:
parent
a42318fc4c
commit
09713f5e23
@ -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() {
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/account" element={<AccountSettingsPage />} />
|
||||
<Route path="/inbox" element={<InboxPage />} />
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/admin/approvals" element={<RequireAdmin><ApprovalsPage /></RequireAdmin>} />
|
||||
<Route path="/admin/attendance" element={<RequireAdmin><AttendanceAdminPage /></RequireAdmin>} />
|
||||
<Route path="/admin/projects" element={<RequireAdmin><ProjectsAdminPage /></RequireAdmin>} />
|
||||
|
||||
@ -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<string, LucideIcon> = {
|
||||
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 }) {
|
||||
|
||||
@ -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<ProjectTask>) =>
|
||||
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 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) =>
|
||||
api.get<ProjectMailsResponse>(`/projects/${id}/mails`).then((r) => r.data);
|
||||
export const getMailNotes = (id: string) =>
|
||||
|
||||
@ -146,6 +146,19 @@ export const LANE_LABELS: Record<string, string> = {
|
||||
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<string, { label: string; fg: string; bg: string; order: number }> = {
|
||||
urgent: { label: "긴급", fg: "#B42318", bg: "#FEE4E2", order: 0 },
|
||||
|
||||
166
src/pages/Calendar.tsx
Normal file
166
src/pages/Calendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,11 +1,12 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
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 { Card, CardHeader, Stat, PageHeader, LoadingState } from "@/components/ui";
|
||||
import { Card, CardHeader, Stat, PageHeader, Badge, LoadingState } from "@/components/ui";
|
||||
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" />}
|
||||
|
||||
{/* 내 이슈 — 전 프로젝트에서 나에게 배정된 작업을 JIRA형 보드로 */}
|
||||
<MyIssuesBoard />
|
||||
|
||||
|
||||
<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="/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 }) {
|
||||
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">
|
||||
|
||||
@ -689,8 +689,13 @@ function MailRow({ projectId, mail, nameOf, open, onToggle, siblings, onNavigate
|
||||
</button>
|
||||
</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">
|
||||
{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)}
|
||||
onBlur={() => { if (memo !== (mail.note ?? "")) save.mutate(); }}
|
||||
placeholder="요약 메모 입력…" rows={2}
|
||||
|
||||
20
src/types.ts
20
src/types.ts
@ -204,6 +204,7 @@ export interface ProjectMail {
|
||||
subject: string;
|
||||
date: string;
|
||||
snippet: string;
|
||||
summary: string; // AI 자동 요약
|
||||
mailbox: string;
|
||||
ts: number;
|
||||
hidden: boolean;
|
||||
@ -292,6 +293,25 @@ export interface ProjectTask {
|
||||
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 {
|
||||
id: string;
|
||||
taskId: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user