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