05296b3166
- FU-C 멀티파트 업로드(DSClient.uploadDocument + LiveDSClient 401 재시도 공유 + 툴바/상태바) - FU-D 네이티브 다운로드(NSSavePanel + URLSession, ?token= 미노출, 임시파일 정리) - 로그아웃(AppModel.logout 세션 전체 초기화 + 계정 메뉴) - 셸 2-column 재구성: 질문/이드 제거, 홈 코크핏 + 문서 3-pane 컬럼 브라우저 (인스펙터 TL;DR/핵심점/심층/불일치) + 도메인 필터 전체 load-all - 적대 리뷰 반영(stale 401 데모션·다운로드 임시파일 정리·메모 저장 saveMemo 경유·도메인 필터 선택 정합) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
387 lines
16 KiB
Swift
387 lines
16 KiB
Swift
import SwiftUI
|
||
import DSKit
|
||
|
||
/// 홈 = 풀폭 데일리 코크핏 (시안 안1). detail 전폭을 받아 1000pt 캔버스로 좌측 정렬, 내부 2칼럼.
|
||
/// 인사 → 오늘 스트립(검토 큐 + 속보 + 스탯) → 좌(빠른캡처·최근활동)/우(도메인분포·고정).
|
||
struct DashboardView: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
ScrollView(.vertical) {
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
GreetingHeader()
|
||
if model.stats == nil && model.tree.isEmpty {
|
||
ProgressView().frame(maxWidth: .infinity, minHeight: 200)
|
||
} else {
|
||
TodayStrip()
|
||
HStack(alignment: .top, spacing: 18) {
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
CaptureCard()
|
||
ActivityTimeline()
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
DomainDistribution()
|
||
PinnedItems()
|
||
}
|
||
.frame(width: 312)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: 1000, alignment: .leading)
|
||
.padding(.horizontal, 30)
|
||
.padding(.vertical, 26)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
.background(Sage.surface)
|
||
}
|
||
}
|
||
|
||
// MARK: - Greeting
|
||
|
||
private struct GreetingHeader: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||
Text("안녕하세요, \(model.currentUser?.username ?? "사용자")")
|
||
.font(.system(size: 22, weight: .bold)).kerning(-0.4).foregroundStyle(Sage.ink)
|
||
Text("오늘도 지식 쌓는 날.").font(.callout).foregroundStyle(Sage.muted)
|
||
}
|
||
Text(Self.today).font(.caption).foregroundStyle(Sage.muted.opacity(0.8))
|
||
}
|
||
.padding(.bottom, 4)
|
||
}
|
||
|
||
static var today: String {
|
||
let f = DateFormatter()
|
||
f.locale = Locale(identifier: "ko_KR")
|
||
f.dateFormat = "y년 M월 d일 EEEE"
|
||
return f.string(from: Date())
|
||
}
|
||
}
|
||
|
||
// MARK: - Today strip (hero)
|
||
|
||
private struct TodayStrip: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
VStack(spacing: 14) {
|
||
HStack(alignment: .top, spacing: 0) {
|
||
reviewQueue
|
||
.frame(minWidth: 150, alignment: .leading)
|
||
Rectangle().fill(Sage.line).frame(width: 1).padding(.horizontal, 22)
|
||
digestTeaser
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
Divider().overlay(Sage.line)
|
||
statRow
|
||
}
|
||
.dashCard(padding: 20)
|
||
}
|
||
|
||
private var reviewQueue: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(model.reviewPendingCount.map(String.init) ?? "—")
|
||
.font(.system(size: 38, weight: .bold)).kerning(-1.5).monospacedDigit()
|
||
.foregroundStyle(Sage.amber)
|
||
Text("검토 대기 문서").font(.caption).foregroundStyle(Sage.muted)
|
||
Button { model.section = .documents } label: {
|
||
Text("검토 시작 →").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private var digestTeaser: some View {
|
||
if let t = topTopic {
|
||
Button { model.section = .digest } label: {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: 8) {
|
||
Chip("속보", Sage.danger)
|
||
Text("\(model.digest?.digestDateDisplay ?? "") 브리핑")
|
||
.font(.caption2).foregroundStyle(Sage.muted)
|
||
}
|
||
Text(t.label).font(.system(size: 15)).foregroundStyle(Sage.ink)
|
||
.lineLimit(2).fixedSize(horizontal: false, vertical: true)
|
||
.multilineTextAlignment(.leading)
|
||
Text(t.meta).font(.caption2).foregroundStyle(Sage.muted)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.buttonStyle(.plain)
|
||
} else {
|
||
Text("오늘 브리핑이 아직 없습니다").font(.callout).foregroundStyle(Sage.muted)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
|
||
private var statRow: some View {
|
||
HStack(spacing: 0) {
|
||
StatCell(value: model.stats?.total ?? 0, label: "전체", color: Sage.brand)
|
||
StatCell(value: model.stats?.counts["document"] ?? 0, label: "문서")
|
||
StatCell(value: domainCount("Industrial_Safety"), label: "산업안전",
|
||
color: Sage.domainColor("Industrial_Safety"))
|
||
StatCell(value: domainCount("Engineering"), label: "엔지니어링",
|
||
color: Sage.domainColor("Engineering"))
|
||
StatCell(value: domainCount("General"), label: "자료실", color: Sage.domainColor("General"))
|
||
StatCell(value: model.stats?.counts["memo"] ?? model.memoList.count, label: "메모")
|
||
}
|
||
}
|
||
|
||
private func domainCount(_ name: String) -> Int {
|
||
model.tree.first { $0.name == name }?.count ?? 0
|
||
}
|
||
|
||
private var topTopic: (label: String, meta: String)? {
|
||
guard let digest = model.digest else { return nil }
|
||
var best: (TopicResponse, String)?
|
||
for c in digest.countries {
|
||
for t in c.topics where best == nil || (t.importanceScore ?? 0) > (best!.0.importanceScore ?? 0) {
|
||
best = (t, c.country)
|
||
}
|
||
}
|
||
guard let (t, country) = best else { return nil }
|
||
let arts = t.articleCount ?? t.articles.count
|
||
var meta = "관련 기사 \(arts)건"
|
||
if let imp = t.importanceScore { meta += " · 중요도 \(String(format: "%.0f", imp))" }
|
||
if !country.isEmpty { meta += " · \(country)" }
|
||
return (t.topicLabel, meta)
|
||
}
|
||
}
|
||
|
||
// MARK: - Left column
|
||
|
||
private struct CaptureCard: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
@Bindable var m = model
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
SectionLabel("빠른 캡처")
|
||
HStack(spacing: 8) {
|
||
TextField("메모 한 줄 남기기…", text: $m.captureText)
|
||
.textFieldStyle(.plain)
|
||
.padding(.horizontal, 14).frame(height: 38)
|
||
.background(Sage.surface, in: RoundedRectangle(cornerRadius: 8))
|
||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Sage.line))
|
||
.onSubmit { Task { await model.saveMemo() } }
|
||
Button { Task { await model.saveMemo() } } label: {
|
||
Text("저장").font(.callout.weight(.semibold)).foregroundStyle(.white)
|
||
.padding(.horizontal, 18).frame(height: 38)
|
||
.background(Sage.brand, in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(model.captureText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
}
|
||
Button {
|
||
guard let url = FilePanels.pickFileToUpload() else { return }
|
||
Task { await model.uploadPicked(url) }
|
||
} label: {
|
||
Text("+ 파일 업로드").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand)
|
||
.padding(.horizontal, 10).padding(.vertical, 5)
|
||
.background(Sage.brand.opacity(0.12), in: Capsule())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.dashCard()
|
||
}
|
||
}
|
||
|
||
private struct ActivityTimeline: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
private var recent: [DocumentResponse] {
|
||
model.documentList
|
||
.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
|
||
.prefix(5).map { $0 }
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack(alignment: .firstTextBaseline) {
|
||
SectionLabel("최근 활동")
|
||
Spacer()
|
||
Button { model.section = .documents } label: {
|
||
Text("전체 보기 →").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
if recent.isEmpty {
|
||
Text("최근 활동이 없습니다").font(.caption).foregroundStyle(Sage.muted)
|
||
} else {
|
||
VStack(spacing: 0) {
|
||
ForEach(Array(recent.enumerated()), id: \.element.id) { idx, doc in
|
||
ActivityRow(doc: doc, isLast: idx == recent.count - 1)
|
||
if idx != recent.count - 1 { Divider().overlay(Sage.line) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.dashCard()
|
||
}
|
||
}
|
||
|
||
private struct ActivityRow: View {
|
||
@Environment(AppModel.self) private var model
|
||
let doc: DocumentResponse
|
||
let isLast: Bool
|
||
|
||
var body: some View {
|
||
HStack(alignment: .top, spacing: 12) {
|
||
Text(Self.relative(doc.updatedAt))
|
||
.font(.caption2).foregroundStyle(Sage.muted)
|
||
.frame(width: 54, alignment: .trailing)
|
||
VStack(spacing: 0) {
|
||
Circle().fill(Sage.domainColor(doc.aiDomain)).frame(width: 8, height: 8).padding(.top, 4)
|
||
if !isLast { Rectangle().fill(Sage.line).frame(width: 1).frame(maxHeight: .infinity) }
|
||
}
|
||
.frame(width: 14)
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text("\(localizedDomain(doc.aiDomain)) · \(doc.displayFormat.uppercased())")
|
||
.font(.caption2.weight(.bold)).foregroundStyle(Sage.domainColor(doc.aiDomain))
|
||
Text(doc.title ?? doc.downloadLabel).font(.callout).foregroundStyle(Sage.ink).lineLimit(2)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.bottom, isLast ? 0 : 10)
|
||
}
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { model.section = .documents; Task { await model.openDocument(doc.id) } }
|
||
}
|
||
|
||
static func relative(_ date: Date?) -> String {
|
||
guard let date else { return "" }
|
||
let f = RelativeDateTimeFormatter()
|
||
f.locale = Locale(identifier: "ko_KR")
|
||
f.unitsStyle = .short
|
||
return f.localizedString(for: date, relativeTo: Date())
|
||
}
|
||
}
|
||
|
||
// MARK: - Right column
|
||
|
||
private struct DomainDistribution: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
private var domains: [DomainTreeNode] { model.tree.sorted { $0.count > $1.count } }
|
||
private var domainTotal: Int { domains.reduce(0) { $0 + $1.count } }
|
||
private var sum: Int { max(1, domainTotal) } // 0-나눗셈 가드 (막대 폭 분모 전용)
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
SectionLabel("도메인 분포")
|
||
// 헤드라인 합계 = 막대/범례와 동일 분모(도메인 트리 합) — 사용자가 범례를 더해 같은 값에 도달.
|
||
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
||
Text("분류").font(.caption).foregroundStyle(Sage.muted)
|
||
Text("\(domainTotal)").font(.system(size: 18, weight: .semibold))
|
||
.monospacedDigit().foregroundStyle(Sage.ink)
|
||
Text("건").font(.caption).foregroundStyle(Sage.muted)
|
||
}
|
||
GeometryReader { geo in
|
||
HStack(spacing: 2) {
|
||
ForEach(domains) { d in
|
||
Rectangle().fill(Sage.domainColor(d.name))
|
||
.frame(width: max(2, geo.size.width * CGFloat(d.count) / CGFloat(sum)))
|
||
}
|
||
}
|
||
}
|
||
.frame(height: 8)
|
||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||
VStack(spacing: 7) {
|
||
ForEach(domains) { d in
|
||
Button {
|
||
model.section = .documents
|
||
Task { await model.loadDocuments(domain: d.path) }
|
||
} label: {
|
||
HStack(spacing: 8) {
|
||
RoundedRectangle(cornerRadius: 2).fill(Sage.domainColor(d.name)).frame(width: 10, height: 10)
|
||
Text(localizedDomain(d.name)).font(.caption).foregroundStyle(Sage.ink)
|
||
.lineLimit(1).frame(maxWidth: .infinity, alignment: .leading)
|
||
Text("\(d.count)").font(.caption.monospacedDigit()).foregroundStyle(Sage.muted)
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.dashCard()
|
||
}
|
||
}
|
||
|
||
private struct PinnedItems: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
private var docs: [DocumentResponse] { model.documentList.filter { $0.pinned == true } }
|
||
private var memos: [MemoResponse] { model.memoList.filter { $0.isPinned } }
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
SectionLabel("고정 항목")
|
||
Spacer()
|
||
Button { model.section = .documents } label: {
|
||
Text("관리 →").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
if docs.isEmpty && memos.isEmpty {
|
||
Text("고정된 항목이 없습니다").font(.caption).foregroundStyle(Sage.muted)
|
||
} else {
|
||
VStack(spacing: 8) {
|
||
ForEach(docs) { d in
|
||
PinRow(kind: "문서", kindColor: Sage.domainColor("Engineering"),
|
||
title: d.title ?? d.downloadLabel, date: d.updatedAtRaw) {
|
||
model.section = .documents; Task { await model.openDocument(d.id) }
|
||
}
|
||
}
|
||
ForEach(memos) { m in
|
||
PinRow(kind: "메모", kindColor: Sage.brand,
|
||
title: m.title ?? (m.content ?? "메모"), date: m.updatedAtRaw ?? "") {
|
||
model.section = .memos; Task { await model.openMemo(m.id) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.dashCard()
|
||
}
|
||
}
|
||
|
||
private struct PinRow: View {
|
||
let kind: String
|
||
let kindColor: Color
|
||
let title: String
|
||
let date: String
|
||
let action: () -> Void
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
HStack(alignment: .top, spacing: 10) {
|
||
Chip(kind, kindColor)
|
||
Text(title).font(.caption).foregroundStyle(Sage.ink).lineLimit(2)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
Text(date.prefix(10)).font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted)
|
||
}
|
||
.padding(10)
|
||
.background(Sage.surface, in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
#if DEBUG
|
||
#Preview("Dashboard") {
|
||
@Previewable @State var model = AppModel.preview
|
||
DashboardView()
|
||
.environment(model)
|
||
.frame(width: 1100, height: 760)
|
||
.task { await model.bootstrap() }
|
||
}
|
||
#endif
|