EENE Dashboard upload to Gitea
This commit is contained in:
262
frontend/src/components/common/StageFormFields.tsx
Normal file
262
frontend/src/components/common/StageFormFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
333
frontend/src/components/common/TaskFormFields.tsx
Normal file
333
frontend/src/components/common/TaskFormFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}" 업무를 준비하지 못했습니다.`));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"> </span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showProgress && (
|
||||
<>
|
||||
<div className="project-sub-divider" aria-hidden="true" />
|
||||
<div className="progress-col">
|
||||
<DonutGauge task={task} />
|
||||
<DonutGauge task={progressTask} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
231
frontend/src/components/dashboard/TaskFeedbackSection.tsx
Normal file
231
frontend/src/components/dashboard/TaskFeedbackSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
291
frontend/src/components/dashboard/TaskMilestoneSection.tsx
Normal file
291
frontend/src/components/dashboard/TaskMilestoneSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
80
frontend/src/components/detail/OverviewEditModal.tsx
Normal file
80
frontend/src/components/detail/OverviewEditModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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="기간을 설정한 업무명만 타임라인에 표시됩니다."
|
||||
|
||||
|
||||
12
frontend/src/components/detail/stageFormTypes.ts
Normal file
12
frontend/src/components/detail/stageFormTypes.ts
Normal 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[];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
221
frontend/src/lib/milestoneWeekFocus.ts
Normal file
221
frontend/src/lib/milestoneWeekFocus.ts
Normal 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);
|
||||
}
|
||||
@@ -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%)',
|
||||
},
|
||||
};
|
||||
|
||||
51
frontend/src/lib/stageFormState.ts
Normal file
51
frontend/src/lib/stageFormState.ts
Normal 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;
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
72
frontend/src/lib/taskFormState.ts
Normal file
72
frontend/src/lib/taskFormState.ts
Normal 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) ?? [],
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
770
frontend/src/styles/task-manager.css
Normal file
770
frontend/src/styles/task-manager.css
Normal 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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user