271 lines
9.5 KiB
HTML
271 lines
9.5 KiB
HTML
<!doctype html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>MemberNo - korName 매칭</title>
|
||
<style>
|
||
:root {
|
||
--bg: #f5f7fb;
|
||
--card: #ffffff;
|
||
--text: #18212f;
|
||
--muted: #546174;
|
||
--line: #d8dee8;
|
||
--brand: #1f6feb;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
background: linear-gradient(180deg, #eef3ff 0%, var(--bg) 35%);
|
||
color: var(--text);
|
||
font-family: "Noto Sans KR", "Malgun Gothic", sans-serif;
|
||
}
|
||
.wrap {
|
||
max-width: 1200px;
|
||
margin: 32px auto;
|
||
padding: 0 16px;
|
||
}
|
||
.card {
|
||
background: var(--card);
|
||
border: 1px solid var(--line);
|
||
border-radius: 12px;
|
||
padding: 18px;
|
||
box-shadow: 0 8px 20px rgba(24, 33, 47, 0.06);
|
||
margin-bottom: 16px;
|
||
}
|
||
h1 { margin: 0 0 10px; font-size: 24px; }
|
||
p { margin: 8px 0; color: var(--muted); }
|
||
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||
.filebox { display: flex; flex-direction: column; gap: 6px; min-width: 280px; }
|
||
label { font-weight: 700; }
|
||
input[type="file"] { padding: 8px; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
|
||
button {
|
||
border: 0;
|
||
border-radius: 8px;
|
||
padding: 10px 14px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
background: var(--brand);
|
||
color: #fff;
|
||
}
|
||
button:disabled { background: #8caee8; cursor: not-allowed; }
|
||
.ghost { background: #eef4ff; color: #204f9c; border: 1px solid #c9daf9; }
|
||
.stats { display: flex; gap: 10px; flex-wrap: wrap; }
|
||
.pill { background: #f4f7ff; border: 1px solid #d7e2ff; border-radius: 999px; padding: 6px 10px; font-size: 13px; }
|
||
.table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 8px; }
|
||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
th, td { border-bottom: 1px solid #ecf0f6; padding: 8px; text-align: left; white-space: nowrap; }
|
||
th { position: sticky; top: 0; background: #f9fbff; }
|
||
.warn { color: #ac3d00; font-weight: 700; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<div class="card">
|
||
<h1>MemberNo → korName 매칭 페이지</h1>
|
||
<p>`dallyproject.csv`의 `MemberNo`를 `member.csv`의 `MemberNo`와 매치해서 `korName`을 붙입니다.</p>
|
||
<p>인코딩이 깨질 경우를 대비해 <b>UTF-8 / EUC-KR(CP949)</b> 자동 시도 후, 실패 시 수동 선택도 지원합니다.</p>
|
||
|
||
<div class="row">
|
||
<div class="filebox">
|
||
<label for="dailyFile">1) dallyproject.csv</label>
|
||
<input id="dailyFile" type="file" accept=".csv,text/csv" />
|
||
</div>
|
||
<div class="filebox">
|
||
<label for="memberFile">2) member.csv</label>
|
||
<input id="memberFile" type="file" accept=".csv,text/csv" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row" style="margin-top: 10px;">
|
||
<button id="runBtn">매칭 실행</button>
|
||
<button id="downloadBtn" class="ghost" disabled>결과 CSV 다운로드</button>
|
||
<select id="encodingSelect" class="ghost" style="padding:10px; border-radius:8px;">
|
||
<option value="auto">인코딩 자동</option>
|
||
<option value="utf-8">UTF-8</option>
|
||
<option value="euc-kr">EUC-KR (CP949)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="stats" id="stats"></div>
|
||
<p id="status">파일을 선택한 뒤 매칭을 실행하세요.</p>
|
||
<div class="table-wrap">
|
||
<table id="resultTable"></table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const dailyFileEl = document.getElementById('dailyFile');
|
||
const memberFileEl = document.getElementById('memberFile');
|
||
const runBtn = document.getElementById('runBtn');
|
||
const downloadBtn = document.getElementById('downloadBtn');
|
||
const tableEl = document.getElementById('resultTable');
|
||
const statusEl = document.getElementById('status');
|
||
const statsEl = document.getElementById('stats');
|
||
const encodingEl = document.getElementById('encodingSelect');
|
||
|
||
let mergedRows = [];
|
||
let mergedHeaders = [];
|
||
|
||
function normalizeHeader(h) {
|
||
return (h || '').replace(/^\uFEFF/, '').trim().toLowerCase();
|
||
}
|
||
|
||
function splitCsvLine(line) {
|
||
const out = [];
|
||
let cur = '';
|
||
let q = false;
|
||
for (let i = 0; i < line.length; i++) {
|
||
const c = line[i];
|
||
if (c === '"') {
|
||
if (q && line[i + 1] === '"') { cur += '"'; i++; }
|
||
else q = !q;
|
||
} else if (c === ',' && !q) {
|
||
out.push(cur);
|
||
cur = '';
|
||
} else {
|
||
cur += c;
|
||
}
|
||
}
|
||
out.push(cur);
|
||
return out;
|
||
}
|
||
|
||
function parseCsv(text) {
|
||
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter(Boolean);
|
||
if (!lines.length) return { headers: [], rows: [] };
|
||
const headers = splitCsvLine(lines[0]).map(h => h.trim());
|
||
const rows = [];
|
||
for (let i = 1; i < lines.length; i++) {
|
||
const vals = splitCsvLine(lines[i]);
|
||
const row = {};
|
||
headers.forEach((h, idx) => row[h] = vals[idx] ?? '');
|
||
rows.push(row);
|
||
}
|
||
return { headers, rows };
|
||
}
|
||
|
||
async function readFileText(file, encodingMode) {
|
||
const ab = await file.arrayBuffer();
|
||
if (encodingMode !== 'auto') {
|
||
return new TextDecoder(encodingMode).decode(ab);
|
||
}
|
||
const utf8 = new TextDecoder('utf-8').decode(ab);
|
||
const sample = utf8.slice(0, 2000);
|
||
const badScore = (sample.match(/<2F>/g) || []).length;
|
||
if (badScore > 3) {
|
||
try {
|
||
return new TextDecoder('euc-kr').decode(ab);
|
||
} catch {
|
||
return utf8;
|
||
}
|
||
}
|
||
return utf8;
|
||
}
|
||
|
||
function toCsv(rows, headers) {
|
||
const esc = (v) => {
|
||
const s = String(v ?? '');
|
||
if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
|
||
return s;
|
||
};
|
||
const lines = [headers.map(esc).join(',')];
|
||
for (const r of rows) lines.push(headers.map(h => esc(r[h])).join(','));
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function renderTable(headers, rows) {
|
||
if (!headers.length) {
|
||
tableEl.innerHTML = '';
|
||
return;
|
||
}
|
||
const head = '<thead><tr>' + headers.map(h => `<th>${h}</th>`).join('') + '</tr></thead>';
|
||
const bodyRows = rows.slice(0, 500).map(r => '<tr>' + headers.map(h => `<td>${(r[h] ?? '').toString().replace(/</g, '<')}</td>`).join('') + '</tr>').join('');
|
||
tableEl.innerHTML = head + '<tbody>' + bodyRows + '</tbody>';
|
||
}
|
||
|
||
function renderStats(total, matched, unmatched) {
|
||
statsEl.innerHTML = `
|
||
<span class="pill">전체 행: ${total.toLocaleString()}</span>
|
||
<span class="pill">매칭 성공: ${matched.toLocaleString()}</span>
|
||
<span class="pill">매칭 실패: ${unmatched.toLocaleString()}</span>
|
||
`;
|
||
}
|
||
|
||
runBtn.addEventListener('click', async () => {
|
||
const dailyFile = dailyFileEl.files[0];
|
||
const memberFile = memberFileEl.files[0];
|
||
if (!dailyFile || !memberFile) {
|
||
statusEl.innerHTML = '<span class="warn">두 파일을 모두 선택해 주세요.</span>';
|
||
return;
|
||
}
|
||
|
||
runBtn.disabled = true;
|
||
downloadBtn.disabled = true;
|
||
statusEl.textContent = 'CSV 읽는 중...';
|
||
|
||
try {
|
||
const mode = encodingEl.value;
|
||
const [dailyText, memberText] = await Promise.all([
|
||
readFileText(dailyFile, mode),
|
||
readFileText(memberFile, mode)
|
||
]);
|
||
|
||
const daily = parseCsv(dailyText);
|
||
const member = parseCsv(memberText);
|
||
|
||
const dailyMemberNo = daily.headers.find(h => normalizeHeader(h) === 'memberno');
|
||
const memberMemberNo = member.headers.find(h => normalizeHeader(h) === 'memberno');
|
||
const korNameHeader = member.headers.find(h => normalizeHeader(h) === 'korname');
|
||
|
||
if (!dailyMemberNo || !memberMemberNo || !korNameHeader) {
|
||
throw new Error('필수 컬럼(MemberNo, korName)을 찾을 수 없습니다. 헤더를 확인해 주세요.');
|
||
}
|
||
|
||
const memberMap = new Map();
|
||
for (const r of member.rows) {
|
||
const key = (r[memberMemberNo] || '').trim();
|
||
if (!key) continue;
|
||
if (!memberMap.has(key)) memberMap.set(key, r[korNameHeader] || '');
|
||
}
|
||
|
||
let matched = 0;
|
||
mergedRows = daily.rows.map(r => {
|
||
const key = (r[dailyMemberNo] || '').trim();
|
||
const name = memberMap.get(key) || '';
|
||
if (name) matched++;
|
||
return { ...r, korName: name };
|
||
});
|
||
mergedHeaders = [...daily.headers, 'korName'];
|
||
|
||
renderStats(mergedRows.length, matched, mergedRows.length - matched);
|
||
renderTable(mergedHeaders, mergedRows);
|
||
|
||
statusEl.textContent = `완료: ${mergedRows.length.toLocaleString()}건 처리 (표는 500행만 미리보기)`;
|
||
downloadBtn.disabled = false;
|
||
} catch (err) {
|
||
statusEl.innerHTML = `<span class="warn">오류: ${err.message}</span>`;
|
||
} finally {
|
||
runBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
downloadBtn.addEventListener('click', () => {
|
||
const csv = toCsv(mergedRows, mergedHeaders);
|
||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'dallyproject_with_korname.csv';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|