EENE Dashboard upload to Gitea

This commit is contained in:
EENE Dashboard
2026-06-18 15:06:37 +09:00
parent d3548cf7ff
commit c31eca4b58
82 changed files with 126 additions and 48 deletions

View File

@@ -100,7 +100,7 @@ const DUMMY_DEPARTMENTS: DeptBlock[] = [
const HUB_MESSAGE = '인사·육성·문화·총무 개선과제 정상 추진';
const ROUTINE_ITEMS = ['채용 운영', '교육 운영', '직원 소통', '자산·시설 관리', '문서·행정 지원'];
const ROUTINE_ITEMS = ['채용 운영', '교육 운영', '운영 지원', '자산·시설 관리', '문서·행정 지원'];
const FOCUS_ITEMS = ['핵심직무 채용 완료', '복지제도 개선안 확정', '안전보안 점검 강화'];

View File

@@ -46,7 +46,7 @@ const VARIANT_DEFAULTS = {
portfolioTab: '분기 전체',
},
routine: {
focusTitle: '업무별 타임라인',
focusTitle: '업무별 타임라인',
projectTitle: '상시 전체 일정',
portfolioTitle: '분기 전체 상시업무',
rowLabelHeader: '업무명',
@@ -140,9 +140,45 @@ export function MilestoneTimeline({
const chartRef = useRef<HTMLDivElement>(null);
const labelsScrollRef = useRef<HTMLDivElement>(null);
const rowRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const measureRef = useRef<HTMLSpanElement>(null);
const dragRef = useRef<{ startX: number; viewport: { start: Date; end: Date } } | null>(null);
const didPanRef = useRef(false);
const [isPanning, setIsPanning] = useState(false);
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 + 20;
};
const handleFocusBarEnter = (
row: { id: string; leftPct: number; widthPct: number },
title: string,
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(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 rowGroups = useMemo(() => {
if (!model) return [];
@@ -286,6 +322,7 @@ export function MilestoneTimeline({
setIsPanning(false);
dragRef.current = null;
didPanRef.current = false;
setExpandedBar(null);
}, [viewMode]);
const handleRowSelect = (milestoneId: string, taskId: string) => {
@@ -294,7 +331,7 @@ export function MilestoneTimeline({
onSelect?.(milestoneId);
};
const renderBar = (
const renderGanttBar = (
group: {
milestoneId: string;
title: string;
@@ -303,13 +340,12 @@ export function MilestoneTimeline({
},
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' : ''}`}
className={`milestone-timeline__bar is-gantt ${isSelected ? 'is-selected' : ''}`}
style={{ left: `${row.leftPct}%`, width: `${row.widthPct}%` }}
aria-label={group.title}
title={group.title}
@@ -317,12 +353,60 @@ export function MilestoneTimeline({
>
<span className="milestone-timeline__bar-track" />
<span className="milestone-timeline__bar-fill" style={{ width: `${row.progress}%` }} />
{showGanttBar && group.progress > 0 && (
{group.progress > 0 && (
<span className="milestone-timeline__bar-progress">{group.progress}%</span>
)}
</button>
));
const renderFocusBar = (
group: {
milestoneId: string;
title: string;
progress: number;
segments: Array<{ id: string; leftPct: number; widthPct: number; progress: number }>;
},
isSelected: boolean,
taskId: string,
) =>
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, width: expandedBar!.widthPx }
: { left: `${row.leftPct}%`, width: `${row.widthPct}%` }
}
aria-label={group.title}
title={group.title}
onMouseEnter={(e) => handleFocusBarEnter(row, group.title, e)}
onMouseLeave={() => setExpandedBar(null)}
onClick={() => handleRowSelect(group.milestoneId, taskId)}
>
<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>
);
});
const renderTicks = () => (
<div className="milestone-timeline__ticks" aria-hidden="true">
{model!.ticks.map((tick) => (
@@ -362,15 +446,12 @@ export function MilestoneTimeline({
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>
);
@@ -425,11 +506,6 @@ export function MilestoneTimeline({
<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>
@@ -478,11 +554,10 @@ export function MilestoneTimeline({
key={row.milestoneId}
className={`milestone-timeline__row${row.isCurrentTask ? ' is-current-task' : ' is-other-task'}`}
>
{renderBar(
{renderGanttBar(
row,
row.milestoneId === selectedId && row.isCurrentTask,
row.taskId,
true,
)}
</div>
),
@@ -530,7 +605,7 @@ export function MilestoneTimeline({
else rowRefs.current.delete(group.milestoneId);
}}
>
{renderBar(group, group.milestoneId === selectedId, ownerTaskId ?? '', true)}
{renderGanttBar(group, group.milestoneId === selectedId, ownerTaskId ?? '')}
</div>
))}
</div>
@@ -541,6 +616,7 @@ export function MilestoneTimeline({
</>
) : (
<div className="milestone-timeline__focus-layout">
<span ref={measureRef} className="milestone-timeline__measure" aria-hidden="true" />
{renderChartHead()}
<div
className={chartPannableClass}
@@ -551,7 +627,7 @@ export function MilestoneTimeline({
<div className="milestone-timeline__rows">
{projectRowGroups.map((group) => (
<div key={group.milestoneId} className="milestone-timeline__row">
{renderBar(group, group.milestoneId === selectedId, ownerTaskId ?? '', false)}
{renderFocusBar(group, group.milestoneId === selectedId, ownerTaskId ?? '')}
</div>
))}
</div>

View File

@@ -31,7 +31,7 @@ export const DEFAULT_HUB_CONFIG: HubConfig = {
{ id: '2', date: '2026-05-15', text: '조직문화 진단·리더십 교육' },
{ id: '3', date: '2026-06-20', text: '분기 성과 점검·평가' },
],
routineLabels: ['채용 운영', '학습 지원', '직원 소통', '자산·시설', '문서·행정'],
routineLabels: ['채용 운영', '학습 지원', '운영 지원', '자산·시설', '문서·행정'],
};
function migrateRoutineLabels(raw: unknown): string[] {
@@ -40,7 +40,7 @@ function migrateRoutineLabels(raw: unknown): string[] {
if (labels.length === ROUTINE_CATEGORIES.length && labels.every((label, i) => label === ROUTINE_CATEGORIES[i])) {
return [...ROUTINE_CATEGORIES];
}
const legacyFull = ['채용 운영', '교육 운영', '직원 소통', '자산·시설', '문서·행정'];
const legacyFull = ['채용 운영', '교육 운영', '직원 소통', '운영 지원', '자산·시설', '문서·행정'];
if (labels.length === legacyFull.length && labels.every((label, i) => label === legacyFull[i])) {
return [...ROUTINE_CATEGORIES];
}
@@ -51,6 +51,9 @@ function migrateRoutineLabels(raw: unknown): string[] {
if (labels.some((label) => label === '교육 운영')) {
return labels.map((label) => (label === '교육 운영' ? '학습 지원' : label));
}
if (labels.some((label) => label === '직원 소통')) {
return labels.map((label) => (label === '직원 소통' ? '운영 지원' : label));
}
return labels.length > 0 ? labels : [...ROUTINE_CATEGORIES];
}

View File

@@ -100,6 +100,7 @@ export function fmtPeriodPickerLabel(
if (entry.startDate && entry.dueDate) return `${fmt(entry.startDate)}~${fmt(entry.dueDate)}`;
if (entry.dueDate) return fmt(entry.dueDate);
if (entry.startDate) return `${fmt(entry.startDate)}~`;
if (!entry.startDate && !entry.dueDate) return '상시';
return `기간 ${index + 1}`;
}
@@ -133,7 +134,11 @@ export function fmtMilestonePeriodSummary(
): string {
const periods = parseMilestonePeriods(milestone);
if (periods.length === 0) return '';
if (periods.length === 1) return fmtPeriodRange(periods[0]) || '';
if (periods.length === 1) {
const only = periods[0];
if (!only.startDate && !only.dueDate) return '상시';
return fmtPeriodRange(only) || '상시';
}
const last = periods[periods.length - 1];
const lastLabel = fmtPeriodRange(last);
return lastLabel ? `${lastLabel}${periods.length - 1}` : `기간 ${periods.length}`;

View File

@@ -2,7 +2,7 @@
export const ROUTINE_CATEGORIES = [
'채용 운영',
'학습 지원',
'직원 소통',
'운영 지원',
'자산·시설',
'문서·행정',
] as const;
@@ -16,8 +16,10 @@ const LEGACY_CATEGORY_ALIASES: Record<string, RoutineCategory> = {
'교육 운영': '학습 지원',
'학습 지원': '학습 지원',
: '학습 지원',
: '직원 소통',
'직원 소통': '직원 소통',
: '운영 지원',
'직원 소통': '운영 지원',
'운영 지원': '운영 지원',
: '운영 지원',
: '자산·시설',
: '자산·시설',
'자산·시설': '자산·시설',
@@ -27,7 +29,6 @@ const LEGACY_CATEGORY_ALIASES: Record<string, RoutineCategory> = {
'문서·행정 지원': '문서·행정',
: '채용 운영',
: '학습 지원',
: '자산·시설',
: '문서·행정',
};

View File

@@ -1007,22 +1007,6 @@
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;