You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
183 lines
5.4 KiB
TypeScript
183 lines
5.4 KiB
TypeScript
// frontend/components/inbox/InboxView.tsx — 인박스 클라이언트 루트
|
|
"use client";
|
|
import { useRef, useState } from "react";
|
|
import { Icon } from "@/components/Icon";
|
|
import { captureInbox, confirmInbox, reclassifyInbox } from "@/lib/inbox/api";
|
|
import { nextType } from "@/lib/inbox/presentation";
|
|
import type { InboxItem, RouteType, Sphere, UiInboxItem } from "@/lib/types";
|
|
import ClassifyPrinciples from "./ClassifyPrinciples";
|
|
import SmartInbox from "./SmartInbox";
|
|
import TodayRouted from "./TodayRouted";
|
|
|
|
function relTime(iso: string): string {
|
|
if (!iso) return "방금";
|
|
const diff = Date.now() - new Date(iso).getTime();
|
|
if (Number.isNaN(diff)) return "방금";
|
|
const m = Math.floor(diff / 60000);
|
|
if (m < 1) return "방금";
|
|
if (m < 60) return `${m}분 전`;
|
|
const h = Math.floor(m / 60);
|
|
if (h < 24) return `${h}시간 전`;
|
|
return `${Math.floor(h / 24)}일 전`;
|
|
}
|
|
|
|
function toUi(it: InboxItem): UiInboxItem {
|
|
return { ...it, status: it.status, time: relTime(it.created_at) };
|
|
}
|
|
|
|
export default function InboxView({
|
|
initialItems,
|
|
initialLoadError,
|
|
}: {
|
|
initialItems: InboxItem[];
|
|
initialLoadError: boolean;
|
|
}) {
|
|
const [items, setItems] = useState<UiInboxItem[]>(() => initialItems.map(toUi));
|
|
const [filter, setFilter] = useState<"all" | Sphere>("all");
|
|
const tmpSeq = useRef(0);
|
|
|
|
const clearFresh = (xs: UiInboxItem[]) => xs.map((x) => (x.fresh ? { ...x, fresh: false } : x));
|
|
|
|
/* ---- 캡처 제출 ---- */
|
|
const submit = async (raw: string, kind: "text" | "voice" | "image") => {
|
|
const text = raw.trim();
|
|
if (!text) return;
|
|
const tmpId = "tmp-" + ++tmpSeq.current;
|
|
setItems((xs) => [
|
|
{
|
|
id: tmpId,
|
|
kind,
|
|
raw: text,
|
|
status: "thinking",
|
|
time: "방금",
|
|
created_at: new Date(0).toISOString(),
|
|
materialized_task_id: null,
|
|
classification: null,
|
|
fresh: true,
|
|
} as UiInboxItem,
|
|
...clearFresh(xs),
|
|
]);
|
|
try {
|
|
const { item, classification } = await captureInbox({ kind, raw: text });
|
|
setItems((xs) =>
|
|
xs.map((x) =>
|
|
x.id === tmpId
|
|
? {
|
|
...item,
|
|
classification,
|
|
status: "classified",
|
|
time: "방금",
|
|
fresh: true,
|
|
}
|
|
: x,
|
|
),
|
|
);
|
|
} catch {
|
|
setItems((xs) => xs.map((x) => (x.id === tmpId ? { ...x, status: "error" } : x)));
|
|
}
|
|
};
|
|
|
|
/* ---- 다르게 분류 (타입 순환) ---- */
|
|
const reType = async (id: string) => {
|
|
const cur = items.find((x) => x.id === id);
|
|
if (!cur?.classification) return;
|
|
const original = cur.classification.type;
|
|
const wanted = nextType(original);
|
|
// 낙관적 미리보기
|
|
setItems((xs) =>
|
|
xs.map((x) =>
|
|
x.id === id && x.classification
|
|
? { ...x, classification: { ...x.classification, type: wanted } }
|
|
: x,
|
|
),
|
|
);
|
|
try {
|
|
const { item, classification } = await reclassifyInbox(id, wanted as RouteType);
|
|
setItems((xs) =>
|
|
xs.map((x) =>
|
|
x.id === id
|
|
? {
|
|
...item,
|
|
classification,
|
|
status: "classified",
|
|
time: x.time,
|
|
fresh: x.fresh,
|
|
}
|
|
: x,
|
|
),
|
|
);
|
|
} catch {
|
|
// 롤백: 원래 타입 복구
|
|
setItems((xs) =>
|
|
xs.map((x) =>
|
|
x.id === id && x.classification
|
|
? { ...x, classification: { ...x.classification, type: original } }
|
|
: x,
|
|
),
|
|
);
|
|
}
|
|
};
|
|
|
|
/* ---- 좋아요(confirm) → 실체화 ---- */
|
|
const confirm = async (id: string) => {
|
|
setItems((xs) => xs.map((x) => (x.id === id ? { ...x, status: "confirmed" } : x)));
|
|
try {
|
|
const res = await confirmInbox(id);
|
|
setItems((xs) =>
|
|
xs.map((x) =>
|
|
x.id === id
|
|
? { ...res.item, classification: x.classification, status: "confirmed", time: x.time }
|
|
: x,
|
|
),
|
|
);
|
|
} catch {
|
|
setItems((xs) => xs.map((x) => (x.id === id ? { ...x, status: "classified" } : x)));
|
|
}
|
|
};
|
|
|
|
const todayRouted = items.filter(
|
|
(x) => x.classification && x.status !== "thinking" && x.status !== "error",
|
|
).length;
|
|
const shown = items.filter(
|
|
(x) => filter === "all" || (x.classification && x.classification.sphere === filter),
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="pagehead">
|
|
<div>
|
|
<div className="ph-eyebrow">
|
|
<span>오늘 {todayRouted}건 정리</span>
|
|
<span className="sep" />
|
|
<span>분류를 고민한 시간 0초</span>
|
|
</div>
|
|
<h1 className="ph-title">
|
|
스마트 인박스 <em>일단 적으세요</em>
|
|
</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="work">
|
|
<div className="board">
|
|
{initialLoadError && (
|
|
<div className="inbox-loaderr card sp2" style={{ gridColumn: "span 4" }}>
|
|
<Icon name="x" /> 인박스를 불러오지 못했어요. 새로고침하거나 백엔드 연결을 확인하세요.
|
|
</div>
|
|
)}
|
|
<SmartInbox
|
|
items={shown}
|
|
todayRouted={todayRouted}
|
|
filter={filter}
|
|
onFilter={setFilter}
|
|
onSubmit={submit}
|
|
onConfirm={confirm}
|
|
onReType={reType}
|
|
/>
|
|
<ClassifyPrinciples />
|
|
<TodayRouted items={items} todayRouted={todayRouted} />
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|