Files
hyungi_document_server/clients/ds-app/Sources/AppFeature/Pages/DashboardView.swift
T
hyungi 05296b3166 feat(ds-app): macOS 앱 마무리 — 업로드·다운로드·로그아웃 + 4섹션 페이지
- 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>
2026-07-01 06:55:52 +09:00

387 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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