EENE Dashboard upload to Gitea
@@ -42,7 +42,7 @@ const HUB_CONFIG = {
|
||||
|
||||
],
|
||||
|
||||
routineLabels: ['채용 운영', '학습 지원', '직원 소통', '자산·시설', '문서·행정'],
|
||||
routineLabels: ['채용 운영', '학습 지원', '운영 지원', '자산·시설', '문서·행정'],
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -13,11 +13,22 @@ export const DEFAULT_HUB_CONFIG = {
|
||||
{ id: '2', date: '2026-05-15', 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>) {
|
||||
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 {
|
||||
sloganTitle: sloganTitle === '분기 슬로건' ? '분기 중점 과제' : sloganTitle,
|
||||
sloganLines: Array.isArray(raw.sloganLines)
|
||||
@@ -27,9 +38,7 @@ function normalizeConfig(raw: Record<string, unknown>) {
|
||||
scheduleItems: Array.isArray(raw.scheduleItems)
|
||||
? (raw.scheduleItems as typeof DEFAULT_HUB_CONFIG.scheduleItems)
|
||||
: DEFAULT_HUB_CONFIG.scheduleItems,
|
||||
routineLabels: Array.isArray(raw.routineLabels)
|
||||
? (raw.routineLabels as string[])
|
||||
: DEFAULT_HUB_CONFIG.routineLabels,
|
||||
routineLabels,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
BIN
data/postgres/base/16384/24652_fsm
Normal file
BIN
data/postgres/base/16384/32768_fsm
Normal file
BIN
data/postgres/base/16384/32768_vm
Normal file
@@ -1645,7 +1645,7 @@
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -100,7 +100,7 @@ const DUMMY_DEPARTMENTS: DeptBlock[] = [
|
||||
|
||||
const HUB_MESSAGE = '인사·육성·문화·총무 개선과제 정상 추진';
|
||||
|
||||
const ROUTINE_ITEMS = ['채용 운영', '교육 운영', '직원 소통', '자산·시설 관리', '문서·행정 지원'];
|
||||
const ROUTINE_ITEMS = ['채용 운영', '교육 운영', '운영 지원', '자산·시설 관리', '문서·행정 지원'];
|
||||
|
||||
const FOCUS_ITEMS = ['핵심직무 채용 완료', '복지제도 개선안 확정', '안전보안 점검 강화'];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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}건`;
|
||||
|
||||
@@ -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> = {
|
||||
'문서·행정 지원': '문서·행정',
|
||||
인사관리: '채용 운영',
|
||||
학습성장: '학습 지원',
|
||||
운영지원: '자산·시설',
|
||||
전산관리: '문서·행정',
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
BIN
uploads/0d379816-5608-4f27-a9fa-a61d80723758.jpg
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
uploads/147b66f8-1d6b-461b-a6b4-60098ca09921.jpg
Normal file
|
After Width: | Height: | Size: 7.7 MiB |
BIN
uploads/31d10e9b-782b-48d4-bf84-a565fc03df4e.jpg
Normal file
|
After Width: | Height: | Size: 9.2 MiB |
BIN
uploads/33fa3bd7-1868-408f-93d9-e702f4c8f782.pdf
Normal file
BIN
uploads/37bd7508-c58f-4b2c-945e-36cf1a98bf82.jpg
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
uploads/4156e971-610e-47ce-bb6f-bc4b5838f9fc.png
Normal file
|
After Width: | Height: | Size: 381 KiB |
BIN
uploads/41b7eb84-283b-43a2-890c-a1f2ff29eb34.pdf
Normal file
BIN
uploads/4232393e-f46f-4884-9974-a29bc6c9ec9e.jpg
Normal file
|
After Width: | Height: | Size: 926 KiB |
BIN
uploads/4d36603f-98f1-4a0f-80e3-0700d1c4099d.jpg
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
uploads/50bd4a1e-46ec-4e44-8251-8e644472a05b.jpg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
uploads/5ac70e63-b082-484b-ba1d-51c7472576c1.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
uploads/6dfd71fa-eea6-4e90-8b24-e3d49f6865f3.jpg
Normal file
|
After Width: | Height: | Size: 7.2 MiB |
BIN
uploads/80033802-e2d0-4118-b217-a768309455cb.png
Normal file
|
After Width: | Height: | Size: 362 KiB |
BIN
uploads/871e94c7-6972-4658-a4dc-783ccc11cdbd.pdf
Normal file
BIN
uploads/9448e193-fe60-4123-80ff-9163c0b5b3dd.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
uploads/994eeb74-a906-44ea-9340-c11d7b8d8254.pdf
Normal file
BIN
uploads/a6fbdd7a-1d79-457d-956f-6489b6a95a08.pdf
Normal file
BIN
uploads/b1449d4d-9081-47c9-9899-8e95fbfd584f.jpg
Normal file
|
After Width: | Height: | Size: 8.3 MiB |
BIN
uploads/c221e025-8e46-404d-b35c-66e661b08613.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
uploads/c4035330-2ea1-4300-9771-ed052e789a17.jpg
Normal file
|
After Width: | Height: | Size: 7.8 MiB |
BIN
uploads/d0193746-c9f4-42f7-95fc-f0b4f027eeb5.pdf
Normal file
BIN
uploads/dba65c4f-f6bc-4a21-b9bc-7b9f1d582406.pdf
Normal file
BIN
uploads/df07def8-d2dd-47c1-ad84-9b08fe1d6b65.png
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
uploads/e778619e-539e-4b47-8c97-f307dd89353d.pdf
Normal file
BIN
uploads/e8acb3ef-2fcf-4119-825c-bb51c33cfa82.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
uploads/e8b14eb9-c2fc-4b42-b996-fc45dc783548.png
Normal file
|
After Width: | Height: | Size: 558 KiB |
BIN
uploads/eb530874-c7e8-4f02-bce2-e23f065cfbc0.jpg
Normal file
|
After Width: | Height: | Size: 8.1 MiB |
BIN
uploads/ed3d87c7-2e62-4edc-8148-35c9096c5b20.jpg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |