chore: snapshot current intranet work

This commit is contained in:
b17301
2026-04-24 09:09:39 +09:00
parent a2affa2d45
commit fb11139da4
23 changed files with 16075 additions and 115 deletions
@@ -0,0 +1,13 @@
Backup created: 2026-04-10 09:00 KST
Files:
- main.py
- data.db
- templates/base.html
- templates/dashboard.html
- templates/index.html
- templates/projects.html
- templates/annual_summary.html
- README.md (if present)
- WORK_SUMMARY_20260408.md
- WORK_SUMMARY_20260409.md
- DB_HEALTH_CHECK_20260409.md
@@ -0,0 +1,169 @@
# DB Health Check 2026-04-09
## Summary
- Current SQLite size and row counts are still within a manageable range for this app.
- Main medium-term risks were:
- concurrent writes causing `database is locked`
- all users sharing one `project_page_state` row
- growing scan cost around billing-link and project-status entry access
- These were addressed without data loss.
## Current Scale
- `transactions`: about 49k rows
- `project_contract_info`: 760 rows
- `project_billing_entries`: 1,774 rows
- `project_related_links`: 1,276 rows
- `project_status`: few rows now, but each row can contain many logical sub-entries
## Improvements Applied
### 1. SQLite concurrency and durability tuning
Applied on every DB connection in [main.py](/home/b17301/my-intranet-app/main.py#L39):
- `PRAGMA journal_mode=WAL`
- `PRAGMA synchronous=NORMAL`
- `PRAGMA foreign_keys=ON`
- `PRAGMA busy_timeout=5000`
- `PRAGMA temp_store=MEMORY`
Effect:
- better concurrent read/write behavior
- lower chance of write collisions during multi-user editing
- safer long-running usage than SQLite defaults
### 2. Added indexes for growth hotspots
Added in [main.py](/home/b17301/my-intranet-app/main.py#L119):
- `transactions`
- `idx_transactions_support_category`
- `idx_transactions_support_account`
- `idx_transactions_source_file`
- `idx_transactions_updated_at`
- `project_billing_entries`
- `idx_project_billing_entries_raw_code`
- `idx_project_billing_entries_round_code`
- `idx_project_billing_entries_source_file`
- `idx_project_billing_entries_updated_at`
- `project_related_links`
- `idx_project_related_links_related`
- `project_page_state`
- `idx_project_page_state_page_session`
Effect:
- faster changed-round grouping
- better support/account based project aggregations
- faster source-file based reimport paths
- safer scaling as billing and transaction history grows
### 3. Page state changed from shared row to session-scoped rows
The old structure used one shared record:
- `project_page_state(page_key PRIMARY KEY, ...)`
This meant different users could overwrite each other's selected project, year, and open/closed detail state.
It now migrates to:
- `project_page_state(page_key, session_id, ...)`
- primary key: `(page_key, session_id)`
Relevant code:
- schema migration: [main.py](/home/b17301/my-intranet-app/main.py#L264)
- load/save logic: [main.py](/home/b17301/my-intranet-app/main.py#L1970)
- API: [main.py](/home/b17301/my-intranet-app/main.py#L3473)
- browser session id: [templates/base.html](/home/b17301/my-intranet-app/templates/base.html#L538)
- client page-state save/load: [templates/projects.html](/home/b17301/my-intranet-app/templates/projects.html#L4880)
Effect:
- browser A and browser B no longer fight over one shared project-search state
### 4. Project status JSON blobs normalized into child tables
Previously, logical row collections were stored only inside JSON columns in `project_status`:
- `collection_entries_json`
- `task_plan_entries_json`
- `exec_budget_entries_json`
- `actual_input_entries_json`
This has now been normalized into child tables:
- `project_collection_entries`
- `project_task_plan_entries`
- `project_exec_budget_entries`
- `project_actual_input_entries`
Relevant code:
- row extraction and migration helpers: [main.py](/home/b17301/my-intranet-app/main.py#L987)
- child-table schema: [main.py](/home/b17301/my-intranet-app/main.py#L273)
- migration from legacy JSON: [main.py](/home/b17301/my-intranet-app/main.py#L1201)
- project status read paths: [main.py](/home/b17301/my-intranet-app/main.py#L2143)
- project status save path: [main.py](/home/b17301/my-intranet-app/main.py#L3323)
Effect:
- less dependence on large JSON blobs for active reads
- cleaner future path for per-section editing
- safer long-term maintainability
## Backward Compatibility
Legacy JSON columns are still kept in `project_status` for compatibility and rollback safety.
Current behavior:
- reads prefer normalized child rows
- if child rows do not exist, legacy JSON/scalar fallback still works
- writes update both:
- scalar summary fields
- legacy JSON cache
- normalized child rows
This avoids data loss during migration.
## Remaining Structural Risk
The biggest remaining architectural limitation is:
- `project_status` still acts as one large parent record for many independently editable sections
So while row collections are normalized now, parent-level fields such as:
- project type
- expected rates
- contract amount
- dates
- notes
still live together in one row and one update flow.
This is acceptable for now, but if many users edit the same project simultaneously, the next best improvement would be:
1. split edit APIs by section
2. add per-section revision tracking
3. optionally move more parent fields into section-specific tables
## Recommendation
Current DB can continue operating efficiently with the applied changes.
Recommended next step if the app keeps expanding:
- introduce section-level save endpoints for
- collection
- task plan
- exec budget
- actual input
- project metadata
That would reduce cross-section write conflicts even more.
@@ -0,0 +1,23 @@
# 작업 요약
작업일: 2026-04-08
## 핵심 변경
- 프로젝트 검색 성능 저하 구간을 줄이기 위해 검색 목록 렌더 흐름을 정리하고 표시 개수를 제한했습니다.
- 프로젝트 정보 페이지의 상세 레이아웃을 재구성해 상단 정보 카드의 중첩 박스를 제거하고 주요 지표 배치를 정리했습니다.
- 계획 대비 실제 비교에서 인건비, 외주비, 제경비, A/S비, 판관비 세부 로직과 실제 집행 합산 기준을 여러 차례 보정했습니다.
- 실투입 관리, 실행예산계획, 과업수행계획 입력 UI와 저장 구조를 정리했습니다.
- 대시보드 상단을 재구성해 사업현황 요약과 수금/지출 구성 그래프를 다시 배치했습니다.
- 프로젝트 페이지 상태 저장은 버튼 클릭 시 DB에 저장되도록 연결했습니다.
## 주요 파일
- `main.py`
- `templates/base.html`
- `templates/dashboard.html`
- `templates/projects.html`
## 참고
- 템플릿 백업은 `template_backups/20260408_ko/`에 생성했습니다.
@@ -0,0 +1,50 @@
# 작업 요약 2026-04-09
## 이번 반영 범위
- 프로젝트 정보 페이지 안정화
- 저장 후 프로젝트 정보 화면이 비거나 모달이 갑자기 닫히는 문제 구조 개선
- 프로젝트 검색/상세/미계약 비용 발생 현황의 데이터 흐름 및 자동 최신화 충돌 완화
- 바로가기, 연관 프로젝트, 세부 내역 정렬/표시 개선
- 사업현황 추가/수정 저장 구조 보강
- 프로젝트 저장을 DB 기준으로 즉시 반영되도록 보강
- 수금정보 분류값 정리
- 기성구분: `선급금 / 기성금 / 준공금`
- 청구구분: `계약분 / 기타`
- 실행예산/실투입/예상 배분 설정 연계 보강
- 계약/청구/변경계약 데이터 반영
- 계약현황, 기성청구현황, 변경계약금액현황(총괄/차수) 파일을 DB에 반영
- 변경차수/보완/연계 프로젝트 자동 연결 로직 보강
- 본계약과 연결 가능한 건은 상세 페이지와 연관 프로젝트 태그/집행내역에 합산 반영
- DB 구조 및 안정성 개선
- 설정성 하드코딩 일부를 DB 설정 테이블로 이동
- 프로젝트 입력 데이터의 섹션 분리 구조 확장
- 건강 점검 문서 추가: `DB_HEALTH_CHECK_20260409.md`
- 대시보드 / 연도별 수익·비용 UI 개선
- 대시보드 카드/그래프 구조 정리
- 연도별 수익/비용 그래프 크기, 라벨, 축, 카드 활용도 개선
- 페이지 공통 여백 구조 정리
## 주요 수정 파일
- `main.py`
- `templates/projects.html`
- `templates/annual_summary.html`
- `templates/base.html`
- `templates/dashboard.html`
- `templates/index.html`
- `data.db`
## 참고 데이터 파일
- `변경계약금액현황(회계)_총괄_20210101_20260409_260409.xlsx`
- `변경계약금액현황(회계)_차수_20210101_20260409_260409.xlsx`
## 비고
- SQLite 기반 운영은 현재 데이터 규모에서는 가능하지만, 동시 작업과 화면 상태 저장은 계속 점검이 필요함
- `data.db-wal`, `data.db-shm` 같은 런타임 임시 파일은 커밋 대상에서 제외함
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,532 @@
{% extends "base.html" %}
{% block title %}연도별 수익/비용{% endblock %}
{% block head_extra %}
<style>
.summary-layout {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.summary-panel {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
align-items: end;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 0;
justify-content: center;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 8px;
color: #2f3c49;
font-size: 13px;
font-weight: 800;
letter-spacing: -0.01em;
background: rgba(255,255,255,0.92);
border: 1px solid var(--line);
border-radius: 12px;
padding: 8px 12px;
}
.legend-swatch {
width: 10px;
height: 10px;
border-radius: 999px;
}
.chart-box {
background:
linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,247,249,0.98)),
radial-gradient(circle at top left, rgba(17, 17, 17, 0.045), transparent 36%);
border: 1px solid var(--line);
border-radius: 16px;
padding: 18px 20px 20px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
}
.chart-legend {
margin-bottom: 12px;
}
.chart-svg {
width: 100%;
height: auto;
aspect-ratio: 1560 / 620;
min-height: 460px;
display: block;
}
.expense-chart-svg {
aspect-ratio: 1560 / 660;
min-height: 510px;
}
.chart-note {
margin-top: 10px;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.summary-overview {
display: grid;
grid-template-rows: auto auto 1fr;
gap: 14px;
min-height: 100%;
}
.metric-grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
align-content: stretch;
}
.chart-stack {
display: grid;
gap: 16px;
min-height: 100%;
}
.summary-overview .stat-card,
.chart-stack .stat-card {
min-height: 112px;
}
.summary-overview .section-title h2,
.chart-stack .section-title h2 {
font-size: 17px;
}
.summary-overview .field select {
min-width: 0;
padding: 10px 12px;
font-size: 13px;
}
.summary-overview .stat-card {
padding: 14px 14px 15px;
border-radius: 12px;
gap: 4px;
}
.summary-overview .stat-card .label {
font-size: 12px;
}
.summary-overview .stat-card .value {
font-size: 22px;
line-height: 1.18;
}
@media (max-width: 1000px) {
.summary-layout,
.summary-panel,
.filter-grid,
.metric-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<section class="panel">
<div class="section-title" style="margin-bottom: 14px;">
<h2>수익/비용 현황</h2>
</div>
<div class="summary-panel">
<div class="summary-overview">
<div class="filter-grid">
<div class="field">
<select id="granularity" aria-label="보기 기준">
<option value="yearly">연간</option>
<option value="monthly">월간</option>
</select>
</div>
<div class="field">
<select id="yearFilter" aria-label="연도 선택">
<option value="recent10">최근 10개년</option>
{% for year in available_years %}
<option value="{{ year }}">{{ year }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="metric-grid" id="metricGrid"></div>
</div>
<div class="chart-stack">
<div class="chart-box">
<div class="section-title" style="margin-bottom: 10px;">
<h2>비용 구조</h2>
</div>
<div class="legend chart-legend" id="expenseLegend"></div>
<svg id="expenseChart" class="chart-svg expense-chart-svg" viewBox="0 0 1560 660" preserveAspectRatio="xMidYMid meet"></svg>
</div>
<div class="chart-box">
<div class="section-title" style="margin-bottom: 10px;">
<h2>수금/비용/영업수지 그래프</h2>
</div>
<div class="legend chart-legend" id="balanceLegend"></div>
<svg id="balanceChart" class="chart-svg" viewBox="0 0 1560 620" preserveAspectRatio="xMidYMid meet"></svg>
</div>
</div>
</div>
</section>
{% endblock %}
{% block script %}
<script>
const yearlySeries = {{ yearly_financial_series | tojson }};
const monthlySeries = {{ monthly_financial_series | tojson }};
const availableYears = [...new Set(yearlySeries.map((item) => item.year).filter((year) => year !== null && year !== undefined))];
const annualMetricCards = {{ annual_metric_cards | tojson }};
const annualExpenseChartMetrics = {{ annual_expense_chart_metrics | tojson }};
const annualBalanceChartMetrics = {{ annual_balance_chart_metrics | tojson }};
const palette = Object.fromEntries(
[...annualExpenseChartMetrics, ...annualBalanceChartMetrics].map((option) => [
option.item_key,
option.value,
]),
);
const labels = Object.fromEntries(
annualMetricCards.map((option) => [option.item_key, option.label]),
);
function formatNumber(value) {
return new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 }).format(value || 0);
}
function formatAxisLabel(value) {
const numeric = Number(value || 0);
if (!numeric) return "0.0";
const sign = numeric < 0 ? "-" : "";
const absolute = Math.abs(numeric);
if (absolute >= 100000000) return `${sign}${(absolute / 100000000).toFixed(1)}`;
if (absolute >= 1000000) return `${sign}${(absolute / 1000000).toFixed(1)}백만`;
if (absolute >= 1000) return `${sign}${(absolute / 1000).toFixed(1)}`;
return formatNumber(numeric);
}
function pickTickStep(maxValue) {
const baseUnit = maxValue < 1000000 ? 1000 : 1000000;
const units = [1, 2, 5];
const raw = Math.max(maxValue / baseUnit, 1);
let power = 1;
while (power * 10 <= raw) power *= 10;
for (const unit of units) {
const candidate = unit * power;
if (candidate >= raw) return candidate * baseUnit;
}
return power * 10 * baseUnit;
}
function buildPositiveAxisScale(maxValue, tickCount = 4) {
const safeMax = Math.max(Number(maxValue || 0), 1);
const paddedMax = safeMax * (safeMax < 1000 ? 1.12 : 1.08);
const tickStep = pickTickStep(paddedMax / tickCount);
const tickMax = Math.max(tickStep, Math.ceil(paddedMax / tickStep) * tickStep);
return { tickStep, tickMax };
}
function buildDynamicPositiveAxis(maxValue, width, height, margin) {
const safeMax = Math.max(Number(maxValue || 0), 1);
const paddedMax = safeMax * 1.08;
const tickStep = pickTickStep(paddedMax / 5);
const tickMax = Math.max(tickStep, Math.ceil(paddedMax / tickStep) * tickStep);
let axis = "";
for (let value = 0; value <= tickMax; value += tickStep) {
const y = height - margin.bottom - ((height - margin.top - margin.bottom) * value) / tickMax;
axis += `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="#d8dee5" stroke-dasharray="4 8" />`;
axis += `<text x="${margin.left - 14}" y="${y + 5}" text-anchor="end" fill="#52606d" font-size="14" font-weight="800">${formatAxisLabel(value)}</text>`;
}
axis += `<line x1="${margin.left}" y1="${height - margin.bottom}" x2="${width - margin.right}" y2="${height - margin.bottom}" stroke="#8ba0ae" stroke-width="1.4" />`;
return { axis, tickStep, tickMax };
}
function buildDynamicBalanceAxis(maxPositiveValue, minNegativeValue, width, height, margin) {
const safePositive = Math.max(Number(maxPositiveValue || 0), 0);
const safeNegative = Math.min(Number(minNegativeValue || 0), 0);
const paddedPositive = safePositive > 0 ? safePositive * 1.08 : 1000;
const paddedNegative = safeNegative < 0 ? safeNegative * 1.08 : 0;
const rangeAbs = Math.max(Math.abs(paddedPositive), Math.abs(paddedNegative), 1);
const tickStep = pickTickStep(rangeAbs / 4);
const positiveMax = Math.max(tickStep, Math.ceil(paddedPositive / tickStep) * tickStep);
const negativeMin = safeNegative < 0 ? Math.min(-tickStep, Math.floor(paddedNegative / tickStep) * tickStep) : 0;
const plotHeight = height - margin.top - margin.bottom;
const totalRange = positiveMax - negativeMin;
const zeroY = margin.top + (plotHeight * positiveMax) / totalRange;
let axis = "";
for (let value = negativeMin; value <= positiveMax; value += tickStep) {
const y = margin.top + ((positiveMax - value) / totalRange) * plotHeight;
axis += `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="#d8dee5" stroke-dasharray="4 8" />`;
axis += `<text x="${margin.left - 14}" y="${y + 5}" text-anchor="end" fill="#52606d" font-size="14" font-weight="800">${formatAxisLabel(value)}</text>`;
}
axis += `<line x1="${margin.left}" y1="${zeroY}" x2="${width - margin.right}" y2="${zeroY}" stroke="#7c8d9a" stroke-width="1.5" />`;
return { axis, tickStep, positiveMax, negativeMin, zeroY, totalRange };
}
function renderLegend(targetId, keys) {
const target = document.getElementById(targetId);
if (!target) return;
target.innerHTML = keys.map((key) => `
<span class="legend-item">
<span class="legend-swatch" style="background:${palette[key]};"></span>
${labels[key]}
</span>
`).join("");
}
function getLatestAvailableYear() {
return String(availableYears[availableYears.length - 1] || "recent10");
}
function syncYearFilter() {
const granularity = document.getElementById("granularity").value;
const yearFilterEl = document.getElementById("yearFilter");
if (!yearFilterEl) return;
if (granularity === "yearly") {
yearFilterEl.value = "recent10";
yearFilterEl.disabled = true;
return;
}
const hasSelectedYear = availableYears.some((year) => String(year) === String(yearFilterEl.value));
if (yearFilterEl.value === "recent10" || !hasSelectedYear) {
yearFilterEl.value = getLatestAvailableYear();
}
yearFilterEl.disabled = false;
}
function getFilteredSeries() {
const granularity = document.getElementById("granularity").value;
const selectedYear = document.getElementById("yearFilter").value;
if (granularity === "yearly" || selectedYear === "recent10") {
return yearlySeries
.slice(-10)
.map((item) => ({ ...item, label: String(item.year) }));
}
return monthlySeries
.filter((item) => String(item.year) === String(selectedYear))
.map((item) => ({ ...item, label: `${item.month}` }));
}
function renderMetrics(series) {
const keys = annualMetricCards.map((option) => option.item_key);
const totals = {};
keys.forEach((key) => {
totals[key] = series.reduce((sum, item) => sum + (item[key] || 0), 0);
});
const grid = document.getElementById("metricGrid");
grid.innerHTML = keys.map((key) => `
<div class="stat-card">
<div class="label">${labels[key]}</div>
<div class="value">${formatNumber(totals[key])}</div>
</div>
`).join("");
}
function buildAxis(maxValue, width, height, margin) {
const { tickStep, tickMax } = buildPositiveAxisScale(maxValue, 4);
let axis = "";
for (let value = 0; value <= tickMax; value += tickStep) {
const y = height - margin.bottom - ((height - margin.top - margin.bottom) * value) / tickMax;
axis += `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="#d8dee5" stroke-dasharray="3 7" />`;
axis += `<text x="${margin.left - 12}" y="${y + 4}" text-anchor="end" fill="#5a6672" font-size="11" font-weight="700">${formatAxisLabel(value)}</text>`;
}
axis += `<line x1="${margin.left}" y1="${height - margin.bottom}" x2="${width - margin.right}" y2="${height - margin.bottom}" stroke="#8ba0ae" stroke-width="1.2" />`;
return { axis, tickMax };
}
function renderEmptyChart(svgId, message) {
const svg = document.getElementById(svgId);
if (!svg) return;
const viewBox = (svg.getAttribute("viewBox") || "0 0 1400 560").split(/\s+/).map(Number);
const width = viewBox[2] || 1400;
const height = viewBox[3] || 560;
svg.innerHTML = `
<rect x="0" y="0" width="${width}" height="${height}" rx="8" fill="#f7fafb" stroke="#d6e2e8"></rect>
<text x="${width / 2}" y="${height / 2}" text-anchor="middle" fill="#667887" font-size="24" font-weight="800">${message}</text>
`;
}
function renderExpenseChart(series) {
const svg = document.getElementById("expenseChart");
const keys = annualExpenseChartMetrics.map((option) => option.item_key);
const isMonthlyView = series.some((item) => String(item.label || "").includes("월"));
renderLegend("expenseLegend", keys);
if (!series.length) {
renderEmptyChart("expenseChart", "표시할 비용 구조 데이터가 없습니다.");
return;
}
const width = 1560;
const height = 660;
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
const margin = { top: 54, right: 36, bottom: 96, left: 124 };
const plotWidth = width - margin.left - margin.right;
const plotHeight = height - margin.top - margin.bottom;
const barWidth = (plotWidth / series.length) * (isMonthlyView ? 0.36 : 0.42);
const step = (width - margin.left - margin.right) / series.length;
const maxValue = Math.max(...series.map((item) => keys.reduce((sum, key) => sum + (item[key] || 0), 0)), 1);
const { axis, tickMax } = buildDynamicPositiveAxis(maxValue, width, height, margin);
let markup = `
<defs>
<filter id="expenseShadow" x="-20%" y="-20%" width="140%" height="160%">
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="rgba(44, 68, 89, 0.12)" />
</filter>
</defs>
<rect x="${margin.left}" y="${margin.top}" width="${plotWidth}" height="${plotHeight}" rx="6" fill="rgba(255,255,255,0.7)" stroke="#dde7ec"></rect>
${axis}
`;
series.forEach((item, index) => {
let cumulative = 0;
const total = keys.reduce((sum, key) => sum + (item[key] || 0), 0);
const x = margin.left + index * step + (step - barWidth) / 2;
keys.forEach((key) => {
const value = item[key] || 0;
const barHeight = (plotHeight * value) / tickMax;
const y = height - margin.bottom - barHeight - (plotHeight * cumulative) / tickMax;
cumulative += value;
markup += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="2" fill="${palette[key]}" filter="url(#expenseShadow)" />`;
if (value > 0 && barHeight >= 34) {
const percent = total ? `${((value / total) * 100).toFixed(1)}%` : "0.0%";
markup += `<text x="${x + barWidth / 2}" y="${y + Math.min(barHeight / 2, 22)}" text-anchor="middle" fill="#ffffff" font-size="${isMonthlyView ? 12.5 : 13.5}" font-weight="900">${percent}</text>`;
}
});
if (total > 0) {
const topY = height - margin.bottom - (plotHeight * total) / tickMax;
markup += `<text x="${x + barWidth / 2}" y="${Math.max(topY - 14, margin.top + 18)}" text-anchor="middle" fill="#2d3c4a" font-size="${isMonthlyView ? 14 : 15.5}" font-weight="900">${formatAxisLabel(total)}</text>`;
}
markup += `<text x="${x + barWidth / 2}" y="${height - margin.bottom + 30}" text-anchor="middle" fill="#53606c" font-size="${isMonthlyView ? 14 : 15}" font-weight="800">${item.label}</text>`;
});
svg.innerHTML = markup;
}
function renderBalanceChart(series) {
const svg = document.getElementById("balanceChart");
const metrics = annualBalanceChartMetrics.map((option) => option.item_key);
renderLegend("balanceLegend", metrics);
if (!series.length) {
renderEmptyChart("balanceChart", "표시할 수익/비용 데이터가 없습니다.");
return;
}
const width = 1560;
const height = 620;
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
const margin = { top: 56, right: 34, bottom: 100, left: 124 };
const plotWidth = width - margin.left - margin.right;
const plotHeight = height - margin.top - margin.bottom;
const maxPositiveValue = Math.max(...series.flatMap((item) => [
item.revenue_sum || 0,
item.total_expense || 0,
Math.max(item.operating_balance || 0, 0),
]), 1);
const minNegativeValue = Math.min(...series.map((item) => Math.min(item.operating_balance || 0, 0)), 0);
const { axis, positiveMax, negativeMin, zeroY, totalRange } = buildDynamicBalanceAxis(maxPositiveValue, minNegativeValue, width, height, margin);
const groupWidth = plotWidth / Math.max(series.length, 1);
const groupGap = groupWidth * 0.22;
const innerGap = 0;
const barWidth = Math.min((groupWidth - groupGap * 2) / metrics.length, 72);
const actualGroupWidth = barWidth * metrics.length + innerGap * (metrics.length - 1);
const groupStartOffset = (groupWidth - actualGroupWidth) / 2;
let markup = `
<defs>
<filter id="balanceShadow" x="-20%" y="-20%" width="140%" height="160%">
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="rgba(44, 68, 89, 0.12)" />
</filter>
</defs>
<rect x="${margin.left}" y="${margin.top}" width="${plotWidth}" height="${plotHeight}" rx="6" fill="rgba(255,255,255,0.7)" stroke="#dde7ec"></rect>
${axis}
`;
series.forEach((item, index) => {
const baseX = margin.left + index * groupWidth;
const positiveLabels = [];
const negativeLabels = [];
metrics.forEach((key, metricIndex) => {
const rawValue = Number(item[key] || 0);
const value = key === "operating_balance" ? rawValue : Math.max(rawValue, 0);
const clampedValue = Math.max(negativeMin, Math.min(positiveMax, value));
const barHeight = (plotHeight * Math.abs(clampedValue)) / totalRange;
const x = baseX + groupStartOffset + metricIndex * (barWidth + innerGap);
const y = clampedValue < 0 ? zeroY : zeroY - barHeight;
markup += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="2" fill="${palette[key]}" filter="url(#balanceShadow)" />`;
if (clampedValue !== 0) {
const label = {
x: x + barWidth / 2,
desiredY: clampedValue < 0
? Math.min(y + barHeight + 22, height - margin.bottom + 12)
: Math.max(y - 14, margin.top + 18),
text: formatAxisLabel(value),
};
if (clampedValue < 0) {
negativeLabels.push(label);
} else {
positiveLabels.push(label);
}
}
});
positiveLabels.sort((a, b) => a.desiredY - b.desiredY);
let lastPositiveY = margin.top - 24;
positiveLabels.forEach((label) => {
const y = Math.max(label.desiredY, lastPositiveY + 18);
lastPositiveY = y;
markup += `<text x="${label.x}" y="${y}" text-anchor="middle" fill="#2d3c4a" font-size="13.5" font-weight="900">${label.text}</text>`;
});
negativeLabels.sort((a, b) => a.desiredY - b.desiredY);
let lastNegativeY = zeroY + 18;
negativeLabels.forEach((label) => {
const y = Math.max(label.desiredY, lastNegativeY + 18);
lastNegativeY = y;
markup += `<text x="${label.x}" y="${Math.min(y, height - margin.bottom + 34)}" text-anchor="middle" fill="#2d3c4a" font-size="13.5" font-weight="900">${label.text}</text>`;
});
markup += `<text x="${baseX + groupWidth / 2}" y="${height - margin.bottom + 34}" text-anchor="middle" fill="#53606c" font-size="15" font-weight="800">${item.label}</text>`;
});
svg.innerHTML = markup;
}
function renderAll() {
syncYearFilter();
const series = getFilteredSeries();
renderMetrics(series);
renderExpenseChart(series);
renderBalanceChart(series);
}
document.getElementById("granularity").addEventListener("change", renderAll);
document.getElementById("yearFilter").addEventListener("change", renderAll);
const granularitySelect = document.getElementById("granularity");
if (granularitySelect) {
granularitySelect.value = "yearly";
}
const yearFilter = document.getElementById("yearFilter");
if (yearFilter) {
yearFilter.value = "recent10";
}
renderAll();
</script>
{% endblock %}
@@ -0,0 +1,682 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}인트라넷 회계 시스템{% endblock %}</title>
<style>
:root {
--bg-a: #f6f6f7;
--bg-b: #ececef;
--panel: rgba(255, 255, 255, 0.94);
--ink: #161616;
--muted: #73777f;
--line: #d9dde3;
--accent: #111111;
--accent-strong: #000000;
--warn: #fff2cb;
--table-alt: #f5f6f8;
--white: #ffffff;
--page-gutter: clamp(14px, 1.8vw, 24px);
--panel-pad: clamp(16px, 1.4vw, 20px);
--page-frame-width: min(1520px, calc(100vw - (var(--page-gutter) * 2)));
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "SUIT", "Noto Sans KR", "Malgun Gothic", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.92), transparent 24%),
linear-gradient(180deg, var(--bg-a), var(--bg-b));
min-height: 100vh;
padding: var(--page-gutter);
}
.page {
width: min(100%, var(--page-frame-width));
margin: 0 auto;
display: grid;
gap: var(--page-gutter);
}
.page > * {
width: 100%;
}
.nav {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--line);
border-radius: 18px;
padding: 8px;
box-shadow: 0 10px 24px rgba(21, 24, 29, 0.06);
}
.nav-spacer {
flex: 1 1 auto;
min-width: 12px;
}
.nav a {
text-decoration: none;
color: var(--ink);
padding: 9px 13px;
border-radius: 10px;
font-weight: 700;
font-size: 14px;
transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease;
}
.nav a.active {
background: var(--accent);
color: var(--white);
}
.nav a:hover {
background: #f3f4f6;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 18px;
padding: var(--panel-pad);
box-shadow: 0 10px 24px rgba(21, 24, 29, 0.045);
backdrop-filter: blur(6px);
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.section-title h2 {
font-size: 21px;
letter-spacing: -0.03em;
}
.section-title p {
color: var(--muted);
font-size: 14px;
}
.message {
background: var(--warn);
border: 1px solid #ead98a;
color: #624c0b;
border-radius: 14px;
padding: 14px 16px;
line-height: 1.6;
}
.stats {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
}
.summary-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-top: 18px;
}
.stat-card {
background: var(--white);
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px 15px 13px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.7);
}
.stat-card .label {
color: var(--muted);
font-size: 13px;
margin-bottom: 6px;
letter-spacing: 0.01em;
font-weight: 700;
}
.stat-card .value {
font-size: clamp(23px, 2vw, 38px);
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.04em;
word-break: break-word;
}
.stat-card .meta {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.two-col {
display: grid;
grid-template-columns: 0.92fr 1.08fr;
gap: 20px;
}
.stack {
display: grid;
gap: 20px;
}
form {
display: grid;
gap: 14px;
}
.upload-box {
background: linear-gradient(180deg, #fbfbfc, #f1f3f6);
border: 1px dashed #c5ccd6;
border-radius: 14px;
padding: 18px;
}
.upload-box p {
color: var(--muted);
line-height: 1.65;
margin-bottom: 10px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.field {
display: grid;
gap: 6px;
}
.field-wide {
grid-column: span 2;
}
.field-full {
grid-column: 1 / -1;
}
label {
font-size: 12px;
font-weight: 700;
color: var(--muted);
}
input,
select,
textarea,
button {
font: inherit;
}
input[type="text"],
input[type="number"],
input[type="date"],
input[type="file"],
select,
textarea {
width: 100%;
border: 1px solid var(--line);
background: #fcfcfd;
border-radius: 10px;
padding: 10px 12px;
color: var(--ink);
box-shadow: inset 0 1px 2px rgba(16, 24, 40, 0.03);
}
textarea {
min-height: 96px;
resize: vertical;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(17, 17, 17, 0.08);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
button,
.button-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 10px;
border: 1px solid transparent;
padding: 9px 14px;
background: var(--accent);
color: var(--white);
font-weight: 700;
font-size: 14px;
cursor: pointer;
text-decoration: none;
transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease, border-color 0.16s ease;
box-shadow: 0 8px 18px rgba(17, 17, 17, 0.14);
}
button:hover,
.button-link:hover {
background: var(--accent-strong);
transform: translateY(-1px);
}
.button-secondary {
background: #ffffff;
color: #1b1d21;
border-color: var(--line);
box-shadow: none;
}
.button-icon {
width: 38px;
height: 38px;
min-width: 38px;
padding: 0;
border-radius: 10px;
box-shadow: none;
flex: 0 0 auto;
}
.button-icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.button-icon.button-secondary svg {
stroke: #1b1d21;
}
.button-icon.danger-lite svg {
stroke: #a93a3a;
}
.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;
}
.table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--white);
}
table {
width: 100%;
border-collapse: collapse;
min-width: 780px;
}
th,
td {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
font-size: 14px;
}
th {
position: sticky;
top: 0;
background: #eff5f7;
color: #345061;
z-index: 1;
}
tbody tr:nth-child(even) td {
background: var(--table-alt);
}
.empty {
padding: 24px;
color: var(--muted);
text-align: center;
}
.search-box {
margin: 16px 0;
}
.mono {
font-family: "Consolas", "Courier New", monospace;
font-size: 13px;
}
.sync-status {
margin-left: auto;
max-width: 240px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(248, 250, 252, 0.92);
box-shadow: none;
padding: 6px 10px;
display: inline-flex;
align-items: center;
gap: 8px;
backdrop-filter: blur(6px);
}
.sync-status-head {
display: inline-flex;
align-items: center;
gap: 8px;
}
.sync-status-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.sync-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #9aa3af;
}
.sync-dot.online {
background: #16a34a;
box-shadow: 0 0 0 4px rgba(22, 163, 74, 0.12);
}
.sync-dot.error {
background: #dc2626;
box-shadow: 0 0 0 4px rgba(220, 38, 38, 0.12);
}
.sync-pill {
display: none;
}
.sync-meta {
display: none;
}
.sync-meta strong {
color: var(--ink);
font-weight: 700;
}
@media (max-width: 1200px) {
.stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-grid,
.two-col {
grid-template-columns: 1fr;
}
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.stats,
.form-grid {
grid-template-columns: 1fr;
}
.field-wide,
.field-full {
grid-column: auto;
}
.sync-status {
width: 100%;
justify-content: center;
}
}
</style>
{% block head_extra %}{% endblock %}
</head>
<body>
<div class="page">
<nav class="nav">
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">대시보드</a>
<a href="/projects" class="{% if request.url.path == '/projects' %}active{% endif %}">프로젝트 정보</a>
<a href="/annual-summary" class="{% if request.url.path == '/annual-summary' %}active{% endif %}">연도별 수익/비용</a>
<div class="nav-spacer"></div>
<aside
class="sync-status"
id="syncStatusWidget"
data-data-version="{{ data_version or '' }}"
data-refresh-url="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}"
data-refresh-mode="{{ 'disabled' if request.url.path == '/projects' else 'auto' }}"
>
<div class="sync-status-head">
<div class="sync-status-title">
<span class="sync-dot" id="syncStatusDot"></span>
<span id="syncStatusLabel">연결 확인 중</span>
</div>
<span class="sync-pill" id="syncSessionPill">세션 준비 중</span>
</div>
<div class="sync-meta">
<div>마지막 확인: <strong id="syncLastChecked">-</strong></div>
<div>서버 시간: <strong id="syncServerTime">{{ server_time or '-' }}</strong></div>
<div>데이터 버전: <strong id="syncDataVersion">{{ data_version or '-' }}</strong></div>
<div>계약/청구 동기화: <strong>{{ import_sync_summary.contract_project_count or 0 }}</strong> / <strong>{{ import_sync_summary.billing_project_count or 0 }}</strong></div>
</div>
</aside>
</nav>
{% if message %}
<div class="message">{{ message }}</div>
{% endif %}
{% block content %}{% endblock %}
</div>
{% block script %}{% endblock %}
<script>
(() => {
const widget = document.getElementById("syncStatusWidget");
if (!widget) return;
const dot = document.getElementById("syncStatusDot");
const label = document.getElementById("syncStatusLabel");
const sessionPill = document.getElementById("syncSessionPill");
const lastChecked = document.getElementById("syncLastChecked");
const serverTime = document.getElementById("syncServerTime");
const dataVersion = document.getElementById("syncDataVersion");
let pageVersion = widget.dataset.dataVersion || "";
const refreshUrl = widget.dataset.refreshUrl || window.location.href;
const refreshMode = widget.dataset.refreshMode || "auto";
let refreshInFlight = false;
let pendingVersion = "";
let isFormDirty = false;
function getSessionId() {
const key = "intranet-client-session-id";
let sessionId = window.localStorage.getItem(key);
if (!sessionId) {
sessionId = `session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
window.localStorage.setItem(key, sessionId);
}
return sessionId;
}
const clientSessionId = getSessionId();
window.clientSessionId = clientSessionId;
sessionPill.textContent = clientSessionId;
function updateWidgetTitle() {
widget.title = [
`상태: ${label.textContent}`,
`세션: ${sessionPill.textContent}`,
`마지막 확인: ${lastChecked.textContent}`,
`서버 시간: ${serverTime.textContent}`,
`데이터 버전: ${dataVersion.textContent}`,
].join("\n");
}
function prepareCollabForms() {
document.querySelectorAll("form[data-collab-form]").forEach((form) => {
let sessionInput = form.querySelector('input[name="client_session_id"]');
if (!sessionInput) {
sessionInput = document.createElement("input");
sessionInput.type = "hidden";
sessionInput.name = "client_session_id";
form.appendChild(sessionInput);
}
sessionInput.value = clientSessionId;
let submittedInput = form.querySelector('input[name="client_submitted_at"]');
if (!submittedInput) {
submittedInput = document.createElement("input");
submittedInput.type = "hidden";
submittedInput.name = "client_submitted_at";
form.appendChild(submittedInput);
}
const markDirty = () => {
isFormDirty = true;
};
form.addEventListener("input", markDirty);
form.addEventListener("change", markDirty);
form.addEventListener("submit", () => {
isFormDirty = false;
submittedInput.value = new Date().toISOString();
});
});
}
prepareCollabForms();
function setStatus(kind, text) {
dot.classList.remove("online", "error");
if (kind === "online") dot.classList.add("online");
if (kind === "error") dot.classList.add("error");
label.textContent = text;
lastChecked.textContent = new Date().toLocaleTimeString("ko-KR", { hour12: false });
updateWidgetTitle();
}
function setPageDataVersion(nextVersion) {
pageVersion = nextVersion || "";
pendingVersion = "";
widget.dataset.dataVersion = pageVersion;
dataVersion.textContent = pageVersion || "-";
updateWidgetTitle();
}
window.__setPageDataVersion = setPageDataVersion;
function hasActiveEditor() {
const active = document.activeElement;
return Boolean(active && active.closest && active.closest("form[data-collab-form]"));
}
function hasOpenModal() {
return Boolean(document.querySelector(".modal-backdrop.open"));
}
function shouldDelayRefresh() {
return isFormDirty || hasActiveEditor() || hasOpenModal() || window.__suspendAutoRefresh === true;
}
async function refreshPageWhenSafe(nextVersion) {
if (refreshInFlight) return;
if (refreshMode === "disabled") {
setPageDataVersion(nextVersion || pageVersion);
setStatus("online", "서버 정상 연결");
return;
}
if (shouldDelayRefresh()) {
pendingVersion = nextVersion || pendingVersion || pageVersion;
setStatus("online", "새 데이터 대기 중");
return;
}
refreshInFlight = true;
pendingVersion = nextVersion || pendingVersion || "";
setStatus("online", "새 데이터 반영 중");
try {
window.location.replace(refreshUrl);
} catch (error) {
refreshInFlight = false;
setStatus("error", "업데이트 재시도 중");
}
}
async function pollHealth() {
try {
const response = await fetch(`/health?ts=${Date.now()}`, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
serverTime.textContent = payload.server_time || "-";
dataVersion.textContent = payload.data_version || "-";
updateWidgetTitle();
if (payload.data_version && payload.data_version !== pageVersion) {
pendingVersion = payload.data_version;
await refreshPageWhenSafe(payload.data_version);
return;
}
if (pendingVersion && pendingVersion !== pageVersion) {
await refreshPageWhenSafe(pendingVersion);
return;
}
setStatus("online", "서버 정상 연결");
} catch (error) {
setStatus("error", "연결 오류");
}
}
pollHealth();
updateWidgetTitle();
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
pollHealth();
}
});
window.setInterval(pollHealth, 15000);
})();
</script>
</body>
</html>
@@ -0,0 +1,589 @@
{% extends "base.html" %}
{% block title %}대시보드{% endblock %}
{% block head_extra %}
<style>
.dashboard-topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.dashboard-topbar-actions {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.dashboard-year-select {
width: 180px;
}
.upload-actions {
position: relative;
display: inline-flex;
align-items: center;
}
.upload-actions:hover .upload-tooltip,
.upload-actions:focus-within .upload-tooltip {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.upload-tooltip {
position: absolute;
top: calc(100% + 12px);
right: 0;
width: 280px;
background: rgba(20, 20, 20, 0.96);
color: #f8fbfd;
border-radius: 14px;
padding: 14px 16px;
font-size: 13px;
line-height: 1.7;
box-shadow: 0 18px 35px rgba(20, 20, 20, 0.18);
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
z-index: 10;
}
.upload-tooltip::before {
content: "";
position: absolute;
top: -8px;
right: 22px;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 8px solid rgba(20, 20, 20, 0.96);
}
.hidden-file-input {
display: none;
}
.dashboard-layout {
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.dashboard-status-panel {
display: grid;
gap: 12px;
}
.dashboard-status-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.dashboard-status-grid .stat-card {
min-height: 84px;
}
.dashboard-status-grid .stat-card .value {
font-size: clamp(18px, 1.5vw, 28px);
line-height: 1.12;
}
.dashboard-chart-stack {
display: grid;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
}
.chart-panel {
display: grid;
gap: 12px;
}
.chart-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.chart-panel-header h3 {
font-size: 18px;
letter-spacing: -0.03em;
}
.filter-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 180px));
gap: 10px;
align-items: center;
}
.chart-shell {
background:
linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,247,249,0.98)),
radial-gradient(circle at top left, rgba(24, 24, 27, 0.045), transparent 38%);
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px 16px 16px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
display: grid;
gap: 12px;
}
.legend-box {
display: flex;
flex-wrap: wrap;
gap: 10px 12px;
}
.legend-box.center {
justify-content: center;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 8px;
border-radius: 10px;
padding: 6px 10px;
background: rgba(255,255,255,0.92);
border: 1px solid var(--line);
color: #363b44;
font-size: 12px;
font-weight: 700;
}
.legend-swatch {
width: 10px;
height: 10px;
border-radius: 999px;
display: inline-block;
}
.chart-svg {
width: 100%;
height: auto;
aspect-ratio: 1120 / 390;
min-height: 300px;
display: block;
}
@media (max-width: 1200px) {
.dashboard-layout {
grid-template-columns: 1fr;
}
.dashboard-chart-stack {
grid-template-rows: auto;
}
}
@media (max-width: 860px) {
.filter-row {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<section class="panel">
<div class="dashboard-topbar">
<div class="section-title" style="margin-bottom: 0;">
<h2>사업현황</h2>
</div>
<div class="dashboard-topbar-actions">
<form method="get" action="/" id="dashboardYearForm">
<select id="dashboardYearSelect" class="dashboard-year-select" name="overview_year" aria-label="사업현황 연도 선택">
<option value="" {% if not overview_selected_year %}selected{% endif %}>최근 10개년</option>
{% for year in available_years %}
<option value="{{ year }}" {% if overview_selected_year == year %}selected{% endif %}>{{ year }}</option>
{% endfor %}
</select>
</form>
<div class="upload-actions">
<form action="/upload" method="post" enctype="multipart/form-data" id="uploadForm">
<input id="excel_file" class="hidden-file-input" type="file" name="excel_file" accept=".xlsx,.xlsm,.xltx,.xltm" required>
<button type="button" id="uploadButton" class="button-icon" title="업로드 저장" aria-label="업로드 저장">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 4h9l3 3v13H6z"></path>
<path d="M9 4v6h6V4"></path>
<path d="M9 17h6"></path>
</svg>
<span class="sr-only">업로드 저장</span>
</button>
</form>
<div class="upload-tooltip">엑셀 파일을 선택하면 회계 데이터를 DB에 바로 저장합니다. 프로젝트 폴더에 둔 파일 외에 추가 파일을 수동 반영할 때 사용하세요.</div>
</div>
</div>
</div>
<div class="dashboard-layout">
<section class="dashboard-status-panel">
<div class="dashboard-status-grid">
<div class="stat-card">
<div class="label">수행 프로젝트</div>
<div class="value">{{ ((project_dashboard.related_projects or 0) - (project_dashboard.completed_projects or 0)) if ((project_dashboard.related_projects or 0) - (project_dashboard.completed_projects or 0)) > 0 else 0 }}</div>
</div>
<div class="stat-card">
<div class="label">종료 프로젝트</div>
<div class="value">{{ project_dashboard.completed_projects or 0 }}</div>
</div>
<div class="stat-card">
<div class="label">수금액</div>
<div class="value">{{ "{:,.0f}".format(project_dashboard.collection_amount or 0) }}</div>
</div>
<div class="stat-card">
<div class="label">비용</div>
<div class="value">{{ "{:,.0f}".format((overview.total_cost or 0) + (overview.total_sga or 0)) }}</div>
</div>
<div class="stat-card">
<div class="label">원가</div>
<div class="value">{{ "{:,.0f}".format(overview.total_cost or 0) }}</div>
</div>
<div class="stat-card">
<div class="label">판관비</div>
<div class="value">{{ "{:,.0f}".format(overview.total_sga or 0) }}</div>
</div>
</div>
</section>
<div class="dashboard-chart-stack">
<section class="chart-panel">
<div class="chart-panel-header">
<h3>수금 구성</h3>
<div class="filter-row">
<select id="revenueGranularity" aria-label="수금 구성 집계 단위">
<option value="yearly" {% if not overview_selected_year %}selected{% endif %}>연도별</option>
<option value="monthly" {% if overview_selected_year %}selected{% endif %}>월별</option>
</select>
<select id="revenueYear" aria-label="수금 구성 연도 선택">
<option value="all">전체연도</option>
{% for year in available_years %}
<option value="{{ year }}" {% if overview_selected_year == year %}selected{% endif %}>{{ year }}</option>
{% endfor %}
</select>
<select id="revenueMetric" aria-label="수금 구성 항목 선택">
<option value="all">전체 항목</option>
{% for option in dashboard_revenue_metric_options %}
<option value="{{ option.item_key }}">{{ option.label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="chart-shell">
<div class="legend-box center" id="revenueLegend"></div>
<svg id="revenueChart" class="chart-svg" viewBox="0 0 1120 390" preserveAspectRatio="xMidYMid meet"></svg>
</div>
</section>
<section class="chart-panel">
<div class="chart-panel-header">
<h3>지출 구성</h3>
<div class="filter-row">
<select id="expenseGranularity" aria-label="지출 구성 집계 단위">
<option value="yearly" {% if not overview_selected_year %}selected{% endif %}>연도별</option>
<option value="monthly" {% if overview_selected_year %}selected{% endif %}>월별</option>
</select>
<select id="expenseYear" aria-label="지출 구성 연도 선택">
<option value="all">전체연도</option>
{% for year in available_years %}
<option value="{{ year }}" {% if overview_selected_year == year %}selected{% endif %}>{{ year }}</option>
{% endfor %}
</select>
<select id="expenseMetric" aria-label="지출 구성 항목 선택">
<option value="all">전체 항목</option>
{% for option in dashboard_expense_metric_options %}
<option value="{{ option.item_key }}">{{ option.label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="chart-shell">
<div class="legend-box center" id="expenseLegend"></div>
<svg id="expenseChart" class="chart-svg" viewBox="0 0 1120 390" preserveAspectRatio="xMidYMid meet"></svg>
</div>
</section>
</div>
</div>
</section>
{% endblock %}
{% block script %}
<script>
const availableYears = {{ available_years | tojson }};
const yearlySummary = {{ yearly_summary | tojson }};
const monthlySummary = {{ monthly_summary | tojson }};
const revenueYearly = {{ project_revenue_mix_yearly | tojson }};
const revenueMonthly = {{ project_revenue_mix_monthly | tojson }};
const pageSelectedYear = {{ overview_selected_year | tojson }};
const dashboardRevenueMetricOptions = {{ dashboard_revenue_metric_options | tojson }};
const dashboardExpenseMetricOptions = {{ dashboard_expense_metric_options | tojson }};
const revenuePalette = Object.fromEntries(
dashboardRevenueMetricOptions.map((option) => [
option.item_key,
{ label: option.label, color: option.value },
]),
);
const expensePalette = Object.fromEntries(
dashboardExpenseMetricOptions.map((option) => [
option.item_key,
{ label: option.label, color: option.value },
]),
);
const revenueMetricMap = {
all: dashboardRevenueMetricOptions.map((option) => option.item_key),
...Object.fromEntries(dashboardRevenueMetricOptions.map((option) => [option.item_key, [option.item_key]])),
};
const expenseMetricMap = {
all: dashboardExpenseMetricOptions.map((option) => option.item_key),
...Object.fromEntries(dashboardExpenseMetricOptions.map((option) => [option.item_key, [option.item_key]])),
};
function formatNumber(value) {
return new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 }).format(value || 0);
}
function formatValueLabel(value) {
const numeric = Number(value || 0);
if (!numeric) return "0.0";
if (numeric >= 100000000) return `${(numeric / 100000000).toFixed(1)}`;
if (numeric >= 1000000) return `${(numeric / 1000000).toFixed(1)}백만`;
if (numeric >= 1000) return `${(numeric / 1000).toFixed(1)}`;
return numeric.toFixed(1);
}
function formatAxisLabel(value) {
const numeric = Number(value || 0);
if (!numeric) return "0";
if (numeric >= 100000000) return `${numeric / 100000000}`;
if (numeric >= 1000000) return `${numeric / 1000000}백만`;
if (numeric >= 1000) return `${numeric / 1000}`;
return formatNumber(numeric);
}
function pickTickStep(maxValue) {
const baseUnit = maxValue < 1000000 ? 1000 : 1000000;
const units = [1, 2, 5];
const raw = Math.max(maxValue / baseUnit, 1);
let power = 1;
while (power * 10 <= raw) power *= 10;
for (const unit of units) {
const candidate = unit * power;
if (candidate >= raw) return candidate * baseUnit;
}
return power * 10 * baseUnit;
}
function buildPositiveAxis(maxValue, tickCount = 4) {
const safeMax = Math.max(Number(maxValue || 0), 1);
const paddedMax = safeMax * (safeMax < 1000 ? 1.12 : 1.08);
const tickStep = pickTickStep(paddedMax / tickCount);
const tickMax = Math.max(tickStep, Math.ceil(paddedMax / tickStep) * tickStep);
return { tickStep, tickMax };
}
function setLegend(containerId, metricKeys, paletteMap) {
const target = document.getElementById(containerId);
if (!target) return;
target.innerHTML = metricKeys.map((key) => `
<span class="legend-item">
<span class="legend-swatch" style="background:${paletteMap[key].color}"></span>
${paletteMap[key].label}
</span>
`).join("");
}
function renderEmptyChart(svgId, message) {
const svg = document.getElementById(svgId);
if (!svg) return;
svg.innerHTML = `
<rect x="0" y="0" width="1120" height="390" rx="8" fill="#f7fafb" stroke="#d6e2e8"></rect>
<text x="560" y="195" text-anchor="middle" fill="#667887" font-size="18" font-weight="700">${message}</text>
`;
}
function renderGroupedBarChart(svgId, rows, metricKeys, paletteMap, options = {}) {
const svg = document.getElementById(svgId);
if (!svg) return;
if (!rows.length || !metricKeys.length) {
renderEmptyChart(svgId, "표시할 집계 데이터가 없습니다.");
return;
}
const granularity = options.granularity || "yearly";
const width = 1120;
const height = 390;
const margin = { top: 34, right: 26, bottom: 74, left: 94 };
const plotWidth = width - margin.left - margin.right;
const plotHeight = height - margin.top - margin.bottom;
const maxValue = Math.max(1, ...rows.flatMap((row) => metricKeys.map((key) => Number(row[key] || 0))));
const { tickStep, tickMax } = buildPositiveAxis(maxValue, 4);
const groupWidth = plotWidth / Math.max(rows.length, 1);
const axisBaseY = height - margin.bottom;
const groupGapRatio = granularity === "monthly" ? 0.28 : 0.18;
const innerGap = granularity === "monthly" ? 7 : 10;
const groupInset = Math.max(groupWidth * groupGapRatio, granularity === "monthly" ? 8 : 12);
const usableGroupWidth = Math.max(groupWidth - groupInset * 2, metricKeys.length * 12);
const barWidth = Math.min((usableGroupWidth - innerGap * Math.max(metricKeys.length - 1, 0)) / Math.max(metricKeys.length, 1), granularity === "monthly" ? 18 : 34);
const actualGroupWidth = barWidth * metricKeys.length + innerGap * Math.max(metricKeys.length - 1, 0);
const groupStartOffset = (groupWidth - actualGroupWidth) / 2;
const xLabelStep = granularity === "monthly" ? 1 : Math.max(1, Math.ceil(rows.length / 10));
let markup = `
<defs>
<linearGradient id="${svgId}Bg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#fcfefe" />
<stop offset="100%" stop-color="#edf4f7" />
</linearGradient>
<filter id="${svgId}Shadow" x="-20%" y="-20%" width="140%" height="160%">
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="rgba(44, 68, 89, 0.14)" />
</filter>
</defs>
<rect x="0" y="0" width="${width}" height="${height}" rx="8" fill="url(#${svgId}Bg)"></rect>
<rect x="${margin.left}" y="${margin.top}" width="${plotWidth}" height="${plotHeight}" rx="4" fill="rgba(255,255,255,0.72)" stroke="#dde7ec"></rect>
`;
for (let value = 0; value <= tickMax; value += tickStep) {
const y = margin.top + plotHeight - (value / tickMax) * plotHeight;
markup += `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="#d8e3e8" stroke-dasharray="3 7"></line>`;
markup += `<text x="${margin.left - 16}" y="${y + 4}" text-anchor="end" fill="#60717d" font-size="11.5" font-weight="700">${formatAxisLabel(value)}</text>`;
}
markup += `<line x1="${margin.left}" y1="${axisBaseY}" x2="${width - margin.right}" y2="${axisBaseY}" stroke="#8ea0ac" stroke-width="1.2"></line>`;
rows.forEach((row, rowIndex) => {
const baseX = margin.left + rowIndex * groupWidth;
const showXAxisLabel = rows.length <= 14 || rowIndex % xLabelStep === 0 || rowIndex === rows.length - 1;
metricKeys.forEach((key, metricIndex) => {
const value = Number(row[key] || 0);
const x = baseX + groupStartOffset + metricIndex * (barWidth + innerGap);
const drawWidth = Math.max(barWidth, 10);
const barHeight = tickMax ? (value / tickMax) * plotHeight : 0;
const y = margin.top + plotHeight - barHeight;
const labelY = Math.max(y - 12, margin.top - 8);
const showValueLabel = barHeight > 24 && (granularity === "yearly" || metricKeys.length <= 2 || drawWidth >= 16);
markup += `<rect x="${x}" y="${y}" width="${drawWidth}" height="${barHeight}" rx="3" fill="${paletteMap[key].color}" filter="url(#${svgId}Shadow)"></rect>`;
if (showValueLabel) {
markup += `<text x="${x + drawWidth / 2}" y="${labelY}" text-anchor="middle" fill="#35505f" font-size="10" font-weight="700">${formatValueLabel(value)}</text>`;
}
});
if (showXAxisLabel) {
markup += `<text x="${baseX + groupWidth / 2}" y="${height - 28}" text-anchor="middle" fill="#405362" font-size="12.5" font-weight="700">${row.label}</text>`;
}
});
svg.innerHTML = markup;
}
function getLatestAvailableYear() {
return String(availableYears[availableYears.length - 1] || "all");
}
function syncYearSelection(selectId, granularity) {
const select = document.getElementById(selectId);
if (!select) return;
if (granularity === "yearly") {
select.value = "all";
select.disabled = true;
return;
}
const hasSelectedYear = availableYears.some((year) => String(year) === String(select.value));
if (select.value === "all" || !hasSelectedYear) {
select.value = pageSelectedYear ? String(pageSelectedYear) : getLatestAvailableYear();
}
select.disabled = false;
}
function getRevenueRows() {
const granularity = document.getElementById("revenueGranularity").value;
const yearFilter = document.getElementById("revenueYear").value;
const source = granularity === "yearly" ? revenueYearly : revenueMonthly;
return source
.filter((row) => {
if (granularity === "yearly") return true;
return yearFilter === "all" ? true : String(row.year) === yearFilter;
})
.map((row) => ({
...row,
label: granularity === "yearly" ? String(row.year) : `${row.month}`,
}))
.slice(granularity === "yearly" ? -10 : 0);
}
function getExpenseRows() {
const granularity = document.getElementById("expenseGranularity").value;
const yearFilter = document.getElementById("expenseYear").value;
const source = granularity === "yearly" ? yearlySummary : monthlySummary;
return source
.filter((row) => {
if (granularity === "yearly") return true;
return yearFilter === "all" ? true : String(row.year) === yearFilter;
})
.map((row) => ({
...row,
label: granularity === "yearly" ? String(row.year) : `${row.month}`,
}))
.slice(granularity === "yearly" ? -10 : 0);
}
function updateRevenueChart() {
const granularity = document.getElementById("revenueGranularity").value;
syncYearSelection("revenueYear", granularity);
const metricKeys = revenueMetricMap[document.getElementById("revenueMetric").value];
setLegend("revenueLegend", metricKeys, revenuePalette);
renderGroupedBarChart("revenueChart", getRevenueRows(), metricKeys, revenuePalette, { granularity });
}
function updateExpenseChart() {
const granularity = document.getElementById("expenseGranularity").value;
syncYearSelection("expenseYear", granularity);
const metricKeys = expenseMetricMap[document.getElementById("expenseMetric").value];
setLegend("expenseLegend", metricKeys, expensePalette);
renderGroupedBarChart("expenseChart", getExpenseRows(), metricKeys, expensePalette, { granularity });
}
document.getElementById("revenueGranularity")?.addEventListener("change", updateRevenueChart);
document.getElementById("revenueYear")?.addEventListener("change", updateRevenueChart);
document.getElementById("revenueMetric")?.addEventListener("change", updateRevenueChart);
document.getElementById("expenseGranularity")?.addEventListener("change", updateExpenseChart);
document.getElementById("expenseYear")?.addEventListener("change", updateExpenseChart);
document.getElementById("expenseMetric")?.addEventListener("change", updateExpenseChart);
document.getElementById("dashboardYearSelect")?.addEventListener("change", (event) => {
event.target.form?.submit();
});
const uploadButton = document.getElementById("uploadButton");
const excelInput = document.getElementById("excel_file");
const uploadForm = document.getElementById("uploadForm");
uploadButton?.addEventListener("click", () => {
excelInput?.click();
});
excelInput?.addEventListener("change", () => {
if (excelInput.files && excelInput.files.length > 0) {
uploadForm.submit();
}
});
syncYearSelection("revenueYear", document.getElementById("revenueGranularity")?.value || "yearly");
syncYearSelection("expenseYear", document.getElementById("expenseGranularity")?.value || "yearly");
updateRevenueChart();
updateExpenseChart();
</script>
{% endblock %}
@@ -0,0 +1,627 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회계 데이터 인트라넷 대시보드</title>
<style>
:root {
--bg-a: #f3efe7;
--bg-b: #d7e5eb;
--panel: rgba(255, 252, 247, 0.92);
--ink: #14212f;
--muted: #5a6672;
--line: #d8dee5;
--accent: #0b6b63;
--accent-strong: #074b49;
--accent-soft: #e6f5f2;
--warn: #fff0c9;
--table-alt: #f9fbfc;
--white: #ffffff;
--page-gutter: clamp(14px, 1.8vw, 24px);
--panel-pad: clamp(16px, 1.4vw, 20px);
--page-frame-width: min(1520px, calc(100vw - (var(--page-gutter) * 2)));
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Noto Sans KR", "Malgun Gothic", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.9), transparent 28%),
linear-gradient(155deg, var(--bg-a), var(--bg-b));
min-height: 100vh;
padding: var(--page-gutter);
}
.page {
width: min(100%, var(--page-frame-width));
margin: 0 auto;
display: grid;
gap: var(--page-gutter);
}
.page > * {
width: 100%;
}
.panel {
background: var(--panel);
border: 1px solid rgba(255, 255, 255, 0.6);
border-radius: 24px;
padding: var(--panel-pad);
box-shadow: 0 20px 45px rgba(51, 76, 92, 0.12);
backdrop-filter: blur(10px);
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.section-title h2 {
font-size: 24px;
}
.section-title p {
color: var(--muted);
font-size: 14px;
}
.message {
background: var(--warn);
border: 1px solid #efd486;
color: #624c0b;
border-radius: 16px;
padding: 14px 16px;
line-height: 1.6;
}
.stats {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
}
.summary-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-top: 18px;
}
.stat-card {
background: var(--white);
border: 1px solid var(--line);
border-radius: 18px;
padding: 18px;
}
.stat-card .label {
color: var(--muted);
font-size: 13px;
margin-bottom: 10px;
}
.stat-card .value {
font-size: 28px;
font-weight: 800;
line-height: 1.1;
}
.two-col {
display: grid;
grid-template-columns: 0.92fr 1.08fr;
gap: 20px;
}
.stack {
display: grid;
gap: 20px;
}
form {
display: grid;
gap: 14px;
}
.upload-box {
background: linear-gradient(180deg, #f8fffd, #eef8f6);
border: 1px dashed #a7d1c8;
border-radius: 18px;
padding: 18px;
}
.upload-box p {
color: var(--muted);
line-height: 1.65;
margin-bottom: 10px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.field {
display: grid;
gap: 7px;
}
.field-wide {
grid-column: span 2;
}
.field-full {
grid-column: 1 / -1;
}
label {
font-size: 13px;
font-weight: 700;
color: var(--muted);
}
input,
select,
textarea,
button {
font: inherit;
}
input[type="text"],
input[type="number"],
input[type="date"],
input[type="file"],
textarea {
width: 100%;
border: 1px solid var(--line);
background: var(--white);
border-radius: 14px;
padding: 12px 14px;
color: var(--ink);
}
textarea {
min-height: 96px;
resize: vertical;
}
input:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(11, 107, 99, 0.12);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
button,
.button-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 999px;
border: none;
padding: 12px 18px;
background: var(--accent);
color: var(--white);
font-weight: 700;
cursor: pointer;
text-decoration: none;
}
button:hover,
.button-link:hover {
background: var(--accent-strong);
}
.button-secondary {
background: #e7f0f5;
color: #204257;
}
.table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: 18px;
background: var(--white);
}
table {
width: 100%;
border-collapse: collapse;
min-width: 780px;
}
th,
td {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
font-size: 14px;
}
th {
position: sticky;
top: 0;
background: #eff5f7;
color: #345061;
z-index: 1;
}
tbody tr:nth-child(even) td {
background: var(--table-alt);
}
.empty {
padding: 24px;
color: var(--muted);
text-align: center;
}
.note-list {
display: grid;
gap: 8px;
color: var(--muted);
line-height: 1.7;
font-size: 14px;
}
details.panel summary {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
font-size: 24px;
font-weight: 700;
}
details.panel summary::-webkit-details-marker {
display: none;
}
.search-box {
margin: 16px 0;
}
.mono {
font-family: "Consolas", "Courier New", monospace;
font-size: 13px;
}
@media (max-width: 1200px) {
.stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-grid {
grid-template-columns: 1fr;
}
.two-col {
grid-template-columns: 1fr;
}
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
body {
padding: 14px;
}
.stats,
.form-grid {
grid-template-columns: 1fr;
}
.field-wide,
.field-full {
grid-column: auto;
}
}
</style>
</head>
<body>
<div class="page">
{% if message %}
<div class="message">{{ message }}</div>
{% endif %}
<section class="panel">
<div class="section-title">
<h2>현황 요약</h2>
<p>DB에 저장된 전체 자료 기준</p>
</div>
<div class="stats">
<div class="stat-card">
<div class="label">전체 데이터 건수</div>
<div class="value">{{ overview.total_rows or 0 }}</div>
</div>
<div class="stat-card">
<div class="label">업로드 파일 수</div>
<div class="value">{{ overview.source_files or 0 }}</div>
</div>
<div class="stat-card">
<div class="label">집계 대상 사업 수</div>
<div class="value">{{ overview.business_count or 0 }}</div>
</div>
<div class="stat-card">
<div class="label">원가 총액</div>
<div class="value">{{ "{:,.0f}".format(overview.total_cost or 0) }}</div>
</div>
<div class="stat-card">
<div class="label">판관비 총액</div>
<div class="value">{{ "{:,.0f}".format(overview.total_sga or 0) }}</div>
</div>
</div>
<div class="summary-grid">
<div class="table-wrap">
{% if yearly_summary %}
<table>
<thead>
<tr>
<th>연도</th>
<th>원가 합계</th>
<th>판관비 합계</th>
<th>원가인건비</th>
<th>원가외주비</th>
</tr>
</thead>
<tbody>
{% for item in yearly_summary %}
<tr>
<td>{{ item.year }}</td>
<td>{{ "{:,.0f}".format(item.cost_sum or 0) }}</td>
<td>{{ "{:,.0f}".format(item.sga_sum or 0) }}</td>
<td>{{ "{:,.0f}".format(item.labor_sum or 0) }}</td>
<td>{{ "{:,.0f}".format(item.outsourcing_sum or 0) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">연간 집계 데이터가 없습니다.</div>
{% endif %}
</div>
<div class="table-wrap">
{% if monthly_summary %}
<table>
<thead>
<tr>
<th>연도</th>
<th></th>
<th>원가 합계</th>
<th>판관비 합계</th>
<th>원가인건비</th>
<th>원가외주비</th>
</tr>
</thead>
<tbody>
{% for item in monthly_summary %}
<tr>
<td>{{ item.year }}</td>
<td>{{ item.month }}</td>
<td>{{ "{:,.0f}".format(item.cost_sum or 0) }}</td>
<td>{{ "{:,.0f}".format(item.sga_sum or 0) }}</td>
<td>{{ "{:,.0f}".format(item.labor_sum or 0) }}</td>
<td>{{ "{:,.0f}".format(item.outsourcing_sum or 0) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">월별 집계 데이터가 없습니다.</div>
{% endif %}
</div>
</div>
</section>
<section class="panel">
<div class="section-title">
<h2>계약·청구 동기화 현황</h2>
<p>계약현황 / 기성청구현황 파일 기준</p>
</div>
<div class="stats">
<div class="stat-card">
<div class="label">계약 프로젝트</div>
<div class="value">{{ import_sync_summary.contract_project_count or 0 }}</div>
</div>
<div class="stat-card">
<div class="label">청구 프로젝트</div>
<div class="value">{{ import_sync_summary.billing_project_count or 0 }}</div>
</div>
<div class="stat-card">
<div class="label">청구 행 수</div>
<div class="value">{{ import_sync_summary.billing_entry_count or 0 }}</div>
</div>
<div class="stat-card">
<div class="label">변경계약 검토</div>
<div class="value">{{ import_sync_summary.review_needed_count or 0 }}</div>
</div>
<div class="stat-card">
<div class="label">청구 수금합계</div>
<div class="value">{{ "{:,.0f}".format(import_sync_summary.total_collected_amount or 0) }}</div>
</div>
</div>
</section>
<section class="two-col">
<div class="stack">
<section class="panel">
<div class="section-title">
<h2>엑셀 업로드</h2>
<p>업로드 즉시 DB 저장</p>
</div>
<div class="upload-box">
<p>
업로드 파일은 이미지에 보인 열 형식 기준으로 읽습니다.
예: 결재상태, 가전표번호, 계정코드, 계정명칭, 차변공급가, 대변공급가, 지원부서코드,
지원부서명, 원가부서코드, 원가부서명, 적요1, 관리항목 등
</p>
<p>
현재 프로젝트 폴더에 있는 엑셀 파일은 서버 시작 시 DB가 비어 있으면 자동으로 적재됩니다.
</p>
<form action="/upload" method="post" enctype="multipart/form-data">
<div class="field">
<label for="excel_file">엑셀 파일 선택</label>
<input id="excel_file" type="file" name="excel_file" accept=".xlsx,.xlsm,.xltx,.xltm" required>
</div>
<div class="actions">
<button type="submit">엑셀을 DB에 저장</button>
</div>
</form>
</div>
</section>
<section class="panel">
<div class="section-title">
<h2>{{ "데이터 수정" if edit_record.id else "DB 직접 입력" }}</h2>
<p>엑셀 없이도 직접 등록/수정 가능</p>
</div>
<form action="/records/save" method="post">
<input type="hidden" name="id" value="{{ edit_record.id }}">
<div class="form-grid">
{% for field_name, field_label in field_labels.items() %}
<div class="field {% if field_name in ['memo1', 'memo2', 'management_item'] %}field-wide{% endif %}">
<label for="{{ field_name }}">{{ field_label }}</label>
{% if field_name in ['memo1', 'memo2', 'management_item'] %}
<textarea id="{{ field_name }}" name="{{ field_name }}">{{ edit_record[field_name] }}</textarea>
{% elif field_name == 'posting_date' %}
<input id="{{ field_name }}" type="date" name="{{ field_name }}" value="{{ edit_record[field_name] }}">
{% elif field_name in ['debit_supply', 'debit_vat', 'credit_supply', 'credit_vat'] %}
<input id="{{ field_name }}" type="number" step="0.01" name="{{ field_name }}" value="{{ edit_record[field_name] }}">
{% else %}
<input id="{{ field_name }}" type="text" name="{{ field_name }}" value="{{ edit_record[field_name] }}">
{% endif %}
</div>
{% endfor %}
</div>
<div class="actions">
<button type="submit">{{ "수정 내용을 저장" if edit_record.id else "새 데이터 저장" }}</button>
{% if edit_record.id %}
<a class="button-link button-secondary" href="/">수정 취소</a>
{% endif %}
</div>
</form>
</section>
</div>
<div class="stack">
<details class="panel">
<summary>
<span>집계 대상 사업</span>
<span style="font-size:14px;color:var(--muted);">검색해서 펼쳐보기</span>
</summary>
<div class="search-box">
<input type="text" id="support-business-search" placeholder="지원부서코드 또는 사업명을 입력하세요.">
</div>
<div class="table-wrap">
{% if support_businesses %}
<table id="support-business-table">
<thead>
<tr>
<th>지원부서코드</th>
<th>사업명</th>
<th>행 수</th>
</tr>
</thead>
<tbody>
{% for item in support_businesses %}
<tr data-search="{{ item.support_dept_code }} {{ item.support_dept_name }}">
<td class="mono">{{ item.support_dept_code }}</td>
<td>{{ item.support_dept_name }}</td>
<td>{{ item.row_count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">아직 표시할 사업 데이터가 없습니다. 엑셀 업로드 또는 수동 입력을 먼저 진행해주세요.</div>
{% endif %}
</div>
</details>
</div>
</section>
<details class="panel">
<summary>
<span>사업별 연도/월 사용 비용</span>
<span style="font-size:14px;color:var(--muted);">검색해서 펼쳐보기</span>
</summary>
<div class="search-box">
<input type="text" id="business-cost-search" placeholder="연도, 월, 지원부서코드, 사업명으로 검색하세요.">
</div>
<div class="table-wrap">
{% if business_monthly_summary %}
<table id="business-cost-table">
<thead>
<tr>
<th>연도</th>
<th></th>
<th>지원부서코드</th>
<th>사업명</th>
<th>원가</th>
<th>판관비</th>
</tr>
</thead>
<tbody>
{% for item in business_monthly_summary %}
<tr data-search="{{ item.year }} {{ item.month }} {{ item.support_dept_code }} {{ item.support_dept_name }}">
<td>{{ item.year }}</td>
<td>{{ item.month }}</td>
<td class="mono">{{ item.support_dept_code }}</td>
<td>{{ item.support_dept_name }}</td>
<td>{{ "{:,.0f}".format(item.cost_sum or 0) }}</td>
<td>{{ "{:,.0f}".format(item.sga_sum or 0) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">사업별 월 집계 데이터가 없습니다.</div>
{% endif %}
</div>
</details>
</div>
<script>
function bindTableSearch(inputId, tableId) {
const input = document.getElementById(inputId);
const table = document.getElementById(tableId);
if (!input || !table) return;
const rows = Array.from(table.querySelectorAll("tbody tr"));
input.addEventListener("input", () => {
const keyword = input.value.trim().toLowerCase();
rows.forEach((row) => {
const haystack = (row.dataset.search || row.textContent || "").toLowerCase();
row.style.display = !keyword || haystack.includes(keyword) ? "" : "none";
});
});
}
bindTableSearch("support-business-search", "support-business-table");
bindTableSearch("business-cost-search", "business-cost-table");
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+2 -1
View File
@@ -6713,4 +6713,5 @@ async def save_project_json(request: Request):
if __name__ == "__main__": if __name__ == "__main__":
auto_reload = os.getenv("INTRANET_AUTO_RELOAD", "1").lower() not in {"0", "false", "no"} auto_reload = os.getenv("INTRANET_AUTO_RELOAD", "1").lower() not in {"0", "false", "no"}
uvicorn.run("main:app", host="0.0.0.0", port=8010, reload=auto_reload, reload_dirs=[str(BASE_DIR)]) port = int(os.getenv("INTRANET_PORT", "8010"))
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=auto_reload, reload_dirs=[str(BASE_DIR)])
+22 -2
View File
@@ -1,12 +1,32 @@
param( param(
[string]$ListenAddress = "0.0.0.0", [string]$ListenAddress = "0.0.0.0",
[int]$Port = 8010, [int]$Port = 8010,
[string]$RuleName = "MyIntranetApp-8010" [string]$RuleName = ""
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
netsh interface portproxy delete v4tov4 listenport=$Port listenaddress=$ListenAddress | Out-Null if ([string]::IsNullOrWhiteSpace($RuleName)) {
$RuleName = "MyIntranetApp-$Port"
}
$windowsIps = Get-NetIPAddress -AddressFamily IPv4 |
Where-Object {
$_.IPAddress -notlike '127.*' `
-and $_.IPAddress -notlike '169.254*' `
-and $_.InterfaceAlias -notlike '*WSL*'
} |
Select-Object -ExpandProperty IPAddress
$listenAddresses = @($ListenAddress)
if ($ListenAddress -eq "0.0.0.0") {
$listenAddresses += $windowsIps
}
$listenAddresses = $listenAddresses | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
foreach ($address in $listenAddresses) {
netsh interface portproxy delete v4tov4 listenport=$Port listenaddress=$address | Out-Null
}
$existingRule = Get-NetFirewallRule -DisplayName $RuleName -ErrorAction SilentlyContinue $existingRule = Get-NetFirewallRule -DisplayName $RuleName -ErrorAction SilentlyContinue
if ($existingRule) { if ($existingRule) {
+3
View File
@@ -3,6 +3,8 @@ set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
PORT="${1:-${INTRANET_PORT:-8010}}"
if [ ! -x ".venv/bin/python" ]; then if [ ! -x ".venv/bin/python" ]; then
echo "가상환경이 없습니다. 먼저 아래를 실행하세요." echo "가상환경이 없습니다. 먼저 아래를 실행하세요."
echo "python3 -m venv .venv" echo "python3 -m venv .venv"
@@ -12,5 +14,6 @@ if [ ! -x ".venv/bin/python" ]; then
fi fi
export INTRANET_AUTO_RELOAD="${INTRANET_AUTO_RELOAD:-1}" export INTRANET_AUTO_RELOAD="${INTRANET_AUTO_RELOAD:-1}"
export INTRANET_PORT="$PORT"
exec ./.venv/bin/python main.py exec ./.venv/bin/python main.py
+27
View File
@@ -0,0 +1,27 @@
@echo off
setlocal
net session >nul 2>&1
if not "%errorlevel%"=="0" (
echo This script must be run as Administrator.
echo.
echo 1. Open Windows Start menu.
echo 2. Type cmd.
echo 3. Right-click Command Prompt and choose "Run as administrator".
echo 4. Run this command:
echo %~f0 %*
echo.
pause
exit /b 1
)
set SCRIPT_DIR=%~dp0
set WSL_IP=%~1
if "%WSL_IP%"=="" (
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%setup_windows_all_portproxies.ps1"
) else (
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%setup_windows_all_portproxies.ps1" -WslIp %WSL_IP%
)
endlocal
+83
View File
@@ -0,0 +1,83 @@
param(
[string]$ListenAddress = "0.0.0.0",
[string]$WslIp = ""
)
$ErrorActionPreference = "Stop"
$apps = @(
@{ Name = "hm-biz-process"; Port = 8000; RuleName = "HM-BIZ-PROCESS-8000" },
@{ Name = "my-intranet-app"; Port = 8010; RuleName = "MyIntranetApp-8010" },
@{ Name = "b17301"; Port = 8020; RuleName = "B17301-8020" }
)
if ([string]::IsNullOrWhiteSpace($WslIp)) {
$detected = wsl.exe hostname -I 2>$null
if (-not $detected) {
throw "WSL IP를 자동으로 찾지 못했습니다. -WslIp 옵션으로 직접 지정해 주세요."
}
$WslIp = (($detected -split "\s+") | Where-Object { $_ -match '^\d+\.\d+\.\d+\.\d+$' } | Select-Object -First 1)
if (-not $WslIp) {
throw "WSL IP를 자동으로 파싱하지 못했습니다. -WslIp 옵션으로 직접 지정해 주세요."
}
}
$windowsIps = Get-NetIPAddress -AddressFamily IPv4 |
Where-Object {
$_.IPAddress -notlike '127.*' `
-and $_.IPAddress -notlike '169.254*' `
-and $_.InterfaceAlias -notlike '*WSL*'
} |
Select-Object -ExpandProperty IPAddress
$listenAddresses = @($ListenAddress)
if ($ListenAddress -eq "0.0.0.0") {
$listenAddresses += $windowsIps
}
$listenAddresses = $listenAddresses | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
Write-Host "Setting Windows portproxy for all WSL apps..." -ForegroundColor Cyan
Write-Host "WSL IP: $WslIp"
foreach ($app in $apps) {
$port = [int]$app.Port
$ruleName = [string]$app.RuleName
Write-Host ""
Write-Host "$($app.Name): $port" -ForegroundColor Cyan
foreach ($address in $listenAddresses) {
netsh interface portproxy delete v4tov4 listenport=$port listenaddress=$address | Out-Null
netsh interface portproxy add v4tov4 listenport=$port listenaddress=$address connectport=$port connectaddress=$WslIp
}
$existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
if ($existingRule) {
Remove-NetFirewallRule -DisplayName $ruleName | Out-Null
}
New-NetFirewallRule `
-DisplayName $ruleName `
-Direction Inbound `
-Action Allow `
-Protocol TCP `
-LocalPort $port | Out-Null
}
Write-Host ""
Write-Host "Completed." -ForegroundColor Green
Write-Host "Use these URLs from another PC on the intranet:"
if ($windowsIps) {
foreach ($ip in $windowsIps) {
Write-Host "hm-biz-process : http://$ip`:8000"
Write-Host "my-intranet-app: http://$ip`:8010"
Write-Host "b17301 : http://$ip`:8020"
}
} else {
Write-Host "hm-biz-process : http://<Windows-IP>:8000"
Write-Host "my-intranet-app: http://<Windows-IP>:8010"
Write-Host "b17301 : http://<Windows-IP>:8020"
}
Write-Host ""
Write-Host "Tip: if WSL restarts and its IP changes, run this script again."
+14
View File
@@ -1,6 +1,20 @@
@echo off @echo off
setlocal setlocal
net session >nul 2>&1
if not "%errorlevel%"=="0" (
echo This script must be run as Administrator.
echo.
echo 1. Open Windows Start menu.
echo 2. Type cmd.
echo 3. Right-click Command Prompt and choose "Run as administrator".
echo 4. Run this command:
echo %~f0 %*
echo.
pause
exit /b 1
)
set SCRIPT_DIR=%~dp0 set SCRIPT_DIR=%~dp0
set WSL_IP=%~1 set WSL_IP=%~1
set PORT=%~2 set PORT=%~2
+28 -9
View File
@@ -2,11 +2,15 @@ param(
[string]$ListenAddress = "0.0.0.0", [string]$ListenAddress = "0.0.0.0",
[int]$Port = 8010, [int]$Port = 8010,
[string]$WslIp = "", [string]$WslIp = "",
[string]$RuleName = "MyIntranetApp-8010" [string]$RuleName = ""
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($RuleName)) {
$RuleName = "MyIntranetApp-$Port"
}
if ([string]::IsNullOrWhiteSpace($WslIp)) { if ([string]::IsNullOrWhiteSpace($WslIp)) {
$detected = wsl.exe hostname -I 2>$null $detected = wsl.exe hostname -I 2>$null
if (-not $detected) { if (-not $detected) {
@@ -24,8 +28,24 @@ Write-Host "ListenAddress: $ListenAddress"
Write-Host "Port: $Port" Write-Host "Port: $Port"
Write-Host "WSL IP: $WslIp" Write-Host "WSL IP: $WslIp"
netsh interface portproxy delete v4tov4 listenport=$Port listenaddress=$ListenAddress | Out-Null $windowsIps = Get-NetIPAddress -AddressFamily IPv4 |
netsh interface portproxy add v4tov4 listenport=$Port listenaddress=$ListenAddress connectport=$Port connectaddress=$WslIp Where-Object {
$_.IPAddress -notlike '127.*' `
-and $_.IPAddress -notlike '169.254*' `
-and $_.InterfaceAlias -notlike '*WSL*'
} |
Select-Object -ExpandProperty IPAddress
$listenAddresses = @($ListenAddress)
if ($ListenAddress -eq "0.0.0.0") {
$listenAddresses += $windowsIps
}
$listenAddresses = $listenAddresses | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
foreach ($address in $listenAddresses) {
netsh interface portproxy delete v4tov4 listenport=$Port listenaddress=$address | Out-Null
netsh interface portproxy add v4tov4 listenport=$Port listenaddress=$address connectport=$Port connectaddress=$WslIp
}
$existingRule = Get-NetFirewallRule -DisplayName $RuleName -ErrorAction SilentlyContinue $existingRule = Get-NetFirewallRule -DisplayName $RuleName -ErrorAction SilentlyContinue
if ($existingRule) { if ($existingRule) {
@@ -39,16 +59,15 @@ New-NetFirewallRule `
-Protocol TCP ` -Protocol TCP `
-LocalPort $Port | Out-Null -LocalPort $Port | Out-Null
$windowsIps = Get-NetIPAddress -AddressFamily IPv4 |
Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254*' -and $_.IPAddress -notlike '172.*' } |
Select-Object -ExpandProperty IPAddress
Write-Host "" Write-Host ""
Write-Host "Completed." -ForegroundColor Green Write-Host "Completed." -ForegroundColor Green
Write-Host "Now open this from another PC on the intranet:" Write-Host "Now open this from another PC on the intranet:"
Write-Host "http://<Windows-IP>:$Port"
if ($windowsIps) { if ($windowsIps) {
Write-Host "Windows IPv4 candidates: $($windowsIps -join ', ')" foreach ($ip in $windowsIps) {
Write-Host "http://$ip`:$Port"
}
} else {
Write-Host "http://<Windows-IP>:$Port"
} }
Write-Host "" Write-Host ""
Write-Host "Tip: if WSL restarts and its IP changes, run this script again. If you omit -WslIp, the script will detect it automatically." Write-Host "Tip: if WSL restarts and its IP changes, run this script again. If you omit -WslIp, the script will detect it automatically."
+7 -4
View File
@@ -4,7 +4,7 @@ set -euo pipefail
WSL_IP="$(hostname -I | awk '{print $1}')" WSL_IP="$(hostname -I | awk '{print $1}')"
WSL_HOST_IP="$(awk '/nameserver/ {print $2; exit}' /etc/resolv.conf)" WSL_HOST_IP="$(awk '/nameserver/ {print $2; exit}' /etc/resolv.conf)"
WINDOWS_IPS="$( WINDOWS_IPS="$(
powershell.exe -NoProfile -Command "(Get-NetIPAddress -AddressFamily IPv4 | Where-Object { \$_.IPAddress -notlike '127.*' -and \$_.IPAddress -notlike '169.254*' } | Select-Object -ExpandProperty IPAddress) -join ','" \ powershell.exe -NoProfile -Command "(Get-NetIPAddress -AddressFamily IPv4 | Where-Object { \$_.IPAddress -notlike '127.*' -and \$_.IPAddress -notlike '169.254*' -and \$_.InterfaceAlias -notlike '*WSL*' } | Select-Object -ExpandProperty IPAddress) -join ','" \
2>/dev/null | tr -d '\r' 2>/dev/null | tr -d '\r'
)" )"
@@ -13,7 +13,10 @@ WSL 앱 정보
- WSL 내부 IP: ${WSL_IP} - WSL 내부 IP: ${WSL_IP}
- Windows 호스트와 연결된 주소 추정값: ${WSL_HOST_IP} - Windows 호스트와 연결된 주소 추정값: ${WSL_HOST_IP}
- Windows IPv4 후보: ${WINDOWS_IPS:-확인 실패} - Windows IPv4 후보: ${WINDOWS_IPS:-확인 실패}
- 앱 포트: 8010 - 포트 배치:
- hm-biz-process: 8000
- my-intranet-app: 8010
- b17301: 8020
중요 중요
- WSL2에서는 다른 PC가 WSL 내부 IP(${WSL_IP})로 직접 접속하지 못하는 경우가 많습니다. - WSL2에서는 다른 PC가 WSL 내부 IP(${WSL_IP})로 직접 접속하지 못하는 경우가 많습니다.
@@ -21,7 +24,7 @@ WSL 앱 정보
- 따라서 Windows 관리자 PowerShell에서 portproxy + 방화벽 규칙을 설정해야 합니다. - 따라서 Windows 관리자 PowerShell에서 portproxy + 방화벽 규칙을 설정해야 합니다.
다음 단계 다음 단계
1. WSL에서 서버 실행: ./scripts/run_server.sh 1. WSL에서 my-intranet-app 실행: ./scripts/run_server.sh 8010
2. Windows 관리자 PowerShell 또는 CMD에서 scripts/setup_windows_portproxy 실행 2. Windows 관리자 PowerShell 또는 CMD에서 scripts/setup_windows_all_portproxies 실행
3. 다른 PC 브라우저에서 http://Windows호스트IP:8010 접속 3. 다른 PC 브라우저에서 http://Windows호스트IP:8010 접속
EOF EOF
+110 -40
View File
@@ -155,6 +155,9 @@
.search-layout { .search-layout {
display: grid; display: grid;
gap: 16px; gap: 16px;
overflow: visible;
position: relative;
z-index: 90;
} }
.analysis-shell { .analysis-shell {
@@ -168,7 +171,7 @@
gap: 12px; gap: 12px;
align-items: start; align-items: start;
position: relative; position: relative;
z-index: 5200; z-index: 120;
} }
.analysis-main { .analysis-main {
@@ -181,16 +184,6 @@
display: none; display: none;
} }
.toolbar-field {
background: rgba(255, 255, 255, 0.95);
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px;
position: relative;
overflow: visible;
z-index: 5300;
}
.toolbar-head { .toolbar-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -208,7 +201,7 @@
flex: 1; flex: 1;
} }
.toolbar-field h3 { .analysis-toolbar h3 {
font-size: 16px; font-size: 16px;
margin-bottom: 0; margin-bottom: 0;
} }
@@ -299,7 +292,7 @@
position: relative; position: relative;
display: grid; display: grid;
gap: 8px; gap: 8px;
z-index: 5400; z-index: 180;
} }
.project-search-dropdown { .project-search-dropdown {
@@ -308,7 +301,7 @@
top: calc(100% + 8px); top: calc(100% + 8px);
left: 0; left: 0;
right: 0; right: 0;
z-index: 9999; z-index: 4000;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 14px; border-radius: 14px;
background: rgba(255, 255, 255, 0.98); background: rgba(255, 255, 255, 0.98);
@@ -917,7 +910,7 @@
border-radius: 10px; border-radius: 10px;
background: #fff; background: #fff;
font: inherit; font: inherit;
color: #111827; color: var(--ink);
resize: vertical; resize: vertical;
} }
@@ -2315,7 +2308,6 @@
<div class="analysis-shell"> <div class="analysis-shell">
<div class="analysis-main"> <div class="analysis-main">
<section class="analysis-toolbar"> <section class="analysis-toolbar">
<div class="toolbar-field">
<div class="toolbar-head"> <div class="toolbar-head">
<div class="toolbar-title-group"> <div class="toolbar-title-group">
<h3>프로젝트 검색</h3> <h3>프로젝트 검색</h3>
@@ -2378,7 +2370,6 @@
</select> </select>
</div> </div>
</div> </div>
</div>
</section> </section>
<div class="analysis-content is-hidden" id="projectAnalysisContent"> <div class="analysis-content is-hidden" id="projectAnalysisContent">
<section class="analysis-panel analysis-related-bar" id="projectRelatedBar"></section> <section class="analysis-panel analysis-related-bar" id="projectRelatedBar"></section>
@@ -5674,6 +5665,9 @@
if (group === "labor_joint") { if (group === "labor_joint") {
return entryGroup === "labor_joint"; return entryGroup === "labor_joint";
} }
if (group === "overhead") {
return entryGroup === "overhead" || entryGroup === "cost_plan";
}
return entryGroup === group; return entryGroup === group;
}) })
: []; : [];
@@ -5853,6 +5847,8 @@
const overheadPlanned = getExecBudgetGroupEntries(item, "cost_plan", "계정별비용계획"); const overheadPlanned = getExecBudgetGroupEntries(item, "cost_plan", "계정별비용계획");
const laborActual = getActualGroupEntries(item, "labor", "실투입 인건비"); const laborActual = getActualGroupEntries(item, "labor", "실투입 인건비");
const laborJointActual = getActualGroupEntries(item, "labor_joint", "실투입 인건비(합사)"); const laborJointActual = getActualGroupEntries(item, "labor_joint", "실투입 인건비(합사)");
const outsourceInputActual = getActualGroupEntries(item, "outsource", "실투입 외주비");
const overheadInputActual = getActualGroupEntries(item, "overhead", "실투입 제경비");
const asActual = getActualGroupEntries(item, "as", "실투입 A/S비"); const asActual = getActualGroupEntries(item, "as", "실투입 A/S비");
const sgaInputActual = getActualGroupEntries(item, "sga", "실투입 판관비"); const sgaInputActual = getActualGroupEntries(item, "sga", "실투입 판관비");
const costEntries = aggregateCodes.flatMap((code) => getCostBreakdownEntries(code).map((entry) => ({ const costEntries = aggregateCodes.flatMap((code) => getCostBreakdownEntries(code).map((entry) => ({
@@ -5863,7 +5859,9 @@
account_name: String(entry.account_name || "").trim(), account_name: String(entry.account_name || "").trim(),
support_dept_code: code, support_dept_code: code,
}))); })));
const outsourceActual = costEntries const outsourceActual = [
...outsourceInputActual,
...costEntries
.filter(isDesignOutsourceEntry) .filter(isDesignOutsourceEntry)
.map((entry) => ({ .map((entry) => ({
...entry, ...entry,
@@ -5875,8 +5873,11 @@
project_code: item.support_dept_code, project_code: item.support_dept_code,
project_name: item.support_dept_name, project_name: item.support_dept_name,
}, },
})); })),
const overheadActual = costEntries ];
const overheadActual = [
...overheadInputActual,
...costEntries
.filter((entry) => !isDesignOutsourceEntry(entry)) .filter((entry) => !isDesignOutsourceEntry(entry))
.map((entry) => ({ .map((entry) => ({
...entry, ...entry,
@@ -5888,7 +5889,8 @@
project_code: item.support_dept_code, project_code: item.support_dept_code,
project_name: item.support_dept_name, project_name: item.support_dept_name,
}, },
})); })),
];
return { return {
collection: { collection: {
planned: revenuePlanned, planned: revenuePlanned,
@@ -5918,6 +5920,18 @@
}; };
} }
function getActualExecutionCostFromDetails(details) {
if (!details) return 0;
return (
sumEntryAmounts(details.labor?.actual)
+ sumEntryAmounts(details.labor?.actual_joint)
+ sumEntryAmounts(details.outsource?.actual)
+ sumEntryAmounts(details.overhead?.actual)
+ sumEntryAmounts(details.as?.actual)
+ sumEntryAmounts(details.sga?.actual)
);
}
function renderAnalysisHero(item) { function renderAnalysisHero(item) {
const editUrl = getProjectEditUrl(item.support_dept_code); const editUrl = getProjectEditUrl(item.support_dept_code);
return ` return `
@@ -6462,16 +6476,8 @@
function renderAnalysisMetrics(item) { function renderAnalysisMetrics(item) {
const details = buildComparisonDetails(item); const details = buildComparisonDetails(item);
const actualExecutionCost = const actualExecutionCost = getActualExecutionCostFromDetails(details);
Number(item.total_expense || 0) const operatingBalance = Number(item.collection_amount || 0) - actualExecutionCost;
|| (
sumEntryAmounts(details.labor.actual)
+ sumEntryAmounts(details.labor.actual_joint)
+ sumEntryAmounts(details.outsource.actual)
+ sumEntryAmounts(details.overhead.actual)
+ sumEntryAmounts(details.as.actual)
+ sumEntryAmounts(details.sga.actual)
);
return ` return `
<h4>핵심 비교 지표</h4> <h4>핵심 비교 지표</h4>
<div class="analysis-kpis"> <div class="analysis-kpis">
@@ -6489,7 +6495,7 @@
</div> </div>
<div class="analysis-kpi"> <div class="analysis-kpi">
<span>영업수지</span> <span>영업수지</span>
<strong>${formatDisplayAmount(item.operating_balance)}</strong> <strong>${formatDisplayAmount(operatingBalance)}</strong>
</div> </div>
<div class="analysis-kpi"> <div class="analysis-kpi">
<span>과업수행계획비용</span> <span>과업수행계획비용</span>
@@ -6706,11 +6712,76 @@
}); });
} }
const aggregatedProjectCostDatasetCache = new Map(); function getProjectStatusYearRange(item) {
const aggregatedAllProjectCostRows = aggregateProjectCostRows(projectCostRows).map((item) => ({ const years = [
Number(item?.latest_year || 0),
Number(String(item?.project_start_date || "").slice(0, 4) || 0),
Number(String(item?.project_end_date || "").slice(0, 4) || 0),
getProjectCodeFullYear(item?.support_dept_code),
].filter(Boolean);
if (!years.length) {
return { min_year: 0, max_year: 0 };
}
return { min_year: Math.min(...years), max_year: Math.max(...years) };
}
function projectStatusMatchesYear(item, yearFilterValue) {
if (!yearFilterValue) return true;
const targetYear = Number(yearFilterValue || 0);
if (!targetYear) return true;
const range = getProjectStatusYearRange(item);
if (range.min_year && range.max_year) {
return range.min_year <= targetYear && targetYear <= range.max_year;
}
return false;
}
function mergeProjectExplorerRows(costRows, statusRows, yearFilterValue = "") {
const merged = new Map();
aggregateProjectCostRows(costRows).forEach((item) => {
merged.set(item.support_dept_code, { ...item });
});
statusRows.forEach((statusItem) => {
const code = String(statusItem?.support_dept_code || "").trim();
if (!code || !projectStatusMatchesYear(statusItem, yearFilterValue)) return;
const yearRange = getProjectStatusYearRange(statusItem);
const current = merged.get(code) || {
support_dept_code: code,
support_dept_name: statusItem.support_dept_name || code,
revenue_amount: 0,
expense_amount: 0,
min_year: yearRange.min_year,
max_year: yearRange.max_year,
matched_years: [],
};
const statusRevenue = Number(statusItem.total_revenue || statusItem.collection_amount || 0);
const statusExpense = Number(statusItem.total_cost || 0) + Number(statusItem.total_sga || 0);
current.support_dept_name = statusItem.support_dept_name || current.support_dept_name || code;
current.revenue_amount = Number(current.revenue_amount || 0) || statusRevenue;
current.expense_amount = Number(current.expense_amount || 0) || statusExpense;
current.min_year = Math.min(
...[current.min_year, yearRange.min_year].map(Number).filter(Boolean),
) || current.min_year || yearRange.min_year || 0;
current.max_year = Math.max(
...[current.max_year, yearRange.max_year].map(Number).filter(Boolean),
) || current.max_year || yearRange.max_year || 0;
current._hasProjectStatus = true;
current._searchText = `${code} ${current.support_dept_name || ""}`.toLowerCase();
merged.set(code, current);
});
return [...merged.values()]
.map((item) => ({
...item, ...item,
_searchText: `${item.support_dept_code || ""} ${item.support_dept_name || ""}`.toLowerCase(), _searchText: `${item.support_dept_code || ""} ${item.support_dept_name || ""}`.toLowerCase(),
})); }))
.sort((a, b) => {
if ((b.max_year || 0) !== (a.max_year || 0)) return (b.max_year || 0) - (a.max_year || 0);
return String(a.support_dept_code).localeCompare(String(b.support_dept_code));
});
}
const aggregatedProjectCostDatasetCache = new Map();
const aggregatedAllProjectCostRows = mergeProjectExplorerRows(projectCostRows, projectStatusRows);
const aggregatedAllProjectCostMap = new Map( const aggregatedAllProjectCostMap = new Map(
aggregatedAllProjectCostRows.map((item) => [item.support_dept_code, item]), aggregatedAllProjectCostRows.map((item) => [item.support_dept_code, item]),
); );
@@ -6722,12 +6793,11 @@
} }
const dataset = !yearFilterValue const dataset = !yearFilterValue
? aggregatedAllProjectCostRows ? aggregatedAllProjectCostRows
: aggregateProjectCostRows( : mergeProjectExplorerRows(
projectCostRows.filter((item) => String(item.year) === String(yearFilterValue)), projectCostRows.filter((item) => String(item.year) === String(yearFilterValue)),
).map((item) => ({ projectStatusRows,
...item, yearFilterValue,
_searchText: `${item.support_dept_code || ""} ${item.support_dept_name || ""}`.toLowerCase(), );
}));
aggregatedProjectCostDatasetCache.set(cacheKey, dataset); aggregatedProjectCostDatasetCache.set(cacheKey, dataset);
return dataset; return dataset;
} }