feat: 업무 상세 부속·활동 피드·내 업무 집계 + 페이지네이션 (5~8단계)

- 5단계(업무 상세 부속): 체크리스트(상세 읽기전용/수정폼 추가·삭제)·댓글/답글·파일 첨부(서버 로컬 업로드 폴더)·승인 워크플로우(요청/승인/수정요청 노트)
- 6단계(활동 피드): 저장소/멤버/업무 행위를 이벤트로 자동 적재, 저장소 활동 피드·업무 활동 탭 실연동(프론트가 type+payload로 문구 조립)
- 7단계(내 업무): 담당/지시 저장소 횡단 집계, MyTasksPage 실연동, TaskAssigneePage(/view) 제거
- 8단계(진행): 목록 페이지네이션 통일 — 백엔드 전체(공통 유틸 + 5개 목록, 저장소 업무는 세그먼트/검색/카운트) + 프론트 더보기(저장소 목록·내 업무 완료, 멤버·저장소 업무 대기)
- UI 보강: 업무 목록 댓글/첨부 분리표기, 첨부 배지 색상·라벨 통일, 완료 업무 D-Day 배지, 활동 이벤트 점검·보강(업무 수정/삭제·답글·첨부 삭제)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 18:13:17 +09:00
parent 875eddf659
commit a056d7f55a
51 changed files with 3437 additions and 1732 deletions
+75 -5
View File
@@ -78,6 +78,40 @@ export function progressOf(done: number, total: number): ProgressInfo {
return { pct, level, label }
}
/** 첨부 파일 종류 (배지 라벨/색상 키) */
export type FileKind = 'pdf' | 'zip' | 'doc' | 'img'
/** 파일명(확장자)으로 종류 파생 — 활동/댓글처럼 종류 정보가 없을 때 사용 */
export function attachmentKindOf(name: string): FileKind {
const ext = name.split('.').pop()?.toLowerCase() ?? ''
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'img'
if (ext === 'pdf') return 'pdf'
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return 'zip'
return 'doc'
}
/** 파일 종류별 배지 라벨 + 색상 클래스(.fi-* — 첨부 카드/활동/댓글 공통) */
export function fileBadge(kind: FileKind): { label: string; cls: string } {
const map: Record<FileKind, { label: string; cls: string }> = {
img: { label: 'IMG', cls: 'fi-img' },
pdf: { label: 'PDF', cls: 'fi-pdf' },
zip: { label: 'ZIP', cls: 'fi-zip' },
doc: { label: 'DOC', cls: 'fi-doc' },
}
return map[kind]
}
/** 바이트 수를 사람이 읽기 쉬운 단위로 (예: 19293798 → "18.4 MB") */
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const exp = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const value = bytes / Math.pow(1024, exp)
// B 는 정수, 그 외는 소수 1자리
const text = exp === 0 ? String(Math.round(value)) : value.toFixed(1)
return `${text} ${units[exp]}`
}
/* ============================================================
* 업무(Task) 표시용 파생 값
* ============================================================ */
@@ -103,6 +137,32 @@ export function monthDayKo(input: Date | string): string {
return `${d.getMonth() + 1}${d.getDate()}`
}
/** 활동 피드 날짜 그룹 라벨 (오늘 / 어제 / 이번 주 / 'M월 D일') */
export function dayGroupKo(input: Date | string): string {
const d = typeof input === 'string' ? new Date(input) : input
if (Number.isNaN(d.getTime())) return ''
const startD = new Date(d.getFullYear(), d.getMonth(), d.getDate())
const now = new Date()
const startNow = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const days = Math.round((startNow.getTime() - startD.getTime()) / 86_400_000)
if (days <= 0) return '오늘'
if (days === 1) return '어제'
if (days < 7) return '이번 주'
return monthDayKo(d)
}
/** HTML 특수문자 이스케이프 — 활동 문구에 사용자 입력을 안전하게 삽입할 때 사용 */
export function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/** D-day 강조 단계 (마감 없음/완료 시 '') */
export type DdayLevel = '' | 'urgent' | 'soon' | 'calm'
/** 업무 마감 정보(원시 dueDate + 완료여부 → 표시 라벨들) */
export interface TaskDueInfo {
/** 전체 날짜 라벨 (예: '6월 18일 (목)') — 마감 없으면 '' */
@@ -113,16 +173,18 @@ export interface TaskDueInfo {
listLabel: string
/** 마감 지남 여부(완료 업무는 false) */
overdue: boolean
/** D-day 강조 단계 (urgent: 지남/임박, soon: 3일 내, calm: 그 외) */
ddayLevel: DdayLevel
}
/** dueDate(ISO|null) + 완료여부로 마감 표시 정보 계산 */
export function taskDueInfo(dueDate: string | null, isDone: boolean): TaskDueInfo {
if (!dueDate) {
return { dateLabel: '', dday: '', listLabel: '', overdue: false }
return { dateLabel: '', dday: '', listLabel: '', overdue: false, ddayLevel: '' }
}
const due = new Date(dueDate)
if (Number.isNaN(due.getTime())) {
return { dateLabel: '', dday: '', listLabel: '', overdue: false }
return { dateLabel: '', dday: '', listLabel: '', overdue: false, ddayLevel: '' }
}
const dateLabel = `${due.getMonth() + 1}${due.getDate()}일 (${WEEKDAYS_KO[due.getDay()]})`
@@ -133,12 +195,20 @@ export function taskDueInfo(dueDate: string | null, isDone: boolean): TaskDueInf
const days = Math.round((startDue.getTime() - startNow.getTime()) / 86_400_000)
if (isDone) {
return { dateLabel, dday: '', listLabel: `${monthDayKo(due)} 완료`, overdue: false }
return {
dateLabel,
dday: '',
listLabel: `${monthDayKo(due)} 완료`,
overdue: false,
ddayLevel: '',
}
}
if (days < 0) {
const passed = `${-days}일 지남`
return { dateLabel, dday: passed, listLabel: passed, overdue: true }
return { dateLabel, dday: passed, listLabel: passed, overdue: true, ddayLevel: 'urgent' }
}
const dday = days === 0 ? 'D-DAY' : `D-${days}`
return { dateLabel, dday, listLabel: dday, overdue: false }
// 임박도: 1일 이내 urgent, 3일 이내 soon, 그 외 calm
const ddayLevel: DdayLevel = days <= 1 ? 'urgent' : days <= 3 ? 'soon' : 'calm'
return { dateLabel, dday, listLabel: dday, overdue: false, ddayLevel }
}