EENE Dashboard upload to Gitea

This commit is contained in:
EENE Dashboard
2026-06-18 12:05:08 +09:00
parent 29ba4867bf
commit d3548cf7ff
74 changed files with 5455 additions and 1261 deletions

View File

@@ -0,0 +1,262 @@
import type { StageFormData } from '../detail/stageFormTypes';
import { newPeriodEntry } from '../../lib/milestonePeriods';
import type { TeamMember } from '../../types';
interface StageFormFieldsProps {
variant: 'project' | 'routine';
form: StageFormData;
onChange: (next: StageFormData) => void;
teamMembers?: TeamMember[];
idPrefix?: string;
}
export function StageFormFields({
variant,
form,
onChange,
teamMembers = [],
idPrefix = 'stage-form',
}: StageFormFieldsProps) {
const isRoutine = variant === 'routine';
const set = <K extends keyof StageFormData>(field: K, value: StageFormData[K]) =>
onChange({ ...form, [field]: value });
const updatePeriodEntry = (id: string, patch: Partial<(typeof form.periodEntries)[0]>) => {
onChange({
...form,
periodEntries: form.periodEntries.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry)),
});
};
const toggleAssignee = (memberId: string) => {
const has = form.assigneeMemberIds.includes(memberId);
onChange({
...form,
assigneeMemberIds: has
? form.assigneeMemberIds.filter((id) => id !== memberId)
: [...form.assigneeMemberIds, memberId],
});
};
return (
<div className="task-form-fields">
<div className="task-form-field">
<label className="task-form-label" htmlFor={`${idPrefix}-title`}>
*
</label>
<input
id={`${idPrefix}-title`}
required
value={form.title}
onChange={(e) => set('title', e.target.value)}
className="task-form-input task-form-input--title"
placeholder="업무 일정 제목"
/>
</div>
{isRoutine && (
<div className="task-form-field">
<label className="task-form-label" htmlFor={`${idPrefix}-subtitle`}>
</label>
<input
id={`${idPrefix}-subtitle`}
value={form.subtitle}
onChange={(e) => set('subtitle', e.target.value)}
className="task-form-input"
placeholder="업무명 아래 표시 (선택)"
/>
</div>
)}
<div className="task-form-field">
<label className="task-form-label" htmlFor={`${idPrefix}-progress`}>
<span className="task-form-progress-val">{form.progress}%</span>
</label>
<input
id={`${idPrefix}-progress`}
type="range"
min={0}
max={100}
step={5}
value={form.progress}
onChange={(e) => set('progress', Number(e.target.value))}
className="task-form-range"
/>
</div>
<div className="task-form-field">
<div className="task-form-label-row">
<span className="task-form-label"> </span>
<button
type="button"
className="task-form-link-btn"
onClick={() => onChange({ ...form, periodEntries: [...form.periodEntries, newPeriodEntry()] })}
>
+
</button>
</div>
{form.periodEntries.length === 0 ? (
<p className="task-form-empty"> . · .</p>
) : (
<div className="task-form-issues">
{form.periodEntries.map((entry, index) => (
<div key={entry.id} className="task-form-issue">
<div className="task-form-label-row">
<span className="task-form-label"> {index + 1}</span>
<button
type="button"
className="task-form-link-btn task-form-link-btn--danger"
onClick={() =>
onChange({
...form,
periodEntries: form.periodEntries.filter((e) => e.id !== entry.id),
})
}
>
</button>
</div>
<div className="task-form-row-2">
<input
type="date"
value={entry.startDate}
onChange={(e) => updatePeriodEntry(entry.id, { startDate: e.target.value })}
className="task-form-input"
aria-label={`기간 ${index + 1} 시작일`}
/>
<input
type="date"
value={entry.dueDate}
onChange={(e) => updatePeriodEntry(entry.id, { dueDate: e.target.value })}
className="task-form-input"
aria-label={`기간 ${index + 1} 종료일`}
/>
</div>
<textarea
value={entry.note}
onChange={(e) => updatePeriodEntry(entry.id, { note: e.target.value })}
rows={2}
className="task-form-input task-form-textarea"
placeholder="이 기간에 수행한 내용"
/>
</div>
))}
</div>
)}
</div>
{isRoutine && teamMembers.length > 0 && (
<div className="task-form-people">
<div className="task-form-field">
<label className="task-form-label" htmlFor={`${idPrefix}-pm`}>
PM
</label>
<select
id={`${idPrefix}-pm`}
value={form.pmMemberId}
onChange={(e) => set('pmMemberId', e.target.value)}
className="task-form-input"
>
<option value=""> </option>
{teamMembers.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
{m.rank ? ` · ${m.rank}` : ''}
</option>
))}
</select>
</div>
<div className="task-form-field">
<span className="task-form-label"> ( )</span>
<div className="task-form-assignees">
{teamMembers.map((m) => {
const checked = form.assigneeMemberIds.includes(m.id);
return (
<label
key={m.id}
className={`task-form-assignee-chip${checked ? ' is-checked' : ''}`}
>
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={() => toggleAssignee(m.id)}
/>
{m.name}
</label>
);
})}
</div>
</div>
</div>
)}
<div className="task-form-field">
<div className="task-form-label-row">
<span className="task-form-label"></span>
<button
type="button"
className="task-form-link-btn"
onClick={() =>
onChange({
...form,
links: [...form.links, { label: '', url: '' }],
})
}
>
+
</button>
</div>
{form.links.length === 0 ? (
<p className="task-form-empty"> .</p>
) : (
<div className="task-form-issues">
{form.links.map((link, index) => (
<div key={index} className="task-form-issue">
<div className="task-form-row-2">
<input
value={link.label}
onChange={(e) => {
const links = [...form.links];
links[index] = { ...links[index], label: e.target.value };
onChange({ ...form, links });
}}
className="task-form-input"
placeholder="표시명"
/>
<input
value={link.url}
onChange={(e) => {
const links = [...form.links];
links[index] = { ...links[index], url: e.target.value };
onChange({ ...form, links });
}}
className="task-form-input"
placeholder="URL"
/>
</div>
<div className="task-form-issue-actions">
<span />
<button
type="button"
className="task-form-link-btn task-form-link-btn--danger"
onClick={() =>
onChange({
...form,
links: form.links.filter((_, i) => i !== index),
})
}
>
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,333 @@
import type { TaskFormData } from '../../lib/taskFormState';
import { STATUS_OPTIONS } from '../../lib/taskFormState';
import { routineCategoryOptions } from '../../lib/routineCategories';
import { newIssueEntry } from '../../lib/taskIssues';
import type { TeamMember } from '../../types';
type SectionOption = { value: string; label: string };
interface TaskFormFieldsProps {
variant: 'project' | 'routine';
form: TaskFormData;
onChange: (next: TaskFormData) => void;
sectionOptions?: SectionOption[];
teamMembers?: TeamMember[];
idPrefix?: string;
}
export function TaskFormFields({
variant,
form,
onChange,
sectionOptions,
teamMembers = [],
idPrefix = 'task-form',
}: TaskFormFieldsProps) {
const isRoutine = variant === 'routine';
const set = <K extends keyof TaskFormData>(field: K, value: TaskFormData[K]) =>
onChange({ ...form, [field]: value });
const toggleAssignee = (memberId: string) => {
const has = form.assigneeMemberIds.includes(memberId);
onChange({
...form,
assigneeMemberIds: has
? form.assigneeMemberIds.filter((id) => id !== memberId)
: [...form.assigneeMemberIds, memberId],
});
};
const updateIssueEntry = (id: string, patch: Partial<(typeof form.issueEntries)[0]>) => {
onChange({
...form,
issueEntries: form.issueEntries.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry)),
});
};
return (
<div className="task-form-fields">
<div className="task-form-field">
<label className="task-form-label" htmlFor={`${idPrefix}-title`}>
*
</label>
<input
id={`${idPrefix}-title`}
required
value={form.title}
onChange={(e) => set('title', e.target.value)}
className="task-form-input task-form-input--title"
placeholder="업무 제목을 입력하세요"
/>
</div>
<div className="task-form-field">
<label className="task-form-label" htmlFor={`${idPrefix}-section`}>
{isRoutine ? '대분류' : '소속 부문'}
</label>
{isRoutine ? (
<select
id={`${idPrefix}-section`}
value={form.category}
onChange={(e) => set('category', e.target.value)}
className="task-form-input"
>
{routineCategoryOptions().map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : (
<select
id={`${idPrefix}-section`}
value={form.section}
onChange={(e) => set('section', e.target.value)}
className="task-form-input"
>
{(sectionOptions ?? [
{ value: '인사관리', label: '인사관리' },
{ value: '운영관리', label: '총무관리' },
]).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
)}
</div>
{!isRoutine && (
<div className="task-form-row-2">
<div className="task-form-field">
<label className="task-form-label" htmlFor={`${idPrefix}-status`}>
</label>
<select
id={`${idPrefix}-status`}
value={form.status}
onChange={(e) => set('status', e.target.value)}
className="task-form-input"
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
<div className="task-form-field">
<label className="task-form-label" htmlFor={`${idPrefix}-progress`}>
<span className="task-form-progress-val">{form.progress}%</span>
</label>
<input
id={`${idPrefix}-progress`}
type="range"
min={0}
max={100}
step={5}
value={form.progress}
onChange={(e) => set('progress', Number(e.target.value))}
className="task-form-range"
/>
</div>
</div>
)}
{isRoutine && (
<div className="task-form-field">
<label className="task-form-label" htmlFor={`${idPrefix}-status`}>
</label>
<select
id={`${idPrefix}-status`}
value={form.status}
onChange={(e) => set('status', e.target.value)}
className="task-form-input"
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
)}
{isRoutine ? (
<div className="task-form-field">
<label className="task-form-label" htmlFor={`${idPrefix}-desc`}>
</label>
<textarea
id={`${idPrefix}-desc`}
value={form.description}
onChange={(e) => set('description', e.target.value)}
rows={3}
className="task-form-input task-form-textarea"
placeholder="내용을 입력하세요"
/>
</div>
) : (
<>
<div className="task-form-field">
<label className="task-form-label" htmlFor={`${idPrefix}-overview`}>
</label>
<textarea
id={`${idPrefix}-overview`}
value={form.description}
onChange={(e) => set('description', e.target.value)}
rows={2}
className="task-form-input task-form-textarea"
placeholder="프로젝트 개요를 입력하세요"
/>
</div>
<div className="task-form-field">
<label className="task-form-label" htmlFor={`${idPrefix}-detail`}>
</label>
<textarea
id={`${idPrefix}-detail`}
value={form.detailDescription}
onChange={(e) => set('detailDescription', e.target.value)}
rows={4}
className="task-form-input task-form-textarea task-form-textarea--tall"
placeholder="상세 내용을 입력하세요"
/>
</div>
</>
)}
{teamMembers.length > 0 && (
<div className="task-form-people">
<div className="task-form-field">
<label className="task-form-label" htmlFor={`${idPrefix}-pm`}>
PM
</label>
<select
id={`${idPrefix}-pm`}
value={form.pmMemberId}
onChange={(e) => set('pmMemberId', e.target.value)}
className="task-form-input"
>
<option value=""> </option>
{teamMembers.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
{m.rank ? ` · ${m.rank}` : ''}
</option>
))}
</select>
</div>
<div className="task-form-field">
<span className="task-form-label"> ( )</span>
<div className="task-form-assignees">
{teamMembers.map((m) => {
const checked = form.assigneeMemberIds.includes(m.id);
return (
<label
key={m.id}
className={`task-form-assignee-chip${checked ? ' is-checked' : ''}`}
>
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={() => toggleAssignee(m.id)}
/>
{m.name}
</label>
);
})}
</div>
</div>
</div>
)}
{!isRoutine && (
<div className="task-form-field">
<span className="task-form-label"></span>
<div className="task-form-row-2">
<input
type="date"
value={form.startDate}
onChange={(e) => set('startDate', e.target.value)}
className="task-form-input"
aria-label="시작일"
/>
<input
type="date"
value={form.dueDate}
onChange={(e) => set('dueDate', e.target.value)}
className="task-form-input"
aria-label="종료일"
/>
</div>
</div>
)}
<div className="task-form-field">
<div className="task-form-label-row">
<span className="task-form-label"> </span>
<button
type="button"
onClick={() => onChange({ ...form, issueEntries: [...form.issueEntries, newIssueEntry()] })}
className="task-form-link-btn"
>
+
</button>
</div>
{form.issueEntries.length === 0 ? (
<p className="task-form-empty"> .</p>
) : (
<div className="task-form-issues">
{form.issueEntries.map((entry, index) => (
<div key={entry.id} className="task-form-issue">
<textarea
value={entry.text}
onChange={(e) => updateIssueEntry(entry.id, { text: e.target.value })}
rows={2}
className="task-form-input task-form-textarea"
placeholder={`이슈 내용 (${index + 1})`}
/>
<div className="task-form-issue-actions">
<label className="task-form-issue-date">
<span className="task-form-issue-date-label"></span>
<input
type="date"
value={entry.occurredOn ?? ''}
onChange={(e) => updateIssueEntry(entry.id, { occurredOn: e.target.value || null })}
className="task-form-input task-form-issue-date-input"
aria-label={`이슈 ${index + 1} 발생일`}
/>
</label>
<label className="task-form-issue-check">
<input
type="checkbox"
checked={entry.showOnCard}
onChange={(e) => updateIssueEntry(entry.id, { showOnCard: e.target.checked })}
/>
</label>
<button
type="button"
onClick={() =>
onChange({
...form,
issueEntries: form.issueEntries.filter((e) => e.id !== entry.id),
})
}
className="task-form-link-btn task-form-link-btn--danger"
>
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,37 +1,13 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import type { Task, TeamMember, TaskIssueEntry } from '../../types';
import { normalizeTaskType } from '../../lib/taskType';
import { newIssueEntry, parseIssueEntries } from '../../lib/taskIssues';
import { getRoutineCategory, routineCategoryOptions } from '../../lib/routineCategories';
import type { Task, TeamMember } from '../../types';
import { TaskFormFields } from './TaskFormFields';
import { buildTaskFormState, type TaskFormData } from '../../lib/taskFormState';
const STATUS_OPTIONS = [
{ value: 'TODO', label: '대기' },
{ value: 'IN_PROGRESS', label: '진행' },
{ value: 'REVIEW', label: '보류' },
{ value: 'DONE', label: '완료' },
];
export interface TaskFormData {
title: string;
section: string;
category: string;
tag: string;
taskType: string;
status: string;
progress: number;
description: string;
issueEntries: TaskIssueEntry[];
quarter: string;
startDate: string;
dueDate: string;
pmMemberId: string;
assigneeMemberIds: string[];
}
export type { TaskFormData };
interface TaskModalProps {
mode: 'add' | 'edit';
/** project: 실행과제 전용 / routine: 기반업무(상시) 전용 */
variant?: 'project' | 'routine';
task?: Task;
defaultSection?: string;
@@ -47,7 +23,7 @@ export function TaskModal({
mode,
variant = 'project',
task,
defaultSection = 'HR',
defaultSection = '인사관리',
defaultCategory = '채용 운영',
defaultQuarter = '2026-Q2',
sectionOptions,
@@ -56,345 +32,53 @@ export function TaskModal({
onClose,
}: TaskModalProps) {
const isRoutine = variant === 'routine';
const toDateInput = (iso: string | null | undefined) => {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
};
const [form, setForm] = useState<TaskFormData>({
title: task?.title ?? '',
section: task?.section ?? defaultSection,
category: (task ? getRoutineCategory(task) : null) ?? defaultCategory,
tag: task?.tag ?? '',
taskType: isRoutine
? '기반업무'
: (task?.taskType ? normalizeTaskType(task.taskType) : '실행과제'),
status: task?.status ?? 'TODO',
progress: task?.progress ?? 0,
description: task?.description ?? '',
issueEntries: task ? parseIssueEntries(task) : [],
quarter: task?.quarter ?? defaultQuarter,
startDate: toDateInput(task?.startDate),
dueDate: toDateInput(task?.dueDate),
pmMemberId: task?.pmMember?.id ?? task?.pmMemberId ?? '',
assigneeMemberIds: task?.assigneeMembers?.map((m) => m.id) ?? [],
});
const toggleAssignee = (memberId: string) => {
setForm((prev) => {
const has = prev.assigneeMemberIds.includes(memberId);
return {
...prev,
assigneeMemberIds: has
? prev.assigneeMemberIds.filter((id) => id !== memberId)
: [...prev.assigneeMemberIds, memberId],
};
});
};
const set = <K extends keyof TaskFormData>(field: K, value: TaskFormData[K]) =>
setForm(prev => ({ ...prev, [field]: value }));
const updateIssueEntry = (id: string, patch: Partial<TaskIssueEntry>) => {
setForm((prev) => ({
...prev,
issueEntries: prev.issueEntries.map((entry) =>
entry.id === id ? { ...entry, ...patch } : entry,
),
}));
};
const addIssueEntry = () => {
setForm((prev) => ({
...prev,
issueEntries: [...prev.issueEntries, newIssueEntry()],
}));
};
const removeIssueEntry = (id: string) => {
setForm((prev) => ({
...prev,
issueEntries: prev.issueEntries.filter((entry) => entry.id !== id),
}));
};
const [form, setForm] = useState<TaskFormData>(() =>
buildTaskFormState({ task, variant, defaultSection, defaultCategory, defaultQuarter }),
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const payload: TaskFormData = {
onSave({
...form,
taskType: isRoutine ? '기반업무' : '실행과제',
};
onSave(payload);
});
};
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50"
onClick={onClose}
>
<div
className="bg-white rounded-2xl shadow-2xl w-[540px] max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 className="text-2xl font-black text-gray-800">
<div className="task-modal-overlay" onClick={onClose}>
<div className="task-modal-shell" onClick={(e) => e.stopPropagation()}>
<div className="task-modal-head">
<h2 className="task-modal-title">
{isRoutine
? (mode === 'add' ? '✚ 상시업무 추가' : '✏ 상시업무 수정')
: (mode === 'add' ? '✚ 프로젝트 추가' : '✏ 프로젝트 수정')}
? mode === 'add'
? '상시업무 추가'
: '상시업무 수정'
: mode === 'add'
? '프로젝트 추가'
: '프로젝트 수정'}
</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl leading-none transition-colors"
>
<button type="button" onClick={onClose} className="task-modal-close" aria-label="닫기">
</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
{/* 제목 */}
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"> *</label>
<input
required
value={form.title}
onChange={(e) => set('title', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-lg outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
placeholder="업무 제목을 입력하세요"
/>
</div>
<form onSubmit={handleSubmit} className="task-modal-body">
<TaskFormFields
variant={variant}
form={form}
onChange={setForm}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
idPrefix={`modal-${mode}`}
/>
{/* 대분류 (상시업무) / 소속 부문 (프로젝트) */}
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5">
{isRoutine ? '대분류' : '소속 부문'}
</label>
{isRoutine ? (
<select
value={form.category}
onChange={(e) => set('category', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:ring-2 transition bg-white focus:border-emerald-400 focus:ring-emerald-100"
>
{routineCategoryOptions().map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) : (
<select
value={form.section}
onChange={(e) => set('section', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:ring-2 transition bg-white focus:border-blue-400 focus:ring-blue-100"
>
{(sectionOptions ?? [
{ value: 'HR', label: 'HR' },
{ value: '운영관리', label: '운영관리' },
]).map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
)}
</div>
{/* 상태 + 진행률 (프로젝트만) */}
{!isRoutine && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<select
value={form.status}
onChange={(e) => set('status', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition bg-white"
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5">
<span className="ml-2 font-black text-gray-800">{form.progress}%</span>
</label>
<div className="flex items-center gap-3 pt-2">
<input
type="range"
min={0}
max={100}
step={5}
value={form.progress}
onChange={(e) => set('progress', Number(e.target.value))}
className="flex-1 accent-blue-500"
/>
</div>
</div>
</div>
)}
{/* 상태 (상시업무) */}
{isRoutine && (
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<select
value={form.status}
onChange={(e) => set('status', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 transition bg-white"
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
)}
{/* 내용 */}
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<textarea
value={form.description}
onChange={(e) => set('description', e.target.value)}
rows={3}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 resize-none transition"
placeholder="내용을 입력하세요"
/>
</div>
{/* PM + 담당자 */}
{teamMembers.length > 0 && (
<div className="space-y-3 rounded-xl border border-emerald-100 bg-emerald-50/40 p-4">
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5">PM</label>
<select
value={form.pmMemberId}
onChange={(e) => set('pmMemberId', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 transition bg-white"
>
<option value=""> </option>
{teamMembers.map((m) => (
<option key={m.id} value={m.id}>
{m.name}{m.rank ? ` · ${m.rank}` : ''}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"> ( )</label>
<div className="flex flex-wrap gap-2">
{teamMembers.map((m) => {
const checked = form.assigneeMemberIds.includes(m.id);
return (
<label
key={m.id}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border cursor-pointer select-none text-sm font-semibold transition ${
checked
? 'bg-emerald-600 text-white border-emerald-600'
: 'bg-white text-gray-600 border-gray-200 hover:border-emerald-300'
}`}
>
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={() => toggleAssignee(m.id)}
/>
{m.name}
</label>
);
})}
</div>
</div>
</div>
)}
{/* 프로젝트 기간 */}
{!isRoutine && (
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<div className="grid grid-cols-2 gap-3">
<input
type="date"
value={form.startDate}
onChange={(e) => set('startDate', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
/>
<input
type="date"
value={form.dueDate}
onChange={(e) => set('dueDate', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
/>
</div>
</div>
)}
{/* 이슈 메모 */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-bold text-gray-500"> </label>
<button
type="button"
onClick={addIssueEntry}
className="text-xs font-bold text-blue-600 hover:text-blue-700 px-2 py-1 rounded-lg hover:bg-blue-50 transition"
>
+
</button>
</div>
{form.issueEntries.length === 0 ? (
<p className="text-sm text-gray-400 rounded-xl border border-dashed border-gray-200 px-4 py-3 text-center">
. .
</p>
) : (
<div className="space-y-2">
{form.issueEntries.map((entry, index) => (
<div
key={entry.id}
className="rounded-xl border border-gray-200 bg-gray-50/60 p-3 space-y-2"
>
<textarea
value={entry.text}
onChange={(e) => updateIssueEntry(entry.id, { text: e.target.value })}
rows={2}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 resize-none transition bg-white"
placeholder={`[날짜] 이슈 내용 (${index + 1})`}
/>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={entry.showOnCard}
onChange={(e) => updateIssueEntry(entry.id, { showOnCard: e.target.checked })}
className="w-4 h-4 accent-blue-500 cursor-pointer"
/>
<span className="text-xs font-semibold text-gray-500"> </span>
</label>
<button
type="button"
onClick={() => removeIssueEntry(entry.id)}
className="text-xs font-bold text-red-500 hover:text-red-600 px-2 py-1 rounded-lg hover:bg-red-50 transition"
>
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* 버튼 */}
<div className="flex justify-end gap-2 pt-2 pb-1">
<button
type="button"
onClick={onClose}
className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-semibold hover:bg-gray-50 transition"
>
<div className="task-form-actions">
<button type="button" onClick={onClose} className="task-form-btn task-form-btn--ghost">
</button>
<button
type="submit"
className={`px-6 py-2.5 rounded-xl text-white font-bold transition ${
isRoutine ? 'bg-emerald-700 hover:bg-emerald-800' : 'bg-blue-600 hover:bg-blue-700'
}`}
className={`task-form-btn task-form-btn--primary${isRoutine ? ' is-routine' : ''}`}
>
{mode === 'add' ? '추가하기' : '저장하기'}
</button>
@@ -402,6 +86,6 @@ export function TaskModal({
</form>
</div>
</div>,
document.body
document.body,
);
}

View File

@@ -4,10 +4,7 @@ import { createPortal } from 'react-dom';
import {
QUARTER_RANGE_LABELS,
buildMonthWeekRows,
dateToQuarter,
isSameDay,
isSameWeek,
quarterEndDate,
startOfDay,
startOfWeekMonday,
toIsoDate,
@@ -16,32 +13,40 @@ import {
const WEEKDAY_LABELS = ['월', '화', '수', '목', '금', '토', '일'];
interface BoardCalendarPopoverProps {
referenceDate: Date;
onChange: (d: Date) => void;
selectedQuarter: string;
referenceWeekMonday: Date;
weekLensActive: boolean;
onSelectWeek: (d: Date) => void;
onSelectQuarter: (quarterKey: string) => void;
onReturnToCurrentQuarter: () => void;
onClose: () => void;
anchorRef: RefObject<HTMLElement | null>;
}
export function BoardCalendarPopover({
referenceDate,
onChange,
selectedQuarter,
referenceWeekMonday,
weekLensActive,
onSelectWeek,
onSelectQuarter,
onReturnToCurrentQuarter,
onClose,
anchorRef,
}: BoardCalendarPopoverProps) {
const [viewYear, setViewYear] = useState(referenceDate.getFullYear());
const [viewMonth, setViewMonth] = useState(referenceDate.getMonth());
const anchorMonday = startOfWeekMonday(referenceWeekMonday);
const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());
const weekRows = useMemo(
() => buildMonthWeekRows(viewYear, viewMonth),
[viewYear, viewMonth],
);
const activeQuarterKey = dateToQuarter(referenceDate);
useEffect(() => {
setViewYear(referenceDate.getFullYear());
setViewMonth(referenceDate.getMonth());
}, [referenceDate]);
if (!weekLensActive) return;
setViewYear(anchorMonday.getFullYear());
setViewMonth(anchorMonday.getMonth());
}, [weekLensActive, anchorMonday]);
useEffect(() => {
const onPointerDown = (e: MouseEvent) => {
@@ -74,18 +79,11 @@ export function BoardCalendarPopover({
setViewMonth(d.getMonth());
};
const pickDate = (d: Date) => {
onChange(startOfDay(d));
};
const pickQuarter = (q: 1 | 2 | 3 | 4) => {
onChange(quarterEndDate(`${viewYear}-Q${q}`));
};
const today = startOfDay(new Date());
const todayMonday = startOfWeekMonday(today);
return createPortal(
<div id="board-calendar-popover" className="board-calendar-popover" style={style} role="dialog" aria-label="기준일 선택">
<div id="board-calendar-popover" className="board-calendar-popover" style={style} role="dialog" aria-label="주차 · 분기 선택">
<div className="board-calendar-popover-head">
<button type="button" className="board-calendar-nav" onClick={() => shiftMonth(-1)} aria-label="이전 달"></button>
<span className="board-calendar-month">{viewYear} {viewMonth + 1}</span>
@@ -95,13 +93,13 @@ export function BoardCalendarPopover({
<div className="board-calendar-quarter-row">
{([1, 2, 3, 4] as const).map((q) => {
const key = `${viewYear}-Q${q}`;
const selected = activeQuarterKey === key;
const selected = !weekLensActive && selectedQuarter === key;
return (
<button
key={key}
type="button"
className={`board-calendar-quarter-chip${selected ? ' is-selected' : ''}`}
onClick={() => pickQuarter(q)}
onClick={() => onSelectQuarter(key)}
>
<span className="board-calendar-quarter-chip-title">{q}</span>
<span className="board-calendar-quarter-chip-range">{QUARTER_RANGE_LABELS[q - 1]}</span>
@@ -122,36 +120,38 @@ export function BoardCalendarPopover({
</thead>
<tbody>
{weekRows.map((row) => {
const weekSelected = isSameWeek(referenceDate, row.monday);
const weekSelected = weekLensActive && isSameWeek(anchorMonday, row.monday);
return (
<tr key={toIsoDate(row.monday)} className={weekSelected ? 'is-selected-week' : undefined}>
<tr
key={toIsoDate(row.monday)}
className={weekSelected ? 'is-selected-week' : undefined}
onClick={() => onSelectWeek(row.monday)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelectWeek(row.monday);
}
}}
>
<td className="board-calendar-grid-week-col">
<button
type="button"
className="board-calendar-week-label-btn"
onClick={() => pickDate(startOfWeekMonday(row.monday))}
>
{row.label}
</button>
<span className="board-calendar-week-label-btn">{row.label}</span>
</td>
{row.days.map((day) => {
const inMonth = day.getMonth() === viewMonth;
const isRef = isSameDay(day, referenceDate);
const isToday = isSameDay(day, today);
const isToday = startOfDay(day).getTime() === today.getTime();
return (
<td key={toIsoDate(day)}>
<button
type="button"
<span
className={[
'board-calendar-day-btn',
'board-calendar-day-cell',
!inMonth ? 'is-outside' : '',
isRef ? 'is-ref' : '',
isToday ? 'is-today' : '',
].filter(Boolean).join(' ')}
onClick={() => pickDate(day)}
>
{day.getDate()}
</button>
</span>
</td>
);
})}
@@ -163,8 +163,8 @@ export function BoardCalendarPopover({
</div>
<div className="board-calendar-foot">
<button type="button" onClick={() => pickDate(startOfWeekMonday(today))}> </button>
<button type="button" onClick={() => pickDate(today)}> </button>
<button type="button" onClick={() => onSelectWeek(todayMonday)}> </button>
<button type="button" onClick={onReturnToCurrentQuarter}> </button>
</div>
</div>,
document.body,

View File

@@ -1,6 +1,6 @@
import { useRef, useState } from 'react';
import { formatReferenceSummary } from '../../lib/boardCalendar';
import { formatBoardCalendarPill } from '../../lib/boardCalendar';
import { isDetailWindowOpen } from '../../lib/dualMonitor';
import {
@@ -38,9 +38,15 @@ interface DashboardHeaderProps {
quarter: string;
referenceDate: Date;
referenceWeekMonday: Date;
onReferenceDateChange: (d: Date) => void;
weekLensActive: boolean;
onSelectWeek: (d: Date) => void;
onSelectQuarter: (quarterKey: string) => void;
onReturnToCurrentQuarter: () => void;
stats: Stats;
@@ -90,9 +96,15 @@ export function DashboardHeader({
quarter,
referenceDate,
referenceWeekMonday,
onReferenceDateChange,
weekLensActive,
onSelectWeek,
onSelectQuarter,
onReturnToCurrentQuarter,
stats,
@@ -304,12 +316,14 @@ export function DashboardHeader({
<div className="side-right-actions shrink-0">
<div className="header-calendar-slot">
<span className="board-calendar-ref-text">{formatReferenceSummary(referenceDate)}</span>
<span className="board-calendar-ref-text">
{formatBoardCalendarPill(quarter, weekLensActive, referenceWeekMonday)}
</span>
<button
ref={calendarBtnRef}
type="button"
className={`header-calendar-btn-new${calendarOpen ? ' active' : ''}`}
title="기준일 · 분기 선택"
title="주차 · 분기 선택"
aria-expanded={calendarOpen}
aria-label="캘린더 열기"
onClick={() => setCalendarOpen((open) => !open)}
@@ -318,10 +332,12 @@ export function DashboardHeader({
</button>
{calendarOpen && (
<BoardCalendarPopover
referenceDate={referenceDate}
onChange={(d) => {
onReferenceDateChange(d);
}}
selectedQuarter={quarter}
referenceWeekMonday={referenceWeekMonday}
weekLensActive={weekLensActive}
onSelectWeek={onSelectWeek}
onSelectQuarter={onSelectQuarter}
onReturnToCurrentQuarter={onReturnToCurrentQuarter}
onClose={() => setCalendarOpen(false)}
anchorRef={calendarBtnRef}
/>

View File

@@ -10,7 +10,7 @@ import { DeptIcon } from './DeptIcon';
import { DeptProjectList } from './DeptProjectList';
import { ContextMenu } from '../common/ContextMenu';
import { TaskModal } from '../common/TaskModal';
import type { TaskFormData } from '../common/TaskModal';
import type { TaskFormData } from '../../lib/taskFormState';
import { projectFormToApiPayload } from '../../lib/taskFormPayload';
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
import type { Task, TeamMember } from '../../types';
@@ -40,6 +40,7 @@ interface DepartmentColumnProps {
tasks: Task[];
orderedIds: string[];
quarter: string;
referenceWeekMonday?: Date;
onSelectTask?: (task: Task) => void;
sectionOptions?: { value: string; label: string }[];
teamMembers?: TeamMember[];
@@ -114,6 +115,7 @@ export function DepartmentColumn({
tasks,
orderedIds,
quarter,
referenceWeekMonday,
onSelectTask,
sectionOptions: externalSectionOptions,
teamMembers = [],
@@ -169,7 +171,7 @@ export function DepartmentColumn({
const create = useMutation({
mutationFn: (data: Partial<Task>) => apiClient.post('/tasks', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
onSuccess: () => invalidateTaskCaches(queryClient),
});
const patch = useMutation({
@@ -180,7 +182,7 @@ export function DepartmentColumn({
const remove = useMutation({
mutationFn: (id: string) => apiClient.delete(`/tasks/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
onSuccess: (_data, id) => invalidateTaskCaches(queryClient, id),
});
const handleColumnContextMenuCapture = (e: React.MouseEvent) => {
@@ -286,6 +288,7 @@ export function DepartmentColumn({
task={task}
variant="project"
sectionOptions={sectionOptions}
referenceWeekMonday={referenceWeekMonday}
onSelect={onSelectTask}
/>
)}

View File

@@ -9,6 +9,7 @@ import { quarterDateBounds, sortScheduleItems, todayIso } from '../../lib/hubSch
import { HubScheduleCarousel } from './HubScheduleCarousel';
import { HubRoutineFocusPanel } from './HubRoutineFocusPanel';
import { ContextMenu } from '../common/ContextMenu';
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
import { routineCategoryShellPayload } from '../../lib/taskFormPayload';
import {
@@ -200,7 +201,7 @@ export function HubColumn({
'/tasks',
routineCategoryShellPayload(label, quarter),
);
await queryClient.invalidateQueries({ queryKey: ['tasks'] });
await invalidateTaskCaches(queryClient, created.id);
onSelectRoutine?.(created);
} catch (err: unknown) {
alert(getApiErrorMessage(err, `"${label}" 업무를 준비하지 못했습니다.`));
@@ -223,7 +224,7 @@ export function HubColumn({
'/tasks',
routineCategoryShellPayload(label, quarter),
);
await queryClient.invalidateQueries({ queryKey: ['tasks'] });
await invalidateTaskCaches(queryClient, created.id);
onSelectRoutineMilestone?.(created.id, milestoneId);
} catch (err: unknown) {
alert(getApiErrorMessage(err, `"${label}" 업무를 준비하지 못했습니다.`));

View File

@@ -1,4 +1,3 @@
import { HubNavChevron } from '../common/HubNavChevron';
import { HubFocusTaskList } from './HubFocusTaskList';
import { ROUTINE_CATEGORIES } from '../../lib/routineCategories';
import type { RoutineCategoryFocus } from '../../hooks/useRoutineCategoryMilestones';
@@ -18,16 +17,6 @@ export function HubRoutineFocusPanel({
}: HubRoutineFocusPanelProps) {
const safeIndex = Math.max(0, Math.min(activeIndex, ROUTINE_CATEGORIES.length - 1));
const active = categories[safeIndex];
const canPrev = safeIndex > 0;
const canNext = safeIndex < ROUTINE_CATEGORIES.length - 1;
const stepPrev = () => {
if (canPrev) onActiveIndexChange(safeIndex - 1);
};
const stepNext = () => {
if (canNext) onActiveIndexChange(safeIndex + 1);
};
return (
<div className="hub-routine-focus">
@@ -47,32 +36,12 @@ export function HubRoutineFocusPanel({
</div>
<div className="hub-routine-focus-body">
<button
type="button"
className="hub-routine-focus-nav hub-routine-focus-nav--prev"
disabled={!canPrev}
onClick={stepPrev}
aria-label="이전 대분류"
>
<HubNavChevron direction="prev" />
</button>
<HubFocusTaskList
milestones={active?.milestones ?? []}
isLoading={!!active?.isLoading}
categoryKey={ROUTINE_CATEGORIES[safeIndex]}
onSelectMilestone={onSelectMilestone}
/>
<button
type="button"
className="hub-routine-focus-nav hub-routine-focus-nav--next"
disabled={!canNext}
onClick={stepNext}
aria-label="다음 대분류"
>
<HubNavChevron direction="next" />
</button>
</div>
</div>
);

View File

@@ -1,6 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { HubScheduleItem } from '../../lib/hubConfig';
import { HubNavChevron } from '../common/HubNavChevron';
import {
findScheduleIndexForToday,
formatScheduleDateParts,
@@ -11,15 +10,28 @@ import {
} from '../../lib/hubSchedule';
const VISIBLE_COUNT = 3;
const ROW_STEP_PX = 46;
const WHEEL_THRESHOLD = 18;
const WHEEL_COOLDOWN_MS = 140;
interface HubScheduleCarouselProps {
items: HubScheduleItem[];
/** 기준일 — 없으면 오늘 */
focusDate?: Date;
}
function slotOpacity(slot: number): number {
if (slot === 0) return 0.4;
if (slot === 1) return 1;
if (slot === 2) return 0.88;
return 0.28;
}
export function HubScheduleCarousel({ items, focusDate }: HubScheduleCarouselProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const wheelAccRef = useRef(0);
const wheelCooldownRef = useRef(false);
const dragRef = useRef<{ y: number; scroll: number } | null>(null);
const focus = focusDate ?? new Date();
const sorted = useMemo(
() => sortScheduleItems(items.filter((i) => i.date && i.text.trim())),
@@ -31,65 +43,134 @@ export function HubScheduleCarousel({ items, focusDate }: HubScheduleCarouselPro
() => scheduleWindowStart(sorted.length, focusIndex, VISIBLE_COUNT),
[sorted.length, focusIndex],
);
const [startIndex, setStartIndex] = useState(initialStart);
useEffect(() => {
setStartIndex(initialStart);
}, [initialStart, sorted.length]);
const maxStart = Math.max(0, sorted.length - VISIBLE_COUNT);
const paged = sorted.length > VISIBLE_COUNT;
const canPrev = startIndex > 0;
const canNext = startIndex < maxStart;
const scrollable = sorted.length > VISIBLE_COUNT;
const stepPrev = () => setStartIndex((i) => Math.max(0, i - VISIBLE_COUNT));
const stepNext = () => setStartIndex((i) => Math.min(maxStart, i + VISIBLE_COUNT));
const clampScrollPx = useCallback(
(px: number) => Math.min(maxStart * ROW_STEP_PX, Math.max(0, px)),
[maxStart],
);
const snapScrollPx = useCallback(
(px: number) => {
const idx = Math.round(px / ROW_STEP_PX);
return clampScrollPx(idx * ROW_STEP_PX);
},
[clampScrollPx],
);
const [scrollPx, setScrollPx] = useState(() => initialStart * ROW_STEP_PX);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
const root = rootRef.current;
if (!root || !paged) return;
setScrollPx(initialStart * ROW_STEP_PX);
}, [initialStart]);
const startIndex = Math.round(scrollPx / ROW_STEP_PX);
const stepBy = useCallback(
(delta: number) => {
if (delta === 0) return;
setScrollPx((prev) => snapScrollPx(prev + delta * ROW_STEP_PX));
},
[snapScrollPx],
);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport || !scrollable) return;
const onWheel = (event: WheelEvent) => {
if (Math.abs(event.deltaY) < 1) return;
event.preventDefault();
if (event.deltaY > 0) {
setStartIndex((i) => Math.min(maxStart, i + VISIBLE_COUNT));
} else {
setStartIndex((i) => Math.max(0, i - VISIBLE_COUNT));
}
if (wheelCooldownRef.current || dragRef.current) return;
wheelAccRef.current += event.deltaY;
if (Math.abs(wheelAccRef.current) < WHEEL_THRESHOLD) return;
const dir = wheelAccRef.current > 0 ? 1 : -1;
wheelAccRef.current = 0;
wheelCooldownRef.current = true;
stepBy(dir);
window.setTimeout(() => {
wheelCooldownRef.current = false;
}, WHEEL_COOLDOWN_MS);
};
root.addEventListener('wheel', onWheel, { passive: false });
return () => root.removeEventListener('wheel', onWheel);
}, [paged, maxStart]);
viewport.addEventListener('wheel', onWheel, { passive: false });
return () => viewport.removeEventListener('wheel', onWheel);
}, [scrollable, stepBy]);
const onPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || !scrollable) return;
dragRef.current = { y: event.clientY, scroll: scrollPx };
setIsDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
event.preventDefault();
},
[scrollPx, scrollable],
);
const onPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
const deltaY = event.clientY - dragRef.current.y;
setScrollPx(clampScrollPx(dragRef.current.scroll - deltaY));
},
[clampScrollPx],
);
const endDrag = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
dragRef.current = null;
setIsDragging(false);
setScrollPx((prev) => snapScrollPx(prev));
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
},
[snapScrollPx],
);
if (sorted.length === 0) {
return (
<div className="hub-schedule-viewport hub-schedule-viewport--empty">
<div className="hub-schedule-wheel-viewport hub-schedule-wheel-viewport--empty">
<p className="board-project-desc hub-schedule-empty"> </p>
</div>
);
}
const visible = sorted.slice(startIndex, startIndex + VISIBLE_COUNT);
return (
<div
ref={rootRef}
className={`hub-schedule-carousel${paged ? ' is-paged' : ''}`}
>
<div className="hub-schedule-viewport">
<ul className="hub-schedule-list hub-list">
{visible.map((item) => {
<div className="hub-schedule-wheel-wrap">
<div
ref={viewportRef}
className={`hub-schedule-wheel-viewport${isDragging ? ' is-dragging' : ''}${scrollable ? ' is-scrollable' : ''}`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div
className={`hub-schedule-wheel-track${isDragging ? ' is-dragging' : ''}`}
style={{ transform: `translateY(-${scrollPx}px)` }}
>
{sorted.map((item, index) => {
const past = isSchedulePast(item.date, focus);
const today = isScheduleToday(item.date, focus);
const dateParts = formatScheduleDateParts(item.date);
const inWindow = index >= startIndex && index < startIndex + VISIBLE_COUNT;
const slot = inWindow ? index - startIndex : -1;
return (
<li
<div
key={item.id}
className={`hub-schedule-item${past ? ' hub-schedule-item--past' : ''}${today ? ' hub-schedule-item--today' : ''}`}
className={`hub-schedule-wheel-row${past ? ' is-past' : ''}${today ? ' is-today' : ''}${inWindow ? ` is-slot-${slot}` : ''}`}
style={inWindow ? { opacity: slotOpacity(slot) } : undefined}
>
<span className="hub-schedule-date board-project-desc">
<span className="hub-schedule-wheel-date">
{dateParts ? (
<>
<span className="hub-schedule-date-month">{dateParts.month}</span>
@@ -99,36 +180,12 @@ export function HubScheduleCarousel({ items, focusDate }: HubScheduleCarouselPro
item.date
)}
</span>
<span className="board-project-desc">{item.text}</span>
</li>
<span className="hub-schedule-wheel-text">{item.text}</span>
</div>
);
})}
</ul>
</div>
</div>
{canPrev && (
<button
type="button"
className="hub-schedule-nav hub-schedule-nav--prev"
onClick={stepPrev}
onContextMenu={(e) => e.stopPropagation()}
aria-label="이전 일정"
>
<HubNavChevron direction="prev" />
</button>
)}
{canNext && (
<button
type="button"
className="hub-schedule-nav hub-schedule-nav--next"
onClick={stepNext}
onContextMenu={(e) => e.stopPropagation()}
aria-label="다음 일정"
>
<HubNavChevron direction="next" />
</button>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useRef } from 'react';
import { useMemo, useRef } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { DraggableAttributes } from '@dnd-kit/core';
@@ -7,6 +7,7 @@ import type { Task } from '../../types';
import { DonutGauge } from './DonutGauge';
import { getProjectTitleStatusClass } from '../../lib/taskStatusVisual';
import { getVisibleIssueEntries } from '../../lib/taskIssues';
import { resolveTaskWeekFocus } from '../../lib/milestoneWeekFocus';
function fmtDate(iso: string | null | undefined): string {
if (!iso) return '';
@@ -21,6 +22,15 @@ function fmtDateRange(task: Task): string {
return `${start} ~ ${end}`;
}
function overviewTextForCard(text: string | null | undefined): string {
if (!text?.trim()) return '';
return text
.split('\n')
.map((l) => l.replace(/^[•·\-]\s*/, '').trim())
.filter(Boolean)
.join(' ');
}
function firstDescriptionLine(text: string | null | undefined): string {
if (!text) return '';
const line = text.split('\n').map((l) => l.replace(/^[•·\-]\s*/, '').trim()).find(Boolean);
@@ -33,12 +43,14 @@ export function SortableTaskCard({
task,
variant = 'project',
onSelect,
referenceWeekMonday,
}: {
task: Task;
variant?: 'project' | 'routine';
sectionOptions?: SectionOption[];
accent?: string;
onSelect?: (task: Task) => void;
referenceWeekMonday?: Date;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
const pointerStart = useRef<{ x: number; y: number } | null>(null);
@@ -63,6 +75,7 @@ export function SortableTaskCard({
<TaskCard
task={task}
variant={variant}
referenceWeekMonday={referenceWeekMonday}
dragRef={setNodeRef}
dragStyle={{
transform: CSS.Transform.toString(transform),
@@ -80,6 +93,7 @@ export function SortableTaskCard({
export function TaskCard({
task,
variant = 'project',
referenceWeekMonday,
dragRef,
dragStyle,
dragAttributes,
@@ -89,6 +103,7 @@ export function TaskCard({
}: {
task: Task;
variant?: 'project' | 'routine';
referenceWeekMonday?: Date;
dragRef?: (node: HTMLElement | null) => void;
dragStyle?: React.CSSProperties;
dragAttributes?: DraggableAttributes;
@@ -125,9 +140,22 @@ export function TaskCard({
}
const dateRange = fmtDateRange(task);
const descLine = task.showDescription ? firstDescriptionLine(task.description) : '';
const overviewText = task.showDescription ? overviewTextForCard(task.description) : '';
const showProgress = task.showProgress !== false;
const visibleIssues = getVisibleIssueEntries(task);
const issueVisibility = referenceWeekMonday ? { weekMonday: referenceWeekMonday } : undefined;
const visibleIssues = getVisibleIssueEntries(task, issueVisibility);
const hasVisibleIssue = visibleIssues.length > 0;
const weekFocus = useMemo(
() => (referenceWeekMonday ? resolveTaskWeekFocus(task, referenceWeekMonday) : null),
[task, referenceWeekMonday],
);
const progressTask = useMemo(() => {
if (!weekFocus) return task;
if (weekFocus.progress >= 100) return { status: 'DONE' as const, progress: 100 };
return { status: 'IN_PROGRESS' as const, progress: weekFocus.progress };
}, [task, weekFocus]);
return (
<article
@@ -141,20 +169,43 @@ export function TaskCard({
>
<div className="project-sub-body">
<div className="project-fields">
<div className={`project-sub-title ${getProjectTitleStatusClass(task)}`}>
<div className={`project-sub-title ${getProjectTitleStatusClass(task, issueVisibility)}`}>
<span className="project-sub-title-text">{task.title}</span>
</div>
{dateRange && (
<div className="project-field">
<span className="project-field-label"> </span>
<span className="project-field-value">{dateRange}</span>
</div>
)}
{descLine && (
<div className="project-field">
<span className="project-field-label"> </span>
<span className="project-field-value">{descLine}</span>
</div>
{referenceWeekMonday ? (
weekFocus ? (
<>
<div className="project-field project-field--week-focus">
<span className="project-field-label"> </span>
<span className="project-field-value">{weekFocus.milestoneTitle}</span>
</div>
<div className="project-field project-field--week-period">
<span className="project-field-label"> </span>
<span className="project-field-value">{weekFocus.periodLabel}</span>
</div>
</>
) : (
<div className="project-field project-field--week-empty">
<span className="project-field-value"> </span>
</div>
)
) : (
<>
{dateRange && (
<div className="project-field">
<span className="project-field-label"> </span>
<span className="project-field-value">{dateRange}</span>
</div>
)}
{overviewText && (
<div
className={`project-field project-field--overview${hasVisibleIssue ? '' : ' project-field--overview-expanded'}`}
>
<span className="project-field-label"></span>
<span className="project-field-value">{overviewText}</span>
</div>
)}
</>
)}
{visibleIssues.map((entry) => (
<div key={entry.id} className="project-field project-field--issue">
@@ -167,18 +218,12 @@ export function TaskCard({
<span className="project-field-value project-field-value--issue">{entry.text}</span>
</div>
))}
{visibleIssues.length === 0 && (
<div className="project-field project-field--issue project-field--issue-reserved" aria-hidden="true">
<span className="project-issue-icon" />
<span className="project-field-value project-field-value--issue">&nbsp;</span>
</div>
)}
</div>
{showProgress && (
<>
<div className="project-sub-divider" aria-hidden="true" />
<div className="progress-col">
<DonutGauge task={task} />
<DonutGauge task={progressTask} />
</div>
</>
)}

View File

@@ -0,0 +1,231 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
import type { Milestone, TaskDetail } from '../../types';
export function TaskFeedbackSection({
taskId,
milestones,
details,
}: {
taskId: string;
milestones: Milestone[];
details: TaskDetail[];
}) {
const queryClient = useQueryClient();
const [expandedId, setExpandedId] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [addMilestoneId, setAddMilestoneId] = useState(milestones[0]?.id ?? '');
const [addContent, setAddContent] = useState('');
const [addAuthor, setAddAuthor] = useState('');
const [editForms, setEditForms] = useState<Record<string, { content: string; authorName: string }>>({});
const sorted = [...details].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
const milestoneTitle = (id: string | null) =>
milestones.find((m) => m.id === id)?.title ?? '—';
const startEdit = (detail: TaskDetail) => {
setExpandedId(detail.id);
setEditForms((prev) => ({
...prev,
[detail.id]: {
content: detail.content,
authorName: detail.authorName ?? detail.author?.name ?? '',
},
}));
};
const saveEdit = async (detailId: string) => {
const form = editForms[detailId];
if (!form?.content.trim()) return;
try {
await apiClient.patch(`/details/item/${detailId}`, {
content: form.content.trim(),
authorName: form.authorName.trim() || null,
});
invalidateTaskCaches(queryClient, taskId);
setExpandedId(null);
} catch (err: unknown) {
alert(getApiErrorMessage(err, '피드백 저장에 실패했습니다.'));
}
};
const remove = async (detailId: string) => {
if (!window.confirm('피드백을 삭제하시겠습니까?')) return;
try {
await apiClient.delete(`/details/item/${detailId}`);
invalidateTaskCaches(queryClient, taskId);
} catch (err: unknown) {
alert(getApiErrorMessage(err, '삭제에 실패했습니다.'));
}
};
const addFeedback = async (e: React.FormEvent) => {
e.preventDefault();
if (!addMilestoneId || !addContent.trim() || !addAuthor.trim()) return;
try {
await apiClient.post(`/details/${taskId}`, {
content: addContent.trim(),
authorName: addAuthor.trim(),
milestoneId: addMilestoneId,
});
invalidateTaskCaches(queryClient, taskId);
setAdding(false);
setAddContent('');
} catch (err: unknown) {
alert(getApiErrorMessage(err, '피드백 추가에 실패했습니다.'));
}
};
return (
<section className="task-manager-section">
<div className="task-manager-section__head">
<h4 className="task-manager-section__title"></h4>
<button
type="button"
className="task-form-link-btn"
disabled={milestones.length === 0}
onClick={() => setAdding(true)}
>
+
</button>
</div>
{milestones.length === 0 && (
<p className="task-form-empty"> .</p>
)}
{adding && milestones.length > 0 && (
<form className="task-manager-subpanel task-manager-subpanel--new" onSubmit={addFeedback}>
<div className="task-form-field">
<label className="task-form-label"> </label>
<select
value={addMilestoneId}
onChange={(e) => setAddMilestoneId(e.target.value)}
className="task-form-input"
>
{milestones.map((m) => (
<option key={m.id} value={m.id}>
{m.title}
</option>
))}
</select>
</div>
<div className="task-form-field">
<label className="task-form-label"> *</label>
<textarea
required
value={addContent}
onChange={(e) => setAddContent(e.target.value)}
rows={3}
className="task-form-input task-form-textarea"
/>
</div>
<div className="task-form-field">
<label className="task-form-label"> *</label>
<input
required
value={addAuthor}
onChange={(e) => setAddAuthor(e.target.value)}
className="task-form-input"
/>
</div>
<div className="task-manager-panel__actions">
<button type="button" className="task-form-btn task-form-btn--ghost" onClick={() => setAdding(false)}>
</button>
<button type="submit" className="task-form-btn task-form-btn--primary">
</button>
</div>
</form>
)}
{sorted.length === 0 && !adding && milestones.length > 0 && (
<p className="task-form-empty"> .</p>
)}
{sorted.map((detail) => {
const editing = expandedId === detail.id;
const form = editForms[detail.id] ?? {
content: detail.content,
authorName: detail.authorName ?? detail.author?.name ?? '',
};
return (
<article key={detail.id} className={`task-manager-subrow${editing ? ' is-expanded' : ''}`}>
<button
type="button"
className="task-manager-subrow__head"
onClick={() => (editing ? setExpandedId(null) : startEdit(detail))}
>
<span className="task-manager-row__chevron" aria-hidden="true">
</span>
<span className="task-manager-subrow__title truncate">{detail.content}</span>
<span className="task-manager-subrow__meta">
<span
className={`task-manager-badge task-manager-badge--status${
!detail.milestoneId ? ' is-legacy' : ''
}`}
>
{!detail.milestoneId ? '레거시·일정 미연결' : milestoneTitle(detail.milestoneId)}
</span>
</span>
</button>
{editing && (
<div className="task-manager-subpanel">
<div className="task-form-field">
<label className="task-form-label"> </label>
<textarea
value={form.content}
onChange={(e) =>
setEditForms((prev) => ({
...prev,
[detail.id]: { ...form, content: e.target.value },
}))
}
rows={3}
className="task-form-input task-form-textarea"
/>
</div>
<div className="task-form-field">
<label className="task-form-label"></label>
<input
value={form.authorName}
onChange={(e) =>
setEditForms((prev) => ({
...prev,
[detail.id]: { ...form, authorName: e.target.value },
}))
}
className="task-form-input"
/>
</div>
<div className="task-manager-panel__actions">
<button
type="button"
className="task-form-btn task-form-btn--danger"
onClick={() => remove(detail.id)}
>
</button>
<button
type="button"
className="task-form-btn task-form-btn--primary"
onClick={() => saveEdit(detail.id)}
>
</button>
</div>
</div>
)}
</article>
);
})}
</section>
);
}

View File

@@ -1,26 +1,35 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createPortal } from 'react-dom';
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
import { TaskModal } from '../common/TaskModal';
import type { TaskFormData } from '../common/TaskModal';
import type { Task, TeamMember } from '../../types';
import { TaskFormFields } from '../common/TaskFormFields';
import type { TaskFormData } from '../../lib/taskFormState';
import { buildTaskFormState, STATUS_LABEL } from '../../lib/taskFormState';
import { TaskMilestoneSection } from './TaskMilestoneSection';
import { TaskFeedbackSection } from './TaskFeedbackSection';
import type { FileRecord, Milestone, Task, TaskDetail, TeamMember } from '../../types';
type TaskWithRelations = Task & {
milestones?: Milestone[];
files?: FileRecord[];
details?: TaskDetail[];
};
import { isProjectTask, isRoutineTask } from '../../lib/taskType';
import { taskFormToApiPayload } from '../../lib/taskFormPayload';
import { projectFormToApiPayload, routineFormToApiPayload } from '../../lib/taskFormPayload';
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
import { formatSectionDisplay } from '../../lib/sections';
import {
BOARD_SLOTS,
BOARD_SLOT_ORDER,
getBoardSlot,
resolveTaskBoardSlot,
slotSectionLabel,
type BoardSlotId,
} from '../../lib/boardLayout';
import { ROUTINE_CATEGORIES, getRoutineCategory } from '../../lib/routineCategories';
import '../../styles/task-manager.css';
const STATUS_LABEL: Record<string, string> = {
IN_PROGRESS: '진행', REVIEW: '보류', TODO: '대기', DONE: '완료', CANCELLED: '취소',
};
const STATUS_STYLE: Record<string, string> = {
IN_PROGRESS: 'bg-blue-100 text-blue-700',
REVIEW: 'bg-orange-100 text-orange-700',
TODO: 'bg-gray-100 text-gray-600',
DONE: 'bg-emerald-100 text-emerald-700',
CANCELLED: 'bg-gray-100 text-gray-400',
};
import { SECTIONS, formatSectionDisplay } from '../../lib/sections';
type MainTab = 'project' | 'routine';
interface TaskManagerProps {
tasks: Task[];
@@ -30,206 +39,449 @@ interface TaskManagerProps {
onClose: () => void;
}
export function TaskManager({ tasks, sectionOptions, quarter, teamMembers = [], onClose }: TaskManagerProps) {
function fmtPeriod(task: Task): string {
if (!task.startDate && !task.dueDate) return '';
return `${task.startDate?.slice(0, 10) ?? '?'} ~ ${task.dueDate?.slice(0, 10) ?? '?'}`;
}
function slotClassForTask(task: Task): string {
if (isRoutineTask(task.taskType)) return 'task-manager-row--routine';
const slotId = resolveTaskBoardSlot(task);
return slotId ? `task-manager-row--${slotId}` : '';
}
function matchesSearch(task: Task, query: string): boolean {
if (!query.trim()) return true;
const q = query.trim().toLowerCase();
const hay = [
task.title,
task.description,
task.detailDescription,
task.category,
task.section,
task.issueNote,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return hay.includes(q);
}
function TaskManagerRow({
task,
variant,
expanded,
onToggle,
sectionOptions,
teamMembers,
quarter,
onSaved,
onDeleted,
}: {
task: Task;
variant: MainTab;
expanded: boolean;
onToggle: () => void;
sectionOptions: { value: string; label: string }[];
teamMembers: TeamMember[];
quarter: string;
onSaved: () => void;
onDeleted: () => void;
}) {
const queryClient = useQueryClient();
const [filterSection, setFilterSection] = useState<string>('전체');
const [filterType, setFilterType] = useState<string>('전체');
const [modalMode, setModalMode] = useState<'add' | 'edit' | null>(null);
const [editingTask, setEditingTask] = useState<Task | null>(null);
const rowRef = useRef<HTMLElement>(null);
const [form, setForm] = useState<TaskFormData>(() =>
buildTaskFormState({ task, variant, defaultQuarter: quarter }),
);
const [saving, setSaving] = useState(false);
const { data: fullTask } = useQuery({
queryKey: ['task', task.id],
queryFn: async () => {
const { data } = await apiClient.get<TaskWithRelations>(`/tasks/${task.id}`);
return data;
},
enabled: expanded,
});
useEffect(() => {
if (expanded) {
setForm(buildTaskFormState({ task, variant, defaultQuarter: quarter }));
requestAnimationFrame(() => {
rowRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
}
}, [expanded, task, variant, quarter]);
const patch = useMutation({
mutationFn: (data: Record<string, unknown>) => apiClient.patch(`/tasks/${task.id}`, data),
onSuccess: () => invalidateTaskCaches(queryClient, task.id),
});
const remove = useMutation({
mutationFn: () => apiClient.delete(`/tasks/${task.id}`),
onSuccess: () => invalidateTaskCaches(queryClient, task.id),
});
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const payload =
variant === 'project'
? projectFormToApiPayload({ ...form, taskType: '실행과제' })
: routineFormToApiPayload({ ...form, taskType: '기반업무' });
await patch.mutateAsync(payload);
onSaved();
} catch (err: unknown) {
alert(getApiErrorMessage(err, '저장에 실패했습니다.'));
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!window.confirm(`"${task.title}" 업무를 삭제하시겠습니까?`)) return;
try {
await remove.mutateAsync();
onDeleted();
} catch (err: unknown) {
alert(getApiErrorMessage(err, '삭제에 실패했습니다.'));
}
};
const statusLabel = STATUS_LABEL[task.status] ?? task.status;
const sectionLabel =
variant === 'project'
? formatSectionDisplay(task.section)
: getRoutineCategory(task) ?? task.category ?? '—';
return (
<article
ref={rowRef}
className={`task-manager-row ${slotClassForTask(task)}${expanded ? ' is-expanded' : ''}`}
>
<button type="button" className="task-manager-row__summary" onClick={onToggle}>
<span className="task-manager-row__chevron" aria-hidden="true">
</span>
<span className="task-manager-row__title">{task.title}</span>
<span className="task-manager-row__meta">
<span
className={`task-manager-badge ${variant === 'project' ? 'task-manager-badge--section' : 'task-manager-badge--category'}`}
>
{sectionLabel}
</span>
{variant === 'project' && (
<span className="task-manager-badge task-manager-badge--progress">{task.progress}%</span>
)}
<span
className={`task-manager-badge task-manager-badge--status${
task.status === 'DONE' ? ' is-done' : task.status === 'IN_PROGRESS' ? ' is-active' : ''
}`}
>
{statusLabel}
</span>
{variant === 'project' && fmtPeriod(task) && (
<span className="task-manager-badge task-manager-badge--status">{fmtPeriod(task)}</span>
)}
</span>
</button>
{expanded && (
<form className="task-manager-panel" onSubmit={handleSave}>
<TaskFormFields
variant={variant}
form={form}
onChange={setForm}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
idPrefix={`mgr-${task.id}`}
/>
{fullTask && (
<>
<TaskMilestoneSection task={fullTask} variant={variant} teamMembers={teamMembers} />
<TaskFeedbackSection
taskId={task.id}
milestones={fullTask.milestones ?? []}
details={fullTask.details ?? []}
/>
</>
)}
{!fullTask && <p className="task-form-empty"> · </p>}
<div className="task-manager-panel__actions">
<button
type="button"
className="task-form-btn task-form-btn--danger"
onClick={handleDelete}
disabled={remove.isPending}
>
</button>
<button type="submit" className="task-form-btn task-form-btn--primary" disabled={saving}>
{saving ? '저장 중…' : '저장'}
</button>
</div>
</form>
)}
</article>
);
}
function TaskManagerNewRow({
variant,
sectionOptions,
teamMembers,
quarter,
defaultSection,
onCreated,
onCancel,
}: {
variant: MainTab;
sectionOptions: { value: string; label: string }[];
teamMembers: TeamMember[];
quarter: string;
defaultSection: string;
onCreated: () => void;
onCancel: () => void;
}) {
const queryClient = useQueryClient();
const [form, setForm] = useState<TaskFormData>(() =>
buildTaskFormState({
variant,
defaultQuarter: quarter,
defaultSection,
defaultCategory: ROUTINE_CATEGORIES[0],
}),
);
const [saving, setSaving] = useState(false);
const create = useMutation({
mutationFn: (data: Record<string, unknown>) => apiClient.post('/tasks', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
});
const patch = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
apiClient.patch(`/tasks/${id}`, data),
onSuccess: (_data, { id }) => invalidateTaskCaches(queryClient, id),
});
const remove = useMutation({
mutationFn: (id: string) => apiClient.delete(`/tasks/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
onSuccess: () => invalidateTaskCaches(queryClient),
});
const matchType = (taskType: string | null | undefined, filter: string) => {
if (filter === '전체') return true;
if (filter === '실행과제') return isProjectTask(taskType);
if (filter === '기반업무') return isRoutineTask(taskType);
return taskType === filter;
};
const filtered = tasks.filter((t) => {
if (filterSection !== '전체' && t.section !== filterSection) return false;
if (!matchType(t.taskType, filterType)) return false;
return true;
});
const handleAdd = async (data: TaskFormData) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
await create.mutateAsync({
...taskFormToApiPayload(data),
const payload = {
...(variant === 'project'
? projectFormToApiPayload({ ...form, taskType: '실행과제' })
: routineFormToApiPayload({ ...form, taskType: '기반업무' })),
priority: 'MEDIUM',
});
setModalMode(null);
};
await create.mutateAsync(payload);
onCreated();
} catch (err: unknown) {
alert(getApiErrorMessage(err, '업무 추가에 실패했습니다.'));
alert(getApiErrorMessage(err, '추가에 실패했습니다.'));
} finally {
setSaving(false);
}
};
const handleEdit = (data: TaskFormData) => {
if (!editingTask) return;
patch.mutate({
id: editingTask.id,
data: taskFormToApiPayload(data),
});
setModalMode(null);
setEditingTask(null);
};
return (
<article className={`task-manager-row is-expanded ${variant === 'routine' ? 'task-manager-row--routine' : 'task-manager-row--hrm'}`}>
<form className="task-manager-panel" style={{ paddingTop: 16 }} onSubmit={handleSubmit}>
<TaskFormFields
variant={variant}
form={form}
onChange={setForm}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
idPrefix="mgr-new"
/>
<div className="task-manager-panel__actions">
<button type="button" className="task-form-btn task-form-btn--ghost" onClick={onCancel}>
</button>
<button
type="submit"
className={`task-form-btn task-form-btn--primary${variant === 'routine' ? ' is-routine' : ''}`}
disabled={saving}
>
{saving ? '추가 중…' : '추가'}
</button>
</div>
</form>
</article>
);
}
const handleDelete = (task: Task) => {
if (window.confirm(`"${task.title}" 업무를 삭제하시겠습니까?`)) {
remove.mutate(task.id);
}
export function TaskManager({ tasks, sectionOptions, quarter, teamMembers = [], onClose }: TaskManagerProps) {
const [mainTab, setMainTab] = useState<MainTab>('project');
const [filterKey, setFilterKey] = useState<string>('전체');
const [search, setSearch] = useState('');
const [expandedId, setExpandedId] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const projectTasks = useMemo(
() => tasks.filter((t) => isProjectTask(t.taskType) && matchesSearch(t, search)),
[tasks, search],
);
const routineTasks = useMemo(
() => tasks.filter((t) => isRoutineTask(t.taskType) && matchesSearch(t, search)),
[tasks, search],
);
const projectFilters = useMemo(
() => [
{ key: '전체', label: '전체' },
...BOARD_SLOTS.map((slot) => ({
key: slot.id,
label: slot.defaultTitle,
})),
],
[],
);
const routineFilters = useMemo(
() => [{ key: '전체', label: '전체' }, ...ROUTINE_CATEGORIES.map((cat) => ({ key: cat, label: cat }))],
[],
);
const filteredProjects = useMemo(() => {
if (filterKey === '전체') return projectTasks;
if (!BOARD_SLOT_ORDER.includes(filterKey as BoardSlotId)) return projectTasks;
const slot = getBoardSlot(filterKey as BoardSlotId);
return projectTasks.filter((t) => resolveTaskBoardSlot(t) === slot.id);
}, [projectTasks, filterKey]);
const filteredRoutines = useMemo(() => {
if (filterKey === '전체') return routineTasks;
return routineTasks.filter((t) => getRoutineCategory(t) === filterKey);
}, [routineTasks, filterKey]);
const visibleTasks = mainTab === 'project' ? filteredProjects : filteredRoutines;
const filters = mainTab === 'project' ? projectFilters : routineFilters;
const defaultSection =
filterKey !== '전체' && mainTab === 'project'
? slotSectionLabel(getBoardSlot(filterKey as BoardSlotId))
: sectionOptions[0]?.value ?? '인사관리';
const switchTab = (tab: MainTab) => {
setMainTab(tab);
setFilterKey('전체');
setExpandedId(null);
setAdding(false);
};
return createPortal(
<div className="fixed inset-0 z-[9000] flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="bg-white rounded-2xl shadow-2xl flex flex-col"
style={{ width: '90vw', maxWidth: 1100, height: '85vh' }}
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
<h2 className="text-xl font-black text-gray-800">📋 </h2>
<div className="flex items-center gap-3">
{/* 부문 필터 */}
<div className="flex gap-1">
{['전체', ...SECTIONS].map((s) => (
<button key={s} onClick={() => setFilterSection(s)}
className={`text-xs font-semibold px-3 py-1.5 rounded-lg transition-colors ${
filterSection === s ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}>{s}</button>
))}
</div>
<div className="w-px h-5 bg-gray-200" />
{/* 유형 필터 */}
<div className="flex gap-1">
{['전체', '실행과제', '기반업무'].map((t) => (
<button key={t} onClick={() => setFilterType(t)}
className={`text-xs font-semibold px-3 py-1.5 rounded-lg transition-colors ${
filterType === t ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}>{t}</button>
))}
</div>
<div className="w-px h-5 bg-gray-200" />
<button
onClick={() => { setEditingTask(null); setModalMode('add'); }}
className="flex items-center gap-1.5 px-4 py-1.5 rounded-lg bg-blue-600 text-white text-sm font-bold hover:bg-blue-700 transition-colors"
>
</button>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none ml-1"></button>
<div className="task-manager-overlay" onClick={onClose}>
<div className="task-manager-shell" onClick={(e) => e.stopPropagation()}>
<header className="task-manager-header">
<div>
<h2 className="task-manager-header__title"></h2>
<p className="task-manager-header__quarter">{quarter}</p>
</div>
<button type="button" className="task-manager-close" onClick={onClose} aria-label="닫기">
</button>
</header>
<div className="task-manager-toolbar">
<div className="task-manager-tabs">
<button
type="button"
className={`task-manager-tab${mainTab === 'project' ? ' is-active' : ''}`}
onClick={() => switchTab('project')}
>
</button>
<button
type="button"
className={`task-manager-tab is-routine${mainTab === 'routine' ? ' is-active' : ''}`}
onClick={() => switchTab('routine')}
>
</button>
</div>
<div className="task-manager-filters">
{filters.map((f) => (
<button
key={f.key}
type="button"
className={`task-manager-chip${filterKey === f.key ? ' is-active' : ''}`}
onClick={() => {
setFilterKey(f.key);
setExpandedId(null);
}}
>
{f.label}
</button>
))}
</div>
<div className="task-manager-toolbar__spacer" />
<input
type="search"
className="task-manager-search"
placeholder="제목·개요 검색"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<button
type="button"
className="task-manager-add"
onClick={() => {
setAdding(true);
setExpandedId(null);
}}
>
+ {mainTab === 'project' ? '프로젝트' : '상시업무'}
</button>
</div>
{/* 테이블 */}
<div className="flex-1 overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-gray-50 border-b border-gray-200 z-10">
<tr>
<th className="text-left px-4 py-3 font-bold text-gray-500 w-24"></th>
<th className="text-left px-4 py-3 font-bold text-gray-500 w-20"></th>
<th className="text-left px-4 py-3 font-bold text-gray-500"></th>
<th className="text-left px-4 py-3 font-bold text-gray-500 w-48"></th>
<th className="text-left px-4 py-3 font-bold text-gray-500 w-40"></th>
<th className="text-center px-4 py-3 font-bold text-gray-500 w-16"></th>
<th className="text-center px-4 py-3 font-bold text-gray-500 w-16"></th>
<th className="text-left px-4 py-3 font-bold text-gray-500 w-48"></th>
<th className="w-20" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filtered.length === 0 ? (
<tr><td colSpan={9} className="text-center py-16 text-gray-300 text-lg"> </td></tr>
) : filtered.map((task) => (
<tr key={task.id} className="hover:bg-blue-50/40 transition-colors group">
<td className="px-4 py-3 text-gray-600 font-medium whitespace-nowrap">{formatSectionDisplay(task.section)}</td>
<td className="px-4 py-3">
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${
isRoutineTask(task.taskType) ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700'
}`}>
{isRoutineTask(task.taskType) ? '기반업무' : '실행과제'}
</span>
</td>
<td className="px-4 py-3 font-semibold text-gray-800 max-w-[200px] truncate">{task.title}</td>
<td className="px-4 py-3 text-gray-500 max-w-[200px] truncate">{task.description ?? '-'}</td>
<td className="px-4 py-3 text-gray-500 whitespace-nowrap text-xs">
{task.startDate || task.dueDate
? `${task.startDate?.slice(0,10) ?? '?'} ~ ${task.dueDate?.slice(0,10) ?? '?'}`
: '-'}
</td>
<td className="px-4 py-3 text-center">
<span className={`font-black text-base ${
task.progress >= 70 ? 'text-emerald-500' : task.progress >= 40 ? 'text-blue-500' : 'text-orange-400'
}`}>{task.progress}%</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${STATUS_STYLE[task.status]}`}>
{STATUS_LABEL[task.status]}
</span>
</td>
<td className="px-4 py-3 text-red-500 max-w-[200px] truncate text-xs">{task.issueNote ?? ''}</td>
<td className="px-4 py-3">
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => { setEditingTask(task); setModalMode('edit'); }}
className="p-1.5 rounded-lg hover:bg-blue-100 text-blue-500 transition-colors"
title="수정"
></button>
<button
onClick={() => handleDelete(task)}
className="p-1.5 rounded-lg hover:bg-red-100 text-red-500 transition-colors"
title="삭제"
>🗑</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
<div className="task-manager-list">
{adding && (
<TaskManagerNewRow
variant={mainTab}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
quarter={quarter}
defaultSection={defaultSection}
onCreated={() => setAdding(false)}
onCancel={() => setAdding(false)}
/>
)}
{visibleTasks.length === 0 && !adding ? (
<p className="task-manager-empty"> .</p>
) : (
visibleTasks.map((task) => (
<TaskManagerRow
key={task.id}
task={task}
variant={mainTab}
expanded={expandedId === task.id}
onToggle={() => setExpandedId((id) => (id === task.id ? null : task.id))}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
quarter={quarter}
onSaved={() => setExpandedId(null)}
onDeleted={() => setExpandedId(null)}
/>
))
)}
</div>
{/* 하단 요약 */}
<div className="px-6 py-3 border-t border-gray-100 shrink-0 flex items-center gap-4 text-xs text-gray-400">
<span> <strong className="text-gray-700">{filtered.length}</strong></span>
<span> <strong className="text-blue-600">{filtered.filter(t => isProjectTask(t.taskType)).length}</strong></span>
<span> <strong className="text-amber-600">{filtered.filter(t => isRoutineTask(t.taskType)).length}</strong></span>
</div>
<footer className="task-manager-footer">
<span>
<strong>{projectTasks.length}</strong>
</span>
<span>
<strong>{routineTasks.length}</strong>
</span>
<span>
<strong>{visibleTasks.length}</strong>
</span>
</footer>
</div>
{modalMode === 'add' && (
<TaskModal
mode="add"
defaultSection={filterSection !== '전체' ? filterSection : '인사관리'}
defaultQuarter={quarter}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
onSave={handleAdd}
onClose={() => setModalMode(null)}
/>
)}
{modalMode === 'edit' && editingTask && (
<TaskModal
mode="edit"
task={editingTask}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
onSave={handleEdit}
onClose={() => { setModalMode(null); setEditingTask(null); }}
/>
)}
</div>,
document.body
document.body,
);
}

View File

@@ -0,0 +1,291 @@
import { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
import { StageFormFields } from '../common/StageFormFields';
import { buildStageFormState, stageFormToApiPayload } from '../../lib/stageFormState';
import type { StageFormData } from '../detail/stageFormTypes';
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
import { fmtMilestonePeriodSummary } from '../../lib/milestonePeriods';
import {
StageModal,
type StageFileSavePayload,
} from '../detail/StageModal';
import { sortFilesByOrder } from '../../lib/fileDisplay';
import type { FileRecord, Milestone, TaskDetail, TeamMember } from '../../types';
type TaskWithRelations = {
id: string;
milestones?: Milestone[];
files?: FileRecord[];
details?: TaskDetail[];
};
function MilestoneRow({
taskId,
milestone,
variant,
teamMembers,
expanded,
onToggle,
onOpenFiles,
}: {
taskId: string;
milestone: Milestone;
variant: 'project' | 'routine';
teamMembers: TeamMember[];
expanded: boolean;
onToggle: () => void;
onOpenFiles: () => void;
}) {
const queryClient = useQueryClient();
const [form, setForm] = useState<StageFormData>(() => buildStageFormState(milestone, variant));
const [saving, setSaving] = useState(false);
useEffect(() => {
if (expanded) {
setForm(buildStageFormState(milestone, variant));
}
}, [expanded, milestone, variant]);
const save = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
await apiClient.patch(`/milestones/item/${milestone.id}`, stageFormToApiPayload(form, variant));
invalidateTaskCaches(queryClient, taskId);
} catch (err: unknown) {
alert(getApiErrorMessage(err, '업무 일정 저장에 실패했습니다.'));
} finally {
setSaving(false);
}
};
const remove = async () => {
if (!window.confirm(`"${milestone.title}" 일정을 삭제하시겠습니까?`)) return;
try {
await apiClient.delete(`/milestones/item/${milestone.id}`);
invalidateTaskCaches(queryClient, taskId);
} catch (err: unknown) {
alert(getApiErrorMessage(err, '삭제에 실패했습니다.'));
}
};
const periodSummary = fmtMilestonePeriodSummary(milestone);
return (
<article className={`task-manager-subrow${expanded ? ' is-expanded' : ''}`}>
<button type="button" className="task-manager-subrow__head" onClick={onToggle}>
<span className="task-manager-row__chevron" aria-hidden="true">
</span>
<span className="task-manager-subrow__title">{milestone.title}</span>
<span className="task-manager-subrow__meta">
{periodSummary && <span className="task-manager-badge task-manager-badge--status">{periodSummary}</span>}
<span className="task-manager-badge task-manager-badge--progress">{milestone.progress}%</span>
</span>
</button>
{expanded && (
<form className="task-manager-subpanel" onSubmit={save}>
<StageFormFields
variant={variant}
form={form}
onChange={setForm}
teamMembers={teamMembers}
idPrefix={`ms-${milestone.id}`}
/>
<div className="task-manager-panel__actions">
<button type="button" className="task-form-btn task-form-btn--ghost" onClick={onOpenFiles}>
</button>
<button type="button" className="task-form-btn task-form-btn--danger" onClick={remove}>
</button>
<button type="submit" className="task-form-btn task-form-btn--primary" disabled={saving}>
{saving ? '저장 중…' : '일정 저장'}
</button>
</div>
</form>
)}
</article>
);
}
function NewMilestoneForm({
taskId,
variant,
teamMembers,
onCreated,
onCancel,
}: {
taskId: string;
variant: 'project' | 'routine';
teamMembers: TeamMember[];
onCreated: () => void;
onCancel: () => void;
}) {
const queryClient = useQueryClient();
const [form, setForm] = useState<StageFormData>(() => buildStageFormState(undefined, variant));
const [saving, setSaving] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.title.trim()) return;
setSaving(true);
try {
await apiClient.post(`/milestones/${taskId}`, stageFormToApiPayload(form, variant));
invalidateTaskCaches(queryClient, taskId);
onCreated();
} catch (err: unknown) {
alert(getApiErrorMessage(err, '업무 일정 추가에 실패했습니다.'));
} finally {
setSaving(false);
}
};
return (
<form className="task-manager-subpanel task-manager-subpanel--new" onSubmit={submit}>
<StageFormFields
variant={variant}
form={form}
onChange={setForm}
teamMembers={teamMembers}
idPrefix="ms-new"
/>
<div className="task-manager-panel__actions">
<button type="button" className="task-form-btn task-form-btn--ghost" onClick={onCancel}>
</button>
<button type="submit" className="task-form-btn task-form-btn--primary" disabled={saving}>
{saving ? '추가 중…' : '일정 추가'}
</button>
</div>
</form>
);
}
export function TaskMilestoneSection({
task,
variant,
teamMembers,
}: {
task: TaskWithRelations;
variant: 'project' | 'routine';
teamMembers: TeamMember[];
}) {
const queryClient = useQueryClient();
const milestones = task.milestones ?? [];
const files = task.files ?? [];
const [expandedId, setExpandedId] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [fileModalMs, setFileModalMs] = useState<Milestone | null>(null);
const [fileSaving, setFileSaving] = useState(false);
const uploadFiles = async (milestoneId: string, filePayload: StageFileSavePayload['uploads']) => {
for (const item of filePayload) {
const form = new FormData();
form.append('file', item.file);
form.append('milestoneId', milestoneId);
form.append('sortOrder', String(item.sortOrder));
if (item.displayName.trim()) {
form.append('displayName', item.displayName.trim());
}
await apiClient.post(`/files/upload/${task.id}`, form);
}
};
const handleFileSave = async (data: StageFormData, filePayload: StageFileSavePayload) => {
if (!fileModalMs) return;
setFileSaving(true);
try {
await apiClient.patch(`/milestones/item/${fileModalMs.id}`, stageFormToApiPayload(data, variant));
for (const id of filePayload.deletedFileIds) {
await apiClient.delete(`/files/${id}`);
}
for (const rep of filePayload.replacements) {
const form = new FormData();
form.append('file', rep.file);
await apiClient.post(`/files/${rep.id}/replace`, form);
}
for (const edit of filePayload.existingEdits) {
const original = files.find((f) => f.id === edit.id);
if (!original) continue;
const prevName = (original.displayName ?? '').trim();
const nextName = edit.displayName.trim();
const prevOrder = original.sortOrder ?? 0;
if (nextName !== prevName || edit.sortOrder !== prevOrder) {
await apiClient.patch(`/files/${edit.id}`, {
displayName: nextName || null,
sortOrder: edit.sortOrder,
});
}
}
if (filePayload.uploads.length > 0) {
await uploadFiles(fileModalMs.id, filePayload.uploads);
}
invalidateTaskCaches(queryClient, task.id);
setFileModalMs(null);
} catch (err: unknown) {
alert(getApiErrorMessage(err, '첨부 파일 저장에 실패했습니다.'));
} finally {
setFileSaving(false);
}
};
return (
<section className="task-manager-section">
<div className="task-manager-section__head">
<h4 className="task-manager-section__title"> </h4>
<button
type="button"
className="task-form-link-btn"
onClick={() => {
setAdding(true);
setExpandedId(null);
}}
>
+
</button>
</div>
{adding && (
<NewMilestoneForm
taskId={task.id}
variant={variant}
teamMembers={teamMembers}
onCreated={() => setAdding(false)}
onCancel={() => setAdding(false)}
/>
)}
{milestones.length === 0 && !adding ? (
<p className="task-form-empty"> .</p>
) : (
milestones.map((ms) => (
<MilestoneRow
key={ms.id}
taskId={task.id}
milestone={ms}
variant={variant}
teamMembers={teamMembers}
expanded={expandedId === ms.id}
onToggle={() => setExpandedId((id) => (id === ms.id ? null : ms.id))}
onOpenFiles={() => setFileModalMs(ms)}
/>
))
)}
{fileModalMs && (
<StageModal
mode="edit"
variant={variant}
milestone={fileModalMs}
existingFiles={sortFilesByOrder(files.filter((f) => f.milestoneId === fileModalMs.id))}
teamMembers={teamMembers}
saving={fileSaving}
onClose={() => setFileModalMs(null)}
onSave={handleFileSave}
/>
)}
</section>
);
}

View File

@@ -1,110 +1,152 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
buildMilestoneTimeline,
computeFullTimelineRange,
milestoneHasTimelineDates,
panPortfolioViewport,
resolveTimelineInitialViewport,
zoomPortfolioViewport,
type PortfolioTaskGroup,
type TimelineMilestoneInput,
type TimelineRangeFallback,
} from '../../lib/milestoneTimeline';
/** focus: 선택 업무 3개월 | project: 프로젝트/해당 업무 전체 | portfolio: 분기 전체 */
export type TimelineViewMode = 'focus' | 'project' | 'portfolio';
interface MilestoneTimelineProps {
milestones: TimelineMilestoneInput[];
portfolioTasks?: PortfolioTaskGroup[];
fallback?: TimelineRangeFallback;
selectedId?: string | null;
onSelect?: (id: string) => void;
ownerTaskId?: string;
title?: string;
subtitle?: string;
emptyMessage?: string;
className?: string;
preserveRowOrder?: boolean;
/** 상시업무 — 시작·종료일 없는 단계는 게이지 미표시 */
hideUndatedBars?: boolean;
variant?: 'project' | 'routine';
viewMode?: TimelineViewMode;
onViewModeChange?: (mode: TimelineViewMode) => void;
focusTitle?: string;
projectTitle?: string;
portfolioTitle?: string;
rowLabelHeader?: string;
}
const VARIANT_DEFAULTS = {
project: {
focusTitle: '업무별 타임라인',
projectTitle: '프로젝트 전체 일정',
portfolioTitle: '분기 전체 프로젝트',
rowLabelHeader: '업무 일정',
focusTab: '선택 업무',
projectTab: '프로젝트',
portfolioTab: '분기 전체',
},
routine: {
focusTitle: '업무명별 타임라인',
projectTitle: '상시 전체 일정',
portfolioTitle: '분기 전체 상시업무',
rowLabelHeader: '업무명',
focusTab: '선택 업무',
projectTab: '분류',
portfolioTab: '분기 전체',
},
} as const;
type PortfolioRow =
| { kind: 'group'; taskId: string; title: string }
| {
kind: 'milestone';
taskId: string;
milestoneId: string;
title: string;
progress: number;
segments: Array<{ id: string; leftPct: number; widthPct: number; progress: number }>;
isCurrentTask: boolean;
};
export function MilestoneTimeline({
milestones,
portfolioTasks,
fallback = {},
selectedId,
onSelect,
title = '업무별 타임라인',
ownerTaskId,
title,
subtitle,
emptyMessage = '기간을 설정한 단계만 표시됩니다.',
preserveRowOrder = false,
hideUndatedBars = true,
className = '',
variant = 'project',
viewMode = 'focus',
onViewModeChange,
focusTitle,
projectTitle,
portfolioTitle,
rowLabelHeader,
}: MilestoneTimelineProps) {
const model = useMemo(
() => buildMilestoneTimeline(milestones, fallback, { preserveOrder: preserveRowOrder, hideUndatedBars }),
[milestones, fallback, preserveRowOrder, hideUndatedBars],
const defaults = VARIANT_DEFAULTS[variant];
const isPortfolio = viewMode === 'portfolio';
const isGantt = viewMode !== 'focus';
const displayTitle =
title ??
(viewMode === 'portfolio'
? (portfolioTitle ?? defaults.portfolioTitle)
: viewMode === 'project'
? (projectTitle ?? defaults.projectTitle)
: (focusTitle ?? defaults.focusTitle));
const labelHeader = rowLabelHeader ?? defaults.rowLabelHeader;
const portfolioFlatMilestones = useMemo(() => {
if (!portfolioTasks?.length) return [];
return portfolioTasks.flatMap((t) => t.milestones);
}, [portfolioTasks]);
const chartMilestones = isPortfolio ? portfolioFlatMilestones : milestones;
const dataRange = useMemo(
() => computeFullTimelineRange(chartMilestones, fallback),
[chartMilestones, fallback],
);
const chartRef = useRef<HTMLDivElement>(null);
const rowRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const measureRef = useRef<HTMLSpanElement>(null);
const [expandedBar, setExpandedBar] = useState<{
id: string;
widthPx: number;
leftPx: number;
} | null>(null);
const measureTitleWidth = (title: string) => {
const el = measureRef.current;
if (!el) return 0;
el.textContent = title;
return el.offsetWidth + 16;
};
const handleBarEnter = (
row: { id: string; title: string; leftPct: number; widthPct: number },
event: React.MouseEvent<HTMLButtonElement>,
) => {
const chart = chartRef.current;
if (!chart) return;
const btn = event.currentTarget;
const chartWidth = chart.clientWidth;
const leftPx = (row.leftPct / 100) * chartWidth;
const origWidthPx = btn.offsetWidth;
const neededWidthPx = measureTitleWidth(row.title);
if (neededWidthPx <= origWidthPx + 1) return;
const widthPx = Math.min(chartWidth, neededWidthPx);
const extra = widthPx - origWidthPx;
let expandedLeftPx = leftPx - extra / 2;
if (expandedLeftPx < 0) expandedLeftPx = 0;
if (expandedLeftPx + widthPx > chartWidth) expandedLeftPx = chartWidth - widthPx;
setExpandedBar({
id: row.id,
widthPx,
leftPx: expandedLeftPx,
});
};
const [chartViewport, setChartViewport] = useState<{ start: Date; end: Date } | null>(null);
useEffect(() => {
if (!selectedId) return;
const row = rowRefs.current.get(selectedId);
const chart = chartRef.current;
if (!row || !chart) return;
if (!dataRange) {
setChartViewport(null);
return;
}
setChartViewport(resolveTimelineInitialViewport(viewMode, chartMilestones, fallback, selectedId));
}, [
viewMode,
dataRange?.start.getTime(),
dataRange?.end.getTime(),
viewMode === 'focus' ? selectedId : undefined,
]);
const rowRect = row.getBoundingClientRect();
const chartRect = chart.getBoundingClientRect();
const model = useMemo(() => {
if (!chartViewport) return null;
return buildMilestoneTimeline(chartMilestones, fallback, {
hideUndatedBars,
focusQuarter: false,
viewport: chartViewport,
ganttBars: isGantt,
});
}, [chartMilestones, fallback, hideUndatedBars, isGantt, chartViewport]);
if (rowRect.top >= chartRect.top && rowRect.bottom <= chartRect.bottom) return;
const delta =
rowRect.top < chartRect.top
? rowRect.top - chartRect.top
: rowRect.bottom - chartRect.bottom;
chart.scrollBy({ top: delta, behavior: 'smooth' });
}, [selectedId, model?.rows.length]);
const rangeSubtitle =
subtitle ?? (model ? `${model.rangeStartLabel} ~ ${model.rangeEndLabel}` : undefined);
const chartRef = useRef<HTMLDivElement>(null);
const labelsScrollRef = useRef<HTMLDivElement>(null);
const rowRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const dragRef = useRef<{ startX: number; viewport: { start: Date; end: Date } } | null>(null);
const didPanRef = useRef(false);
const [isPanning, setIsPanning] = useState(false);
const rowGroups = useMemo(() => {
if (!model) return [];
const titleById = new Map(chartMilestones.map((m) => [m.id, m.title]));
const groups: Array<{
milestoneId: string;
title: string;
@@ -118,7 +160,7 @@ export function MilestoneTimeline({
indexByMilestone.set(row.milestoneId, groups.length);
groups.push({
milestoneId: row.milestoneId,
title: row.title,
title: titleById.get(row.milestoneId) ?? row.title,
progress: row.progress,
segments: [row],
});
@@ -127,109 +169,394 @@ export function MilestoneTimeline({
}
}
return groups;
}, [model]);
}, [model, chartMilestones]);
const rowGroupByMilestone = useMemo(() => {
const map = new Map<string, (typeof rowGroups)[number]>();
for (const g of rowGroups) map.set(g.milestoneId, g);
return map;
}, [rowGroups]);
const portfolioRows = useMemo((): PortfolioRow[] => {
if (!isPortfolio || !portfolioTasks) return [];
const rows: PortfolioRow[] = [];
for (const task of portfolioTasks) {
const dated = task.milestones.filter((m) =>
hideUndatedBars ? milestoneHasTimelineDates(m) : true,
);
if (dated.length === 0) continue;
rows.push({ kind: 'group', taskId: task.id, title: task.title });
for (const m of dated) {
const group = rowGroupByMilestone.get(m.id);
rows.push({
kind: 'milestone',
taskId: task.id,
milestoneId: m.id,
title: m.title,
progress: group?.progress ?? m.progress ?? 0,
segments: group?.segments ?? [],
isCurrentTask: task.id === ownerTaskId,
});
}
}
return rows;
}, [isPortfolio, portfolioTasks, rowGroupByMilestone, hideUndatedBars, ownerTaskId]);
const projectRowGroups = useMemo(() => {
if (isPortfolio) return [];
return rowGroups;
}, [isPortfolio, rowGroups]);
const syncScroll = (source: 'labels' | 'chart') => {
const labels = labelsScrollRef.current;
const chart = chartRef.current;
if (!labels || !chart) return;
if (source === 'labels') chart.scrollTop = labels.scrollTop;
else labels.scrollTop = chart.scrollTop;
};
const handleChartPanStart = (e: React.MouseEvent) => {
if (!chartViewport || !dataRange || e.button !== 0) return;
e.preventDefault();
didPanRef.current = false;
dragRef.current = { startX: e.clientX, viewport: chartViewport };
setIsPanning(true);
};
const handleChartPanMove = useCallback(
(e: MouseEvent) => {
const drag = dragRef.current;
if (!drag || !dataRange || !chartRef.current) return;
if (Math.abs(e.clientX - drag.startX) > 4) didPanRef.current = true;
const chartWidth = chartRef.current.clientWidth;
if (chartWidth <= 0) return;
const windowMs = drag.viewport.end.getTime() - drag.viewport.start.getTime();
const deltaMs = -((e.clientX - drag.startX) / chartWidth) * windowMs;
setChartViewport(panPortfolioViewport(drag.viewport, dataRange, deltaMs));
},
[dataRange],
);
const handleChartPanEnd = useCallback(() => {
dragRef.current = null;
setIsPanning(false);
}, []);
useEffect(() => {
if (!isPanning) return;
window.addEventListener('mousemove', handleChartPanMove);
window.addEventListener('mouseup', handleChartPanEnd);
return () => {
window.removeEventListener('mousemove', handleChartPanMove);
window.removeEventListener('mouseup', handleChartPanEnd);
};
}, [isPanning, handleChartPanMove, handleChartPanEnd]);
useEffect(() => {
if (!chartViewport || !dataRange) return;
const el = chartRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = el.getBoundingClientRect();
const anchorPct = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0.5;
const factor = e.deltaY > 0 ? 1.12 : 0.88;
setChartViewport(zoomPortfolioViewport(chartViewport, dataRange, factor, anchorPct));
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [chartViewport, dataRange]);
useEffect(() => {
if (!selectedId || !isGantt || isPortfolio) return;
const row = rowRefs.current.get(selectedId);
const chart = chartRef.current;
if (!row || !chart) return;
const rowRect = row.getBoundingClientRect();
const chartRect = chart.getBoundingClientRect();
if (rowRect.top >= chartRect.top && rowRect.bottom <= chartRect.bottom) return;
const delta =
rowRect.top < chartRect.top
? rowRect.top - chartRect.top
: rowRect.bottom - chartRect.bottom;
chart.scrollBy({ top: delta, behavior: 'smooth' });
}, [selectedId, model?.rows.length, viewMode, isGantt, isPortfolio]);
useEffect(() => {
setIsPanning(false);
dragRef.current = null;
didPanRef.current = false;
}, [viewMode]);
const handleRowSelect = (milestoneId: string, taskId: string) => {
if (didPanRef.current) return;
if (isPortfolio && ownerTaskId && taskId !== ownerTaskId) return;
onSelect?.(milestoneId);
};
const renderBar = (
group: {
milestoneId: string;
title: string;
progress: number;
segments: Array<{ id: string; leftPct: number; widthPct: number; progress: number }>;
},
isSelected: boolean,
taskId: string,
showGanttBar: boolean,
) =>
group.segments.map((row) => (
<button
key={row.id}
type="button"
className={`milestone-timeline__bar ${isSelected ? 'is-selected' : ''} ${showGanttBar ? 'is-gantt' : ''}`}
style={{ left: `${row.leftPct}%`, width: `${row.widthPct}%` }}
aria-label={group.title}
title={group.title}
onClick={() => handleRowSelect(group.milestoneId, taskId)}
>
<span className="milestone-timeline__bar-track" />
<span className="milestone-timeline__bar-fill" style={{ width: `${row.progress}%` }} />
{showGanttBar && group.progress > 0 && (
<span className="milestone-timeline__bar-progress">{group.progress}%</span>
)}
</button>
));
const renderTicks = () => (
<div className="milestone-timeline__ticks" aria-hidden="true">
{model!.ticks.map((tick) => (
<span
key={`${tick.label}-${tick.leftPct}`}
className={`milestone-timeline__tick${tick.isToday ? ' is-today' : ''}`}
style={{ left: `${tick.leftPct}%` }}
title={tick.isToday ? `오늘 (${tick.label})` : undefined}
>
<span className="milestone-timeline__tick-label">{tick.label}</span>
</span>
))}
</div>
);
const renderGrid = (includeToday = true) => (
<div className="milestone-timeline__grid" aria-hidden="true">
{model!.ticks.map((tick) => (
<span
key={`grid-${tick.label}-${tick.leftPct}`}
className="milestone-timeline__grid-line"
style={{ left: `${tick.leftPct}%` }}
/>
))}
{includeToday && model!.todayLeftPct != null && (
<span
className="milestone-timeline__today-line"
style={{ left: `${model!.todayLeftPct}%` }}
/>
)}
</div>
);
const rowCount = isPortfolio ? portfolioRows.length : projectRowGroups.length;
const chartPannableClass = `milestone-timeline__chart milestone-timeline__chart--pannable${isPanning ? ' is-panning' : ''}`;
const renderChartHead = () => (
<div className="milestone-timeline__chart-head">
{renderTicks()}
<span className="milestone-timeline__viewport-range">
{model!.rangeStartLabel}~{model!.rangeEndLabel}
</span>
</div>
);
const renderPanHint = () => (
<p className="milestone-timeline__pan-hint">
드래그: 기간 · : 확대/ · {model!.rangeStartLabel}~{model!.rangeEndLabel}
</p>
);
return (
<footer className={`milestone-timeline ${className}`.trim()}>
<footer
className={`milestone-timeline ${isGantt ? 'milestone-timeline--gantt' : ''} ${isPortfolio ? 'milestone-timeline--portfolio' : ''} ${className}`.trim()}
style={rowCount > 0 ? ({ '--mt-row-count': String(rowCount) } as React.CSSProperties) : undefined}
>
<div className="milestone-timeline__head">
<span className="milestone-timeline__title">{title}</span>
{rangeSubtitle && (
<span className="milestone-timeline__subtitle truncate">{rangeSubtitle}</span>
<span className="milestone-timeline__title">{displayTitle}</span>
{subtitle && <span className="milestone-timeline__subtitle truncate">{subtitle}</span>}
{onViewModeChange && (
<div className="milestone-timeline__view-toggle" role="tablist" aria-label="타임라인 보기">
<button
type="button"
role="tab"
aria-selected={viewMode === 'focus'}
className={viewMode === 'focus' ? 'is-active' : ''}
onClick={() => onViewModeChange('focus')}
>
{defaults.focusTab}
</button>
<button
type="button"
role="tab"
aria-selected={viewMode === 'project'}
className={viewMode === 'project' ? 'is-active' : ''}
onClick={() => onViewModeChange('project')}
>
{defaults.projectTab}
</button>
<button
type="button"
role="tab"
aria-selected={viewMode === 'portfolio'}
className={viewMode === 'portfolio' ? 'is-active' : ''}
onClick={() => onViewModeChange('portfolio')}
>
{defaults.portfolioTab}
</button>
</div>
)}
</div>
{!model ? (
<p className="milestone-timeline__empty">{emptyMessage}</p>
) : (
<div className="milestone-timeline__body">
<div className="milestone-timeline__ticks" aria-hidden="true">
{model.ticks.map((tick) => (
<span
key={`${tick.label}-${tick.leftPct}`}
className={`milestone-timeline__tick${tick.isToday ? ' is-today' : ''}`}
style={{ left: `${tick.leftPct}%` }}
title={tick.isToday ? `오늘 (${tick.label})` : undefined}
>
<span className="milestone-timeline__tick-label">{tick.label}</span>
</span>
))}
) : isPortfolio ? (
<div className="milestone-timeline__portfolio-layout">
<div className="milestone-timeline__portfolio-head">
<div className="milestone-timeline__row-labels-head">{labelHeader}</div>
<div className="milestone-timeline__portfolio-chart-head">
<div className="milestone-timeline__body milestone-timeline__body--ticks-only">
{renderTicks()}
</div>
{model && (
<span className="milestone-timeline__viewport-range">
{model.rangeStartLabel}~{model.rangeEndLabel}
</span>
)}
</div>
</div>
<div className="milestone-timeline__chart" ref={chartRef}>
<span ref={measureRef} className="milestone-timeline__measure" aria-hidden="true" />
<div className="milestone-timeline__grid" aria-hidden="true">
{model.ticks.map((tick) => (
<span
key={`grid-${tick.label}-${tick.leftPct}`}
className="milestone-timeline__grid-line"
style={{ left: `${tick.leftPct}%` }}
/>
<div className="milestone-timeline__portfolio-scroll">
<div
className="milestone-timeline__row-labels-body"
ref={labelsScrollRef}
onScroll={() => syncScroll('labels')}
>
{portfolioRows.map((row) =>
row.kind === 'group' ? (
<div
key={`group-${row.taskId}`}
className="milestone-timeline__group-label"
title={row.title}
>
{row.title}
</div>
) : (
<button
key={row.milestoneId}
type="button"
className={`milestone-timeline__row-label milestone-timeline__row-label--child${row.milestoneId === selectedId && row.isCurrentTask ? ' is-selected' : ''}${row.isCurrentTask ? ' is-current-task' : ' is-other-task'}`}
onClick={() => handleRowSelect(row.milestoneId, row.taskId)}
title={row.title}
>
{row.title}
</button>
),
)}
</div>
<div
className={chartPannableClass}
ref={chartRef}
onMouseDown={handleChartPanStart}
onScroll={() => syncScroll('chart')}
>
{renderGrid()}
<div className="milestone-timeline__rows">
{portfolioRows.map((row) =>
row.kind === 'group' ? (
<div key={`g-${row.taskId}`} className="milestone-timeline__row milestone-timeline__row--group" />
) : (
<div
key={row.milestoneId}
className={`milestone-timeline__row${row.isCurrentTask ? ' is-current-task' : ' is-other-task'}`}
>
{renderBar(
row,
row.milestoneId === selectedId && row.isCurrentTask,
row.taskId,
true,
)}
</div>
),
)}
</div>
</div>
</div>
{renderPanHint()}
</div>
) : isGantt ? (
<>
<div className="milestone-timeline__gantt-layout">
<div className="milestone-timeline__row-labels">
<div className="milestone-timeline__row-labels-head">{labelHeader}</div>
<div className="milestone-timeline__row-labels-body">
{projectRowGroups.map((group) => (
<button
key={group.milestoneId}
type="button"
className={`milestone-timeline__row-label${group.milestoneId === selectedId ? ' is-selected' : ''}`}
onClick={() => handleRowSelect(group.milestoneId, ownerTaskId ?? '')}
title={group.title}
>
{group.title}
</button>
))}
</div>
</div>
<div className="milestone-timeline__body">
{renderChartHead()}
<div
className={chartPannableClass}
ref={chartRef}
onMouseDown={handleChartPanStart}
>
{renderGrid()}
<div className="milestone-timeline__rows">
{projectRowGroups.map((group) => (
<div
key={group.milestoneId}
className="milestone-timeline__row"
ref={(el) => {
if (el) rowRefs.current.set(group.milestoneId, el);
else rowRefs.current.delete(group.milestoneId);
}}
>
{renderBar(group, group.milestoneId === selectedId, ownerTaskId ?? '', true)}
</div>
))}
</div>
</div>
</div>
</div>
{renderPanHint()}
</>
) : (
<div className="milestone-timeline__focus-layout">
{renderChartHead()}
<div
className={chartPannableClass}
ref={chartRef}
onMouseDown={handleChartPanStart}
>
{renderGrid()}
<div className="milestone-timeline__rows">
{projectRowGroups.map((group) => (
<div key={group.milestoneId} className="milestone-timeline__row">
{renderBar(group, group.milestoneId === selectedId, ownerTaskId ?? '', false)}
</div>
))}
</div>
<div className="milestone-timeline__rows">
{rowGroups.map((group) => {
const isSelected = group.milestoneId === selectedId;
return (
<div
key={group.milestoneId}
className="milestone-timeline__row"
ref={(el) => {
if (el) rowRefs.current.set(group.milestoneId, el);
else rowRefs.current.delete(group.milestoneId);
}}
>
{group.segments.map((row) => {
const isExpanded = expandedBar?.id === row.id;
return (
<button
key={row.id}
type="button"
className={`milestone-timeline__bar ${isSelected ? 'is-selected' : ''} ${isExpanded ? 'is-expanded' : ''}`}
style={
isExpanded
? {
left: `${expandedBar.leftPx}px`,
width: `${expandedBar.widthPx}px`,
}
: {
left: `${row.leftPct}%`,
width: `${row.widthPct}%`,
}
}
aria-label={group.title}
title={group.title}
onMouseEnter={(e) => handleBarEnter({ ...row, title: group.title }, e)}
onMouseLeave={() => setExpandedBar(null)}
onClick={() => onSelect?.(group.milestoneId)}
>
<span className="milestone-timeline__bar-track" />
<span
className="milestone-timeline__bar-fill"
style={{ width: `${row.progress}%` }}
/>
<span className="milestone-timeline__bar-label-wrap" aria-hidden="true">
<span
className="milestone-timeline__bar-label milestone-timeline__bar-label--fill"
style={{ clipPath: `inset(0 ${100 - row.progress}% 0 0)` }}
>
{group.title}
</span>
<span
className="milestone-timeline__bar-label milestone-timeline__bar-label--track"
style={{ clipPath: `inset(0 0 0 ${row.progress}%)` }}
>
{group.title}
</span>
</span>
</button>
);
})}
</div>
);
})}
</div>
</div>
{renderPanHint()}
</div>
)}
</footer>

View File

@@ -0,0 +1,80 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
export interface OverviewFormData {
description: string;
detailDescription: string;
}
interface OverviewEditModalProps {
initial: OverviewFormData;
onSave: (data: OverviewFormData) => Promise<void>;
onClose: () => void;
saving?: boolean;
}
export function OverviewEditModal({
initial,
onSave,
onClose,
saving,
}: OverviewEditModalProps) {
const [form, setForm] = useState<OverviewFormData>(initial);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSave(form);
};
return createPortal(
<div className="task-modal-overlay" onClick={onClose}>
<div className="task-modal-shell" style={{ width: 'min(480px, 94vw)' }} onClick={(e) => e.stopPropagation()}>
<div className="task-modal-head">
<h2 className="task-modal-title"> </h2>
<button type="button" onClick={onClose} className="task-modal-close" aria-label="닫기">
</button>
</div>
<form onSubmit={handleSubmit} className="task-modal-body">
<div className="task-form-fields">
<div className="task-form-field">
<label className="task-form-label" htmlFor="overview-desc">
</label>
<textarea
id="overview-desc"
value={form.description}
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
rows={3}
className="task-form-input task-form-textarea"
placeholder="프로젝트 개요"
/>
</div>
<div className="task-form-field">
<label className="task-form-label" htmlFor="overview-detail">
</label>
<textarea
id="overview-detail"
value={form.detailDescription}
onChange={(e) => setForm((p) => ({ ...p, detailDescription: e.target.value }))}
rows={5}
className="task-form-input task-form-textarea task-form-textarea--tall"
placeholder="상세 내용"
/>
</div>
</div>
<div className="task-form-actions">
<button type="button" onClick={onClose} className="task-form-btn task-form-btn--ghost">
</button>
<button type="submit" className="task-form-btn task-form-btn--primary" disabled={saving}>
{saving ? '저장 중…' : '저장'}
</button>
</div>
</form>
</div>
</div>,
document.body,
);
}

View File

@@ -41,11 +41,14 @@ import { useTeamMembers } from '../../hooks/useTeamMembers';
import { ResultPreview } from './ResultPreview';
import { MilestoneTimeline } from './MilestoneTimeline';
import { MilestoneTimeline, type TimelineViewMode } from './MilestoneTimeline';
import { MilestoneContentList } from './MilestoneContentList';
import { taskTimelineFallback } from '../../lib/milestoneTimeline';
import { taskTimelineFallback, sortMilestonesForTimeline } from '../../lib/milestoneTimeline';
import { serializePeriodEntries } from '../../lib/milestonePeriods';
import { formatQuarterShort } from '../../lib/boardCalendar';
import { getMilestonePeopleHeaderParts, getTaskPeopleHeaderParts } from '../../lib/teamStatus';
import type { Task, Milestone, FileRecord } from '../../types';
@@ -100,16 +103,28 @@ export function RoutineDetailView({
const [tabSwitching, setTabSwitching] = useState(false);
const [timelineView, setTimelineView] = useState<TimelineViewMode>('focus');
useEffect(() => {
setActiveTaskId(initialTask.id);
setTimelineView('focus');
}, [initialTask.id]);
useEffect(() => {
setTimelineView('focus');
}, [activeTaskId]);
const { data: activeTask = initialTask } = useQuery({
queryKey: ['task', activeTaskId],
queryFn: async () => {
@@ -233,6 +248,21 @@ export function RoutineDetailView({
const selectedStage = milestones.find((m) => m.id === selectedStageId) ?? null;
const headerPeople = useMemo(() => {
const fromStage = getMilestonePeopleHeaderParts(selectedStage);
if (fromStage.pmName || fromStage.assigneeNames.length > 0) return fromStage;
return getTaskPeopleHeaderParts(activeTask);
}, [selectedStage, activeTask]);
const headerProgressLabel = useMemo(() => {
if (selectedStage) return `${selectedStage.progress}%`;
if (milestones.length === 0) return '—';
const avg = Math.round(
milestones.reduce((sum, m) => sum + (m.progress ?? 0), 0) / milestones.length,
);
return `${avg}%`;
}, [selectedStage, milestones]);
const stageFiles = useMemo(
() =>
@@ -257,6 +287,24 @@ export function RoutineDetailView({
);
const quarterRoutineTasks = useMemo(
() => quarterTasks.filter((t) => isRoutineTask(t.taskType)),
[quarterTasks],
);
const portfolioTasks = useMemo(
() =>
quarterRoutineTasks.map((t) => ({
id: t.id,
title: t.title,
milestones: sortMilestonesForTimeline((t.milestones ?? []) as Milestone[]),
})),
[quarterRoutineTasks],
);
const showGanttPanel = timelineView !== 'focus';
const showPortfolioFull = timelineView === 'portfolio';
const deleteStage = useMutation({
@@ -475,11 +523,45 @@ export function RoutineDetailView({
</div>
<div className="detail-page-header__meta">
<span>
<span className="detail-page-header__meta-label"> </span>
<span className="detail-page-header__meta-value">{formatQuarterShort(activeTask.quarter)}</span>
</span>
<span className="detail-page-header__divider" aria-hidden="true" />
<span>
<span className="detail-page-header__meta-label"> </span>
<span className="detail-page-header__meta-value">{milestones.length}</span>
</span>
{(headerPeople.pmName || headerPeople.assigneeNames.length > 0) && (
<>
<span className="detail-page-header__divider" aria-hidden="true" />
{headerPeople.pmName && (
<span>
<span className="detail-page-header__meta-label">PM </span>
<span className="detail-page-header__meta-value">{headerPeople.pmName}</span>
</span>
)}
{headerPeople.assigneeNames.length > 0 && (
<span>
<span className="detail-page-header__meta-label"> </span>
<span className="detail-page-header__meta-value">
{headerPeople.assigneeNames.join(' · ')}
</span>
</span>
)}
</>
)}
<span className="detail-page-header__badge">{headerProgressLabel}</span>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-[1fr_3fr] grid-rows-1">
<div className={`grid min-h-0 flex-1 grid-rows-1 ${showPortfolioFull ? 'detail-page-grid--portfolio' : 'grid-cols-[1fr_3fr]'}`}>
{!showPortfolioFull && (
<aside className="detail-aside grid h-full min-h-0 grid-rows-[2fr_1fr] overflow-hidden">
@@ -588,9 +670,13 @@ export function RoutineDetailView({
</aside>
)}
<div className="flex h-full min-h-0 min-w-0 flex-col">
<div className={`detail-main-panel flex h-full min-h-0 min-w-0 flex-col${showGanttPanel ? ' detail-panel--gantt' : ''}${showPortfolioFull ? ' detail-panel--portfolio' : ''}`}>
{timelineView === 'focus' && (
<ResultPreview
@@ -602,19 +688,29 @@ export function RoutineDetailView({
/>
)}
<MilestoneTimeline
variant="routine"
milestones={milestones}
portfolioTasks={portfolioTasks}
fallback={taskTimelineFallback(activeTask)}
selectedId={selectedStageId}
onSelect={setSelectedStageId}
preserveRowOrder
ownerTaskId={activeTaskId}
viewMode={timelineView}
onViewModeChange={setTimelineView}
emptyMessage="기간을 설정한 업무명만 타임라인에 표시됩니다."

View File

@@ -0,0 +1,12 @@
import type { MilestonePeriodEntry } from '../types';
import type { MilestoneLink } from '../types';
export interface StageFormData {
title: string;
subtitle: string;
periodEntries: MilestonePeriodEntry[];
progress: number;
links: MilestoneLink[];
pmMemberId: string;
assigneeMemberIds: string[];
}

View File

@@ -1,42 +1,80 @@
import { useCallback, useMemo, useState } from 'react';
import {
BOARD_REF_DATE_KEY,
BOARD_QUARTER_KEY,
dateToQuarter,
parseIsoDate,
parseQuarterKey,
startOfDay,
toIsoDate,
startOfWeekMonday,
} from '../lib/boardCalendar';
export function useBoardReferenceDate() {
const [referenceDate, setReferenceDateState] = useState<Date>(() => {
try {
const stored = localStorage.getItem(BOARD_REF_DATE_KEY);
if (stored) {
const parsed = parseIsoDate(stored);
if (parsed) return startOfDay(parsed);
}
} catch {
/* ignore */
}
return startOfDay(new Date());
});
function readInitialQuarter(): string {
const todayQuarter = dateToQuarter(new Date());
try {
const stored = localStorage.getItem(BOARD_QUARTER_KEY);
if (parseQuarterKey(stored)) return stored!;
localStorage.removeItem('eene-board-reference-date');
} catch {
/* ignore */
}
return todayQuarter;
}
const setReferenceDate = useCallback((d: Date) => {
const normalized = startOfDay(d);
setReferenceDateState(normalized);
export function useBoardReferenceDate() {
const [selectedQuarter, setSelectedQuarterState] = useState(readInitialQuarter);
const [weekLensActive, setWeekLensActive] = useState(false);
const [referenceWeekMonday, setReferenceWeekMonday] = useState(() => startOfWeekMonday(new Date()));
const persistQuarter = useCallback((quarterKey: string) => {
setSelectedQuarterState(quarterKey);
try {
localStorage.setItem(BOARD_REF_DATE_KEY, toIsoDate(normalized));
localStorage.setItem(BOARD_QUARTER_KEY, quarterKey);
} catch {
/* ignore */
}
}, []);
const quarter = useMemo(() => dateToQuarter(referenceDate), [referenceDate]);
/** 분기 보기 — 카드 기본(제목·수행기간·개요·이슈) */
const selectQuarter = useCallback(
(quarterKey: string) => {
if (!parseQuarterKey(quarterKey)) return;
persistQuarter(quarterKey);
setWeekLensActive(false);
},
[persistQuarter],
);
const resetToToday = useCallback(() => {
setReferenceDate(startOfDay(new Date()));
}, [setReferenceDate]);
/** 오늘이 포함된 분기 + 분기 보기 (첫 화면 복귀) */
const returnToCurrentQuarter = useCallback(() => {
persistQuarter(dateToQuarter(new Date()));
setReferenceWeekMonday(startOfWeekMonday(new Date()));
setWeekLensActive(false);
}, [persistQuarter]);
return { referenceDate, setReferenceDate, quarter, resetToToday };
/** 주차 보기 — 카드에 해당 주 업무 1건 */
const selectWeek = useCallback(
(d: Date) => {
const monday = startOfWeekMonday(startOfDay(d));
setReferenceWeekMonday(monday);
setWeekLensActive(true);
persistQuarter(dateToQuarter(monday));
},
[persistQuarter],
);
const referenceDate = useMemo(
() => (weekLensActive ? referenceWeekMonday : startOfWeekMonday(new Date())),
[weekLensActive, referenceWeekMonday],
);
return {
quarter: selectedQuarter,
selectedQuarter,
referenceDate,
referenceWeekMonday,
weekLensActive,
selectWeek,
selectQuarter,
returnToCurrentQuarter,
};
}

View File

@@ -221,6 +221,45 @@ body,
opacity: 0.85;
}
.board-calendar-grid tbody tr {
cursor: pointer;
}
.board-calendar-grid tbody tr:hover {
background: var(--board-cal-surface);
}
.board-calendar-grid tr.is-selected-week {
background: rgba(55, 161, 132, 0.12);
}
.board-calendar-grid tr.is-selected-week .board-calendar-week-label-btn {
color: var(--board-cal-accent-dark);
font-weight: 800;
}
.board-calendar-day-cell {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 28px;
font-size: 12px;
font-weight: 600;
color: #1f2937;
}
.board-calendar-day-cell.is-outside {
color: #cbd5e1;
font-weight: 500;
}
.board-calendar-day-cell.is-today {
box-shadow: inset 0 0 0 1px var(--board-cal-accent);
border-radius: 6px;
color: var(--board-cal-accent);
}
.board-calendar-grid-wrap {
overflow-x: auto;
}

View File

@@ -1,4 +1,7 @@
export const BOARD_REF_DATE_KEY = 'eene-board-reference-date';
export const BOARD_QUARTER_KEY = 'eene-board-selected-quarter-v2';
export const QUARTER_RANGE_LABELS = ['1.01~3.31', '4.01~6.30', '7.01~9.30', '10.01~12.31'] as const;
export function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
@@ -25,6 +28,22 @@ export function quarterToLabel(quarter: string): string {
return quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기 업무');
}
export function formatQuarterShort(quarter: string): string {
return quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기');
}
export function formatQuarterRangeLabel(quarter: string): string {
const m = quarter.match(/^\d{4}-Q([1-4])$/);
if (!m) return '';
return QUARTER_RANGE_LABELS[Number(m[1]) - 1];
}
export function formatQuarterPillLabel(quarter: string): string {
const title = formatQuarterShort(quarter);
const range = formatQuarterRangeLabel(quarter);
return range ? `${title} · ${range}` : title;
}
export function weekOfMonthLabel(d: Date): string {
return `${d.getMonth() + 1}${Math.ceil(d.getDate() / 7)}주차`;
}
@@ -33,17 +52,46 @@ export function quarterNumber(d: Date): number {
return Math.floor(d.getMonth() / 3) + 1;
}
export function formatReferenceSummary(d: Date): string {
return `기준일 ${toIsoDate(d)} · ${quarterNumber(d)}분기`;
export function endOfWeekSunday(monday: Date): Date {
const d = startOfDay(monday);
d.setDate(d.getDate() + 6);
return d;
}
/** @deprecated use formatReferenceSummary */
export function formatReferenceWeekSummary(d: Date): string {
const monday = startOfWeekMonday(d);
return `${weekOfMonthLabel(monday)} · ${quarterNumber(monday)}분기`;
}
export function parseQuarterKey(value: string | null | undefined): string | null {
if (!value || !/^\d{4}-Q[1-4]$/.test(value)) return null;
return value;
}
export function formatBoardCalendarPill(
selectedQuarter: string,
weekLensActive: boolean,
weekMonday: Date,
): string {
if (weekLensActive) {
const anchor = isSameWeek(new Date(), weekMonday) ? startOfDay(new Date()) : startOfWeekMonday(weekMonday);
return `기준일 ${toIsoDate(anchor)} · ${weekOfMonthLabel(weekMonday)}`;
}
return formatQuarterPillLabel(selectedQuarter);
}
export function formatWeekRangeLabel(weekMonday: Date): string {
const start = startOfWeekMonday(weekMonday);
const end = endOfWeekSunday(start);
const fmt = (dt: Date) => `${dt.getMonth() + 1}/${dt.getDate()}`;
return `${fmt(start)} ~ ${fmt(end)}`;
}
/** @deprecated use formatBoardCalendarPill */
export function formatReferencePill(d: Date): string {
return formatReferenceSummary(d);
return formatReferenceWeekSummary(d);
}
export const QUARTER_RANGE_LABELS = ['1.01~3.31', '4.01~6.30', '7.01~9.30', '10.01~12.31'] as const;
export function startOfWeekMonday(d: Date): Date {
const x = startOfDay(d);
const dow = x.getDay();

View File

@@ -9,10 +9,11 @@
const CHANNEL_NAME = 'eee_dashboard';
const DETAIL_WINDOW_NAME = 'eene_detail';
const SELECTED_TASK_KEY = 'eee_selected_task';
const SELECTED_STAGE_KEY = 'eee_selected_stage';
const PLACEMENT_STORAGE_KEY = 'eee_detail_window_placement';
export type DualMonitorEvent =
| { type: 'TASK_SELECTED'; taskId: string }
| { type: 'TASK_SELECTED'; taskId: string; stageId?: string | null }
| { type: 'TASK_DESELECTED' }
| { type: 'REQUEST_SYNC' }
| { type: 'REFRESH' };
@@ -206,15 +207,25 @@ export function isDualModeActive(): boolean {
return dualModeActive && isDetailWindowOpen();
}
function persistSelectedTask(taskId: string | null) {
if (taskId) sessionStorage.setItem(SELECTED_TASK_KEY, taskId);
else sessionStorage.removeItem(SELECTED_TASK_KEY);
function persistSelectedTask(taskId: string | null, stageId?: string | null) {
if (taskId) {
sessionStorage.setItem(SELECTED_TASK_KEY, taskId);
if (stageId) sessionStorage.setItem(SELECTED_STAGE_KEY, stageId);
else sessionStorage.removeItem(SELECTED_STAGE_KEY);
} else {
sessionStorage.removeItem(SELECTED_TASK_KEY);
sessionStorage.removeItem(SELECTED_STAGE_KEY);
}
}
export function getPersistedTaskId(): string | null {
return sessionStorage.getItem(SELECTED_TASK_KEY);
}
export function getPersistedStageId(): string | null {
return sessionStorage.getItem(SELECTED_STAGE_KEY);
}
export function registerSyncProvider(fn: () => string | null): () => void {
syncProvider = fn;
return () => {
@@ -242,14 +253,14 @@ function schedulePlacementRetries(win: Window, placement: WindowPlacement) {
}
}
function postTaskSelected(taskId: string) {
getChannel().postMessage({ type: 'TASK_SELECTED', taskId } satisfies DualMonitorEvent);
function postTaskSelected(taskId: string, stageId?: string | null) {
getChannel().postMessage({ type: 'TASK_SELECTED', taskId, stageId } satisfies DualMonitorEvent);
}
function scheduleTaskSelected(taskId: string) {
postTaskSelected(taskId);
setTimeout(() => postTaskSelected(taskId), 500);
setTimeout(() => postTaskSelected(taskId), 1500);
function scheduleTaskSelected(taskId: string, stageId?: string | null) {
postTaskSelected(taskId, stageId);
setTimeout(() => postTaskSelected(taskId, stageId), 500);
setTimeout(() => postTaskSelected(taskId, stageId), 1500);
}
function openDetailWindowWithPlacement(placement: WindowPlacement): Window | null {
@@ -304,16 +315,18 @@ export async function openDetailWindow(onPopupClosed?: () => void): Promise<Wind
}
const savedTaskId = getPersistedTaskId();
if (savedTaskId) scheduleTaskSelected(savedTaskId);
const savedStageId = getPersistedStageId();
if (savedTaskId) scheduleTaskSelected(savedTaskId, savedStageId);
return win;
}
/** 업무 선택 — await getScreenDetails(권한) 후 팝업 open */
export async function sendTaskSelected(
taskId: string,
stageId?: string | null,
onPopupClosed?: () => void,
): Promise<boolean> {
persistSelectedTask(taskId);
persistSelectedTask(taskId, stageId);
if (!isDetailWindowOpen()) {
dualModeActive = true;
@@ -322,14 +335,14 @@ export async function sendTaskSelected(
dualModeActive = false;
return false;
}
scheduleTaskSelected(taskId);
scheduleTaskSelected(taskId, stageId);
if (lastPlacementIssue) {
window.alert(lastPlacementIssue);
}
return true;
}
scheduleTaskSelected(taskId);
scheduleTaskSelected(taskId, stageId);
try {
detailWindow!.focus();
@@ -357,7 +370,7 @@ export function requestDetailSync(): void {
function respondToSyncRequest() {
const id = syncProvider?.() ?? getPersistedTaskId();
if (id) postTaskSelected(id);
if (id) postTaskSelected(id, getPersistedStageId());
else getChannel().postMessage({ type: 'TASK_DESELECTED' } satisfies DualMonitorEvent);
}

View File

@@ -1,4 +1,4 @@
import { quarterDateBounds } from './hubSchedule';
import { quarterEndDate, quarterStartDate } from './boardCalendar';
export interface TimelineMilestoneInput {
id: string;
@@ -43,14 +43,19 @@ export interface MilestoneTimelineModel {
rangeEndLabel: string;
ticks: TimelineTick[];
rows: TimelineRow[];
todayLeftPct?: number | null;
}
const DAY_MS = 86_400_000;
const RANGE_PAD_DAYS = 2;
/** 하루·단일일정 막대가 타임라인에서 묻히지 않도록 */
const MIN_BAR_WIDTH_PCT = 2;
/** 간트 진행률(18px) 뱃지가 잘리지 않도록 */
const MIN_GANTT_BAR_WIDTH_PCT = 11;
/** 오늘 눈금과 겹치는 기존 날짜 라벨 제거 (주간 눈금 등) */
const TODAY_TICK_MIN_GAP_PCT = 3.2;
/** 이 이상이면 일/주 단위 대신 월별 눈금 */
const MONTH_TICK_MIN_DAYS = 28;
export function taskTimelineFallback(task: {
startDate?: string | null;
@@ -97,10 +102,138 @@ function collectMilestoneTimes(milestones: TimelineMilestoneInput[]): number[] {
return times;
}
function quarterRange(quarterKey: string): { start: Date; end: Date } {
return {
start: startOfDay(quarterStartDate(quarterKey)),
end: startOfDay(quarterEndDate(quarterKey)),
};
}
const TIMELINE_MONTH_COUNT = 3;
function monthStart(d: Date): Date {
return startOfDay(new Date(d.getFullYear(), d.getMonth(), 1));
}
function monthEnd(d: Date): Date {
return startOfDay(new Date(d.getFullYear(), d.getMonth() + 1, 0));
}
function addMonths(monthAnchor: Date, delta: number): Date {
return new Date(monthAnchor.getFullYear(), monthAnchor.getMonth() + delta, 1);
}
function monthSpanCount(firstMonth: Date, lastMonth: Date): number {
return (lastMonth.getFullYear() - firstMonth.getFullYear()) * 12 + (lastMonth.getMonth() - firstMonth.getMonth()) + 1;
}
/** 월 초 기준 정확히 3개월 */
function threeMonthWindowFrom(windowStart: Date): { start: Date; end: Date } {
const start = monthStart(windowStart);
const end = monthEnd(addMonths(start, TIMELINE_MONTH_COUNT - 1));
return { start, end };
}
/**
* 상세 타임라인 — 항상 3개월 분할
* - 1달 업무: 앞·뒤 1달씩 (±1)
* - 2달 업무: 앞 1달 + 업무 2달
* - 3달 업무: 업무 3달 그대로 (여유 달 없음)
* - 4달 이상: 오늘(또는 기준일)이 포함된 3달, 업무 범위 안에서 이동
*/
function resolveThreeMonthWindow(
coreStart: Date,
coreEnd: Date,
refDate: Date = new Date(),
): { start: Date; end: Date } {
const firstMonth = monthStart(coreStart);
const lastMonth = monthStart(coreEnd);
const spanMonths = monthSpanCount(firstMonth, lastMonth);
if (spanMonths <= 1) {
return threeMonthWindowFrom(addMonths(firstMonth, -1));
}
if (spanMonths === 2) {
return threeMonthWindowFrom(addMonths(firstMonth, -1));
}
if (spanMonths === 3) {
return threeMonthWindowFrom(firstMonth);
}
const ref = startOfDay(refDate);
const refClamped =
ref.getTime() < coreStart.getTime() ? coreStart : ref.getTime() > coreEnd.getTime() ? coreEnd : ref;
let windowStart = monthStart(refClamped);
const earliest = firstMonth;
const latestStart = addMonths(lastMonth, -(TIMELINE_MONTH_COUNT - 1));
if (windowStart < earliest) windowStart = earliest;
if (windowStart > latestStart) windowStart = latestStart;
return threeMonthWindowFrom(windowStart);
}
interface TimelineFocusRange {
start: Date;
end: Date;
}
function getSelectedMilestoneSpan(
milestones: TimelineMilestoneInput[],
selectedId: string | null | undefined,
): { start: Date; end: Date } | null {
if (!selectedId) return null;
const m = milestones.find((x) => x.id === selectedId);
if (!m) return null;
const spans = milestonePeriodSpans(m);
if (spans.length === 0) {
if (m.startDate || m.dueDate) {
const start = parseDay(m.startDate ?? m.dueDate!);
const end = parseDay(m.dueDate ?? m.startDate!);
return { start, end: end.getTime() < start.getTime() ? start : end };
}
return null;
}
const startMs = Math.min(...spans.map((s) => s.startMs));
const endMs = Math.max(...spans.map((s) => s.endMs));
return { start: new Date(startMs), end: new Date(endMs) };
}
/**
* 선택 업무 기준 타임라인 — 항상 3개월
*/
function resolveTimelineFocusRange(
milestones: TimelineMilestoneInput[],
selectedId: string | null | undefined,
fallback: TimelineRangeFallback,
): TimelineFocusRange {
const span = getSelectedMilestoneSpan(milestones, selectedId);
if (span) {
return resolveThreeMonthWindow(span.start, span.end);
}
if (fallback.quarter) {
return quarterRange(fallback.quarter);
}
return resolveThreeMonthWindow(new Date(), new Date());
}
function computeRange(
milestones: TimelineMilestoneInput[],
fallback: TimelineRangeFallback,
options?: { selectedId?: string | null; focusQuarter?: boolean },
): { start: Date; end: Date } | null {
if (options?.focusQuarter !== false) {
return resolveTimelineFocusRange(milestones, options?.selectedId, fallback);
}
const times = collectMilestoneTimes(milestones);
if (times.length >= 1) {
@@ -121,17 +254,50 @@ function computeRange(
}
if (fallback.quarter) {
const { min, max } = quarterDateBounds(fallback.quarter);
return { start: parseDay(min), end: parseDay(max) };
const range = quarterRange(fallback.quarter);
return { start: range.start, end: range.end };
}
return null;
}
function buildTicks(rangeStart: Date, rangeEnd: Date): TimelineTick[] {
function buildMonthTicks(rangeStart: Date, rangeEnd: Date): TimelineTick[] {
const rangeMs = Math.max(rangeEnd.getTime() - rangeStart.getTime(), DAY_MS);
const now = startOfDay(new Date());
const todayInRange = now >= rangeStart && now <= rangeEnd;
const nowMs = now.getTime();
const ticks: TimelineTick[] = [];
for (let i = 0; i < TIMELINE_MONTH_COUNT; i++) {
const d = new Date(rangeStart.getFullYear(), rangeStart.getMonth() + i, 1);
ticks.push({
label: `${d.getMonth() + 1}`,
leftPct: ((d.getTime() - rangeStart.getTime()) / rangeMs) * 100,
isToday: todayInRange && d.getTime() === nowMs,
});
}
if (todayInRange && !ticks.some((tick) => tick.isToday)) {
const todayLeftPct = ((nowMs - rangeStart.getTime()) / rangeMs) * 100;
ticks.push({
label: fmtTimelineDayLabel(now),
leftPct: todayLeftPct,
isToday: true,
});
ticks.sort((a, b) => a.leftPct - b.leftPct);
}
return ticks;
}
function buildTicks(rangeStart: Date, rangeEnd: Date, focusQuarter?: boolean): TimelineTick[] {
if (focusQuarter !== false) {
return buildMonthTicks(rangeStart, rangeEnd);
}
const rangeMs = Math.max(rangeEnd.getTime() - rangeStart.getTime(), DAY_MS);
const totalDays = Math.ceil(rangeMs / DAY_MS) + 1;
const stepDays = totalDays > 45 ? 7 : totalDays > 28 ? 2 : 1;
const stepDays = totalDays > 14 ? 7 : totalDays > 7 ? 2 : 1;
const now = startOfDay(new Date());
const todayInRange = now >= rangeStart && now <= rangeEnd;
const nowMs = now.getTime();
@@ -193,11 +359,46 @@ function milestonePeriodSpans(m: TimelineMilestoneInput): Array<{ startMs: numbe
return spans;
}
/** 간트 행 순서 — 과거(상단) → 최신(하단) */
function pickTimelineSortMs(m: TimelineMilestoneInput): number {
const spans = milestonePeriodSpans(m);
if (spans.length > 0) return Math.min(...spans.map((s) => s.startMs));
if (m.startDate) return parseDay(m.startDate).getTime();
if (m.dueDate) return parseDay(m.dueDate).getTime();
if (m.createdAt) return new Date(m.createdAt).getTime();
return Number.MAX_SAFE_INTEGER;
}
export function sortMilestonesForTimeline(milestones: TimelineMilestoneInput[]): TimelineMilestoneInput[] {
return [...milestones].sort((a, b) => {
const diff = pickTimelineSortMs(a) - pickTimelineSortMs(b);
if (diff !== 0) return diff;
return a.order - b.order;
});
}
/** 분기 전체 간트 — 프로젝트명 · 업무명 라벨 */
export function flattenTasksToTimelineMilestones(
tasks: Array<{ id: string; title: string; milestones?: TimelineMilestoneInput[] }>,
): TimelineMilestoneInput[] {
const items: TimelineMilestoneInput[] = [];
for (const t of tasks) {
for (const m of t.milestones ?? []) {
items.push({
...m,
title: `${t.title} · ${m.title}`,
});
}
}
return sortMilestonesForTimeline(items);
}
function buildRows(
ordered: TimelineMilestoneInput[],
rangeStart: Date,
rangeEnd: Date,
hideUndatedBars?: boolean,
minBarWidthPct: number = MIN_BAR_WIDTH_PCT,
): TimelineRow[] {
const items =
hideUndatedBars === false
@@ -205,7 +406,8 @@ function buildRows(
: ordered.filter((m) => milestoneHasDates(m));
const rangeStartMs = rangeStart.getTime();
const rangeMs = Math.max(rangeEnd.getTime() - rangeStartMs, DAY_MS);
const rangeEndMs = rangeEnd.getTime();
const rangeMs = Math.max(rangeEndMs - rangeStartMs, DAY_MS);
const rows: TimelineRow[] = [];
@@ -214,14 +416,17 @@ function buildRows(
if (spans.length === 0) continue;
spans.forEach((span, index) => {
const { startMs, endMs } = span;
const spanMs = endMs - startMs;
const clipStartMs = Math.max(span.startMs, rangeStartMs);
const clipEndMs = Math.min(span.endMs, rangeEndMs);
if (clipEndMs < clipStartMs) return;
const spanMs = clipEndMs - clipStartMs;
const isPoint = spanMs === 0;
let widthPct = isPoint ? MIN_BAR_WIDTH_PCT : (spanMs / rangeMs) * 100;
widthPct = Math.max(MIN_BAR_WIDTH_PCT, widthPct);
let widthPct = isPoint ? minBarWidthPct : (spanMs / rangeMs) * 100;
widthPct = Math.max(minBarWidthPct, widthPct);
let leftPct = ((startMs - rangeStartMs) / rangeMs) * 100;
let leftPct = ((clipStartMs - rangeStartMs) / rangeMs) * 100;
if (isPoint) leftPct -= widthPct / 2;
leftPct = Math.max(0, Math.min(100 - widthPct, leftPct));
@@ -242,26 +447,198 @@ function buildRows(
export function buildMilestoneTimeline(
milestones: TimelineMilestoneInput[],
fallback: TimelineRangeFallback = {},
options?: { preserveOrder?: boolean; hideUndatedBars?: boolean },
options?: {
hideUndatedBars?: boolean;
selectedId?: string | null;
focusQuarter?: boolean;
viewport?: { start: Date; end: Date };
ganttBars?: boolean;
},
): MilestoneTimelineModel | null {
if (milestones.length === 0) return null;
const range = computeRange(milestones, fallback);
const focusQuarter = options?.focusQuarter !== false && !options?.viewport;
const range =
options?.viewport ??
computeRange(milestones, fallback, {
selectedId: options?.selectedId,
focusQuarter,
});
if (!range) return null;
const ordered = options?.preserveOrder
? [...milestones]
: [...milestones].sort((a, b) => a.order - b.order);
const ordered = sortMilestonesForTimeline(milestones);
const rows = buildRows(ordered, range.start, range.end, options?.hideUndatedBars);
if (rows.length === 0) return null;
const minBarWidthPct = options?.ganttBars ? MIN_GANTT_BAR_WIDTH_PCT : MIN_BAR_WIDTH_PCT;
const rows = buildRows(ordered, range.start, range.end, options?.hideUndatedBars, minBarWidthPct);
if (rows.length === 0 && !focusQuarter && !options?.viewport) return null;
const ticks = buildTicks(range.start, range.end);
const useMonthTicks =
focusQuarter || preferMonthTicks(range.start, range.end);
const ticks = useMonthTicks
? buildMonthTicksInRange(range.start, range.end)
: buildTicks(range.start, range.end, false);
const todayTick = ticks.find((tick) => tick.isToday);
return {
rangeStartLabel: fmtTimelineDayLabel(range.start),
rangeEndLabel: fmtTimelineDayLabel(range.end),
ticks,
rows,
todayLeftPct: todayTick?.leftPct ?? null,
};
}
export interface PortfolioTaskGroup {
id: string;
title: string;
milestones: TimelineMilestoneInput[];
}
export function computeFullTimelineRange(
milestones: TimelineMilestoneInput[],
fallback: TimelineRangeFallback = {},
): { start: Date; end: Date } | null {
return computeRange(milestones, fallback, { focusQuarter: false });
}
export type TimelineViewportMode = 'focus' | 'project' | 'portfolio';
/** 보기 모드별 차트 초기 viewport (pan/zoom 데이터 범위는 computeFullTimelineRange) */
export function resolveTimelineInitialViewport(
viewMode: TimelineViewportMode,
milestones: TimelineMilestoneInput[],
fallback: TimelineRangeFallback = {},
selectedId?: string | null,
): { start: Date; end: Date } | null {
const dataRange = computeFullTimelineRange(milestones, fallback);
if (!dataRange) return null;
if (viewMode === 'project') {
return dataRange;
}
if (viewMode === 'focus') {
const focusRange = computeRange(milestones, fallback, {
selectedId,
focusQuarter: true,
});
return focusRange ?? resolvePortfolioInitialViewport(dataRange);
}
return resolvePortfolioInitialViewport(dataRange);
}
export function resolvePortfolioInitialViewport(
dataRange: { start: Date; end: Date },
refDate: Date = new Date(),
): { start: Date; end: Date } {
return resolveThreeMonthWindow(dataRange.start, dataRange.end, refDate);
}
export function clampPortfolioViewport(
viewport: { start: Date; end: Date },
dataRange: { start: Date; end: Date },
): { start: Date; end: Date } {
const dataStartMs = dataRange.start.getTime();
const dataEndMs = dataRange.end.getTime();
const dataSpanMs = Math.max(dataEndMs - dataStartMs, DAY_MS);
let windowMs = Math.max(viewport.end.getTime() - viewport.start.getTime(), 7 * DAY_MS);
windowMs = Math.min(windowMs, dataSpanMs);
let startMs = viewport.start.getTime();
let endMs = startMs + windowMs;
if (endMs > dataEndMs) {
endMs = dataEndMs;
startMs = endMs - windowMs;
}
if (startMs < dataStartMs) {
startMs = dataStartMs;
endMs = startMs + windowMs;
}
endMs = Math.min(endMs, dataEndMs);
startMs = Math.max(startMs, dataStartMs);
return { start: new Date(startMs), end: new Date(endMs) };
}
export function panPortfolioViewport(
viewport: { start: Date; end: Date },
dataRange: { start: Date; end: Date },
deltaMs: number,
): { start: Date; end: Date } {
return clampPortfolioViewport(
{
start: new Date(viewport.start.getTime() + deltaMs),
end: new Date(viewport.end.getTime() + deltaMs),
},
dataRange,
);
}
export function zoomPortfolioViewport(
viewport: { start: Date; end: Date },
dataRange: { start: Date; end: Date },
factor: number,
anchorPct: number,
): { start: Date; end: Date } {
const startMs = viewport.start.getTime();
const endMs = viewport.end.getTime();
const windowMs = Math.max(endMs - startMs, DAY_MS);
const dataSpanMs = Math.max(dataRange.end.getTime() - dataRange.start.getTime(), DAY_MS);
const minWindow = 7 * DAY_MS;
const newWindow = Math.min(dataSpanMs, Math.max(minWindow, windowMs * factor));
const anchorMs = startMs + windowMs * Math.min(1, Math.max(0, anchorPct));
const newStartMs = anchorMs - newWindow * anchorPct;
return clampPortfolioViewport(
{ start: new Date(newStartMs), end: new Date(newStartMs + newWindow) },
dataRange,
);
}
function preferMonthTicks(rangeStart: Date, rangeEnd: Date): boolean {
const days = (rangeEnd.getTime() - rangeStart.getTime()) / DAY_MS;
if (days >= MONTH_TICK_MIN_DAYS) return true;
if (monthStart(rangeStart).getTime() !== monthStart(rangeEnd).getTime()) return true;
return days >= 14;
}
function shouldUseMonthTicks(rangeStart: Date, rangeEnd: Date): boolean {
return preferMonthTicks(rangeStart, rangeEnd);
}
function buildMonthTicksInRange(rangeStart: Date, rangeEnd: Date): TimelineTick[] {
const rangeMs = Math.max(rangeEnd.getTime() - rangeStart.getTime(), DAY_MS);
const now = startOfDay(new Date());
const todayInRange = now >= rangeStart && now <= rangeEnd;
const nowMs = now.getTime();
const ticks: TimelineTick[] = [];
for (let cursor = monthStart(rangeStart); cursor <= rangeEnd; cursor = addMonths(cursor, 1)) {
ticks.push({
label: `${cursor.getMonth() + 1}`,
leftPct: ((cursor.getTime() - rangeStart.getTime()) / rangeMs) * 100,
isToday: todayInRange && cursor.getTime() === nowMs,
});
}
if (todayInRange && !ticks.some((tick) => tick.isToday)) {
const todayLeftPct = ((nowMs - rangeStart.getTime()) / rangeMs) * 100;
ticks.push({
label: fmtTimelineDayLabel(now),
leftPct: todayLeftPct,
isToday: true,
});
ticks.sort((a, b) => a.leftPct - b.leftPct);
}
return ticks;
}
export function milestoneHasTimelineDates(m: TimelineMilestoneInput): boolean {
return milestoneHasDates(m);
}

View File

@@ -0,0 +1,221 @@
import {
endOfWeekSunday,
parseIsoDate,
quarterEndDate,
quarterStartDate,
startOfDay,
} from './boardCalendar';
import { fmtPeriodRange, parseMilestonePeriods } from './milestonePeriods';
import type { Milestone, Task } from '../types';
export interface TaskWeekFocus {
milestoneId: string;
milestoneTitle: string;
periodLabel: string;
progress: number;
}
export interface TaskFocusOptions {
weekMonday: Date;
weekLensActive?: boolean;
quarter?: string;
}
type TaskWithMilestones = Task & { milestones?: Milestone[] };
interface PeriodSpan {
milestoneId: string;
milestoneTitle: string;
periodLabel: string;
progress: number;
startMs: number;
endMs: number;
sortDue: string;
sortStart: string;
order: number;
}
function periodOverlapsRange(
startIso: string | null | undefined,
endIso: string | null | undefined,
rangeStart: Date,
rangeEnd: Date,
): boolean {
const startMs = startIso ? parseIsoDate(startIso)?.getTime() : null;
const endMs = endIso ? parseIsoDate(endIso)?.getTime() : null;
if (startMs == null && endMs == null) return false;
const spanStart = startMs ?? endMs!;
const spanEnd = endMs ?? startMs!;
const rs = startOfDay(rangeStart).getTime();
const re = startOfDay(rangeEnd).getTime();
return spanStart <= re && spanEnd >= rs;
}
function collectPeriodSpans(task: TaskWithMilestones): PeriodSpan[] {
const spans: PeriodSpan[] = [];
for (const ms of task.milestones ?? []) {
const title = ms.title?.trim();
if (!title) continue;
const periods = parseMilestonePeriods(ms);
if (periods.length > 0) {
for (const period of periods) {
if (!period.startDate && !period.dueDate) continue;
const startMs = period.startDate
? (parseIsoDate(period.startDate)?.getTime() ?? Number.NaN)
: Number.NaN;
const endMs = period.dueDate
? (parseIsoDate(period.dueDate)?.getTime() ?? Number.NaN)
: Number.NaN;
const resolvedStart = Number.isFinite(startMs) ? startMs : endMs;
const resolvedEnd = Number.isFinite(endMs) ? endMs : startMs;
if (!Number.isFinite(resolvedStart) || !Number.isFinite(resolvedEnd)) continue;
spans.push({
milestoneId: ms.id,
milestoneTitle: title,
periodLabel: fmtPeriodRange(period) || '—',
progress: ms.progress ?? 0,
startMs: Math.min(resolvedStart, resolvedEnd),
endMs: Math.max(resolvedStart, resolvedEnd),
sortDue: period.dueDate || period.startDate || '9999-12-31',
sortStart: period.startDate || period.dueDate || '9999-12-31',
order: ms.order ?? 0,
});
}
continue;
}
if (!ms.startDate && !ms.dueDate) continue;
const startMs = ms.startDate ? (parseIsoDate(ms.startDate)?.getTime() ?? Number.NaN) : Number.NaN;
const endMs = ms.dueDate ? (parseIsoDate(ms.dueDate)?.getTime() ?? Number.NaN) : Number.NaN;
const resolvedStart = Number.isFinite(startMs) ? startMs : endMs;
const resolvedEnd = Number.isFinite(endMs) ? endMs : startMs;
if (!Number.isFinite(resolvedStart) || !Number.isFinite(resolvedEnd)) continue;
spans.push({
milestoneId: ms.id,
milestoneTitle: title,
periodLabel:
fmtPeriodRange({ startDate: ms.startDate ?? '', dueDate: ms.dueDate ?? '' }) || '—',
progress: ms.progress ?? 0,
startMs: Math.min(resolvedStart, resolvedEnd),
endMs: Math.max(resolvedStart, resolvedEnd),
sortDue: ms.dueDate || ms.startDate || '9999-12-31',
sortStart: ms.startDate || ms.dueDate || '9999-12-31',
order: ms.order ?? 0,
});
}
return spans;
}
function pickBestSpan(spans: PeriodSpan[]): PeriodSpan | null {
if (spans.length === 0) return null;
const sorted = [...spans].sort((a, b) => {
const dueCmp = a.sortDue.localeCompare(b.sortDue);
if (dueCmp !== 0) return dueCmp;
const startCmp = a.sortStart.localeCompare(b.sortStart);
if (startCmp !== 0) return startCmp;
return a.order - b.order;
});
return sorted[0];
}
function spansOverlappingRange(
task: TaskWithMilestones,
rangeStart: Date,
rangeEnd: Date,
): PeriodSpan[] {
return collectPeriodSpans(task).filter((span) => {
const rs = startOfDay(rangeStart).getTime();
const re = startOfDay(rangeEnd).getTime();
return span.startMs <= re && span.endMs >= rs;
});
}
function spanToFocus(span: PeriodSpan): TaskWeekFocus {
return {
milestoneId: span.milestoneId,
milestoneTitle: span.milestoneTitle,
periodLabel: span.periodLabel,
progress: span.progress,
};
}
/** 선택 주(월요일 기준)에 해당하는 업무 1건 — 겹치면 마감이 가장 가까운 것 */
export function resolveTaskWeekFocus(
task: TaskWithMilestones,
weekMonday: Date,
): TaskWeekFocus | null {
const weekStart = startOfDay(weekMonday);
const weekEnd = endOfWeekSunday(weekMonday);
const best = pickBestSpan(spansOverlappingRange(task, weekStart, weekEnd));
return best ? spanToFocus(best) : null;
}
export function resolveTaskWeekMilestoneId(
task: TaskWithMilestones,
weekMonday: Date,
): string | null {
return resolveTaskWeekFocus(task, weekMonday)?.milestoneId ?? null;
}
function resolveTaskQuarterFocus(task: TaskWithMilestones, quarter: string): TaskWeekFocus | null {
const rangeStart = startOfDay(quarterStartDate(quarter));
const rangeEnd = startOfDay(quarterEndDate(quarter));
const best = pickBestSpan(spansOverlappingRange(task, rangeStart, rangeEnd));
return best ? spanToFocus(best) : null;
}
/** 기준일과 가장 가까운 업무 — 진행 중 우선, 없으면 가장 최근에 끝난 업무 */
export function resolveTaskNearestMilestoneId(
task: TaskWithMilestones,
refDate: Date,
): string | null {
const refMs = startOfDay(refDate).getTime();
const spans = collectPeriodSpans(task);
if (spans.length === 0) return null;
let best: { span: PeriodSpan; score: number } | null = null;
for (const span of spans) {
let score: number;
if (refMs >= span.startMs && refMs <= span.endMs) {
score = 0;
} else if (refMs > span.endMs) {
score = refMs - span.endMs;
} else {
score = span.startMs - refMs + Number.MAX_SAFE_INTEGER / 2;
}
if (
!best ||
score < best.score ||
(score === best.score && span.endMs > best.span.endMs) ||
(score === best.score && span.endMs === best.span.endMs && span.order < best.span.order)
) {
best = { span, score };
}
}
return best?.span.milestoneId ?? null;
}
/**
* 상세 진입 시 업무 선택 — 주차 → (분기 보기일 때) 분기 → 기준일과 가장 가까운 업무
*/
export function resolveTaskFocusMilestoneId(
task: TaskWithMilestones,
options: TaskFocusOptions,
): string | null {
const weekFocus = resolveTaskWeekFocus(task, options.weekMonday);
if (weekFocus) return weekFocus.milestoneId;
if (!options.weekLensActive && options.quarter) {
const quarterFocus = resolveTaskQuarterFocus(task, options.quarter);
if (quarterFocus) return quarterFocus.milestoneId;
}
const refDate = options.weekLensActive ? options.weekMonday : new Date();
return resolveTaskNearestMilestoneId(task, refDate);
}

View File

@@ -32,11 +32,13 @@ export function taskBelongsToSection(
return normalizeSection(taskSection) === columnSection;
}
/** 화면·듀얼모니터 상세에 표시할 부문명 */
/** 화면·듀얼모니터 상세·업무관리 — 4분면 보드 표시명 */
export function formatSectionDisplay(section: string | null | undefined): string {
if (!section?.trim()) return '—';
if (section.trim() === '조직문화') return '조직문화';
const key = normalizeSection(section);
if (key) return key;
return section?.trim() || '—';
if (key) return COLUMN_META[key].boardTitle;
return section.trim();
}
export function canonicalSection(section: string | null | undefined): SectionKey {
@@ -45,24 +47,27 @@ export function canonicalSection(section: string | null | undefined): SectionKey
export const COLUMN_META: Record<
SectionKey,
{ titleEn: string; accent: string; displayTitle: string; routineBg: string }
{ titleEn: string; accent: string; displayTitle: string; boardTitle: string; routineBg: string }
> = {
: {
titleEn: 'HRM',
accent: '#07412e',
displayTitle: '인사관리',
boardTitle: '인사관리',
routineBg: 'linear-gradient(180deg, #dce8e3 0%, #e8f0ec 100%)',
},
: {
titleEn: 'HRD',
accent: '#29724f',
displayTitle: '성장지원',
displayTitle: '인재육성',
boardTitle: '인재육성',
routineBg: 'linear-gradient(180deg, #d8ebe3 0%, #e6f2ec 100%)',
},
: {
titleEn: 'GA',
accent: '#36816d',
displayTitle: '총무관리',
boardTitle: '총무관리',
routineBg: 'linear-gradient(180deg, #d4ece4 0%, #e4f3ed 100%)',
},
};

View File

@@ -0,0 +1,51 @@
import { decodeRoutineStageDescription } from './routineMilestone';
import { parseMilestonePeriods, serializePeriodEntries } from './milestonePeriods';
import type { Milestone, MilestoneLink } from '../types';
import type { StageFormData } from '../components/detail/stageFormTypes';
export type { StageFormData };
export function parseMilestoneLinks(raw: string | null | undefined): MilestoneLink[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as MilestoneLink[];
return Array.isArray(parsed) ? parsed.filter((l) => l.url) : [];
} catch {
return [];
}
}
export function buildStageFormState(
milestone: Milestone | undefined,
variant: 'project' | 'routine',
): StageFormData {
const legacyOverview =
variant === 'routine' ? decodeRoutineStageDescription(milestone?.description).overview : '';
return {
title: milestone?.title ?? '',
subtitle: milestone?.subtitle?.trim() ?? legacyOverview,
periodEntries: parseMilestonePeriods(milestone),
progress: milestone?.progress ?? 0,
links: parseMilestoneLinks(milestone?.links),
pmMemberId: milestone?.pmMemberId ?? milestone?.pmMember?.id ?? '',
assigneeMemberIds: milestone?.assigneeMembers?.map((m) => m.id) ?? [],
};
}
export function stageFormToApiPayload(data: StageFormData, variant: 'project' | 'routine') {
const base = {
title: data.title.trim(),
periodEntries: serializePeriodEntries(data.periodEntries),
progress: data.progress,
links: data.links,
};
if (variant === 'routine') {
return {
...base,
subtitle: data.subtitle.trim() || null,
pmMemberId: data.pmMemberId || null,
assigneeMemberIds: data.assigneeMemberIds,
};
}
return base;
}

View File

@@ -1,4 +1,4 @@
import type { TaskFormData } from '../components/common/TaskModal';
import type { TaskFormData } from './taskFormState';
import type { RoutineCategory } from './routineCategories';
import { displayFlagsForTaskType } from './taskType';
import { serializeIssueEntries } from './taskIssues';
@@ -34,7 +34,10 @@ export function taskFormToApiPayload(data: TaskFormData): Record<string, unknown
}
export function projectFormToApiPayload(data: TaskFormData): Record<string, unknown> {
return basePayload(data, '실행과제');
return {
...basePayload(data, '실행과제'),
detailDescription: data.detailDescription || null,
};
}
export function routineFormToApiPayload(data: TaskFormData): Record<string, unknown> {

View File

@@ -0,0 +1,72 @@
import type { Task, TaskIssueEntry } from '../types';
import { normalizeTaskType } from './taskType';
import { parseIssueEntries } from './taskIssues';
import { getRoutineCategory } from './routineCategories';
export interface TaskFormData {
title: string;
section: string;
category: string;
tag: string;
taskType: string;
status: string;
progress: number;
description: string;
detailDescription: string;
issueEntries: TaskIssueEntry[];
quarter: string;
startDate: string;
dueDate: string;
pmMemberId: string;
assigneeMemberIds: string[];
}
export const STATUS_OPTIONS = [
{ value: 'TODO', label: '대기' },
{ value: 'IN_PROGRESS', label: '진행' },
{ value: 'REVIEW', label: '보류' },
{ value: 'DONE', label: '완료' },
] as const;
export const STATUS_LABEL: Record<string, string> = {
IN_PROGRESS: '진행',
REVIEW: '보류',
TODO: '대기',
DONE: '완료',
CANCELLED: '취소',
};
function toDateInput(iso: string | null | undefined): string {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
}
export function buildTaskFormState(opts: {
task?: Task;
variant: 'project' | 'routine';
defaultSection?: string;
defaultCategory?: string;
defaultQuarter?: string;
}): TaskFormData {
const { task, variant, defaultSection = '인사관리', defaultCategory = '채용 운영', defaultQuarter = '2026-Q2' } =
opts;
const isRoutine = variant === 'routine';
return {
title: task?.title ?? '',
section: task?.section ?? defaultSection,
category: (task ? getRoutineCategory(task) : null) ?? defaultCategory,
tag: task?.tag ?? '',
taskType: isRoutine ? '기반업무' : task?.taskType ? normalizeTaskType(task.taskType) : '실행과제',
status: task?.status ?? 'TODO',
progress: task?.progress ?? 0,
description: task?.description ?? '',
detailDescription: task?.detailDescription ?? '',
issueEntries: task ? parseIssueEntries(task) : [],
quarter: task?.quarter ?? defaultQuarter,
startDate: toDateInput(task?.startDate),
dueDate: toDateInput(task?.dueDate),
pmMemberId: task?.pmMember?.id ?? task?.pmMemberId ?? '',
assigneeMemberIds: task?.assigneeMembers?.map((m) => m.id) ?? [],
};
}

View File

@@ -1,19 +1,32 @@
import type { Task } from '../types';
import { isSameWeek, parseIsoDate, toIsoDate } from './boardCalendar';
export interface TaskIssueEntry {
id: string;
text: string;
showOnCard: boolean;
/** YYYY-MM-DD — 주차 렌즈에서 해당 주에만 카드 표시 */
occurredOn?: string | null;
}
export function newIssueEntry(text = ''): TaskIssueEntry {
export interface IssueVisibilityOptions {
weekMonday?: Date;
}
export function newIssueEntry(text = '', occurredOn?: string): TaskIssueEntry {
return {
id: `issue-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
text,
showOnCard: true,
occurredOn: occurredOn ?? toIsoDate(new Date()),
};
}
function normalizeOccurredOn(raw: unknown): string | null {
if (typeof raw !== 'string' || !raw.trim()) return null;
return parseIsoDate(raw.trim()) ? raw.trim() : null;
}
export function normalizeIssueEntries(raw: unknown): TaskIssueEntry[] {
if (!Array.isArray(raw)) return [];
const entries: TaskIssueEntry[] = [];
@@ -25,6 +38,7 @@ export function normalizeIssueEntries(raw: unknown): TaskIssueEntry[] {
id: typeof row.id === 'string' && row.id ? row.id : newIssueEntry().id,
text,
showOnCard: row.showOnCard !== false,
occurredOn: normalizeOccurredOn(row.occurredOn),
});
}
return entries;
@@ -35,19 +49,37 @@ export function parseIssueEntries(task: Pick<Task, 'issueEntries' | 'issueNote'
if (fromJson.length > 0) return fromJson;
const legacy = task.issueNote?.trim();
if (!legacy) return [];
return [{ id: 'legacy', text: legacy, showOnCard: task.showIssue !== false }];
return [{ id: 'legacy', text: legacy, showOnCard: task.showIssue !== false, occurredOn: null }];
}
export function getVisibleIssueEntries(task: Pick<Task, 'issueEntries' | 'issueNote' | 'showIssue'>): TaskIssueEntry[] {
return parseIssueEntries(task).filter((entry) => entry.showOnCard && entry.text.trim());
function issueInWeek(entry: TaskIssueEntry, weekMonday: Date): boolean {
if (!entry.occurredOn) return false;
const day = parseIsoDate(entry.occurredOn);
if (!day) return false;
return isSameWeek(day, weekMonday);
}
export function hasVisibleIssue(task: Pick<Task, 'issueEntries' | 'issueNote' | 'showIssue'>): boolean {
return getVisibleIssueEntries(task).length > 0;
export function getVisibleIssueEntries(
task: Pick<Task, 'issueEntries' | 'issueNote' | 'showIssue'>,
options?: IssueVisibilityOptions,
): TaskIssueEntry[] {
const entries = parseIssueEntries(task).filter((entry) => entry.showOnCard && entry.text.trim());
if (!options?.weekMonday) return entries;
return entries.filter((entry) => issueInWeek(entry, options.weekMonday!));
}
export function getPrimaryIssueText(task: Pick<Task, 'issueEntries' | 'issueNote' | 'showIssue'>): string | null {
const visible = getVisibleIssueEntries(task);
export function hasVisibleIssue(
task: Pick<Task, 'issueEntries' | 'issueNote' | 'showIssue'>,
options?: IssueVisibilityOptions,
): boolean {
return getVisibleIssueEntries(task, options).length > 0;
}
export function getPrimaryIssueText(
task: Pick<Task, 'issueEntries' | 'issueNote' | 'showIssue'>,
options?: IssueVisibilityOptions,
): string | null {
const visible = getVisibleIssueEntries(task, options);
if (visible.length > 0) return visible[visible.length - 1].text;
return null;
}
@@ -58,6 +90,7 @@ export function serializeIssueEntries(entries: TaskIssueEntry[]): TaskIssueEntry
id: entry.id,
text: entry.text.trim(),
showOnCard: entry.showOnCard,
occurredOn: entry.occurredOn?.trim() || null,
}))
.filter((entry) => entry.text.length > 0);
}

View File

@@ -1,5 +1,5 @@
import type { Task, TaskStatus } from '../types';
import { hasVisibleIssue } from './taskIssues';
import { hasVisibleIssue, type IssueVisibilityOptions } from './taskIssues';
/** DashboardHeader STAT_ACCENT 와 동일 */
export const TASK_STAT_COLORS = {
@@ -20,8 +20,9 @@ export interface DonutDisplay {
export function getProjectTitleStatusClass(
task: Pick<Task, 'status' | 'showIssue' | 'issueNote' | 'issueEntries'>,
options?: IssueVisibilityOptions,
): string {
if (hasVisibleIssue(task)) return 'project-sub-title--issue';
if (hasVisibleIssue(task, options)) return 'project-sub-title--issue';
switch (task.status) {
case 'IN_PROGRESS':
return 'project-sub-title--in-progress';

View File

@@ -1,4 +1,4 @@
import type { Task, TeamMember } from '../types';
import type { Milestone, Task, TeamMember } from '../types';
import { isRoutineTask, isProjectTask } from './taskType';
/** 셀 컬럼 기본 순서 — 팀원 데이터에 다른 셀이 있으면 뒤에 추가 */
@@ -142,6 +142,20 @@ export function getTaskPeopleHeaderParts(task: Task): {
return { pmName, assigneeNames };
}
/** 상세 헤더 — 단계(마일스톤) PM / 담당 */
export function getMilestonePeopleHeaderParts(milestone: Milestone | null | undefined): {
pmName: string | null;
assigneeNames: string[];
} {
if (!milestone) return { pmName: null, assigneeNames: [] };
const pmId = milestone.pmMember?.id ?? milestone.pmMemberId ?? null;
const pmName = milestone.pmMember?.name?.trim() || null;
const assigneeNames = (milestone.assigneeMembers ?? [])
.filter((m) => m.name?.trim() && m.id !== pmId)
.map((m) => m.name!.trim());
return { pmName, assigneeNames };
}
export function getHighlightMemberIds(task: Task | null | undefined): string[] {
if (!task) return [];
const ids: string[] = [];

View File

@@ -5,6 +5,7 @@ import App from './App';
import './index.css';
import './styles/quarter-board.css';
import './styles/detail-theme.css';
import './styles/task-manager.css';
const queryClient = new QueryClient({
defaultOptions: {

View File

@@ -25,6 +25,8 @@ import { DonutGauge } from '../components/dashboard/DonutGauge';
import { TaskManager } from '../components/dashboard/TaskManager';
import { useSocket } from '../contexts/SocketContext';
import { sendTaskSelected, openDetailWindow, registerSyncProvider, isDetailWindowOpen, getWindowPlacementHint } from '../lib/dualMonitor';
import { resolveTaskFocusMilestoneId } from '../lib/milestoneWeekFocus';
import { startOfWeekMonday } from '../lib/boardCalendar';
import { TaskDetailShell } from '../pages/DetailPage';
import { isRoutineTask, isProjectTask, displayFlagsForTaskType } from '../lib/taskType';
import { hasVisibleIssue } from '../lib/taskIssues';
@@ -97,7 +99,20 @@ export default function DashboardPage() {
const queryClient = useQueryClient();
const socket = useSocket();
const { referenceDate, setReferenceDate, quarter } = useBoardReferenceDate();
const {
referenceDate,
referenceWeekMonday,
quarter,
weekLensActive,
selectWeek,
selectQuarter,
returnToCurrentQuarter,
} = useBoardReferenceDate();
const issueVisibility = useMemo(
() => (weekLensActive ? { weekMonday: referenceWeekMonday } : undefined),
[weekLensActive, referenceWeekMonday],
);
const { data: tasks = [], isLoading } = useTasks({ quarter });
const { data: teamMembers = [] } = useTeamMembers();
@@ -288,7 +303,7 @@ export default function DashboardPage() {
};
const filtered = tasks.filter((t) => {
if (issueFilterActive) return hasVisibleIssue(t);
if (issueFilterActive) return hasVisibleIssue(t, issueVisibility);
return taskMatchesStatusFilters(t, activeFilters);
});
@@ -311,9 +326,9 @@ export default function DashboardPage() {
(t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO',
).length,
done: boardProjectTasks.filter((t) => t.status === 'DONE').length,
issues: boardProjectTasks.filter((t) => hasVisibleIssue(t)).length,
issues: boardProjectTasks.filter((t) => hasVisibleIssue(t, issueVisibility)).length,
}),
[boardProjectTasks],
[boardProjectTasks, issueVisibility],
);
const routineTasks = useMemo(
@@ -360,7 +375,7 @@ export default function DashboardPage() {
setSelectedTaskId(taskId);
setDetailStageId(stageId ?? null);
if (teamPanelOpen) setActiveTeamProjectId(taskId);
void sendTaskSelected(taskId, () => setDetailPopupOpen(false)).then((popupOpened) => {
void sendTaskSelected(taskId, stageId ?? null, () => setDetailPopupOpen(false)).then((popupOpened) => {
if (popupOpened) {
setDetailPopupOpen(true);
} else if (!ultraWideLayout) {
@@ -380,6 +395,15 @@ export default function DashboardPage() {
});
};
const resolveProjectStageId = (task: (typeof filtered)[number]) => {
const weekMonday = weekLensActive ? referenceWeekMonday : startOfWeekMonday(new Date());
return resolveTaskFocusMilestoneId(task, {
weekMonday,
weekLensActive,
quarter,
});
};
const renderDeptSlot = (slotId: typeof BOARD_SLOT_ORDER[number]) => {
const slot = getBoardSlot(slotId);
const label = slotSectionLabel(slot);
@@ -390,7 +414,8 @@ export default function DashboardPage() {
tasks={filtered.filter((t) => taskBelongsToBoardSlot(t, slot))}
orderedIds={columnOrders[label] ?? []}
quarter={quarter}
onSelectTask={(t) => handleSelectTask(t.id)}
referenceWeekMonday={weekLensActive ? referenceWeekMonday : undefined}
onSelectTask={(t) => handleSelectTask(t.id, resolveProjectStageId(t))}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
/>
@@ -425,8 +450,11 @@ export default function DashboardPage() {
)}
<DashboardHeader
quarter={quarter}
referenceDate={referenceDate}
onReferenceDateChange={setReferenceDate}
referenceWeekMonday={referenceWeekMonday}
weekLensActive={weekLensActive}
onSelectWeek={selectWeek}
onSelectQuarter={selectQuarter}
onReturnToCurrentQuarter={returnToCurrentQuarter}
stats={stats}
activeFilters={activeFilters}
issueFilterActive={issueFilterActive}

View File

@@ -2,9 +2,10 @@ import { useState, useEffect, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient, getApiErrorMessage } from '../lib/apiClient';
import { onDualMonitorEvent, getPersistedTaskId } from '../lib/dualMonitor';
import { onDualMonitorEvent, getPersistedTaskId, getPersistedStageId } from '../lib/dualMonitor';
import { ContextMenu } from '../components/common/ContextMenu';
import { FeedbackModal, type FeedbackFormData } from '../components/detail/FeedbackModal';
import { OverviewEditModal, type OverviewFormData } from '../components/detail/OverviewEditModal';
import { ResultPreview } from '../components/detail/ResultPreview';
import {
StageModal,
@@ -16,11 +17,15 @@ import { sortFilesByOrder } from '../lib/fileDisplay';
import { useAuth } from '../contexts/AuthContext';
import { formatSectionDisplay } from '../lib/sections';
import { getTaskPeopleHeaderParts } from '../lib/teamStatus';
import { isRoutineTask } from '../lib/taskType';
import { isRoutineTask, isProjectTask } from '../lib/taskType';
import { RoutineDetailShell } from '../components/detail/RoutineDetailView';
import { MilestoneTimeline } from '../components/detail/MilestoneTimeline';
import { MilestoneTimeline, type TimelineViewMode } from '../components/detail/MilestoneTimeline';
import { MilestoneContentList } from '../components/detail/MilestoneContentList';
import { taskTimelineFallback } from '../lib/milestoneTimeline';
import {
taskTimelineFallback,
sortMilestonesForTimeline,
} from '../lib/milestoneTimeline';
import { resolveTaskNearestMilestoneId } from '../lib/milestoneWeekFocus';
import { fmtMilestonePeriodSummary, serializePeriodEntries } from '../lib/milestonePeriods';
import type { Task, Milestone, FileRecord, TaskDetail } from '../types';
import { useSocket } from '../contexts/SocketContext';
@@ -62,13 +67,17 @@ function sortByIsoDesc<T>(items: T[], pick: (item: T) => string) {
return [...items].sort((a, b) => new Date(pick(b)).getTime() - new Date(pick(a)).getTime());
}
/** 시작일이 늦은 단계가 상단 (시작일 없으면 종료일 → 생성일 순) */
function sortStagesByStartDesc(stages: Milestone[]) {
const pickStart = (m: Milestone) =>
m.startDate ?? m.dueDate ?? m.createdAt;
return [...stages].sort(
(a, b) => new Date(pickStart(b)).getTime() - new Date(pickStart(a)).getTime(),
);
/** 시작일이 빠른 단계가 상단 (시작일 없으면 종료일 → 생성일 순) */
function sortStagesByStartAsc(stages: Milestone[]) {
return sortMilestonesForTimeline(stages);
}
function defaultStageId(milestones: Milestone[]): string | null {
if (milestones.length === 0) return null;
const sorted = sortStagesByStartAsc(milestones);
const nearest = resolveTaskNearestMilestoneId({ milestones }, new Date());
if (nearest && sorted.some((m) => m.id === nearest)) return nearest;
return sorted[0]?.id ?? null;
}
function feedbackAuthorName(detail: TaskDetail) {
@@ -119,9 +128,18 @@ function PanelLabel({ children, sub }: { children: React.ReactNode; sub?: string
);
}
function LeftSection({ children }: { children: React.ReactNode }) {
function LeftSection({
children,
onContextMenu,
}: {
children: React.ReactNode;
onContextMenu?: (e: React.MouseEvent) => void;
}) {
return (
<section className="flex min-h-0 flex-col overflow-hidden border-b border-[#e8edf2] px-4 py-3 last:border-b-0">
<section
className="flex min-h-0 flex-col overflow-hidden border-b border-[#e8edf2] px-4 py-3 last:border-b-0"
onContextMenu={onContextMenu}
>
{children}
</section>
);
@@ -179,7 +197,7 @@ function DetailHeader({ task }: { task: Task }) {
</span>
<span className="detail-page-header__divider" aria-hidden="true" />
<span>
<span className="detail-page-header__meta-label"> </span>
<span className="detail-page-header__meta-label"> </span>
<span className="detail-page-header__meta-value">{formatSectionDisplay(task.section)}</span>
</span>
<span className="detail-page-header__badge">{status.label}</span>
@@ -188,7 +206,13 @@ function DetailHeader({ task }: { task: Task }) {
);
}
function DetailView({ task }: { task: TaskWithRelations }) {
function DetailView({
task,
initialStageId,
}: {
task: TaskWithRelations;
initialStageId?: string | null;
}) {
const qc = useQueryClient();
const { user } = useAuth();
const [stageSaving, setStageSaving] = useState(false);
@@ -198,27 +222,65 @@ function DetailView({ task }: { task: TaskWithRelations }) {
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; stageId: string } | null>(null);
const [contentCtx, setContentCtx] = useState<{ x: number; y: number } | null>(null);
const [feedbackCtx, setFeedbackCtx] = useState<{ x: number; y: number; detailId?: string } | null>(null);
const [overviewCtx, setOverviewCtx] = useState<{ x: number; y: number } | null>(null);
const [overviewModalOpen, setOverviewModalOpen] = useState(false);
const [overviewSaving, setOverviewSaving] = useState(false);
const [overviewExpanded, setOverviewExpanded] = useState(false);
const [timelineView, setTimelineView] = useState<TimelineViewMode>('focus');
const milestones = task.milestones ?? [];
const files = task.files ?? [];
const details = task.details ?? [];
const sortedStages = useMemo(
() => sortStagesByStartDesc(milestones),
() => sortStagesByStartAsc(milestones),
[milestones],
);
const [selectedId, setSelectedId] = useState<string | null>(sortedStages[0]?.id ?? null);
const { data: quarterTasks = [] } = useQuery({
queryKey: ['tasks', { quarter: task.quarter }],
queryFn: async () => {
const { data } = await apiClient.get<Task[]>('/tasks', { params: { quarter: task.quarter } });
return data;
},
enabled: timelineView === 'portfolio',
staleTime: 30_000,
});
const quarterProjectTasks = useMemo(
() => quarterTasks.filter((t) => isProjectTask(t.taskType)),
[quarterTasks],
);
const portfolioTasks = useMemo(
() =>
quarterProjectTasks.map((t) => ({
id: t.id,
title: t.title,
milestones: sortMilestonesForTimeline((t.milestones ?? []) as Milestone[]),
})),
[quarterProjectTasks],
);
const showGanttPanel = timelineView !== 'focus';
const showPortfolioFull = timelineView === 'portfolio';
const [selectedId, setSelectedId] = useState<string | null>(() => {
if (initialStageId && milestones.some((m) => m.id === initialStageId)) return initialStageId;
return defaultStageId(milestones);
});
useEffect(() => {
if (!selectedId || !sortedStages.some((s) => s.id === selectedId)) {
setSelectedId(sortedStages[0]?.id ?? null);
if (initialStageId && sortedStages.some((s) => s.id === initialStageId)) {
setSelectedId(initialStageId);
return;
}
}, [task.id, sortedStages, selectedId]);
setSelectedId(defaultStageId(sortedStages));
}, [task.id, initialStageId, sortedStages]);
useEffect(() => {
setOverviewExpanded(false);
setTimelineView('focus');
}, [task.id]);
const selected = sortedStages.find((m) => m.id === selectedId) ?? sortedStages[0] ?? null;
@@ -248,7 +310,7 @@ function DetailView({ task }: { task: TaskWithRelations }) {
const deleteMs = useMutation({
mutationFn: (id: string) => apiClient.delete(`/milestones/item/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['task', task.id] }),
onSuccess: () => invalidateTaskCaches(qc, task.id),
});
const uploadFiles = async (milestoneId: string, filePayload: StageFileSavePayload['uploads']) => {
@@ -320,7 +382,7 @@ function DetailView({ task }: { task: TaskWithRelations }) {
alert(`단계는 저장됐지만 ${getApiErrorMessage(err, '파일 처리에 실패했습니다.')}`);
}
await qc.invalidateQueries({ queryKey: ['task', task.id] });
await invalidateTaskCaches(qc, task.id);
setStageModal(null);
} catch (err: unknown) {
alert(getApiErrorMessage(err, '단계 저장에 실패했습니다.'));
@@ -349,7 +411,7 @@ function DetailView({ task }: { task: TaskWithRelations }) {
authorName: data.authorName.trim() || null,
});
}
await qc.invalidateQueries({ queryKey: ['task', task.id] });
await invalidateTaskCaches(qc, task.id);
setFeedbackModal(null);
} catch (err: unknown) {
alert(getApiErrorMessage(err, '피드백 저장에 실패했습니다.'));
@@ -360,39 +422,70 @@ function DetailView({ task }: { task: TaskWithRelations }) {
const deleteFeedback = useMutation({
mutationFn: (id: string) => apiClient.delete(`/details/item/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['task', task.id] }),
onSuccess: () => invalidateTaskCaches(qc, task.id),
});
const overviewRaw = task.description?.trim() ?? '';
const overviewDisplay = overviewRaw || '등록된 개요가 없습니다.';
const canExpandOverview =
overviewRaw.length > 0 && (overviewRaw.includes('\n') || overviewRaw.length > 56);
const detailRaw = task.detailDescription?.trim() ?? '';
const hasDetailContent = detailRaw.length > 0;
const handleOverviewSave = async (data: OverviewFormData) => {
setOverviewSaving(true);
try {
await apiClient.patch(`/tasks/${task.id}`, {
description: data.description.trim() || null,
detailDescription: data.detailDescription.trim() || null,
});
await invalidateTaskCaches(qc, task.id);
setOverviewModalOpen(false);
} catch (err: unknown) {
alert(getApiErrorMessage(err, '개요 저장에 실패했습니다.'));
} finally {
setOverviewSaving(false);
}
};
return (
<div className="grid h-full min-h-0 grid-cols-[1fr_3fr] grid-rows-1">
{/* 좌 1/4 — 1:2:2:1 세로 비율 */}
<div
className={`grid h-full min-h-0 grid-rows-1 ${showPortfolioFull ? 'detail-page-grid--portfolio' : 'grid-cols-[1fr_3fr]'}`}
>
{!showPortfolioFull && (
<aside className="detail-aside detail-aside--project grid h-full min-h-0 overflow-hidden">
<LeftSection>
<LeftSection
onContextMenu={(e) => {
e.preventDefault();
setOverviewCtx({ x: e.clientX, y: e.clientY });
}}
>
<PanelLabel></PanelLabel>
<div className={`detail-overview${overviewExpanded ? ' is-expanded' : ''}`}>
<div className={`detail-overview${hasDetailContent ? ' detail-overview--has-detail' : ''}`}>
<p
className={`detail-body-text detail-overview__text${overviewExpanded ? '' : ' is-clamped'}`}
className={`detail-body-text detail-overview__text${hasDetailContent ? ' is-clamped' : ''}`}
>
{overviewDisplay}
</p>
{canExpandOverview && (
{hasDetailContent && (
<button
type="button"
className="detail-overview__more"
aria-expanded={overviewExpanded}
aria-label={overviewExpanded ? '개요 접기' : '개요 더 보기'}
title={overviewExpanded ? '접기' : '더 보기'}
aria-label={overviewExpanded ? '상세내용 접기' : '상세내용 펼치기'}
title={overviewExpanded ? '상세내용 접기' : '상세내용 펼치기'}
onClick={() => setOverviewExpanded((v) => !v)}
>
<OverviewMoreIcon expanded={overviewExpanded} />
</button>
)}
</div>
{hasDetailContent && (
<div
className={`detail-overview-detail${overviewExpanded ? ' is-expanded' : ''}`}
aria-hidden={!overviewExpanded}
>
<p className="detail-body-text detail-overview-detail__text">{detailRaw}</p>
</div>
)}
</LeftSection>
<LeftSection>
@@ -498,21 +591,30 @@ function DetailView({ task }: { task: TaskWithRelations }) {
</div>
</LeftSection>
</aside>
)}
{/* 우 3/4 */}
<div className="flex h-full min-h-0 min-w-0 flex-col">
<ResultPreview
files={stageFiles}
links={stageLinks}
hasSelectedStage={!!selectedId}
/>
<div
className={`detail-main-panel flex h-full min-h-0 min-w-0 flex-col${showGanttPanel ? ' detail-panel--gantt' : ''}${showPortfolioFull ? ' detail-panel--portfolio' : ''}`}
>
{timelineView === 'focus' && (
<ResultPreview
files={stageFiles}
links={stageLinks}
hasSelectedStage={!!selectedId}
/>
)}
<MilestoneTimeline
variant="project"
milestones={sortedStages}
portfolioTasks={portfolioTasks}
fallback={taskTimelineFallback(task)}
selectedId={selectedId}
onSelect={setSelectedId}
preserveRowOrder
ownerTaskId={task.id}
viewMode={timelineView}
onViewModeChange={setTimelineView}
emptyMessage="기간을 설정한 업무 일정만 타임라인에 표시됩니다."
/>
</div>
@@ -590,6 +692,33 @@ function DetailView({ task }: { task: TaskWithRelations }) {
/>
)}
{overviewCtx && (
<ContextMenu
x={overviewCtx.x}
y={overviewCtx.y}
onClose={() => setOverviewCtx(null)}
items={[
{
label: '수정',
icon: '✏️',
onClick: () => setOverviewModalOpen(true),
},
]}
/>
)}
{overviewModalOpen && (
<OverviewEditModal
initial={{
description: task.description ?? '',
detailDescription: task.detailDescription ?? '',
}}
saving={overviewSaving}
onClose={() => setOverviewModalOpen(false)}
onSave={handleOverviewSave}
/>
)}
{feedbackCtx?.detailId && (
<ContextMenu
x={feedbackCtx.x}
@@ -670,7 +799,7 @@ export function TaskDetailShell({
<div className="detail-page-shell flex h-full min-h-0 flex-col overflow-hidden">
<DetailHeader task={task} />
<div className="h-full min-h-0 flex-1">
<DetailView task={task} />
<DetailView task={task} initialStageId={initialStageId} />
</div>
</div>
)}
@@ -686,6 +815,7 @@ export default function DetailPage() {
const [taskId, setTaskId] = useState<string | null>(
() => routeTaskId ?? getPersistedTaskId(),
);
const [stageId, setStageId] = useState<string | null>(() => getPersistedStageId());
useEffect(() => {
if (routeTaskId) setTaskId(routeTaskId);
@@ -694,8 +824,14 @@ export default function DetailPage() {
useEffect(() => {
const unsub = onDualMonitorEvent(
(evt) => {
if (evt.type === 'TASK_SELECTED') setTaskId(evt.taskId);
if (evt.type === 'TASK_DESELECTED') setTaskId(null);
if (evt.type === 'TASK_SELECTED') {
setTaskId(evt.taskId);
setStageId(evt.stageId ?? null);
}
if (evt.type === 'TASK_DESELECTED') {
setTaskId(null);
setStageId(null);
}
},
{ isPopupView },
);
@@ -706,11 +842,11 @@ export default function DetailPage() {
return (
<div className="detail-popup-view flex h-screen w-screen overflow-hidden">
<aside className="app-right detail-open flex h-full min-h-0 w-full flex-col overflow-hidden">
<TaskDetailShell taskId={taskId} />
<TaskDetailShell taskId={taskId} initialStageId={stageId} />
</aside>
</div>
);
}
return <TaskDetailShell taskId={taskId} />;
return <TaskDetailShell taskId={taskId} initialStageId={stageId} />;
}

View File

@@ -51,7 +51,20 @@ export default function DummyDashboardPage() {
const [issueFilterActive, setIssueFilterActive] = useState(false);
const [teamPanelOpen, setTeamPanelOpen] = useState(false);
const [columnOrders, setColumnOrders] = useState<Record<string, string[]>>({});
const { referenceDate, setReferenceDate, quarter } = useBoardReferenceDate();
const {
referenceDate,
referenceWeekMonday,
quarter,
weekLensActive,
selectWeek,
selectQuarter,
returnToCurrentQuarter,
} = useBoardReferenceDate();
const issueVisibility = useMemo(
() => (weekLensActive ? { weekMonday: referenceWeekMonday } : undefined),
[weekLensActive, referenceWeekMonday],
);
const { data: tasks = [], isLoading } = useTasks({ quarter });
@@ -115,7 +128,7 @@ export default function DummyDashboardPage() {
}, [tasks]);
const filtered = tasks.filter((t) => {
if (issueFilterActive) return hasVisibleIssue(t);
if (issueFilterActive) return hasVisibleIssue(t, issueVisibility);
return taskMatchesStatusFilters(t, activeFilters);
});
@@ -137,9 +150,9 @@ export default function DummyDashboardPage() {
(t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO',
).length,
done: boardProjectTasks.filter((t) => t.status === 'DONE').length,
issues: boardProjectTasks.filter((t) => hasVisibleIssue(t)).length,
issues: boardProjectTasks.filter((t) => hasVisibleIssue(t, issueVisibility)).length,
}),
[boardProjectTasks],
[boardProjectTasks, issueVisibility],
);
const routineTasks = useMemo(
@@ -177,8 +190,11 @@ export default function DummyDashboardPage() {
<div className="app-main relative flex h-screen min-w-0 flex-col overflow-hidden bg-[#e9eef2]">
<DashboardHeader
quarter={quarter}
referenceDate={referenceDate}
onReferenceDateChange={setReferenceDate}
referenceWeekMonday={referenceWeekMonday}
weekLensActive={weekLensActive}
onSelectWeek={selectWeek}
onSelectQuarter={selectQuarter}
onReturnToCurrentQuarter={returnToCurrentQuarter}
stats={stats}
activeFilters={activeFilters}
issueFilterActive={issueFilterActive}

View File

@@ -10,8 +10,11 @@
--detail-header-bg: var(--app-header-bg);
--detail-header-border: var(--app-header-border);
--detail-page-bg: #fff;
--detail-card-bg: linear-gradient(168deg, #ffffff 0%, var(--detail-ref-hub-soft) 100%);
--detail-page-bg: #f2f5f9;
--detail-aside-bg: #ffffff;
--detail-panel-bg: #ffffff;
--detail-zone-border: var(--detail-border);
--detail-card-bg: #ffffff;
--detail-text-title: var(--detail-ref-title);
--detail-text-body: var(--detail-ref-title);
--detail-text-secondary: var(--detail-ref-hub-dark);
@@ -133,18 +136,31 @@
.detail-page-header__tab {
flex-shrink: 0;
padding: 4px 14px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 3px 12px;
border: 1px solid transparent;
border-radius: 12px;
border-radius: 10px;
font-family: inherit;
font-size: 20px;
font-weight: 500;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.3px;
line-height: 1.2;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.85);
color: rgba(255, 255, 255, 0.88);
}
.detail-page-header__tab::before {
content: "";
flex-shrink: 0;
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
opacity: 0.9;
}
.detail-page-header__tab:hover:not(:disabled) {
@@ -156,10 +172,14 @@
border-color: rgba(255, 255, 255, 0.35);
background: #fff;
color: var(--detail-ref-title);
font-weight: 600;
font-weight: 700;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.detail-page-header__tab.is-active::before {
background: var(--detail-ref-hub-dark);
}
.detail-page-header__tab:disabled {
opacity: 0.55;
cursor: wait;
@@ -168,6 +188,8 @@
/* ─── 본문 타이포 (quarter-board project-field · board-project-title) ─── */
.detail-section-label {
margin: 0;
padding-left: 10px;
border-left: 3px solid var(--detail-ref-hub);
color: var(--detail-text-title);
font-size: 20px;
font-weight: 700;
@@ -187,7 +209,7 @@
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--detail-border);
flex-shrink: 0;
@@ -330,10 +352,24 @@
}
.detail-aside {
background: var(--detail-card-bg);
background: var(--detail-aside-bg);
border-right: 1px solid var(--detail-border);
}
.detail-aside > section {
background: transparent;
border-bottom-color: var(--detail-border);
}
.detail-main-panel {
background: var(--detail-panel-bg);
border-left: 1px solid var(--detail-border);
}
.detail-page-shell > .grid {
background: var(--detail-page-bg);
}
/* 프로젝트 상세 — 업무 일정 3단계 고정 노출, 업무내용 위로 */
.detail-aside--project {
--detail-stage-card-h: 80px;
@@ -360,16 +396,49 @@
}
.detail-overview {
display: flex;
align-items: flex-start;
gap: 4px;
position: relative;
width: 100%;
min-width: 0;
}
.detail-overview--has-detail .detail-overview__text {
padding-right: 26px;
}
.detail-overview__text {
flex: 1;
min-width: 0;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
}
.detail-overview-detail {
max-height: 0;
overflow: hidden;
opacity: 0;
margin-top: 0;
transition:
max-height 0.28s ease,
opacity 0.22s ease,
margin-top 0.28s ease;
}
.detail-overview-detail.is-expanded {
max-height: min(42vh, 280px);
opacity: 1;
margin-top: 6px;
overflow-y: auto;
}
.detail-overview-detail__text {
margin: 0;
padding-top: 2px;
white-space: pre-wrap;
word-break: break-word;
color: var(--detail-text-body, #1e293b);
font-size: var(--detail-body-size, 20px);
line-height: 1.45;
}
.detail-overview__text.is-clamped {
@@ -377,6 +446,7 @@
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
white-space: normal;
}
.detail-overview.is-expanded .detail-overview__text {
@@ -387,17 +457,21 @@
}
.detail-overview__more {
position: absolute;
top: 2px;
right: 0;
z-index: 1;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-top: 2px;
margin-top: 0;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
background: var(--detail-card-bg, #fff);
color: var(--detail-text-secondary);
cursor: pointer;
opacity: 0.72;
@@ -454,7 +528,7 @@
.detail-stage-card {
border-color: var(--detail-border);
background: rgba(255, 255, 255, 0.55);
background: #fff;
}
.detail-stage-card.is-selected {
@@ -495,17 +569,29 @@
cursor: default;
}
/* ─── 업무별 타임라인 (간트) — 게이지 4행 고정 ─── */
/* ─── 업무별 타임라인 (간트) — 대시보드 타이포 기준 ─── */
.milestone-timeline {
--mt-row-height: 28px;
--mt-row-gap: 4px;
/* 타이포: 최소 18 / 내용 20 / 강조 24 */
--mt-font-min: 18px;
--mt-font-min-weight: 500;
--mt-font-content: 20px;
--mt-font-content-weight: 500;
--mt-font-title: 24px;
--mt-font-title-weight: 600;
--mt-row-height: 38px;
--mt-bar-height: 28px;
--mt-tick-height: 26px;
--mt-row-gap: 6px;
--mt-visible-rows: 4;
--mt-chart-height: calc(
var(--mt-visible-rows) * var(--mt-row-height)
+ (var(--mt-visible-rows) - 1) * var(--mt-row-gap)
+ 12px
+ 14px
);
--mt-footer-height: calc(
38px + 8px + var(--mt-tick-height) + 4px + var(--mt-chart-height) + 26px
);
--mt-footer-height: calc(26px + 33px + 8px + 22px + 4px + var(--mt-chart-height));
flex-shrink: 0;
display: flex;
@@ -516,7 +602,7 @@
max-height: var(--mt-footer-height);
padding: 12px 20px 14px;
border-top: 1px solid var(--detail-border);
background: linear-gradient(168deg, #ffffff 0%, var(--detail-ref-hub-soft) 100%);
background: #fff;
overflow: hidden;
}
@@ -530,16 +616,16 @@
.milestone-timeline__title {
color: var(--detail-text-title);
font-size: 24px;
font-weight: 600;
font-size: var(--mt-font-title);
font-weight: var(--mt-font-title-weight);
letter-spacing: -0.2px;
line-height: 1.35;
}
.milestone-timeline__subtitle {
color: var(--detail-accent);
font-size: 20px;
font-weight: 600;
font-size: var(--mt-font-content);
font-weight: var(--mt-font-content-weight);
letter-spacing: -0.1px;
max-width: 45%;
}
@@ -555,8 +641,8 @@
border-radius: 8px;
background: rgba(255, 255, 255, 0.45);
color: var(--detail-text-muted);
font-size: 20px;
font-weight: 500;
font-size: var(--mt-font-content);
font-weight: var(--mt-font-content-weight);
line-height: 1.45;
opacity: 0.72;
text-align: center;
@@ -569,12 +655,13 @@
flex-direction: column;
gap: 4px;
overflow: hidden;
position: relative;
}
.milestone-timeline__ticks {
position: relative;
flex-shrink: 0;
height: 22px;
height: var(--mt-tick-height);
margin: 0 4px;
}
@@ -583,8 +670,8 @@
top: 0;
transform: translateX(-50%);
color: var(--detail-text-body);
font-size: 13px;
font-weight: 600;
font-size: var(--mt-font-min);
font-weight: var(--mt-font-min-weight);
letter-spacing: -0.2px;
line-height: 1;
white-space: nowrap;
@@ -601,7 +688,7 @@
.milestone-timeline__tick.is-today .milestone-timeline__tick-label {
color: var(--detail-ref-hub-dark);
font-weight: 800;
font-weight: 700;
text-decoration: underline;
text-decoration-color: var(--detail-ref-hub);
text-underline-offset: 2px;
@@ -614,12 +701,12 @@
height: var(--mt-chart-height);
min-height: var(--mt-chart-height);
max-height: var(--mt-chart-height);
overflow-y: auto;
overflow-x: hidden;
overflow: hidden auto;
margin: 0 4px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.55);
border: 1px solid var(--detail-border);
isolation: isolate;
}
.milestone-timeline__grid {
@@ -656,7 +743,7 @@
.milestone-timeline__bar {
position: absolute;
top: 50%;
height: 22px;
height: var(--mt-bar-height);
transform: translateY(-50%);
padding: 0;
border: none;
@@ -672,6 +759,10 @@
opacity: 0.92;
}
.milestone-timeline__bar.is-gantt {
overflow: visible;
}
.milestone-timeline__bar.is-expanded {
z-index: 5;
overflow: visible;
@@ -689,8 +780,8 @@
visibility: hidden;
pointer-events: none;
white-space: nowrap;
font-size: 18px;
font-weight: 500;
font-size: var(--mt-font-min);
font-weight: var(--mt-font-min-weight);
letter-spacing: -0.1px;
}
@@ -732,12 +823,12 @@
.milestone-timeline__bar-label {
position: absolute;
inset: 0;
padding: 0 8px;
padding: 0 10px;
overflow: hidden;
min-width: 0;
font-size: 18px;
font-weight: 500;
line-height: 22px;
font-size: var(--mt-font-min);
font-weight: var(--mt-font-min-weight);
line-height: var(--mt-bar-height);
letter-spacing: -0.1px;
white-space: nowrap;
text-overflow: ellipsis;
@@ -763,3 +854,301 @@
overflow: visible;
text-overflow: clip;
}
/* ─── 타임라인 보기 전환 ─── */
.milestone-timeline__view-toggle {
display: inline-flex;
gap: 4px;
margin-left: auto;
padding: 3px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.65);
border: 1px solid var(--detail-border);
}
.milestone-timeline__view-toggle button {
padding: 6px 14px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--detail-text-muted);
font-size: var(--mt-font-min);
font-weight: var(--mt-font-min-weight);
cursor: pointer;
white-space: nowrap;
}
.milestone-timeline__view-toggle button.is-active {
background: var(--detail-ref-hub);
color: #fff;
font-weight: 600;
}
.detail-panel--gantt {
min-height: 0;
}
.detail-page-grid--portfolio {
grid-template-columns: 1fr;
}
.detail-panel--portfolio {
min-height: 0;
}
.detail-panel--gantt .milestone-timeline--gantt,
.detail-panel--portfolio .milestone-timeline--portfolio {
flex: 1;
min-height: 0;
max-height: none;
height: auto;
--mt-visible-rows: max(4, var(--mt-row-count, 4));
}
.milestone-timeline--gantt .milestone-timeline__gantt-layout,
.milestone-timeline--portfolio .milestone-timeline__gantt-layout {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(200px, 32%) minmax(0, 1fr);
gap: 0;
overflow: hidden;
position: relative;
isolation: isolate;
}
.milestone-timeline--gantt .milestone-timeline__row-labels,
.milestone-timeline--portfolio .milestone-timeline__row-labels {
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid var(--detail-border);
background: rgba(255, 255, 255, 0.55);
position: relative;
z-index: 1;
}
.milestone-timeline--gantt .milestone-timeline__row-labels-head,
.milestone-timeline--portfolio .milestone-timeline__row-labels-head {
flex-shrink: 0;
height: var(--mt-tick-height);
margin-bottom: 4px;
padding: 0 10px;
color: var(--detail-text-muted);
font-size: var(--mt-font-min);
font-weight: var(--mt-font-min-weight);
letter-spacing: 0.02em;
line-height: var(--mt-tick-height);
}
.milestone-timeline--gantt .milestone-timeline__row-labels-body,
.milestone-timeline--portfolio .milestone-timeline__row-labels-body {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--mt-row-gap);
padding: 6px 0;
}
.milestone-timeline--gantt .milestone-timeline__row-label,
.milestone-timeline--portfolio .milestone-timeline__row-label {
height: var(--mt-row-height);
flex-shrink: 0;
padding: 0 10px;
border: none;
background: transparent;
color: var(--detail-text-title);
font-size: var(--mt-font-min);
font-weight: var(--mt-font-min-weight);
text-align: left;
line-height: var(--mt-row-height);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.milestone-timeline--gantt .milestone-timeline__row-label.is-selected,
.milestone-timeline--portfolio .milestone-timeline__row-label.is-selected {
color: var(--detail-ref-hub-dark);
background: rgba(74, 144, 217, 0.1);
font-weight: 600;
}
.milestone-timeline--portfolio .milestone-timeline__portfolio-layout {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
}
.milestone-timeline--portfolio .milestone-timeline__portfolio-head {
display: grid;
grid-template-columns: minmax(200px, 32%) minmax(0, 1fr);
gap: 0;
flex-shrink: 0;
align-items: end;
}
.milestone-timeline--portfolio .milestone-timeline__portfolio-chart-head {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.milestone-timeline--portfolio .milestone-timeline__body--ticks-only {
position: relative;
height: var(--mt-tick-height);
margin: 0 4px;
}
.milestone-timeline--portfolio .milestone-timeline__viewport-range {
padding: 0 8px 2px;
color: var(--detail-text-muted);
font-size: var(--mt-font-min);
font-weight: var(--mt-font-min-weight);
text-align: right;
}
.milestone-timeline__viewport-range {
padding: 0 8px 2px;
color: var(--detail-text-muted);
font-size: var(--mt-font-min);
font-weight: var(--mt-font-min-weight);
text-align: right;
}
.milestone-timeline--portfolio .milestone-timeline__portfolio-scroll {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(200px, 32%) minmax(0, 1fr);
gap: 0;
overflow: hidden;
}
.milestone-timeline--portfolio .milestone-timeline__group-label {
height: var(--mt-row-height);
flex-shrink: 0;
padding: 0 10px;
color: var(--detail-text-title);
font-size: var(--mt-font-content);
font-weight: 600;
line-height: var(--mt-row-height);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: rgba(74, 144, 217, 0.06);
border-bottom: 1px solid rgba(74, 144, 217, 0.1);
}
.milestone-timeline--portfolio .milestone-timeline__row-label--child {
padding-left: 24px;
font-weight: var(--mt-font-min-weight);
font-size: var(--mt-font-min);
}
.milestone-timeline--portfolio .milestone-timeline__row--group {
background: transparent;
pointer-events: none;
}
.milestone-timeline__chart-head {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex-shrink: 0;
}
.milestone-timeline__focus-layout {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
}
.milestone-timeline__chart--pannable {
cursor: grab;
user-select: none;
}
.milestone-timeline__chart--pannable.is-panning {
cursor: grabbing;
}
.milestone-timeline__pan-hint,
.milestone-timeline--portfolio .milestone-timeline__portfolio-hint {
flex-shrink: 0;
margin: 0;
padding: 0 4px 2px;
color: var(--detail-text-muted);
font-size: var(--mt-font-min);
font-weight: var(--mt-font-min-weight);
text-align: right;
opacity: 0.85;
}
.milestone-timeline--portfolio .milestone-timeline__grid--rows {
inset: 0;
z-index: 0;
}
.milestone-timeline--portfolio .milestone-timeline__row-label.is-other-task,
.milestone-timeline--portfolio .milestone-timeline__row.is-other-task .milestone-timeline__bar {
opacity: 0.72;
}
.milestone-timeline--portfolio .milestone-timeline__row-labels-body {
border-right: 1px solid var(--detail-border);
background: rgba(255, 255, 255, 0.55);
}
.milestone-timeline--gantt .milestone-timeline__body,
.milestone-timeline--portfolio .milestone-timeline__body {
min-width: 0;
}
.milestone-timeline--gantt .milestone-timeline__chart,
.milestone-timeline--portfolio .milestone-timeline__chart {
flex: 1;
min-height: calc(var(--mt-visible-rows) * var(--mt-row-height) + (var(--mt-visible-rows) - 1) * var(--mt-row-gap) + 14px);
max-height: none;
height: auto;
}
.milestone-timeline__today-line {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
transform: translateX(-50%);
background: var(--detail-ref-hub);
box-shadow: 0 0 6px rgba(74, 144, 217, 0.35);
z-index: 2;
pointer-events: none;
}
.milestone-timeline__bar-progress {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
z-index: 3;
padding: 2px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: var(--detail-ref-hub-dark);
border: 1px solid rgba(74, 144, 217, 0.25);
font-size: var(--mt-font-min);
font-weight: 600;
line-height: 1.25;
pointer-events: none;
}

View File

@@ -485,14 +485,22 @@
transition: stroke 0.2s ease;
}
/* 선택(is-active) 시에만 연동 — 아이콘 직접 hover 없음 */
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item.is-active) .hub-diamond-icon {
color: var(--ref-hub-routine-active);
filter: drop-shadow(0 0 6px rgba(37, 99, 171, 0.35));
transform: scale(1.1);
.dummy-board-page .hub-diamond-icon:hover svg path,
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item:hover) .hub-diamond-icon svg path {
filter: none !important;
}
/* 선택(is-active) · hover — 색 진하게만, 그림자 없음 */
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item.is-active) .hub-diamond-icon,
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item:hover) .hub-diamond-icon {
filter: none;
transform: none;
}
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item.is-active) .hub-diamond-icon {
color: var(--ref-hub-routine-active);
}
/* quarter-board 녹색 path hover 덮어쓰기 */
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item.is-active) .hub-diamond-icon svg path {
stroke: var(--ref-hub-routine-active) !important;
filter: none !important;
@@ -500,8 +508,6 @@
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item:hover:not(.is-active)) .hub-diamond-icon {
color: var(--ref-hub-routine-hover);
filter: none;
transform: scale(1);
}
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item:hover:not(.is-active)) .hub-diamond-icon svg path {
@@ -532,33 +538,33 @@
.dummy-board-page .hub-routine-item {
color: var(--ref-hub-routine);
padding: 2px 2px;
transition: color 0.2s ease, text-shadow 0.2s ease;
text-shadow: none;
font-weight: 500;
transition: color 0.2s ease, font-weight 0.2s ease;
text-shadow: none !important;
}
.dummy-board-page .hub-routine-item::before {
background: currentColor;
transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: none;
transition: background 0.2s ease;
box-shadow: none !important;
}
.dummy-board-page .hub-routine-item:hover {
color: var(--ref-hub-routine-hover);
text-shadow: none;
font-weight: 700;
text-shadow: none !important;
}
.dummy-board-page .hub-routine-item.is-active {
color: var(--ref-hub-routine-active);
text-shadow: 0 0 6px rgba(37, 99, 171, 0.35);
}
.dummy-board-page .hub-routine-item:hover::before {
transform: scale(1.05);
font-weight: 700;
text-shadow: none !important;
}
.dummy-board-page .hub-routine-item:hover::before,
.dummy-board-page .hub-routine-item.is-active::before {
transform: scale(1.1);
box-shadow: 0 0 5px rgba(37, 99, 171, 0.4);
box-shadow: none !important;
transform: none;
}
.dummy-board-page .hub-box--focus {
@@ -1008,6 +1014,7 @@
--dept-donut-size: 96px;
--project-field-line-gap: 5px;
--project-field-edge-pad: 10px;
--project-week-row-font-size: 20px;
}
.dummy-board-page--2slots .dept-card {
@@ -1020,12 +1027,12 @@
justify-content: space-between;
gap: 12px;
min-height: calc(var(--dept-icon-size-2slots) - var(--dept-icon-protrude) + 6px);
padding: calc(var(--dept-icon-protrude) + 2px) var(--dept-slot-pad-x);
padding: 14px var(--dept-slot-pad-x);
}
.dummy-board-page--2slots .dept-head .dept-icon {
left: var(--dept-slot-pad-x);
top: calc(-1 * var(--dept-icon-protrude));
top: calc(-0.9 * var(--dept-icon-protrude));
width: var(--dept-icon-size-2slots);
height: var(--dept-icon-size-2slots);
}
@@ -1050,7 +1057,7 @@
flex-direction: column;
flex: 1 1 0;
min-height: 0;
padding: var(--dept-head-list-gap) var(--dept-slot-pad-x) var(--dept-list-inner-bottom);
padding: 4px var(--dept-slot-pad-x) var(--dept-list-inner-bottom);
overflow: hidden;
border-radius: 0 0 var(--dept-card-inner-radius) var(--dept-card-inner-radius);
background: var(--ref-soft, #f4f6f9);
@@ -1125,7 +1132,7 @@
display: flex;
flex-direction: column;
gap: 5px;
transform: translateY(-50%);
transform: translateY(-70%);
pointer-events: none;
}
@@ -1178,24 +1185,19 @@
margin-top: auto;
}
.dummy-board-page--2slots .project-field--issue-reserved {
visibility: hidden;
pointer-events: none;
}
.dummy-board-page--2slots .project-sub-title,
.dummy-board-page--2slots .project-field {
flex-shrink: 0;
}
/* 수행 기간 · 주요 내용 — 라벨 글씨만 숨김 (값은 표시) */
.dummy-board-page--2slots .project-field:not(.project-field--issue) {
/* 수행 기간 · 개요 — 라벨 글씨만 숨김. 주차 보기(--week-*)는 라벨 표시 */
.dummy-board-page--2slots .project-field:not(.project-field--issue):not(.project-field--week-focus):not(.project-field--week-period):not(.project-field--week-empty) {
grid-template-columns: 1fr;
gap: 0;
padding-left: var(--project-title-indent);
}
.dummy-board-page--2slots .project-field:not(.project-field--issue) .project-field-label {
.dummy-board-page--2slots .project-field:not(.project-field--issue):not(.project-field--week-focus):not(.project-field--week-period):not(.project-field--week-empty) .project-field-label {
display: none;
}
@@ -1225,7 +1227,32 @@
line-height: 1.28;
}
.dummy-board-page--2slots .project-field .project-field-value {
.dummy-board-page--2slots .project-field--issue-reserved {
visibility: hidden;
pointer-events: none;
}
.dummy-board-page--2slots .project-field--overview:not(.project-field--overview-expanded) .project-field-value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
line-height: 1.28;
}
.dummy-board-page--2slots .project-field--overview-expanded .project-field-value {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
text-overflow: ellipsis;
line-height: 1.28;
word-break: break-word;
min-height: calc(2 * 1.28em + var(--project-field-line-gap));
}
.dummy-board-page--2slots .project-field:not(.project-field--overview) .project-field-value:not(.project-field-value--issue) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1241,6 +1268,45 @@
text-overflow: ellipsis;
}
.dummy-board-page--2slots .project-field--week-focus,
.dummy-board-page--2slots .project-field--week-period {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 2px 10px;
align-items: baseline;
padding-left: var(--project-title-indent);
}
.dummy-board-page--2slots .project-field--week-focus .project-field-label,
.dummy-board-page--2slots .project-field--week-period .project-field-label,
.dummy-board-page--2slots .project-field--week-focus .project-field-value,
.dummy-board-page--2slots .project-field--week-period .project-field-value {
font-family: inherit;
font-size: var(--project-week-row-font-size);
line-height: 1.28;
letter-spacing: -0.2px;
}
.dummy-board-page--2slots .project-field--week-focus .project-field-label,
.dummy-board-page--2slots .project-field--week-period .project-field-label {
display: block !important;
color: #94a3b8;
font-weight: 600;
white-space: nowrap;
}
.dummy-board-page--2slots .project-field--week-focus .project-field-value,
.dummy-board-page--2slots .project-field--week-period .project-field-value {
color: #5a6b62;
font-weight: 500;
min-width: 0;
}
.dummy-board-page--2slots .project-field--week-empty .project-field-value {
color: #94a3b8;
font-weight: 500;
}
.dummy-board-page--2slots .project-field--issue {
display: grid;
grid-template-columns: 18px 1fr;

View File

@@ -526,15 +526,15 @@
--hub-band-h: min(calc(var(--hub-row-h) * 0.473), 171px);
--hub-slogan-band-h: min(calc(var(--hub-row-h) * 0.567), 205px);
/* 일정: 박스 top → 제목 (플래너·헤더 띠에서 역산) */
--hub-title-top: 20px;
--hub-title-top: 12px;
/* 슬로건: 포스트잇 상단 = 부서 카드 헤더 상단 */
--hub-slogan-pad-top: 0;
--hub-slogan-postit-pad-top: 10px;
--hub-slogan-postit-pad-bottom: 10px;
/* 일정: 헤더 띠 + 플래너 (플래너 padding-top도 title-top에서 역산) */
--hub-head-offset-top: -2px;
--hub-head-pad-top: 8px;
--hub-head-pad-bottom: 7px;
--hub-head-pad-top: 5px;
--hub-head-pad-bottom: 6px;
--hub-card-pad-x: 12px;
--hub-card-pad-bottom: 10px;
--hub-postit-pad-x: 14px;
@@ -605,24 +605,9 @@
.hub-routine-focus-body {
display: flex;
align-items: stretch;
gap: 4px;
min-height: 0;
flex: 1;
}
.hub-routine-focus-nav {
flex: 0 0 20px;
border: none;
background: none;
color: var(--hub-diamond-border, #2f8a66);
opacity: 0.45;
cursor: pointer;
align-self: center;
}
.hub-routine-focus-nav:disabled {
opacity: 0.15;
cursor: default;
width: 100%;
}
.hub-focus-task-list {
@@ -1075,8 +1060,8 @@
}
.hub-box--focus .hub-schedule-planner {
flex: 1 1 auto;
height: 100%;
flex: 0 0 auto;
height: auto;
min-height: 0;
justify-content: flex-start;
}
@@ -1086,20 +1071,139 @@
}
.hub-box--focus .hub-schedule-viewport {
flex: 1 1 auto;
flex: 0 0 auto;
min-height: 0;
}
.hub-box--focus .hub-schedule-viewport {
flex: 1 1 auto;
min-height: 0;
.hub-box--focus .hub-schedule-wheel-wrap {
width: 100%;
flex: 0 0 auto;
--hub-schedule-row-gap: 8px;
--hub-schedule-row-h: 38px;
--hub-schedule-row-step: calc(var(--hub-schedule-row-h) + var(--hub-schedule-row-gap));
--hub-schedule-view-h: calc(var(--hub-schedule-row-step) * 3 - var(--hub-schedule-row-gap));
}
.hub-box--focus .hub-schedule-list {
.hub-schedule-wheel-viewport {
position: relative;
width: 100%;
height: var(--hub-schedule-view-h);
overflow: hidden;
box-sizing: border-box;
padding: 0 16px 0 32px;
touch-action: none;
user-select: none;
}
.hub-schedule-wheel-viewport.is-scrollable {
cursor: grab;
}
.hub-schedule-wheel-viewport.is-scrollable.is-dragging {
cursor: grabbing;
}
.hub-schedule-wheel-viewport.is-scrollable.is-dragging .hub-schedule-wheel-row {
transition: none;
}
.hub-schedule-wheel-viewport--empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 56px;
height: auto;
padding: 0 16px;
}
.hub-schedule-wheel-viewport::before {
content: '';
position: absolute;
left: 32px;
right: 16px;
top: 50%;
transform: translateY(-50%);
height: var(--hub-schedule-row-h);
border-radius: 4px;
background: rgba(90, 107, 98, 0.06);
pointer-events: none;
z-index: 0;
}
.hub-schedule-wheel-track {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: var(--hub-schedule-row-gap, 8px);
transition: transform 0.38s cubic-bezier(0.25, 0.8, 0.25, 1);
will-change: transform;
}
.hub-schedule-wheel-track.is-dragging {
transition: none;
}
.hub-schedule-wheel-row {
flex: 0 0 var(--hub-schedule-row-h);
height: var(--hub-schedule-row-h);
display: flex;
align-items: baseline;
gap: 12px;
box-sizing: border-box;
padding: 5px 0;
border-bottom: 1px dashed #e8e0d4;
transition: opacity 0.28s ease;
}
.hub-schedule-wheel-row:last-child {
border-bottom: none;
}
/* hub-focus-task-item · board-project-desc 와 동일 본문 */
.hub-schedule-wheel-date,
.hub-schedule-wheel-text {
font-size: 20px;
line-height: 1.45;
font-weight: 600;
color: #5a6b62;
}
.hub-schedule-wheel-date {
flex: 0 0 auto;
display: inline-grid;
grid-template-columns: auto 2em;
column-gap: 0.15em;
align-items: baseline;
white-space: nowrap;
}
.hub-schedule-wheel-text {
flex: 1 1 auto;
min-height: 0;
justify-content: flex-start;
padding: 0;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hub-schedule-wheel-row.is-past .hub-schedule-wheel-date,
.hub-schedule-wheel-row.is-past .hub-schedule-wheel-text {
color: #8a9690;
}
.hub-schedule-wheel-row.is-today .hub-schedule-wheel-date,
.hub-schedule-wheel-row.is-today .hub-schedule-wheel-text {
color: #5a6b62;
font-weight: 700;
}
.hub-schedule-date-month {
text-align: right;
}
.hub-schedule-date-day {
text-align: right;
}
.hub-schedule-planner::before {
@@ -1115,7 +1219,7 @@
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 6px;
gap: 4px;
padding:
calc(var(--hub-title-top) - var(--hub-head-pad-top) - var(--hub-head-offset-top))
var(--hub-card-pad-x)
@@ -1545,119 +1649,10 @@
cursor: grabbing;
}
/* ─── 분기 일정 캐러셀 (3건 표시 · 오늘 기준 · 좌우 탐색) ─── */
.hub-schedule-viewport {
position: relative;
display: flex;
align-items: stretch;
justify-content: center;
width: 100%;
min-height: 0;
flex: 1 1 auto;
padding: 0;
box-sizing: border-box;
overflow: visible;
}
.hub-schedule-viewport .hub-schedule-list {
flex: 1 1 auto;
min-height: 0;
width: 100%;
margin: 0;
padding: 6px calc(var(--hub-body-inset) - var(--hub-card-pad-x)) 4px
calc(var(--hub-body-inset) - var(--hub-card-pad-x));
position: relative;
z-index: 1;
box-sizing: border-box;
}
.hub-schedule-viewport--empty {
min-height: 56px;
}
/* (legacy hub-schedule-carousel rules removed — use hub-schedule-wheel-* in media block) */
.hub-schedule-empty {
margin: 0;
text-align: center;
opacity: 0.65;
}
.hub-schedule-item--past .hub-schedule-date,
.hub-schedule-item--past .board-project-desc {
opacity: 0.72;
}
.hub-schedule-item--today .hub-schedule-date,
.hub-schedule-item--today .hub-schedule-date-month,
.hub-schedule-item--today .hub-schedule-date-day,
.hub-schedule-item--today .board-project-desc {
color: #5a6b62;
font-weight: 700;
}
.hub-schedule-date {
flex-shrink: 0;
font-weight: 600;
}
.hub-schedule-nav {
position: absolute;
top: 2px;
bottom: 2px;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
width: 34px;
padding: 0;
border: none;
border-radius: 0;
color: #5a5349;
font-size: 26px;
font-weight: 400;
line-height: 1;
cursor: pointer;
opacity: 0.55;
pointer-events: auto;
transition: opacity 0.2s ease, color 0.2s ease;
}
.hub-schedule-nav--prev {
left: -6px;
padding-right: 2px;
background: linear-gradient(
90deg,
rgba(242, 236, 227, 0.95) 0%,
rgba(242, 236, 227, 0.55) 60%,
transparent 100%
);
}
.hub-schedule-nav--next {
right: -6px;
padding-left: 2px;
background: linear-gradient(
270deg,
rgba(242, 236, 227, 0.95) 0%,
rgba(242, 236, 227, 0.55) 60%,
transparent 100%
);
}
.hub-schedule-nav:hover:not(:disabled) {
opacity: 0.85;
color: #3d3832;
}
.hub-schedule-nav-icon {
display: block;
font-size: 28px;
line-height: 0.75;
transform: scaleY(1.55);
transform-origin: center center;
}
.hub-schedule-nav:disabled {
opacity: 0.28;
pointer-events: none;
cursor: default;
}

View File

@@ -3,7 +3,7 @@
.routine-detail {
--rd-header-bg: linear-gradient(180deg, #37a184 0%, #29724f 20%, #07412e 100%);
--rd-emerald-deep: #1e3a5f;
--rd-bg-page: #fff;
--rd-bg-page: #f2f5f9;
--rd-bg-card: #ffffff;
--rd-bg-card-hover: #f8fafc;
--rd-bg-stage-selected: #f5faff;
@@ -358,6 +358,19 @@
line-height: 1.3;
}
.routine-detail .grid {
background: var(--rd-bg-page);
}
.routine-detail__panel .milestone-timeline {
flex-shrink: 0;
}
.routine-detail__panel .milestone-timeline--gantt,
.routine-detail__panel .milestone-timeline--portfolio,
.detail-panel--gantt .milestone-timeline--gantt,
.detail-panel--portfolio .milestone-timeline--portfolio {
flex: 1;
min-height: 0;
flex-shrink: 1;
}

View File

@@ -0,0 +1,770 @@
/* ── 공통 업무 폼 (모달 · 업무관리) ── */
.task-form-fields {
display: flex;
flex-direction: column;
gap: 14px;
}
.task-form-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.task-form-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.task-form-label {
font-size: 13px;
font-weight: 700;
color: #64748b;
}
.task-form-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.task-form-progress-val {
margin-left: 6px;
font-weight: 800;
color: #1e293b;
}
.task-form-input {
width: 100%;
box-sizing: border-box;
border: 1px solid #dbe3ea;
border-radius: 10px;
padding: 10px 14px;
font-size: 15px;
outline: none;
background: #fff;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.task-form-input:focus {
border-color: #4a90d9;
box-shadow: 0 0 0 3px rgba(74, 144, 217, 0.12);
}
.task-form-input--title {
font-size: 17px;
font-weight: 600;
}
.task-form-textarea {
resize: vertical;
min-height: 72px;
line-height: 1.45;
}
.task-form-textarea--tall {
min-height: 96px;
}
.task-form-range {
width: 100%;
accent-color: #29724f;
}
.task-form-people {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border-radius: 12px;
border: 1px solid #d4ebe3;
background: rgba(232, 245, 240, 0.45);
}
.task-form-assignees {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.task-form-assignee-chip {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid #dbe3ea;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #64748b;
cursor: pointer;
user-select: none;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.task-form-assignee-chip.is-checked {
background: #29724f;
border-color: #29724f;
color: #fff;
}
.task-form-empty {
margin: 0;
padding: 12px 14px;
border-radius: 10px;
border: 1px dashed #dbe3ea;
font-size: 13px;
color: #94a3b8;
text-align: center;
}
.task-form-issues {
display: flex;
flex-direction: column;
gap: 8px;
}
.task-form-issue {
padding: 10px;
border-radius: 10px;
border: 1px solid #e2e8f0;
background: #f8fafc;
}
.task-form-issue-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 6px;
flex-wrap: wrap;
}
.task-form-issue-date {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: #64748b;
}
.task-form-issue-date-label {
flex-shrink: 0;
}
.task-form-issue-date-input {
width: auto;
min-width: 0;
padding: 4px 8px;
font-size: 12px;
}
.task-form-issue-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: #64748b;
cursor: pointer;
}
.task-form-link-btn {
border: none;
background: transparent;
font-size: 12px;
font-weight: 700;
color: #2563ab;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
}
.task-form-link-btn:hover {
background: rgba(74, 144, 217, 0.1);
}
.task-form-link-btn--danger {
color: #dc2626;
}
.task-form-link-btn--danger:hover {
background: rgba(220, 38, 38, 0.08);
}
.task-form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
}
.task-form-btn {
border: none;
border-radius: 10px;
padding: 10px 18px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.task-form-btn--ghost {
background: #fff;
border: 1px solid #dbe3ea;
color: #64748b;
}
.task-form-btn--ghost:hover {
background: #f8fafc;
}
.task-form-btn--primary {
background: #29724f;
color: #fff;
}
.task-form-btn--primary:hover {
background: #1f5a3d;
}
.task-form-btn--primary.is-routine {
background: #0d4a38;
}
.task-form-btn--danger {
background: #fff;
border: 1px solid #fecaca;
color: #dc2626;
}
.task-form-btn--danger:hover {
background: #fef2f2;
}
/* ── TaskModal ── */
.task-modal-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(7, 33, 24, 0.55);
backdrop-filter: blur(2px);
}
.task-modal-shell {
width: min(540px, 94vw);
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: 16px;
background: #fff;
box-shadow: 0 24px 64px rgba(7, 33, 24, 0.28);
}
.task-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
background: linear-gradient(120deg, #0d4a38 0%, #29724f 100%);
color: #fff;
}
.task-modal-title {
margin: 0;
font-size: 20px;
font-weight: 800;
}
.task-modal-close {
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.82);
font-size: 22px;
line-height: 1;
cursor: pointer;
padding: 4px;
}
.task-modal-close:hover {
color: #fff;
}
.task-modal-body {
overflow-y: auto;
padding: 18px 20px 20px;
}
/* ── 업무관리 ── */
.task-manager-overlay {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
background: rgba(7, 33, 24, 0.58);
backdrop-filter: blur(3px);
}
.task-manager-shell {
display: flex;
flex-direction: column;
width: min(1180px, 100%);
height: min(calc(100dvh - 40px), 920px);
max-height: calc(100dvh - 40px);
min-height: 0;
border-radius: 16px;
overflow: hidden;
background: #f4f7f5;
box-shadow: 0 28px 80px rgba(7, 33, 24, 0.32);
}
.task-manager-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 22px;
background: linear-gradient(120deg, #0d4a38 0%, #29724f 55%, #37a184 100%);
color: #fff;
}
.task-manager-header__title {
margin: 0;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.02em;
}
.task-manager-header__quarter {
margin-top: 2px;
font-size: 13px;
font-weight: 600;
opacity: 0.82;
}
.task-manager-close {
border: none;
background: rgba(255, 255, 255, 0.12);
color: #fff;
width: 36px;
height: 36px;
border-radius: 10px;
font-size: 20px;
cursor: pointer;
}
.task-manager-close:hover {
background: rgba(255, 255, 255, 0.22);
}
.task-manager-toolbar {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 12px 18px;
border-bottom: 1px solid #dbe3ea;
background: #fff;
}
.task-manager-tabs {
display: flex;
gap: 6px;
}
.task-manager-tab {
border: 1px solid #dbe3ea;
background: #f8fafc;
color: #64748b;
border-radius: 999px;
padding: 7px 16px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.task-manager-tab.is-active {
background: #29724f;
border-color: #29724f;
color: #fff;
}
.task-manager-tab.is-active.is-routine {
background: #0d4a38;
border-color: #0d4a38;
}
.task-manager-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.task-manager-chip {
border: 1px solid #dbe3ea;
background: #fff;
color: #64748b;
border-radius: 999px;
padding: 5px 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.task-manager-chip.is-active {
background: rgba(41, 114, 79, 0.12);
border-color: #29724f;
color: #1f5a3d;
}
.task-manager-toolbar__spacer {
flex: 1 1 auto;
}
.task-manager-search {
min-width: 180px;
max-width: 260px;
flex: 1 1 180px;
border: 1px solid #dbe3ea;
border-radius: 999px;
padding: 7px 14px;
font-size: 13px;
outline: none;
}
.task-manager-search:focus {
border-color: #4a90d9;
box-shadow: 0 0 0 3px rgba(74, 144, 217, 0.12);
}
.task-manager-add {
border: none;
background: #29724f;
color: #fff;
border-radius: 999px;
padding: 8px 16px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.task-manager-add:hover {
background: #1f5a3d;
}
.task-manager-list {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
padding: 14px 18px 18px;
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
}
.task-manager-empty {
margin: auto;
padding: 48px 24px;
text-align: center;
color: #94a3b8;
font-size: 16px;
flex-shrink: 0;
}
.task-manager-row {
flex-shrink: 0;
flex-grow: 0;
border-radius: 12px;
border: 1px solid #dbe3ea;
background: #fff;
overflow: visible;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.task-manager-row.is-expanded {
border-color: #7eb3e8;
box-shadow: 0 4px 16px rgba(41, 114, 79, 0.08);
overflow: visible;
}
.task-manager-row--hrm {
box-shadow: inset 4px 0 0 #29724f;
}
.task-manager-row--hrd {
box-shadow: inset 4px 0 0 #37a184;
}
.task-manager-row--ex {
box-shadow: inset 4px 0 0 #4a9480;
}
.task-manager-row--ga {
box-shadow: inset 4px 0 0 #0d4a38;
}
.task-manager-row--routine {
box-shadow: inset 4px 0 0 #5b2d8a;
}
.task-manager-row__summary {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 8px 12px;
width: 100%;
padding: 12px 14px;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
box-sizing: border-box;
}
.task-manager-row__summary:hover {
background: rgba(41, 114, 79, 0.04);
}
.task-manager-row__chevron {
flex-shrink: 0;
width: 18px;
color: #94a3b8;
transition: transform 0.15s ease;
}
.task-manager-row.is-expanded .task-manager-row__chevron {
transform: rotate(90deg);
}
.task-manager-row__title {
flex: 1 1 auto;
min-width: 0;
font-size: 16px;
font-weight: 700;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-manager-row__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex: 1 1 180px;
min-width: 0;
}
.task-manager-badge {
font-size: 11px;
font-weight: 700;
padding: 3px 8px;
border-radius: 999px;
white-space: nowrap;
}
.task-manager-badge--section {
background: rgba(41, 114, 79, 0.1);
color: #1f5a3d;
}
.task-manager-badge--category {
background: rgba(91, 45, 138, 0.1);
color: #5b2d8a;
}
.task-manager-badge--progress {
background: rgba(74, 144, 217, 0.12);
color: #2563ab;
font-size: 13px;
}
.task-manager-badge--status {
background: #f1f5f9;
color: #64748b;
}
.task-manager-badge--status.is-done {
background: rgba(41, 114, 79, 0.12);
color: #1f5a3d;
}
.task-manager-badge--status.is-active {
background: rgba(74, 144, 217, 0.12);
color: #2563ab;
}
.task-manager-panel {
padding: 12px 16px 16px;
border-top: 1px solid #eef2f6;
animation: task-manager-panel-in 0.2s ease;
}
.task-manager-row.is-expanded .task-manager-panel {
max-height: min(78vh, 860px);
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
.task-manager-section {
margin-top: 18px;
padding-top: 16px;
border-top: 1px dashed #dbe3ea;
}
.task-manager-section__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.task-manager-section__title {
margin: 0;
font-size: 15px;
font-weight: 800;
color: #1f5a3d;
}
.task-manager-subrow {
margin-bottom: 6px;
border-radius: 10px;
border: 1px solid #e2e8f0;
background: #fafbfc;
overflow: hidden;
}
.task-manager-subrow.is-expanded {
border-color: #b8d4c8;
background: #fff;
}
.task-manager-subrow__head {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 12px;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
}
.task-manager-subrow__head:hover {
background: rgba(41, 114, 79, 0.04);
}
.task-manager-subrow__title {
flex: 1 1 auto;
min-width: 0;
font-size: 14px;
font-weight: 700;
color: #334155;
}
.task-manager-subrow__meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex-shrink: 0;
}
.task-manager-subpanel {
padding: 0 12px 12px;
border-top: 1px solid #eef2f6;
}
.task-manager-subpanel--new {
padding: 12px;
margin-bottom: 8px;
border: 1px dashed #b8d4c8;
border-radius: 10px;
background: #f8fbf9;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes task-manager-panel-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.task-manager-panel__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #eef2f6;
}
.task-manager-footer {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 16px;
padding: 10px 18px;
border-top: 1px solid #dbe3ea;
background: #fff;
font-size: 12px;
color: #64748b;
}
.task-manager-badge--status.is-legacy {
background: rgba(245, 158, 11, 0.15);
color: #b45309;
}
.task-manager-footer strong {
color: #1e293b;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

View File

@@ -36,6 +36,7 @@ export interface Task {
id: string;
title: string;
description: string | null;
detailDescription?: string | null;
status: TaskStatus;
priority: Priority;
quarter: string;
@@ -62,6 +63,7 @@ export interface Task {
creator?: Pick<User, 'id' | 'name'>;
pmMember?: TeamMemberBrief | null;
assigneeMembers?: TeamMemberBrief[];
milestones?: Milestone[];
_count?: { files: number; details: number };
}