Files
PTC/legacy/ptc_page.html

649 lines
34 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PTC 거래 원장 분석 페이지</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--ink: #132238;
--muted: #64748b;
--line: #dbe4ef;
--soft: #eef4fb;
--card: rgba(255,255,255,0.92);
--blue: #0f4c81;
--cyan: #118ab2;
--red: #c44536;
--gold: #b88917;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 'IBM Plex Sans KR', sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(17,138,178,0.14), transparent 28%),
radial-gradient(circle at top right, rgba(15,76,129,0.14), transparent 26%),
linear-gradient(180deg, #f8fbff 0%, #eef3f8 100%);
}
.shell {
width: min(1440px, calc(100vw - 32px));
margin: 0 auto;
padding: 28px 0 60px;
}
.panel {
background: var(--card);
border: 1px solid rgba(219,228,239,0.95);
border-radius: 24px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.06);
backdrop-filter: blur(10px);
}
.metric {
border-radius: 20px;
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(247,250,253,0.96));
}
.table-wrap {
overflow: auto;
border-radius: 18px;
border: 1px solid var(--line);
background: white;
}
table { width: 100%; border-collapse: collapse; min-width: 760px; }
th {
position: sticky;
top: 0;
background: #eff5fb;
color: #35506b;
font-size: 12px;
font-weight: 700;
text-align: left;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
td {
padding: 11px 14px;
border-bottom: 1px solid #edf2f7;
font-size: 13px;
vertical-align: top;
}
tr:hover td { background: #f9fbfd; }
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
.badge-blue { background: #e6f3fb; color: var(--blue); }
.badge-red { background: #fdeceb; color: var(--red); }
.badge-gold { background: #fff6de; color: var(--gold); }
.badge-slate { background: #edf2f7; color: #475569; }
.pill {
border: 1px solid var(--line);
background: white;
border-radius: 999px;
padding: 8px 14px;
font-size: 12px;
font-weight: 600;
color: #46627d;
}
.field {
width: 100%;
height: 42px;
border-radius: 12px;
border: 1px solid var(--line);
background: white;
padding: 0 12px;
font-size: 13px;
color: var(--ink);
outline: none;
}
.field:focus { border-color: var(--cyan); box-shadow: 0 0 0 3px rgba(17,138,178,0.14); }
.upload {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 46px;
padding: 0 18px;
border-radius: 14px;
border: 1px solid rgba(17,138,178,0.18);
background: linear-gradient(135deg, #0f4c81, #118ab2);
color: white;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.subtle {
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.hero-grid {
display: grid;
grid-template-columns: 1.35fr 1fr;
gap: 20px;
}
.cards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.split {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 18px;
}
@media (max-width: 1080px) {
.hero-grid, .split, .cards { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useEffect, useMemo, useRef, useState } = React;
const MASTER_PTC = {
'103':'보통예금','110':'받을어음','124':'매도가능증권','135':'매입부가세','178':'회원권','191':'출자금','192':'임차보증금','193':'주임종대여금','194':'전도금','195':'보증금','196':'대여금','206':'기계장치','208':'차량운반구','210':'공구와기구','212':'비품','219':'시설장치','231':'영업권','241':'사용수익기부자산','257':'가수금','258':'매출부가세','259':'선수금','260':'단기차입금','290':'주임종차입금','293':'장기차입금','294':'임대보증금','401':'공사수입','402':'용역수입','403':'기타수입','501':'관리 임금','502':'공무 임금','503':'시공 임금','504':'설계 임금','505':'지원 임금','511':'관리 퇴직금','512':'공무 퇴직금','513':'시공 퇴직금','514':'설계 퇴직금','515':'지원 퇴직금','521':'소득세','522':'주민세','523':'4대보험','524':'퇴직급여','711':'강관','712':'PHC','713':'결합구','714':'부자재','715':'주자재','721':'항타장비','722':'두부보강','723':'시험용역','724':'노무비','725':'외주비 등','726':'제작','727':'인장','728':'가설','729':'철근가공','730':'공장제작','731':'장비비','732':'유류비','733':'운반비','734':'주재비','735':'기타경비','736':'복리후생비','737':'여비교통비','738':'지급임차료','739':'보증수수료','740':'소모자재비','741':'잡자재대','742':'가스수도료','743':'수선비','744':'안전관리비(현장)','801':'감가상각비(자산)','811':'복리후생비','812':'여비교통비','813':'접대비','814':'통신비','817':'세금과공과금','819':'지급임차료','821':'보험료','822':'차량유지비','823':'연구개발비','825':'교육훈련비','826':'도서인쇄비','827':'광고선전비','829':'사무용품비','830':'소모품비','831':'지급수수료','843':'부서비','849':'지원서비스','850':'안전관리비(본사)','901':'이자수입','902':'국고보조금','903':'잡이익','904':'배당수익','961':'이자비용','962':'잡손실','963':'가지급금','999':'법인세등'
};
const SOURCE_HEADERS = ['거래일','입/출금','계정코드','구분','부서','거래처','프로젝트코드','프로젝트 구분(안)','프로젝트명','적요','공급가액','부가세','합계금액','비고'];
const numberFmt = (value) => new Intl.NumberFormat('ko-KR').format(Math.round(value || 0));
function excelDateToText(value) {
if (value === null || value === undefined || value === '') return '';
if (value instanceof Date && !isNaN(value.getTime())) return value.toISOString().slice(0, 10);
if (typeof value === 'number') {
const d = new Date((value - 25569) * 86400 * 1000);
return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);
}
const text = String(value).trim();
if (/^\d+$/.test(text)) {
const n = Number(text);
const d = new Date((n - 25569) * 86400 * 1000);
return isNaN(d.getTime()) ? text : d.toISOString().slice(0, 10);
}
return text.replace(/[./]/g, '-').slice(0, 10);
}
function toAmount(value) {
const text = String(value ?? '').trim();
if (!text || text === '-') return 0;
return parseFloat(text.replace(/,/g, '')) || 0;
}
function normalizeType(inOut, accountName) {
if (String(inOut).includes('입')) return 'revenue';
if (String(inOut).includes('출')) {
if (String(accountName).includes('수입') || String(accountName).includes('매출')) return 'revenue';
return 'cost_expense';
}
return 'unknown';
}
function parseWorkbook(file) {
return new Promise(async (resolve, reject) => {
try {
const arr = await file.arrayBuffer();
const wb = XLSX.read(arr, { type: 'array', cellDates: true });
const sheet = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(sheet, { raw: true, defval: '' });
const items = rows.map((row, index) => {
const accountCode = String(row['계정코드'] || '').trim();
const accountName = String(row['구분'] || '').trim() || MASTER_PTC[accountCode] || '';
const supplyAmount = toAmount(row['공급가액']);
const vatAmount = toAmount(row['부가세']);
const totalAmount = toAmount(row['합계금액']);
return {
id: index + 1,
transactionDate: excelDateToText(row['거래일']),
inOut: String(row['입/출금'] || '').trim(),
accountCode,
accountName,
department: String(row['부서'] || '').trim(),
vendor: String(row['거래처'] || '').trim(),
projectCode: String(row['프로젝트코드'] || '').trim(),
projectType: String(row['프로젝트 구분(안)'] || '').trim(),
projectName: String(row['프로젝트명'] || '').trim(),
description: String(row['적요'] || '').trim(),
supplyAmount,
vatAmount,
totalAmount,
remarks: String(row['비고'] || '').trim(),
normalizedType: normalizeType(row['입/출금'], accountName)
};
});
resolve(items);
} catch (error) {
reject(error);
}
});
}
function App() {
const inputRef = useRef(null);
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const [projectTypeFilter, setProjectTypeFilter] = useState('전체');
const [inOutFilter, setInOutFilter] = useState('전체');
const loadFile = async (file) => {
if (!file) return;
setLoading(true);
try {
const items = await parseWorkbook(file);
setRows(items);
} catch (error) {
console.error(error);
window.alert('PTC 엑셀을 읽는 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
const tryAutoLoad = async () => {
try {
const res = await fetch('./PTC(2023-2026.02).xlsx');
if (!res.ok) return;
const blob = await res.blob();
const file = new File([blob], 'PTC(2023-2026.02).xlsx');
await loadFile(file);
} catch (error) {
console.log('Auto load skipped');
}
};
useEffect(() => {
tryAutoLoad();
}, []);
const projectTypes = useMemo(() => (
['전체', ...Array.from(new Set(rows.map(item => item.projectType).filter(Boolean))).sort()]
), [rows]);
const filteredRows = useMemo(() => {
const q = keyword.trim().toLowerCase();
return rows.filter((item) => {
const matchKeyword = !q || [
item.accountCode,
item.accountName,
item.department,
item.vendor,
item.projectCode,
item.projectType,
item.projectName,
item.description
].some(value => String(value || '').toLowerCase().includes(q));
const matchType = projectTypeFilter === '전체' || item.projectType === projectTypeFilter;
const matchInOut = inOutFilter === '전체' || item.inOut === inOutFilter;
return matchKeyword && matchType && matchInOut;
});
}, [rows, keyword, projectTypeFilter, inOutFilter]);
const summary = useMemo(() => {
const result = {
count: filteredRows.length,
incomeCount: 0,
expenseCount: 0,
supplySum: 0,
vatSum: 0,
totalSum: 0,
minDate: '',
maxDate: ''
};
const dates = filteredRows.map(item => item.transactionDate).filter(Boolean).sort();
filteredRows.forEach((item) => {
if (item.inOut === '입금') result.incomeCount += 1;
if (item.inOut === '출금') result.expenseCount += 1;
result.supplySum += item.supplyAmount;
result.vatSum += item.vatAmount;
result.totalSum += item.totalAmount;
});
result.minDate = dates[0] || '';
result.maxDate = dates[dates.length - 1] || '';
return result;
}, [filteredRows]);
const topAccounts = useMemo(() => {
const map = new Map();
filteredRows.forEach((item) => {
const key = `${item.accountCode}__${item.accountName}`;
if (!map.has(key)) {
map.set(key, { code: item.accountCode, name: item.accountName, total: 0, count: 0 });
}
const current = map.get(key);
current.total += item.supplyAmount;
current.count += 1;
});
return Array.from(map.values())
.sort((a, b) => b.total - a.total)
.slice(0, 10);
}, [filteredRows]);
const topProjects = useMemo(() => {
const map = new Map();
filteredRows.forEach((item) => {
const key = `${item.projectCode}__${item.projectName}`;
if (!map.has(key)) {
map.set(key, {
projectCode: item.projectCode || '(없음)',
projectName: item.projectName || '(없음)',
projectType: item.projectType || '(없음)',
total: 0,
count: 0
});
}
const current = map.get(key);
current.total += item.supplyAmount;
current.count += 1;
});
return Array.from(map.values())
.sort((a, b) => b.total - a.total)
.slice(0, 10);
}, [filteredRows]);
const dataIssues = useMemo(() => {
const missingCritical = filteredRows.filter(item =>
!item.accountCode || !item.accountName || !item.transactionDate || !item.description
).length;
const inconsistentProjectNames = {};
const inconsistentProjectTypes = {};
filteredRows.forEach((item) => {
if (!item.projectCode) return;
inconsistentProjectNames[item.projectCode] = inconsistentProjectNames[item.projectCode] || new Set();
inconsistentProjectTypes[item.projectCode] = inconsistentProjectTypes[item.projectCode] || new Set();
if (item.projectName) inconsistentProjectNames[item.projectCode].add(item.projectName);
if (item.projectType) inconsistentProjectTypes[item.projectCode].add(item.projectType);
});
const projectNameMismatch = Object.entries(inconsistentProjectNames)
.filter(([, set]) => set.size > 1)
.map(([projectCode, set]) => ({ projectCode, values: Array.from(set) }))
.slice(0, 8);
const projectTypeMismatch = Object.entries(inconsistentProjectTypes)
.filter(([, set]) => set.size > 1)
.map(([projectCode, set]) => ({ projectCode, values: Array.from(set) }))
.slice(0, 8);
return {
missingCritical,
projectNameMismatch,
projectTypeMismatch
};
}, [filteredRows]);
return (
<div className="shell">
<section className="panel p-6 md:p-8">
<div className="hero-grid">
<div>
<div className="badge badge-blue mb-4">PTC Only View</div>
<h1 className="text-3xl md:text-4xl font-bold tracking-tight leading-tight mb-3">
PTC 거래 원장 중심으로 다시 구성한
<br />
실행 데이터 분석 페이지
</h1>
<p className="subtle max-w-2xl">
페이지는 `combine.html`에서 PTC 관련 흐름만 남긴 버전입니다.
현재 기준으로는 예산보다 실적 원장 확인에 초점을 맞추고,
`PTC(2023-2026.02).xlsx` 헤더 구조와 거래 패턴을 바로 있게 구성했습니다.
</p>
<div className="flex flex-wrap gap-2 mt-5">
<span className="pill">헤더 14 기준</span>
<span className="pill">입금/출금 분리</span>
<span className="pill">계정코드/프로젝트코드 검토</span>
<span className="pill">데이터 품질 확인</span>
</div>
</div>
<div className="panel p-5 md:p-6" style={{ background: 'linear-gradient(180deg, rgba(244,249,255,0.95), rgba(255,255,255,0.96))' }}>
<div className="text-sm font-semibold text-slate-600 mb-2">파일 상태</div>
<div className="text-2xl font-bold mb-3">{rows.length ? 'PTC 데이터 로드 완료' : '엑셀 파일 대기 중'}</div>
<div className="subtle mb-4">
자동 로드가 되지 않으면 아래 버튼으로 직접 엑셀을 선택하면 됩니다.
</div>
<div className="flex flex-wrap gap-3 items-center">
<button className="upload" onClick={() => inputRef.current?.click()}>
{loading ? '불러오는 중...' : 'PTC 엑셀 업로드'}
</button>
<input
ref={inputRef}
type="file"
accept=".xlsx,.xls"
style={{ display: 'none' }}
onChange={(e) => loadFile(e.target.files?.[0])}
/>
<div className="badge badge-slate">
{rows.length ? `${numberFmt(rows.length)}건 로드` : '미로드'}
</div>
</div>
<div className="mt-5 text-xs text-slate-500 leading-6">
대상 파일: <strong>PTC(2023-2026.02).xlsx</strong><br />
기준 컬럼: {SOURCE_HEADERS.join(' / ')}
</div>
</div>
</div>
</section>
<section className="cards mt-5">
<div className="metric p-5">
<div className="text-xs text-slate-500 font-semibold">조회 건수</div>
<div className="text-3xl font-bold mt-2">{numberFmt(summary.count)}</div>
<div className="subtle mt-2">필터 적용 남은 </div>
</div>
<div className="metric p-5">
<div className="text-xs text-slate-500 font-semibold">기간</div>
<div className="text-xl font-bold mt-2">{summary.minDate || '-'} {summary.maxDate ? `~ ${summary.maxDate}` : ''}</div>
<div className="subtle mt-2">거래일 기준 범위</div>
</div>
<div className="metric p-5">
<div className="text-xs text-slate-500 font-semibold">공급가액 합계</div>
<div className="text-3xl font-bold mt-2">{numberFmt(summary.supplySum)}</div>
<div className="subtle mt-2">현재 필터 기준 공급가액 총합</div>
</div>
<div className="metric p-5">
<div className="text-xs text-slate-500 font-semibold">입금 / 출금</div>
<div className="text-3xl font-bold mt-2">{numberFmt(summary.incomeCount)} / {numberFmt(summary.expenseCount)}</div>
<div className="subtle mt-2"> 개수 기준</div>
</div>
</section>
<section className="panel p-5 md:p-6 mt-5">
<div className="flex flex-col md:flex-row md:items-end gap-3">
<div className="flex-1">
<div className="text-sm font-semibold mb-2">검색</div>
<input
className="field"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="계정코드, 계정명, 부서, 거래처, 프로젝트명, 적요로 검색"
/>
</div>
<div className="w-full md:w-52">
<div className="text-sm font-semibold mb-2">프로젝트 구분</div>
<select className="field" value={projectTypeFilter} onChange={(e) => setProjectTypeFilter(e.target.value)}>
{projectTypes.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
</div>
<div className="w-full md:w-44">
<div className="text-sm font-semibold mb-2">입출금</div>
<select className="field" value={inOutFilter} onChange={(e) => setInOutFilter(e.target.value)}>
<option value="전체">전체</option>
<option value="입금">입금</option>
<option value="출금">출금</option>
</select>
</div>
</div>
</section>
<section className="split mt-5">
<div className="panel p-5 md:p-6">
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-lg font-bold">계정코드 상위 집계</div>
<div className="subtle">공급가액 기준으로 PTC 계정 사용량을 우선 확인합니다.</div>
</div>
<span className="badge badge-blue">Top 10</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>계정코드</th>
<th>계정명</th>
<th>건수</th>
<th>공급가액</th>
</tr>
</thead>
<tbody>
{topAccounts.map((item) => (
<tr key={`${item.code}-${item.name}`}>
<td>{item.code}</td>
<td>{item.name || MASTER_PTC[item.code] || '-'}</td>
<td>{numberFmt(item.count)}</td>
<td>{numberFmt(item.total)}</td>
</tr>
))}
{!topAccounts.length && (
<tr><td colSpan="4">데이터가 없습니다.</td></tr>
)}
</tbody>
</table>
</div>
</div>
<div className="panel p-5 md:p-6">
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-lg font-bold">데이터 품질 체크</div>
<div className="subtle">staging 적재 전에 먼저 봐야 하는 불일치 포인트입니다.</div>
</div>
<span className="badge badge-gold">검토 필요</span>
</div>
<div className="space-y-4">
<div className="metric p-4">
<div className="text-xs text-slate-500 font-semibold">핵심 누락값</div>
<div className="text-2xl font-bold mt-1">{numberFmt(dataIssues.missingCritical)}</div>
<div className="subtle mt-2">계정코드, 계정명, 거래일, 적요 일부가 비어 있는 </div>
</div>
<div className="metric p-4">
<div className="text-xs text-slate-500 font-semibold">프로젝트코드-프로젝트명 불일치</div>
<div className="text-2xl font-bold mt-1">{numberFmt(dataIssues.projectNameMismatch.length)} 코드</div>
<div className="subtle mt-2">같은 프로젝트코드에 이름이 여러 개인 경우</div>
</div>
<div className="metric p-4">
<div className="text-xs text-slate-500 font-semibold">프로젝트코드-프로젝트구분 불일치</div>
<div className="text-2xl font-bold mt-1">{numberFmt(dataIssues.projectTypeMismatch.length)} 코드</div>
<div className="subtle mt-2">같은 코드가 관리/시공/설계 등으로 섞이는 경우</div>
</div>
</div>
</div>
</section>
<section className="panel p-5 md:p-6 mt-5">
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-lg font-bold">프로젝트 상위 집계</div>
<div className="subtle">공급가액 기준으로 PTC 파일에서 많이 움직인 프로젝트를 먼저 봅니다.</div>
</div>
<span className="badge badge-red">Project Focus</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>프로젝트코드</th>
<th>프로젝트명</th>
<th>구분</th>
<th>건수</th>
<th>공급가액</th>
</tr>
</thead>
<tbody>
{topProjects.map((item) => (
<tr key={`${item.projectCode}-${item.projectName}`}>
<td>{item.projectCode}</td>
<td>{item.projectName}</td>
<td>{item.projectType}</td>
<td>{numberFmt(item.count)}</td>
<td>{numberFmt(item.total)}</td>
</tr>
))}
{!topProjects.length && (
<tr><td colSpan="5">데이터가 없습니다.</td></tr>
)}
</tbody>
</table>
</div>
</section>
<section className="panel p-5 md:p-6 mt-5">
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-lg font-bold">원본 미리보기</div>
<div className="subtle">PTC 원본에서 실제로 어떤 행이 들어오는지 바로 확인할 있습니다.</div>
</div>
<span className="badge badge-slate">Preview 30</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>거래일</th>
<th>/출금</th>
<th>계정코드</th>
<th>구분</th>
<th>부서</th>
<th>거래처</th>
<th>프로젝트코드</th>
<th>프로젝트명</th>
<th>적요</th>
<th>공급가액</th>
</tr>
</thead>
<tbody>
{filteredRows.slice(0, 30).map((item) => (
<tr key={item.id}>
<td>{item.transactionDate || '-'}</td>
<td>{item.inOut || '-'}</td>
<td>{item.accountCode || '-'}</td>
<td>{item.accountName || '-'}</td>
<td>{item.department || '-'}</td>
<td>{item.vendor || '-'}</td>
<td>{item.projectCode || '-'}</td>
<td>{item.projectName || '-'}</td>
<td>{item.description || '-'}</td>
<td>{numberFmt(item.supplyAmount)}</td>
</tr>
))}
{!filteredRows.length && (
<tr><td colSpan="10">표시할 데이터가 없습니다.</td></tr>
)}
</tbody>
</table>
</div>
</section>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>