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:
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/** 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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user