This commit is contained in:
1 parent
176316378b
commit
72c69af2fd
6 files changed
+224
-51
No files matched your search
+8
-7
@@ -221,11 +221,12 @@ button { cursor: pointer; }
|
||||
.qa-table { width: 100%; min-width: 850px; border-collapse: collapse; table-layout: fixed; }
|
||||
.qa-table th { height: 48px; padding: 0 12px; border-bottom: 1px solid #252525; font-weight: 500; }
|
||||
.qa-table td { height: 62px; padding: 0 12px; border-bottom: 1px solid var(--line); color: #5c5c5c; text-align: center; }
|
||||
.qa-table tbody tr { cursor: pointer; transition: background .15s ease; }
|
||||
.qa-table tbody tr:hover { background: #fafafa; }
|
||||
.qa-table tbody tr { cursor: pointer; background: #fff; transition: background .15s ease; }
|
||||
.qa-table tbody tr:hover { background: #fff; }
|
||||
.qa-table .subject { overflow: hidden; color: #222; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.qa-table .subject-link { display: block; overflow: hidden; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.subject-link { font-weight: 500; }
|
||||
.notice-row td { background: #fcfcf8; font-weight: 500; }
|
||||
.notice-row td { background: #fff; font-weight: 500; }
|
||||
.category { display: inline-flex; align-items: center; justify-content: center; min-width: 66px; padding: 3px 7px; border-radius: 2px; background: #f0f4fb; color: var(--navy); font-size: 12px; }
|
||||
.category.notice { background: #fff6e7; color: #9b6915; }
|
||||
.lock { display: inline-block; margin-right: 6px; color: #888; font-size: 12px; }
|
||||
@@ -369,17 +370,17 @@ button { cursor: pointer; }
|
||||
.qa-list-page .table-wrap { width: 100%; max-width: 100%; overflow: hidden; border-top: 2px solid #111; }
|
||||
.qa-list-page .qa-table { display: table; width: 100%; max-width: 100%; min-width: 0 !important; table-layout: fixed; }
|
||||
.qa-list-page .qa-table th { height: 48px; padding: 0 10px; overflow: hidden; border-bottom: 1px solid #111; color: #111; font-size: 14px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.qa-list-page .qa-table td { height: 58px; padding: 0 10px; overflow: hidden; border-bottom: 1px solid #ededed; background: #f3f3f3; color: #3e4650; font-size: 14px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.qa-list-page .qa-table td { height: 58px; padding: 0 10px; overflow: hidden; border-bottom: 1px solid #ededed; background: #fff; color: #3e4650; font-size: 14px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.qa-list-page .qa-table tbody tr:hover { background: transparent; }
|
||||
.qa-list-page .qa-table tbody tr:hover td { background: #ededed; }
|
||||
.qa-list-page .qa-table tbody tr:hover td { background: #fff; }
|
||||
.qa-list-page .qa-table .subject { color: #39434f; }
|
||||
.qa-list-page .qa-table .subject-link { font-weight: 500; }
|
||||
.qa-list-page .qa-table .category-cell { white-space: nowrap; }
|
||||
.qa-list-page .qa-table .category,
|
||||
.qa-list-page .qa-table .status { padding: 0; border: 0; border-radius: 0; background: transparent; color: inherit; font-size: inherit; font-weight: inherit; }
|
||||
.qa-list-page .qa-table .status { min-width: 0; }
|
||||
.qa-list-page .qa-table .notice-row td { background: #f3f3f3; color: #3e4650; }
|
||||
.qa-list-page .table-empty { background: #f3f3f3; }
|
||||
.qa-list-page .qa-table .notice-row td { background: #fff; color: #3e4650; }
|
||||
.qa-list-page .table-empty { background: #fff; }
|
||||
.qa-list-page .list-footer { position: relative; min-height: 42px; margin-top: 25px; }
|
||||
.qa-list-page .list-footer .pagination { margin: 0; }
|
||||
.qa-list-page .list-footer > .button { position: absolute; top: 0; right: 0; height: 42px; padding: 0 25px; background: #000; color: #fff; font-size: 14px; font-weight: 700; }
|
||||
|
||||
+148
-37
@@ -32,6 +32,62 @@
|
||||
return readJson(STORAGE_KEY, []).filter(function (post) { return post.category !== '공지사항'; });
|
||||
}
|
||||
|
||||
function firstValue(source, keys) {
|
||||
if (!source || typeof source !== "object") return "";
|
||||
for (let index = 0; index < keys.length; index += 1) {
|
||||
const value = source[keys[index]];
|
||||
if (value !== undefined && value !== null && value !== "") return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeCategory(value) {
|
||||
const raw = String(value || "").trim();
|
||||
return ({
|
||||
ERROR_QNA: "오류문의",
|
||||
IMPROVEMENT_QNA: "개선문의",
|
||||
GENERAL_QNA: "일반문의",
|
||||
오류: "오류문의",
|
||||
개선: "개선문의",
|
||||
일반: "일반문의"
|
||||
})[raw.toUpperCase()] || raw || "일반문의";
|
||||
}
|
||||
|
||||
function normalizeRemotePost(item) {
|
||||
item = item || {};
|
||||
const requester = item.requester || item.author || item.user || {};
|
||||
const fields = item.fields && typeof item.fields === "object" && !Array.isArray(item.fields) ? item.fields : {};
|
||||
function value(keys) {
|
||||
return firstValue(item, keys) || firstValue(fields, keys) || firstValue(requester, keys);
|
||||
}
|
||||
const createdAt = value(["createdAt", "created_at", "date"]) || new Date().toISOString();
|
||||
const secret = value(["is_secret", "isSecret", "secret"]);
|
||||
return {
|
||||
id: value(["id", "feedbackId", "feedback_id"]),
|
||||
feedbackId: value(["id", "feedbackId", "feedback_id"]),
|
||||
requesterId: value(["requester_id", "requesterId", "requester_uuid"]) || firstValue(requester, ["id", "uuid", "userId"]),
|
||||
requesterTenantId: value(["requester_tenant_id", "requesterTenantId", "tenant_id", "tenantId"]),
|
||||
category: normalizeCategory(value(["Category", "category"])),
|
||||
company: value(["requester_company", "company", "organization", "tenant_name", "tenantName", "requester_affiliation"]),
|
||||
department: value(["requester_department", "department", "team"]) || "-",
|
||||
author: value(["requester_name", "author_name", "authorName", "name", "author"]) || "작성자",
|
||||
title: value(["title", "subject"]) || "제목 없음",
|
||||
content: value(["contents", "content", "body"]) || "",
|
||||
date: String(createdAt).slice(0, 10),
|
||||
createdAt: createdAt,
|
||||
status: normalizeStatus(value(["feedback_status", "feedbackStatus", "status_code", "status"])),
|
||||
secret: secret === true || String(secret).toLowerCase() === "true" || String(secret) === "1",
|
||||
attachments: value(["attachments", "images"]) || [],
|
||||
raw: item
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRemotePosts() {
|
||||
const result = await requestJson("/api/feedbacks?limit=100&page=1", { method: "GET" });
|
||||
const items = Array.isArray(result) ? result : (result.items || result.data && result.data.items || []);
|
||||
return { posts: (Array.isArray(items) ? items : []).map(normalizeRemotePost).filter(function (post) { return post.id; }), meta: result.meta || result.data && result.data.meta || {} };
|
||||
}
|
||||
|
||||
function savePost(post) {
|
||||
const posts = readJson(STORAGE_KEY, []);
|
||||
posts.unshift(post);
|
||||
@@ -139,6 +195,23 @@
|
||||
} catch (error) { return {}; }
|
||||
}
|
||||
|
||||
function getTenantName(tenant) {
|
||||
if (!tenant || typeof tenant !== 'object') return '';
|
||||
return tenant.name || tenant.tenantName || tenant.displayName || tenant.label || tenant.nameKo || tenant.koreanName || '';
|
||||
}
|
||||
|
||||
function getCompanyNameFromEmail(email) {
|
||||
const domain = String(email || '').split('@').pop().trim().toLowerCase();
|
||||
return ({
|
||||
'samaneng.com': '삼안',
|
||||
'hanmaceng.co.kr': '한맥기술',
|
||||
'brsw.kr': '바론그룹',
|
||||
'jangheon.co.kr': '장헌산업',
|
||||
'jangheon.com': '(주)장헌',
|
||||
'hallasanup.com': '한라산업개발'
|
||||
})[domain] || '';
|
||||
}
|
||||
|
||||
function collectSsoClaims() {
|
||||
const storedClaims = parseJson(
|
||||
sessionStorage.getItem('ssoClaims') || sessionStorage.getItem('baronClaims') || sessionStorage.getItem('claims'),
|
||||
@@ -159,9 +232,13 @@
|
||||
const tenantId = raw.tenantId || raw.tenant_id || custom.tenantId || custom.tenant_id || claims.tenantId || claims.tenant_id || (tenantList[0] && (tenantList[0].id || tenantList[0].tenantId)) || '';
|
||||
const tenantIds = tenantList.map(function (tenant) { return typeof tenant === 'string' ? tenant : (tenant.id || tenant.tenantId || tenant.key || ''); }).filter(Boolean);
|
||||
if (tenantId && tenantIds.indexOf(tenantId) === -1) tenantIds.unshift(tenantId);
|
||||
const primaryTenant = tenantList.find(function (tenant) { return typeof tenant !== 'string' && (tenant.id || tenant.tenantId) === tenantId; }) || tenantList[0] || {};
|
||||
const tenantName = raw.tenantName || raw.tenant_name || custom.tenantName || custom.tenant_name || claims.tenantName || claims.tenant_name || getTenantName(primaryTenant);
|
||||
const ssoSubject = raw.ssoSubject || raw.oauthSubject || raw.subject || claims.sub || claims.subject || raw.userId || sessionStorage.getItem('descopeUserId') || '';
|
||||
const userUuid = raw.userUuid || raw.userId || claims.userId || claims.user_id || sessionStorage.getItem('descopeUserId') || '';
|
||||
const loginId = raw.loginId || (raw.loginIds || [])[0] || raw.email || claims.email || claimProfile.email || sessionStorage.getItem('loginId') || readCookie('descope_login_id') || '';
|
||||
const email = raw.email || claims.email || claimProfile.email || sessionStorage.getItem('loginId') || '';
|
||||
const company = getCompanyNameFromEmail(email) || custom.company || raw.company || claims.company || sessionStorage.getItem('company') || '';
|
||||
return {
|
||||
userUuid: userUuid,
|
||||
ssoSubject: ssoSubject,
|
||||
@@ -172,10 +249,11 @@
|
||||
scope: raw.scope || claims.scope || sessionStorage.getItem('ssoScope') || '',
|
||||
roles: raw.roles || raw.roleNames || claims.roles || claims.roleNames || [],
|
||||
loginId: loginId,
|
||||
email: raw.email || claims.email || claimProfile.email || sessionStorage.getItem('loginId') || '',
|
||||
email: email,
|
||||
name: raw.name || claims.name || claimProfile.name || sessionStorage.getItem('userName') || readCookie('descope_user_name') || '',
|
||||
phone: raw.phone || raw.phoneNumber || claims.phone || claims.phone_number || sessionStorage.getItem('phone') || '',
|
||||
company: custom.company || raw.company || claims.company || sessionStorage.getItem('company') || '',
|
||||
tenantName: tenantName,
|
||||
company: company,
|
||||
familyCompany: custom.familyCompany || custom.family_company || raw.familyCompany || sessionStorage.getItem('familyCompany') || '',
|
||||
department: custom.team || custom.department || raw.department || claims.department || sessionStorage.getItem('team') || '',
|
||||
rawClaims: claims
|
||||
@@ -260,7 +338,8 @@
|
||||
}
|
||||
|
||||
async function requestJson(path, options) {
|
||||
const sameOriginProxy = !config.apiBaseUrl && (path === config.createFeedbackPath || /^\/api\/feedbacks\/[^/]+(?:\/comments)?$/.test(path));
|
||||
const requestPath = String(path).split("?")[0];
|
||||
const sameOriginProxy = !config.apiBaseUrl && (requestPath === config.createFeedbackPath || requestPath === "/api/feedbacks" || /^\/api\/feedbacks\/[^\/]+(?:\/comments)?$/.test(requestPath));
|
||||
if (!config.apiBaseUrl && !sameOriginProxy) return { local: true };
|
||||
const target = config.apiBaseUrl ? config.apiBaseUrl.replace(/\/$/, '') + path : path;
|
||||
const response = await fetch(target, Object.assign({ credentials: 'include' }, options));
|
||||
@@ -313,53 +392,76 @@
|
||||
}
|
||||
|
||||
function initList() {
|
||||
const tableBody = qs('#qaRows');
|
||||
const tableBody = qs("#qaRows");
|
||||
if (!tableBody) return;
|
||||
const searchForm = qs('#searchForm');
|
||||
const empty = qs('#tableEmpty');
|
||||
const resultCount = qs('#resultCount');
|
||||
const pagination = qs('#pagination');
|
||||
const searchForm = qs("#searchForm");
|
||||
const empty = qs("#tableEmpty");
|
||||
const resultCount = qs("#resultCount");
|
||||
const pagination = qs("#pagination");
|
||||
let page = 1;
|
||||
let posts = getPosts();
|
||||
let remoteLoaded = false;
|
||||
|
||||
function getPostCompany(post, user) {
|
||||
const company = user && (user.company || user.tenantName);
|
||||
if (company && post.requesterTenantId && user.tenantId && String(post.requesterTenantId) === String(user.tenantId)) return company;
|
||||
return post.company || "외부 사용자";
|
||||
}
|
||||
|
||||
function render() {
|
||||
const query = (qs('#query').value || '').trim().toLowerCase();
|
||||
const selectedCategory = qs('input[name="category"]:checked');
|
||||
const categoryFilter = selectedCategory ? selectedCategory.value : '전체';
|
||||
const onlyMine = qs('#onlyMine').checked;
|
||||
const query = (qs("#query").value || "").trim().toLowerCase();
|
||||
const selectedCategory = qs("input[name=category]:checked");
|
||||
const categoryFilter = selectedCategory ? selectedCategory.value : "전체";
|
||||
const onlyMine = qs("#onlyMine").checked;
|
||||
const user = getAuthUser();
|
||||
const filtered = getPosts().filter(function (post) {
|
||||
const matchesCategory = categoryFilter === '전체' || categoryFilter === post.category;
|
||||
const haystack = [post.title, post.content, post.company, post.department, post.author].join(' ').toLowerCase();
|
||||
const filtered = posts.filter(function (post) {
|
||||
const matchesCategory = categoryFilter === "전체" || categoryFilter === post.category;
|
||||
const company = getPostCompany(post, user);
|
||||
const haystack = [post.title, post.content, company, post.department, post.author].join(" ").toLowerCase();
|
||||
const matchesQuery = !query || haystack.indexOf(query) > -1;
|
||||
const matchesMine = !onlyMine || (user && (post.author === user.name || post.author === user.loginId));
|
||||
const matchesMine = !onlyMine || isPostOwner(post, user);
|
||||
return matchesCategory && matchesQuery && matchesMine;
|
||||
});
|
||||
const pageSize = config.pageSize || 10;
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
page = Math.min(page, totalPages);
|
||||
const rows = filtered.slice((page - 1) * pageSize, page * pageSize);
|
||||
if (resultCount) resultCount.textContent = '총 ' + filtered.length + '건';
|
||||
empty.style.display = rows.length ? 'none' : 'block';
|
||||
if (resultCount) resultCount.textContent = "총 " + filtered.length + "건";
|
||||
if (empty) {
|
||||
empty.textContent = remoteLoaded || posts.length ? "검색 조건에 맞는 문의가 없습니다." : "문의 목록을 불러오는 중입니다.";
|
||||
empty.style.display = rows.length ? "none" : "block";
|
||||
}
|
||||
tableBody.innerHTML = rows.map(function (post, index) {
|
||||
const isNotice = post.category === '공지사항';
|
||||
const number = isNotice ? '공지' : filtered.length - ((page - 1) * pageSize + index);
|
||||
const isNotice = post.category === "공지사항";
|
||||
const number = isNotice ? "공지" : filtered.length - ((page - 1) * pageSize + index);
|
||||
const status = normalizeStatus(post.status);
|
||||
const statusClass = status === '답변완료' ? 'done' : (status === '문의검토' || status === '정밀검토' ? 'review' : '');
|
||||
return '<tr class="' + (isNotice ? 'notice-row' : '') + '" data-id="' + escapeHtml(post.id) + '">' +
|
||||
'<td>' + number + '</td><td class="category-cell">' + escapeHtml(post.category.replace('문의', '')) + '</td>' +
|
||||
'<td>' + escapeHtml(post.company) + '</td><td>' + escapeHtml(post.department) + '</td><td>' + escapeHtml(post.author) + '</td>' +
|
||||
'<td class="subject"><span class="subject-link">' + (post.secret ? '<span class="lock">🔒</span>' : '') + escapeHtml(post.title) + '</span>' + (!isNotice && post.date === '2026-09-17' ? '<span class="new-mark">N</span>' : '') + '</td>' +
|
||||
'<td>' + escapeHtml(post.date) + '</td><td>' + (status ? '<span class="status ' + statusClass + '">' + escapeHtml(status) + '</span>' : '-') + '</td></tr>';
|
||||
}).join('');
|
||||
qsa('#qaRows tr').forEach(function (row) { row.addEventListener('click', function () { window.location.href = 'detail.html?id=' + encodeURIComponent(row.dataset.id); }); });
|
||||
pagination.innerHTML = Array.from({ length: totalPages }, function (_, i) { return '<button type="button" class="' + (i + 1 === page ? 'active' : '') + '" data-page="' + (i + 1) + '">' + (i + 1) + '</button>'; }).join('');
|
||||
qsa('#pagination button').forEach(function (button) { button.addEventListener('click', function () { page = Number(button.dataset.page); render(); window.scrollTo({ top: 360, behavior: 'smooth' }); }); });
|
||||
const statusClass = status === "답변완료" ? "done" : (status === "문의검토" || status === "정밀검토" ? "review" : "");
|
||||
const company = getPostCompany(post, user);
|
||||
return "<tr class=\"" + (isNotice ? "notice-row" : "") + "\" data-id=\"" + escapeHtml(post.id) + "\">" +
|
||||
"<td>" + number + "</td><td class=\"category-cell\">" + escapeHtml(post.category.replace("문의", "")) + "</td>" +
|
||||
"<td>" + escapeHtml(company) + "</td><td>" + escapeHtml(post.department) + "</td><td>" + escapeHtml(post.author) + "</td>" +
|
||||
"<td class=\"subject\"><span class=\"subject-link\">" + (post.secret ? "<span class=\"lock\">🔒</span>" : "") + escapeHtml(post.title) + "</span>" + (!isNotice && post.date === "2026-09-17" ? "<span class=\"new-mark\">N</span>" : "") + "</td>" +
|
||||
"<td>" + escapeHtml(post.date) + "</td><td>" + (status ? "<span class=\"status " + statusClass + "\">" + escapeHtml(status) + "</span>" : "-") + "</td></tr>";
|
||||
}).join("");
|
||||
qsa("#qaRows tr").forEach(function (row) { row.addEventListener("click", function () { window.location.href = "detail.html?id=" + encodeURIComponent(row.dataset.id); }); });
|
||||
pagination.innerHTML = Array.from({ length: totalPages }, function (_, i) { return "<button type=\"button\" class=\"" + (i + 1 === page ? "active" : "") + "\" data-page=\"" + (i + 1) + "\">" + (i + 1) + "</button>"; }).join("");
|
||||
qsa("#pagination button").forEach(function (button) { button.addEventListener("click", function () { page = Number(button.dataset.page); render(); window.scrollTo({ top: 360, behavior: "smooth" }); }); });
|
||||
}
|
||||
|
||||
searchForm.addEventListener('submit', function (event) { event.preventDefault(); page = 1; render(); });
|
||||
qsa('input[name="category"], #onlyMine').forEach(function (input) { input.addEventListener('change', function () { page = 1; render(); }); });
|
||||
searchForm.addEventListener("submit", function (event) { event.preventDefault(); page = 1; render(); });
|
||||
qsa("input[name=category], #onlyMine").forEach(function (input) { input.addEventListener("change", function () { page = 1; render(); }); });
|
||||
render();
|
||||
refreshRemoteStatuses(getPosts()).then(function (changed) { if (changed) render(); });
|
||||
if (authReady) authReady.then(render);
|
||||
loadRemotePosts().then(function (result) {
|
||||
posts = result.posts;
|
||||
remoteLoaded = true;
|
||||
render();
|
||||
}).catch(function (error) {
|
||||
console.warn("[QA] feedback list load failed:", error.message);
|
||||
remoteLoaded = true;
|
||||
render();
|
||||
refreshRemoteStatuses(posts).then(function (changed) { if (changed) render(); });
|
||||
});
|
||||
}
|
||||
|
||||
function initWrite() {
|
||||
@@ -383,7 +485,7 @@
|
||||
if (!qs('#category').value || !title || !content) { errorBox.textContent = '구분, 제목, 내용을 모두 입력해주세요.'; errorBox.style.display = 'block'; return; }
|
||||
const feedbackId = makeUuid();
|
||||
const createdAt = new Date().toISOString();
|
||||
const post = { id: feedbackId, feedbackId: feedbackId, requesterId: currentUser.requesterId, requesterTenantId: currentUser.requesterTenantId, category: qs('#category').value, company: currentUser.familyCompany || currentUser.company || '외부 사용자', department: currentUser.department || '-', author: currentUser.name || currentUser.loginId, title: title, date: createdAt.slice(0, 10), createdAt: createdAt, status: '접수', secret: qs('#secret').checked, content: content };
|
||||
const post = { id: feedbackId, feedbackId: feedbackId, requesterId: currentUser.requesterId, requesterTenantId: currentUser.requesterTenantId, category: qs('#category').value, company: currentUser.company || currentUser.tenantName || currentUser.familyCompany || '외부 사용자', department: currentUser.department || '-', author: currentUser.name || currentUser.loginId, title: title, date: createdAt.slice(0, 10), createdAt: createdAt, status: '접수', secret: qs('#secret').checked, content: content };
|
||||
const submitButton = qs('#submitButton');
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = '등록 중...';
|
||||
@@ -407,7 +509,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
function initDetail() {
|
||||
async function initDetail() {
|
||||
const detail = qs('#detail');
|
||||
if (!detail) return;
|
||||
const detailListLink = qs('.detail-actions > a', detail);
|
||||
@@ -415,7 +517,15 @@
|
||||
const bottomBackButton = qs('#backButton', detail);
|
||||
if (bottomBackButton && bottomBackButton.parentElement) bottomBackButton.parentElement.remove();
|
||||
const id = new URLSearchParams(window.location.search).get('id') || 'EG-1048';
|
||||
const post = getPosts().find(function (item) { return item.id === id; });
|
||||
let post = getPosts().find(function (item) { return item.id === id; });
|
||||
if (!post) {
|
||||
try {
|
||||
const result = await requestJson("/api/feedbacks/" + encodeURIComponent(id), { method: "GET" });
|
||||
post = normalizeRemotePost(result.feedback || result.data || result);
|
||||
} catch (error) {
|
||||
console.warn("[QA] feedback detail load failed:", error.message);
|
||||
}
|
||||
}
|
||||
if (!post) {
|
||||
qs('[data-detail-title]').textContent = '문의 내용을 찾을 수 없습니다.';
|
||||
qs('[data-detail-content]').textContent = '목록에 등록된 문의가 없습니다.';
|
||||
@@ -426,7 +536,8 @@
|
||||
qs('[data-detail-status]').textContent = normalizeStatus(post.status) || '공지';
|
||||
qs('[data-detail-date]').textContent = post.date;
|
||||
qs('[data-detail-author]').textContent = post.author;
|
||||
qs('[data-detail-company]').textContent = post.company;
|
||||
const detailUser = getAuthUser();
|
||||
qs('[data-detail-company]').textContent = detailUser && (detailUser.company || detailUser.tenantName) && post.requesterTenantId && String(detailUser.tenantId) === String(post.requesterTenantId) ? (detailUser.company || detailUser.tenantName) : (post.company || '외부 사용자');
|
||||
qs('[data-detail-department]').textContent = post.department;
|
||||
qs('[data-detail-content]').textContent = post.content;
|
||||
qs('[data-detail-secret]').hidden = !post.secret;
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@
|
||||
<body>
|
||||
<main class="egbim-page"><section class="page-hero"><div class="hero-inner"><span class="hero-kicker">EG-BIM SUPPORT</span><h1>Q&A</h1><p>EG-BIM 관련 문의하기</p></div><nav class="sub-nav" aria-label="EG-BIM 지원 메뉴"><div class="sub-nav-inner"><a href="index.html" aria-current="page">문의하기(Q&A)</a><a href="#" aria-disabled="true" class="disabled-tab">원격지원</a></div></nav></section>
|
||||
<section class="page-shell" id="detail"><div class="section-heading"><div><h2>문의 상세</h2><p>문의 내용과 답변을 확인할 수 있습니다.</p></div></div><article class="detail-card"><div class="detail-head"><div class="detail-title-line"><h2><span class="category" data-detail-category>문의</span> <span data-detail-title>문의 제목</span></h2><span class="status review" data-detail-status>문의접수</span></div><div class="detail-meta"><span data-detail-date>2026-09-17</span><span data-detail-author>작성자</span><span data-detail-company>회사</span><span data-detail-department>부서</span><span data-detail-secret hidden>🔒 비밀글</span></div></div><div class="detail-body" data-detail-content></div><div class="attachments"><h3>첨부파일</h3><div data-detail-attachments><span class="help-text">첨부된 파일이 없습니다.</span></div></div><div class="detail-actions"><div class="owner-actions"><button class="button button-outline" id="editButton" type="button" hidden>수정</button><button class="button button-danger" id="deleteButton" type="button" hidden>삭제</button></div><a class="button button-outline" href="index.html">목록</a></div></article><form id="editForm" class="edit-form" hidden><h3>문의 수정</h3><div class="form-row"><label class="form-label" for="editCategory">구분</label><div class="form-control"><select id="editCategory" class="select"><option value="오류문의">오류문의</option><option value="개선문의">개선문의</option><option value="일반문의">일반문의</option></select></div></div><div class="form-row"><label class="form-label" for="editTitle">제목</label><div class="form-control"><input id="editTitle" class="input" maxlength="200" required></div></div><div class="form-row"><label class="form-label" for="editContent">내용</label><div class="form-control column"><textarea id="editContent" class="textarea" maxlength="10000" required></textarea></div></div><div class="form-row"><div class="form-label">설정</div><div class="form-control"><label class="check"><input id="editSecret" type="checkbox"> 비밀글</label></div></div><div class="form-footer"><button class="button button-outline" id="editCancel" type="button">취소</button><button class="button button-primary" type="submit">수정 저장</button></div><div id="editError" class="form-error" role="alert" hidden></div></form><section class="comment-box"><h3>답변 <span id="commentCount">0</span></h3><div id="comments"></div><form id="commentForm" class="comment-form"><label class="sr-only" for="commentContent">댓글 내용</label><textarea id="commentContent" class="textarea" maxlength="5000" placeholder="답변 또는 추가 문의를 입력해주세요" required></textarea><div class="comment-attachment-input"><label class="button button-primary comment-image-button" for="commentImages">이미지 첨부</label><input id="commentImages" type="file" accept="image/*" multiple><div id="commentImagePreview" class="comment-image-preview"></div></div><div class="comment-form-actions"><span id="commentError" class="form-error" role="alert" hidden></span><button class="button button-primary" id="commentSubmit" type="submit">댓글 등록</button></div></form></section><div class="form-footer"><button class="button button-outline" id="backButton" type="button">목록으로</button></div></section>
|
||||
</main><script src="config.js?v=20260921-3"></script><script src="app.js?v=20260921-3"></script>
|
||||
</main><script src="config.js?v=20260921-3"></script><script src="app.js?v=20260928-1"></script>
|
||||
</body></html>
|
||||
+1
-1
@@ -23,6 +23,6 @@
|
||||
</section>
|
||||
</main>
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
<script src="config.js?v=20260921-3"></script><script src="app.js?v=20260921-3"></script>
|
||||
<script src="config.js?v=20260921-3"></script><script src="app.js?v=20260928-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -4,5 +4,5 @@
|
||||
<body>
|
||||
<main class="egbim-page"><section class="page-hero"><div class="hero-inner"><span class="hero-kicker">EG-BIM SUPPORT</span><h1>Q&A</h1><p>EG-BIM 관련 문의하기</p></div><nav class="sub-nav" aria-label="EG-BIM 지원 메뉴"><div class="sub-nav-inner"><a href="index.html" aria-current="page">문의하기(Q&A)</a><a href="#" aria-disabled="true" class="disabled-tab">원격지원</a></div></nav></section>
|
||||
<section class="page-shell"><div class="section-heading"><div><h2>문의 등록</h2><p>로그인한 사용자 정보로 문의가 등록됩니다.</p></div></div><div class="form-notice" data-auth-state>로그인 상태를 확인하고 있습니다.</div><div class="form-error" id="formError" role="alert"></div><form id="qaForm" novalidate><div class="form-card" id="writeFields"><div class="form-row"><label class="form-label" for="category">구분 <span class="required">*</span></label><div class="form-control"><select class="select" id="category" required><option value="">선택해주세요</option><option>오류문의</option><option>개선문의</option><option>일반문의</option></select></div></div><div class="form-row"><label class="form-label" for="title">제목 <span class="required">*</span></label><div class="form-control"><input class="input" id="title" type="text" maxlength="100" placeholder="문의 제목을 입력해주세요" required></div></div><div class="form-row"><span class="form-label">공개 설정</span><div class="form-control"><label class="check"><input id="secret" type="checkbox"> 비밀글로 등록</label></div></div><div class="form-row"><label class="form-label" for="content">내용 <span class="required">*</span></label><div class="form-control column"><textarea class="textarea" id="content" placeholder="문의 내용을 작성해주세요" required></textarea><span class="help-text">개인정보, 비밀번호, 인증번호 등 민감한 정보는 입력하지 마세요.</span></div></div><div class="form-row"><label class="form-label" for="attachment">첨부파일</label><div class="form-control column"><input class="file-input" id="attachment" type="file" multiple><span class="help-text">파일은 문의와 함께 안전하게 업로드됩니다. 파일당 최대 30MB</span></div></div></div><div class="form-footer"><a class="button button-outline" href="index.html">취소</a><button class="button button-primary" id="submitButton" type="submit">문의 등록</button></div></form></section>
|
||||
</main><div id="toast" class="toast" role="status" aria-live="polite"></div><script src="config.js?v=20260921-3"></script><script src="app.js?v=20260921-3"></script>
|
||||
</main><div id="toast" class="toast" role="status" aria-live="polite"></div><script src="config.js?v=20260921-3"></script><script src="app.js?v=20260928-1"></script>
|
||||
</body></html>
|
||||
+65
-4
@@ -10,7 +10,10 @@ export default {
|
||||
if (url.pathname === '/auth/callback') return finishLogin(request, env);
|
||||
if (url.pathname === '/auth/logout') return logout(request, env);
|
||||
if (url.pathname === '/auth/session') return sessionResponse(request, env);
|
||||
if (url.pathname === '/api/feedbacks') return createFeedback(request, env);
|
||||
if (url.pathname === '/api/feedbacks') {
|
||||
if (request.method === 'GET') return listFeedbacks(request, env, url);
|
||||
return createFeedback(request, env);
|
||||
}
|
||||
if (/^\/api\/feedbacks\/[^/]+\/comments$/.test(url.pathname)) return feedbackComments(request, env, url);
|
||||
if (/^\/api\/feedbacks\/[^/]+$/.test(url.pathname)) return feedbackDetail(request, env, url);
|
||||
|
||||
@@ -187,6 +190,18 @@ function requiredAuthConfig(env) {
|
||||
return keys.filter((key) => !env[key]);
|
||||
}
|
||||
|
||||
function companyNameFromEmail(email) {
|
||||
const domain = String(email || '').split('@').pop().trim().toLowerCase();
|
||||
return ({
|
||||
'samaneng.com': '삼안',
|
||||
'hanmaceng.co.kr': '한맥기술',
|
||||
'brsw.kr': '바론그룹',
|
||||
'jangheon.co.kr': '장헌산업',
|
||||
'jangheon.com': '(주)장헌',
|
||||
'hallasanup.com': '한라산업개발'
|
||||
})[domain] || '';
|
||||
}
|
||||
|
||||
function normalizeClaims(claims) {
|
||||
const profile = claims.profile && typeof claims.profile === 'object' ? claims.profile : {};
|
||||
const custom = claims.customAttributes || claims.custom_attributes || {};
|
||||
@@ -195,11 +210,17 @@ function normalizeClaims(claims) {
|
||||
const profilePhone = profile.phone || profile.phone_number || profile.phoneNumber || profile.mobile || profile.mobilePhone || firstProfilePhone?.phoneNumber || firstProfilePhone?.phone_number || firstProfilePhone?.number || firstProfilePhone?.value || firstProfilePhone?.phone || firstProfilePhone || '';
|
||||
const customPhone = custom.phone || custom.phone_number || custom.phoneNumber || custom.mobile || custom.mobilePhone || '';
|
||||
const tenants = claims.tenants || claims.tenantIds || custom.tenants || [];
|
||||
const tenantList = Array.isArray(tenants) ? tenants : Object.keys(tenants).map((key) => ({ id: key, ...(tenants[key] || {}) }));
|
||||
const tenantList = Array.isArray(tenants) ? tenants : Object.keys(tenants).map((key) => {
|
||||
const value = tenants[key];
|
||||
return value && typeof value === 'object' ? { id: key, ...value } : { id: key, name: value || '' };
|
||||
});
|
||||
const tenantId = claims.tenant_id || claims.tenantId || custom.tenant_id || custom.tenantId || tenantList[0]?.id || tenantList[0]?.tenantId || '';
|
||||
const tenantIds = tenantList.map((tenant) => typeof tenant === 'string' ? tenant : (tenant.id || tenant.tenantId || tenant.key || '')).filter(Boolean);
|
||||
if (tenantId && !tenantIds.includes(tenantId)) tenantIds.unshift(tenantId);
|
||||
const primaryTenant = tenantList.find((tenant) => typeof tenant !== 'string' && (tenant.id || tenant.tenantId) === tenantId) || tenantList[0] || {};
|
||||
const tenantName = claims.tenant_name || claims.tenantName || custom.tenant_name || custom.tenantName || (typeof primaryTenant === 'object' ? (primaryTenant.name || primaryTenant.tenantName || primaryTenant.displayName || primaryTenant.label || '') : '');
|
||||
const email = claims.email || profile.email || '';
|
||||
const company = companyNameFromEmail(email) || custom.company || claims.company || claims.organization || profile.company || '';
|
||||
const subject = claims.sub || claims.subject || claims.userId || claims.user_id || '';
|
||||
return {
|
||||
userUuid: claims.userId || claims.user_id || subject,
|
||||
@@ -211,10 +232,11 @@ function normalizeClaims(claims) {
|
||||
scope: claims.scope || '',
|
||||
roles: claims.roles || claims.roleNames || [],
|
||||
loginId: claims.email || claims.loginId || claims.preferred_username || profile.email || '',
|
||||
email: claims.email || profile.email || '',
|
||||
email,
|
||||
name: claims.name || profile.name || claims.display_name || '',
|
||||
phone: claims.phone || claims.phone_number || claims.phoneNumber || claims.mobile || claims.mobilePhone || customPhone || profilePhone,
|
||||
company: custom.company || claims.company || claims.organization || profile.company || '',
|
||||
tenantName,
|
||||
company,
|
||||
familyCompany: custom.familyCompany || custom.family_company || claims.familyCompany || claims.affiliation || '',
|
||||
department: custom.team || custom.department || claims.department || primaryTenant.name || '',
|
||||
position: custom.position || claims.position || profile.position || primaryTenant.position || primaryTenant.title || '',
|
||||
@@ -414,6 +436,45 @@ async function createFeedback(request, env) {
|
||||
return json({ id });
|
||||
}
|
||||
|
||||
async function listFeedbacks(request, env, url) {
|
||||
if (request.method !== "GET") return json({ error: "method_not_allowed" }, 405, { allow: "GET" });
|
||||
const user = await getSession(request, env);
|
||||
if (!user) return json({ error: "unauthenticated" }, 401);
|
||||
if (!env.ABC_API_KEY) return json({ error: "feedback_api_not_configured" }, 503);
|
||||
if (!env.ABC_PROJECT_ID || !env.ABC_CHANNEL_ID) return json({ error: "feedback_target_not_configured" }, 503);
|
||||
|
||||
const apiBaseUrl = String(env.ABC_API_BASE_URL || "https://feedback.hmac.kr").replace(/\/$/, "");
|
||||
const endpoint = new URL(apiBaseUrl + "/api/projects/" + encodeURIComponent(env.ABC_PROJECT_ID) + "/channels/" + encodeURIComponent(env.ABC_CHANNEL_ID) + "/feedbacks");
|
||||
["cursor", "searchText", "fieldKey", "issueName"].forEach((key) => {
|
||||
const value = url.searchParams.get(key);
|
||||
if (value) endpoint.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const requestedLimit = Number(url.searchParams.get("limit") || 100);
|
||||
const requestedPage = Number(url.searchParams.get("page") || 1);
|
||||
endpoint.searchParams.set("limit", String(Number.isFinite(requestedLimit) ? Math.min(Math.max(Math.floor(requestedLimit), 1), 100) : 100));
|
||||
endpoint.searchParams.set("page", String(Number.isFinite(requestedPage) ? Math.max(Math.floor(requestedPage), 1) : 1));
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(endpoint, { method: "GET", headers: { accept: "application/json", "x-api-key": env.ABC_API_KEY } });
|
||||
} catch (error) {
|
||||
console.error("feedback_list_api_request_failed", error instanceof Error ? error.message : "unknown");
|
||||
return json({ error: "feedback_api_unreachable" }, 502);
|
||||
}
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
console.error("feedback_list_api_rejected", response.status);
|
||||
const upstreamMessage = String(result.message || result.error || result.code || "").slice(0, 240);
|
||||
return json({ error: "feedback_api_rejected", message: upstreamMessage || "문의 목록 조회에 실패했습니다.", status: response.status }, response.status >= 500 ? 502 : response.status);
|
||||
}
|
||||
|
||||
const items = Array.isArray(result) ? result : (result.items || result.data?.items || []);
|
||||
const meta = result.meta || result.data?.meta || {};
|
||||
return json({ items: Array.isArray(items) ? items : [], meta });
|
||||
}
|
||||
|
||||
async function feedbackComments(request, env, url) {
|
||||
if (request.method !== 'GET' && request.method !== 'POST') return json({ error: 'method_not_allowed' }, 405, { allow: 'GET, POST' });
|
||||
const user = await getSession(request, env);
|
||||
|
||||
Reference in new issue
Block a user