169 lines
5.6 KiB
TypeScript
169 lines
5.6 KiB
TypeScript
import type { Milestone, MilestonePeriodEntry } from '../types';
|
|
import { routineStageBody } from './routineMilestone';
|
|
|
|
export type { MilestonePeriodEntry };
|
|
|
|
export function newPeriodEntry(): MilestonePeriodEntry {
|
|
return {
|
|
id: `period-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
|
startDate: '',
|
|
dueDate: '',
|
|
note: '',
|
|
};
|
|
}
|
|
|
|
function toDateInput(iso: string | null | undefined) {
|
|
if (!iso) return '';
|
|
return new Date(iso).toISOString().slice(0, 10);
|
|
}
|
|
|
|
export function normalizePeriodEntries(raw: unknown): MilestonePeriodEntry[] {
|
|
if (!Array.isArray(raw)) return [];
|
|
const entries: MilestonePeriodEntry[] = [];
|
|
for (const item of raw) {
|
|
if (!item || typeof item !== 'object') continue;
|
|
const row = item as Record<string, unknown>;
|
|
const startDate = typeof row.startDate === 'string' ? row.startDate.trim() : '';
|
|
const dueDate = typeof row.dueDate === 'string' ? row.dueDate.trim() : '';
|
|
const note = typeof row.note === 'string' ? row.note : '';
|
|
if (!startDate && !dueDate && !note.trim()) continue;
|
|
entries.push({
|
|
id: typeof row.id === 'string' && row.id ? row.id : newPeriodEntry().id,
|
|
startDate,
|
|
dueDate,
|
|
note,
|
|
});
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function legacyDescriptionNote(description: string | null | undefined): string {
|
|
return routineStageBody(description).trim();
|
|
}
|
|
|
|
export function parseMilestonePeriods(
|
|
milestone: Pick<Milestone, 'id' | 'periodEntries' | 'startDate' | 'dueDate' | 'description'> | null | undefined,
|
|
): MilestonePeriodEntry[] {
|
|
if (!milestone) return [];
|
|
const legacyNote = legacyDescriptionNote(milestone.description);
|
|
const fromJson = normalizePeriodEntries(milestone.periodEntries);
|
|
if (fromJson.length > 0) {
|
|
if (legacyNote && !fromJson.some((p) => p.note.trim())) {
|
|
return fromJson.map((p, i) => (i === 0 ? { ...p, note: legacyNote } : p));
|
|
}
|
|
return fromJson;
|
|
}
|
|
if (milestone.startDate || milestone.dueDate || legacyNote) {
|
|
return [
|
|
{
|
|
id: milestone.id ? `period-legacy-${milestone.id}` : newPeriodEntry().id,
|
|
startDate: toDateInput(milestone.startDate),
|
|
dueDate: toDateInput(milestone.dueDate),
|
|
note: legacyNote,
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
export function serializePeriodEntries(entries: MilestonePeriodEntry[]): MilestonePeriodEntry[] {
|
|
return entries
|
|
.map((entry) => ({
|
|
id: entry.id,
|
|
startDate: entry.startDate.trim(),
|
|
dueDate: entry.dueDate.trim(),
|
|
note: entry.note.trim(),
|
|
}))
|
|
.filter((entry) => entry.startDate || entry.dueDate || entry.note);
|
|
}
|
|
|
|
export function fmtPeriodRange(entry: Pick<MilestonePeriodEntry, 'startDate' | 'dueDate'>) {
|
|
const fmt = (iso: string) => {
|
|
const d = new Date(iso);
|
|
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`;
|
|
};
|
|
if (entry.startDate && entry.dueDate) return `${fmt(entry.startDate)} ~ ${fmt(entry.dueDate)}`;
|
|
if (entry.dueDate) return fmt(entry.dueDate);
|
|
if (entry.startDate) return `${fmt(entry.startDate)} ~`;
|
|
return '';
|
|
}
|
|
|
|
/** 업무내용 기간 선택 — MM.DD~MM.DD (연도 생략) */
|
|
export function fmtPeriodPickerLabel(
|
|
entry: Pick<MilestonePeriodEntry, 'startDate' | 'dueDate'>,
|
|
index: number,
|
|
): string {
|
|
const fmt = (iso: string) => {
|
|
const d = new Date(iso);
|
|
return `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
|
|
};
|
|
if (entry.startDate && entry.dueDate) return `${fmt(entry.startDate)}~${fmt(entry.dueDate)}`;
|
|
if (entry.dueDate) return fmt(entry.dueDate);
|
|
if (entry.startDate) return `${fmt(entry.startDate)}~`;
|
|
return `기간 ${index + 1}`;
|
|
}
|
|
|
|
export function parsePeriodNoteLines(text: string | null | undefined): string[] {
|
|
if (!text) return [];
|
|
return text
|
|
.split('\n')
|
|
.map((line) => line.replace(/^[•·\-]\s*/, '').trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/** 최신 기간 우선 — 종료일·시작일 내림차순 */
|
|
export function sortPeriodsByRecent(periods: MilestonePeriodEntry[]): MilestonePeriodEntry[] {
|
|
return [...periods].sort((a, b) => {
|
|
const ta = a.dueDate || a.startDate || '';
|
|
const tb = b.dueDate || b.startDate || '';
|
|
if (ta && tb) return tb.localeCompare(ta);
|
|
if (tb) return 1;
|
|
if (ta) return -1;
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
export function pickLatestPeriodId(periods: MilestonePeriodEntry[]): string | null {
|
|
return sortPeriodsByRecent(periods)[0]?.id ?? null;
|
|
}
|
|
|
|
/** 목록·카드용 — 최신(마지막) 기간 또는 요약 */
|
|
export function fmtMilestonePeriodSummary(
|
|
milestone: Pick<Milestone, 'periodEntries' | 'startDate' | 'dueDate'>,
|
|
): string {
|
|
const periods = parseMilestonePeriods(milestone);
|
|
if (periods.length === 0) return '';
|
|
if (periods.length === 1) return fmtPeriodRange(periods[0]) || '';
|
|
const last = periods[periods.length - 1];
|
|
const lastLabel = fmtPeriodRange(last);
|
|
return lastLabel ? `${lastLabel} 외 ${periods.length - 1}건` : `기간 ${periods.length}건`;
|
|
}
|
|
|
|
export interface MilestoneContentBlock {
|
|
key: string;
|
|
label: string;
|
|
lines: string[];
|
|
}
|
|
|
|
/** @deprecated MilestoneContentList에서 기간 선택 UI로 대체 */
|
|
export function buildMilestoneContentBlocks(
|
|
milestone: Pick<Milestone, 'id' | 'periodEntries' | 'startDate' | 'dueDate' | 'description'> | null | undefined,
|
|
): MilestoneContentBlock[] {
|
|
if (!milestone) return [];
|
|
|
|
const blocks: MilestoneContentBlock[] = [];
|
|
const periods = sortPeriodsByRecent(parseMilestonePeriods(milestone)).reverse();
|
|
|
|
periods.forEach((period, index) => {
|
|
const lines = parsePeriodNoteLines(period.note);
|
|
if (lines.length === 0) return;
|
|
blocks.push({
|
|
key: period.id,
|
|
label: fmtPeriodPickerLabel(period, index),
|
|
lines,
|
|
});
|
|
});
|
|
|
|
return blocks;
|
|
}
|