Organize root directory: Moved legacy HTML files to legacy/
This commit is contained in:
648
legacy/ptc_page.html
Normal file
648
legacy/ptc_page.html
Normal file
@@ -0,0 +1,648 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user