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

@@ -42,7 +42,7 @@ const HUB_CONFIG = {
], ],
routineLabels: ['채용 운영', '학습 지원', '직원 소통', '자산·시설', '문서·행정'], routineLabels: ['채용 운영', '학습 지원', '운영 지원', '자산·시설', '문서·행정'],
}; };

View File

@@ -13,11 +13,22 @@ export const DEFAULT_HUB_CONFIG = {
{ id: '2', date: '2026-05-15', text: '조직문화 진단·리더십 교육' }, { id: '2', date: '2026-05-15', text: '조직문화 진단·리더십 교육' },
{ id: '3', date: '2026-06-20', text: '분기 성과 점검·평가' }, { id: '3', date: '2026-06-20', text: '분기 성과 점검·평가' },
], ],
routineLabels: ['채용 운영', '학습 지원', '직원 소통', '자산·시설', '문서·행정'], routineLabels: ['채용 운영', '학습 지원', '운영 지원', '자산·시설', '문서·행정'],
}; };
function migrateRoutineLabels(labels: string[]): string[] {
return labels.map((label) => {
if (label === '교육 운영') return '학습 지원';
if (label === '직원 소통') return '운영 지원';
return label;
});
}
function normalizeConfig(raw: Record<string, unknown>) { function normalizeConfig(raw: Record<string, unknown>) {
const sloganTitle = (raw.sloganTitle as string) ?? DEFAULT_HUB_CONFIG.sloganTitle; const sloganTitle = (raw.sloganTitle as string) ?? DEFAULT_HUB_CONFIG.sloganTitle;
const routineLabels = Array.isArray(raw.routineLabels)
? migrateRoutineLabels(raw.routineLabels as string[])
: DEFAULT_HUB_CONFIG.routineLabels;
return { return {
sloganTitle: sloganTitle === '분기 슬로건' ? '분기 중점 과제' : sloganTitle, sloganTitle: sloganTitle === '분기 슬로건' ? '분기 중점 과제' : sloganTitle,
sloganLines: Array.isArray(raw.sloganLines) sloganLines: Array.isArray(raw.sloganLines)
@@ -27,9 +38,7 @@ function normalizeConfig(raw: Record<string, unknown>) {
scheduleItems: Array.isArray(raw.scheduleItems) scheduleItems: Array.isArray(raw.scheduleItems)
? (raw.scheduleItems as typeof DEFAULT_HUB_CONFIG.scheduleItems) ? (raw.scheduleItems as typeof DEFAULT_HUB_CONFIG.scheduleItems)
: DEFAULT_HUB_CONFIG.scheduleItems, : DEFAULT_HUB_CONFIG.scheduleItems,
routineLabels: Array.isArray(raw.routineLabels) routineLabels,
? (raw.routineLabels as string[])
: DEFAULT_HUB_CONFIG.routineLabels,
}; };
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1645,7 +1645,7 @@
<div class="hub-routine-grid"> <div class="hub-routine-grid">
<button type="button" class="hub-routine-item">채용 운영</button> <button type="button" class="hub-routine-item">채용 운영</button>
<button type="button" class="hub-routine-item">교육 운영</button> <button type="button" class="hub-routine-item">교육 운영</button>
<button type="button" class="hub-routine-item">직원 소통</button> <button type="button" class="hub-routine-item">운영 지원</button>
<button type="button" class="hub-routine-item">자산·시설</button> <button type="button" class="hub-routine-item">자산·시설</button>
<button type="button" class="hub-routine-item">문서·행정</button> <button type="button" class="hub-routine-item">문서·행정</button>
</div> </div>

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ export const DEFAULT_HUB_CONFIG: HubConfig = {
{ id: '2', date: '2026-05-15', text: '조직문화 진단·리더십 교육' }, { id: '2', date: '2026-05-15', text: '조직문화 진단·리더십 교육' },
{ id: '3', date: '2026-06-20', text: '분기 성과 점검·평가' }, { id: '3', date: '2026-06-20', text: '분기 성과 점검·평가' },
], ],
routineLabels: ['채용 운영', '학습 지원', '직원 소통', '자산·시설', '문서·행정'], routineLabels: ['채용 운영', '학습 지원', '운영 지원', '자산·시설', '문서·행정'],
}; };
function migrateRoutineLabels(raw: unknown): string[] { 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])) { if (labels.length === ROUTINE_CATEGORIES.length && labels.every((label, i) => label === ROUTINE_CATEGORIES[i])) {
return [...ROUTINE_CATEGORIES]; return [...ROUTINE_CATEGORIES];
} }
const legacyFull = ['채용 운영', '교육 운영', '직원 소통', '자산·시설', '문서·행정']; const legacyFull = ['채용 운영', '교육 운영', '직원 소통', '운영 지원', '자산·시설', '문서·행정'];
if (labels.length === legacyFull.length && labels.every((label, i) => label === legacyFull[i])) { if (labels.length === legacyFull.length && labels.every((label, i) => label === legacyFull[i])) {
return [...ROUTINE_CATEGORIES]; return [...ROUTINE_CATEGORIES];
} }
@@ -51,6 +51,9 @@ function migrateRoutineLabels(raw: unknown): string[] {
if (labels.some((label) => label === '교육 운영')) { if (labels.some((label) => label === '교육 운영')) {
return labels.map((label) => (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]; 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.startDate && entry.dueDate) return `${fmt(entry.startDate)}~${fmt(entry.dueDate)}`;
if (entry.dueDate) return fmt(entry.dueDate); if (entry.dueDate) return fmt(entry.dueDate);
if (entry.startDate) return `${fmt(entry.startDate)}~`; if (entry.startDate) return `${fmt(entry.startDate)}~`;
if (!entry.startDate && !entry.dueDate) return '상시';
return `기간 ${index + 1}`; return `기간 ${index + 1}`;
} }
@@ -133,7 +134,11 @@ export function fmtMilestonePeriodSummary(
): string { ): string {
const periods = parseMilestonePeriods(milestone); const periods = parseMilestonePeriods(milestone);
if (periods.length === 0) return ''; 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 last = periods[periods.length - 1];
const lastLabel = fmtPeriodRange(last); const lastLabel = fmtPeriodRange(last);
return lastLabel ? `${lastLabel}${periods.length - 1}` : `기간 ${periods.length}`; return lastLabel ? `${lastLabel}${periods.length - 1}` : `기간 ${periods.length}`;

View File

@@ -2,7 +2,7 @@
export const ROUTINE_CATEGORIES = [ export const ROUTINE_CATEGORIES = [
'채용 운영', '채용 운영',
'학습 지원', '학습 지원',
'직원 소통', '운영 지원',
'자산·시설', '자산·시설',
'문서·행정', '문서·행정',
] as const; ] 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; 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 { .milestone-timeline--portfolio .milestone-timeline__portfolio-scroll {
flex: 1; flex: 1;
min-height: 0; min-height: 0;

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB