feat: add member history list snapshot and compare views
This commit is contained in:
@@ -620,9 +620,9 @@ def sync_seat_assignment_versions(cur, member_ids: list[int], change_reason: str
|
|||||||
def fetch_members_as_of(cur, as_of: datetime) -> list[dict[str, object]]:
|
def fetch_members_as_of(cur, as_of: datetime) -> list[dict[str, object]]:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT m.id,
|
SELECT mv.member_id AS id,
|
||||||
mv.name,
|
mv.name,
|
||||||
m.employee_id,
|
COALESCE(m.employee_id, '') AS employee_id,
|
||||||
mv.company,
|
mv.company,
|
||||||
mv.rank,
|
mv.rank,
|
||||||
mv.role,
|
mv.role,
|
||||||
@@ -637,25 +637,137 @@ def fetch_members_as_of(cur, as_of: datetime) -> list[dict[str, object]]:
|
|||||||
mv.email,
|
mv.email,
|
||||||
COALESCE(sav.seat_label, '') AS seat_label,
|
COALESCE(sav.seat_label, '') AS seat_label,
|
||||||
mv.photo_url,
|
mv.photo_url,
|
||||||
m.sort_order,
|
COALESCE(m.sort_order, 2147483647) AS sort_order,
|
||||||
mv.created_at,
|
mv.created_at,
|
||||||
mv.valid_from AS updated_at
|
mv.valid_from AS updated_at
|
||||||
FROM members m
|
FROM member_versions mv
|
||||||
JOIN member_versions mv
|
LEFT JOIN members m
|
||||||
ON mv.member_id = m.id
|
ON m.id = mv.member_id
|
||||||
AND mv.valid_from <= %s
|
|
||||||
AND (mv.valid_to IS NULL OR mv.valid_to > %s)
|
|
||||||
LEFT JOIN seat_assignment_versions sav
|
LEFT JOIN seat_assignment_versions sav
|
||||||
ON sav.member_id = m.id
|
ON sav.member_id = mv.member_id
|
||||||
AND sav.valid_from <= %s
|
AND sav.valid_from <= %s
|
||||||
AND (sav.valid_to IS NULL OR sav.valid_to > %s)
|
AND (sav.valid_to IS NULL OR sav.valid_to > %s)
|
||||||
ORDER BY m.sort_order ASC, m.id ASC
|
WHERE mv.valid_from <= %s
|
||||||
|
AND (mv.valid_to IS NULL OR mv.valid_to > %s)
|
||||||
|
ORDER BY COALESCE(m.sort_order, 2147483647) ASC, mv.member_id ASC
|
||||||
""",
|
""",
|
||||||
(as_of, as_of, as_of, as_of),
|
(as_of, as_of, as_of, as_of),
|
||||||
)
|
)
|
||||||
return cur.fetchall()
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def build_member_compare_items(from_items: list[dict[str, object]], to_items: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||||
|
tracked_fields = (
|
||||||
|
("company", "소속회사", "기본"),
|
||||||
|
("rank", "직급", "기본"),
|
||||||
|
("role", "직책", "기본"),
|
||||||
|
("department", "부서", "조직"),
|
||||||
|
("grp", "그룹", "조직"),
|
||||||
|
("division", "디비전", "조직"),
|
||||||
|
("team", "팀", "조직"),
|
||||||
|
("cell", "셀", "조직"),
|
||||||
|
("work_status", "근무상태", "기본"),
|
||||||
|
("work_time", "근무시간", "기본"),
|
||||||
|
("phone", "전화번호", "기본"),
|
||||||
|
("email", "이메일", "기본"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_summary(item: dict[str, object] | None) -> list[str]:
|
||||||
|
if not item:
|
||||||
|
return []
|
||||||
|
summary_fields = (
|
||||||
|
("rank", "직급"),
|
||||||
|
("role", "직책"),
|
||||||
|
("department", "부서"),
|
||||||
|
("grp", "그룹"),
|
||||||
|
("division", "디비전"),
|
||||||
|
("team", "팀"),
|
||||||
|
("cell", "셀"),
|
||||||
|
)
|
||||||
|
lines: list[str] = []
|
||||||
|
for field, label in summary_fields:
|
||||||
|
value = str(item.get(field) or "").strip()
|
||||||
|
if value:
|
||||||
|
lines.append(f"{label}: {value}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
from_map = {int(item["id"]): item for item in from_items}
|
||||||
|
to_map = {int(item["id"]): item for item in to_items}
|
||||||
|
all_ids = sorted(set(from_map) | set(to_map))
|
||||||
|
items: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
for member_id in all_ids:
|
||||||
|
before = from_map.get(member_id)
|
||||||
|
after = to_map.get(member_id)
|
||||||
|
if before is None and after is not None:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"member_id": member_id,
|
||||||
|
"name": str(after.get("name") or "-"),
|
||||||
|
"status": "added",
|
||||||
|
"status_label": "신규",
|
||||||
|
"categories": ["신규"],
|
||||||
|
"changes": [],
|
||||||
|
"before_lines": [],
|
||||||
|
"after_lines": build_summary(after),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if before is not None and after is None:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"member_id": member_id,
|
||||||
|
"name": str(before.get("name") or "-"),
|
||||||
|
"status": "removed",
|
||||||
|
"status_label": "삭제",
|
||||||
|
"categories": ["삭제"],
|
||||||
|
"changes": [],
|
||||||
|
"before_lines": build_summary(before),
|
||||||
|
"after_lines": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if before is None or after is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
changes: list[dict[str, str]] = []
|
||||||
|
categories: set[str] = set()
|
||||||
|
for field, label, category in tracked_fields:
|
||||||
|
before_value = str(before.get(field) or "").strip()
|
||||||
|
after_value = str(after.get(field) or "").strip()
|
||||||
|
if before_value == after_value:
|
||||||
|
continue
|
||||||
|
changes.append(
|
||||||
|
{
|
||||||
|
"field": field,
|
||||||
|
"label": label,
|
||||||
|
"before": before_value,
|
||||||
|
"after": after_value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
categories.add(category)
|
||||||
|
|
||||||
|
if not changes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"member_id": member_id,
|
||||||
|
"name": str(after.get("name") or before.get("name") or "-"),
|
||||||
|
"status": "updated",
|
||||||
|
"status_label": "변경",
|
||||||
|
"categories": sorted(categories),
|
||||||
|
"changes": changes,
|
||||||
|
"before_lines": [f"{change['label']}: {change['before'] or '-'}" for change in changes],
|
||||||
|
"after_lines": [f"{change['label']}: {change['after'] or '-'}" for change in changes],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
order_map = {"added": 0, "updated": 1, "removed": 2}
|
||||||
|
items.sort(key=lambda item: (order_map.get(str(item.get("status") or ""), 9), str(item.get("name") or ""), int(item.get("member_id") or 0)))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
def serialize_seat_map_payload(payload: SeatMapPayload) -> tuple[object, ...]:
|
def serialize_seat_map_payload(payload: SeatMapPayload) -> tuple[object, ...]:
|
||||||
return (
|
return (
|
||||||
payload.name.strip(),
|
payload.name.strip(),
|
||||||
@@ -3959,6 +4071,19 @@ def list_members(as_of: str | None = None) -> dict[str, list[dict[str, object]]]
|
|||||||
return {"items": fetch_members_as_of(cur, parsed_as_of)}
|
return {"items": fetch_members_as_of(cur, parsed_as_of)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/history/members/compare")
|
||||||
|
def compare_members_history(from_date: str, to_date: str) -> dict[str, list[dict[str, object]]]:
|
||||||
|
parsed_from = parse_as_of(from_date)
|
||||||
|
parsed_to = parse_as_of(to_date)
|
||||||
|
if parsed_from is None or parsed_to is None:
|
||||||
|
raise HTTPException(status_code=400, detail="from_date and to_date are required.")
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
from_items = fetch_members_as_of(cur, parsed_from)
|
||||||
|
to_items = fetch_members_as_of(cur, parsed_to)
|
||||||
|
return {"items": build_member_compare_items(from_items, to_items)}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/members")
|
@app.post("/api/members")
|
||||||
def create_member(payload: MemberPayload) -> dict[str, object]:
|
def create_member(payload: MemberPayload) -> dict[str, object]:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|||||||
@@ -802,6 +802,132 @@ body {
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-toolbar-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-mode-btn {
|
||||||
|
border: 1px solid #c7d2fe;
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #4338ca;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-date-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-date-input {
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-date-separator {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view-status {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-compare-status {
|
||||||
|
width: 82px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-compare-category {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-empty-cell {
|
||||||
|
padding: 24px 16px !important;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-compare-cell {
|
||||||
|
text-align: left !important;
|
||||||
|
vertical-align: top !important;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-compare-line + .list-compare-line {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-compare-chip-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-compare-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-compare-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 54px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-compare-status-added {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-compare-status-updated {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-compare-status-removed {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
.fab-container {
|
.fab-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 30px;
|
bottom: 30px;
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의
|
|||||||
let photoPreviewObjectUrl = null;
|
let photoPreviewObjectUrl = null;
|
||||||
let seatMapLayoutCache = null;
|
let seatMapLayoutCache = null;
|
||||||
let activeAsOfDate = '';
|
let activeAsOfDate = '';
|
||||||
|
const listViewState = {
|
||||||
|
mode: 'current',
|
||||||
|
snapshotDate: '',
|
||||||
|
compareFromDate: '',
|
||||||
|
compareToDate: '',
|
||||||
|
snapshotMembers: [],
|
||||||
|
compareItems: [],
|
||||||
|
};
|
||||||
const seatMapOfficeKeys = ['technical-development-center', 'hanmac-building-6f', 'hanmac-building-7f'];
|
const seatMapOfficeKeys = ['technical-development-center', 'hanmac-building-6f', 'hanmac-building-7f'];
|
||||||
|
|
||||||
const levelOrder = ['부서', '그룹', '디비전', '팀', '셀'];
|
const levelOrder = ['부서', '그룹', '디비전', '팀', '셀'];
|
||||||
@@ -126,6 +134,14 @@ function withAsOf(url) {
|
|||||||
return `${url}${separator}as_of=${encodeURIComponent(activeAsOfDate)}`;
|
return `${url}${separator}as_of=${encodeURIComponent(activeAsOfDate)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDefaultHistoryDate() {
|
||||||
|
if (activeAsOfDate) {
|
||||||
|
return activeAsOfDate;
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadProfilePhoto(file, memberName) {
|
async function uploadProfilePhoto(file, memberName) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
@@ -1326,6 +1342,14 @@ function openListViewModal(event) {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultDate = getDefaultHistoryDate();
|
||||||
|
listViewState.mode = 'current';
|
||||||
|
listViewState.snapshotDate = defaultDate;
|
||||||
|
listViewState.compareFromDate = defaultDate;
|
||||||
|
listViewState.compareToDate = defaultDate;
|
||||||
|
listViewState.snapshotMembers = [];
|
||||||
|
listViewState.compareItems = [];
|
||||||
|
|
||||||
const modal = document.getElementById('modal');
|
const modal = document.getElementById('modal');
|
||||||
modal.querySelector('.modal-content').classList.add('wide');
|
modal.querySelector('.modal-content').classList.add('wide');
|
||||||
document.getElementById('modal-title').innerText = '인원 명단';
|
document.getElementById('modal-title').innerText = '인원 명단';
|
||||||
@@ -1335,16 +1359,51 @@ function openListViewModal(event) {
|
|||||||
isListMode = true;
|
isListMode = true;
|
||||||
editingMembers = cloneMembers(members);
|
editingMembers = cloneMembers(members);
|
||||||
fieldsArea.innerHTML = `
|
fieldsArea.innerHTML = `
|
||||||
<div class="mb-4 flex gap-2 p-1">
|
<div class="list-toolbar">
|
||||||
|
<div class="list-toolbar-row">
|
||||||
|
<button type="button" onclick="showCurrentListView()" class="list-mode-btn">현재 명단</button>
|
||||||
|
<div class="list-date-group">
|
||||||
|
<input type="date" id="list-snapshot-date" value="${escapeHtml(defaultDate)}" class="list-date-input">
|
||||||
|
<button type="button" onclick="loadSnapshotListView()" class="list-mode-btn">기준일 조회</button>
|
||||||
|
</div>
|
||||||
|
<div class="list-date-group">
|
||||||
|
<input type="date" id="list-compare-from" value="${escapeHtml(defaultDate)}" class="list-date-input">
|
||||||
|
<span class="list-date-separator">~</span>
|
||||||
|
<input type="date" id="list-compare-to" value="${escapeHtml(defaultDate)}" class="list-date-input">
|
||||||
|
<button type="button" onclick="loadCompareListView()" class="list-mode-btn">변경 비교</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-toolbar-row">
|
||||||
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-slate-50 border-2 border-slate-100 p-3 rounded-xl text-sm outline-none font-bold focus:border-indigo-400 transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
|
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-slate-50 border-2 border-slate-100 p-3 rounded-xl text-sm outline-none font-bold focus:border-indigo-400 transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
|
||||||
<button onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-indigo-600 text-white px-5 rounded-xl font-bold text-sm">검색</button>
|
<button type="button" onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-indigo-600 text-white px-5 rounded-xl font-bold text-sm">검색</button>
|
||||||
|
</div>
|
||||||
|
<div id="list-view-status" class="list-view-status"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="list-table-container" class="overflow-y-auto flex-1 border rounded-xl"></div>
|
<div id="list-table-container" class="overflow-y-auto flex-1 border rounded-xl"></div>
|
||||||
`;
|
`;
|
||||||
renderListViewTable();
|
renderListViewModalContent();
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyListViewChanges() {
|
||||||
|
if (listViewState.mode !== 'current') {
|
||||||
|
closeModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm('리스트의 변경 사항을 메인 화면에 반영하시겠습니까?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await syncMembers(editingMembers);
|
||||||
|
isListMode = false;
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderListViewFooter() {
|
||||||
const footer = document.getElementById('modal-footer-area');
|
const footer = document.getElementById('modal-footer-area');
|
||||||
if (isAdmin) {
|
if (!footer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (listViewState.mode === 'current' && isAdmin) {
|
||||||
footer.innerHTML = `
|
footer.innerHTML = `
|
||||||
<div class="flex gap-2 w-full justify-between items-center">
|
<div class="flex gap-2 w-full justify-between items-center">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -1358,19 +1417,135 @@ function openListViewModal(event) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
|
||||||
footer.innerHTML = '<div class="flex gap-2 w-full justify-end items-center"><button onclick="closeModal()" class="bg-indigo-600 text-white px-10 py-2.5 rounded-lg text-xs font-bold shadow-lg shadow-indigo-200">닫기</button></div>';
|
|
||||||
}
|
|
||||||
modal.style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyListViewChanges() {
|
|
||||||
if (!confirm('리스트의 변경 사항을 메인 화면에 반영하시겠습니까?')) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await syncMembers(editingMembers);
|
footer.innerHTML = '<div class="flex gap-2 w-full justify-end items-center"><button onclick="closeModal()" class="bg-indigo-600 text-white px-10 py-2.5 rounded-lg text-xs font-bold shadow-lg shadow-indigo-200">닫기</button></div>';
|
||||||
isListMode = false;
|
}
|
||||||
closeModal();
|
|
||||||
|
function getRenderableListMembers() {
|
||||||
|
if (listViewState.mode === 'snapshot') {
|
||||||
|
return listViewState.snapshotMembers;
|
||||||
|
}
|
||||||
|
return editingMembers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getListSearchEntries() {
|
||||||
|
if (listViewState.mode === 'compare') {
|
||||||
|
return (listViewState.compareItems || []).map((item) => ({
|
||||||
|
rowId: `list-compare-row-${item.member_id}`,
|
||||||
|
name: String(item.name || ''),
|
||||||
|
values: [String(item.name || ''), ...(item.before_lines || []), ...(item.after_lines || [])],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return getRenderableListMembers().map((member) => ({
|
||||||
|
rowId: `list-row-${member._id}`,
|
||||||
|
name: String(member['이름'] || ''),
|
||||||
|
values: [
|
||||||
|
String(member['이름'] || ''),
|
||||||
|
...levelOrder.map((level) => String(member[level] || '')),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderListViewCompareTable() {
|
||||||
|
const container = document.getElementById('list-table-container');
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = listViewState.compareItems || [];
|
||||||
|
let html = `
|
||||||
|
<table class="list-table list-compare-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-name">이름</th>
|
||||||
|
<th class="col-compare-status">상태</th>
|
||||||
|
<th class="col-compare-category">변경유형</th>
|
||||||
|
<th>이전</th>
|
||||||
|
<th>현재</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
html += '<tr><td colspan="5" class="list-empty-cell">선택한 기간 사이의 구성원 변경 내역이 없습니다.</td></tr>';
|
||||||
|
} else {
|
||||||
|
rows.forEach((item) => {
|
||||||
|
const categories = (item.categories || []).map((category) => `<span class="list-compare-chip">${escapeHtml(category)}</span>`).join('');
|
||||||
|
const beforeLines = (item.before_lines || []).map((line) => `<div class="list-compare-line">${escapeHtml(line)}</div>`).join('') || '<span class="text-slate-300">-</span>';
|
||||||
|
const afterLines = (item.after_lines || []).map((line) => `<div class="list-compare-line">${escapeHtml(line)}</div>`).join('') || '<span class="text-slate-300">-</span>';
|
||||||
|
html += `
|
||||||
|
<tr id="list-compare-row-${item.member_id}">
|
||||||
|
<td class="font-black text-slate-700">${escapeHtml(item.name || '-')}</td>
|
||||||
|
<td><span class="list-compare-status list-compare-status-${escapeHtml(item.status || 'updated')}">${escapeHtml(item.status_label || '-')}</span></td>
|
||||||
|
<td><div class="list-compare-chip-group">${categories || '<span class="text-slate-300">-</span>'}</div></td>
|
||||||
|
<td class="list-compare-cell">${beforeLines}</td>
|
||||||
|
<td class="list-compare-cell">${afterLines}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderListViewModalContent() {
|
||||||
|
const status = document.getElementById('list-view-status');
|
||||||
|
if (status) {
|
||||||
|
if (listViewState.mode === 'snapshot') {
|
||||||
|
status.textContent = listViewState.snapshotDate
|
||||||
|
? `${listViewState.snapshotDate} 기준 인원 명단입니다.`
|
||||||
|
: '기준일을 선택한 뒤 조회하세요.';
|
||||||
|
} else if (listViewState.mode === 'compare') {
|
||||||
|
status.textContent = (listViewState.compareFromDate && listViewState.compareToDate)
|
||||||
|
? `${listViewState.compareFromDate} ~ ${listViewState.compareToDate} 변경 내역입니다.`
|
||||||
|
: '비교 시작일과 종료일을 선택하세요.';
|
||||||
|
} else {
|
||||||
|
status.textContent = '현재 조직 인원 명단입니다.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listViewState.mode === 'compare') {
|
||||||
|
renderListViewCompareTable();
|
||||||
|
} else {
|
||||||
|
renderListViewTable();
|
||||||
|
}
|
||||||
|
renderListViewFooter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCurrentListView() {
|
||||||
|
listViewState.mode = 'current';
|
||||||
|
renderListViewModalContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSnapshotListView() {
|
||||||
|
const snapshotDate = document.getElementById('list-snapshot-date')?.value || '';
|
||||||
|
if (!snapshotDate) {
|
||||||
|
alert('기준일을 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = await apiFetch(`/api/members?as_of=${encodeURIComponent(snapshotDate)}`);
|
||||||
|
listViewState.snapshotDate = snapshotDate;
|
||||||
|
listViewState.snapshotMembers = (payload.items || []).map(toLegacyMember);
|
||||||
|
listViewState.mode = 'snapshot';
|
||||||
|
renderListViewModalContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCompareListView() {
|
||||||
|
const fromDate = document.getElementById('list-compare-from')?.value || '';
|
||||||
|
const toDate = document.getElementById('list-compare-to')?.value || '';
|
||||||
|
if (!fromDate || !toDate) {
|
||||||
|
alert('비교 시작일과 종료일을 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = await apiFetch(`/api/history/members/compare?from_date=${encodeURIComponent(fromDate)}&to_date=${encodeURIComponent(toDate)}`);
|
||||||
|
listViewState.compareFromDate = fromDate;
|
||||||
|
listViewState.compareToDate = toDate;
|
||||||
|
listViewState.compareItems = Array.isArray(payload.items) ? payload.items : [];
|
||||||
|
listViewState.mode = 'compare';
|
||||||
|
renderListViewModalContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderListViewTable() {
|
function renderListViewTable() {
|
||||||
@@ -1379,13 +1554,17 @@ function renderListViewTable() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = `<table class="list-table"><thead><tr>${isAdmin ? '<th width="40">순서</th>' : ''}<th class="col-name">이름</th><th class="col-rank">직급</th><th class="col-pos">직책</th><th class="col-unit-sm">셀</th><th class="col-unit-sm">팀</th><th class="col-unit-lg">디비전</th><th class="col-unit-lg">그룹</th><th class="col-unit-lg">부서</th><th class="col-corp">소속</th><th class="col-action">${isAdmin ? '관리' : '조회'}</th></tr></thead><tbody id="list-body">`;
|
const sourceMembers = getRenderableListMembers();
|
||||||
|
const editable = isAdmin && listViewState.mode === 'current';
|
||||||
|
const inspectable = !editable && listViewState.mode === 'current';
|
||||||
|
const groupColumnCount = editable ? 11 : 10;
|
||||||
|
let html = `<table class="list-table"><thead><tr>${editable ? '<th width="40">순서</th>' : ''}<th class="col-name">이름</th><th class="col-rank">직급</th><th class="col-pos">직책</th><th class="col-unit-sm">셀</th><th class="col-unit-sm">팀</th><th class="col-unit-lg">디비전</th><th class="col-unit-lg">그룹</th><th class="col-unit-lg">부서</th><th class="col-corp">소속</th><th class="col-action">${editable ? '관리' : '조회'}</th></tr></thead><tbody id="list-body">`;
|
||||||
const lastValues = {};
|
const lastValues = {};
|
||||||
levelOrder.forEach((level) => {
|
levelOrder.forEach((level) => {
|
||||||
lastValues[level] = '';
|
lastValues[level] = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
editingMembers.forEach((member, index) => {
|
sourceMembers.forEach((member, index) => {
|
||||||
let isAnyParentCollapsed = false;
|
let isAnyParentCollapsed = false;
|
||||||
levelOrder.forEach((level, depth) => {
|
levelOrder.forEach((level, depth) => {
|
||||||
const value = (member[level] || '').trim();
|
const value = (member[level] || '').trim();
|
||||||
@@ -1399,8 +1578,8 @@ function renderListViewTable() {
|
|||||||
}
|
}
|
||||||
if (value !== lastValues[level]) {
|
if (value !== lastValues[level]) {
|
||||||
const isCollapsed = collapsedUnits.has(key);
|
const isCollapsed = collapsedUnits.has(key);
|
||||||
const dragAttr = isAdmin ? `draggable="true" ondragstart="handleListGroupDragStart(event, '${jsString(level)}', '${jsString(value)}')" ondragover="event.preventDefault()" ondrop="handleListGroupDrop(event, '${jsString(level)}', '${jsString(value)}')"` : '';
|
const dragAttr = editable ? `draggable="true" ondragstart="handleListGroupDragStart(event, '${jsString(level)}', '${jsString(value)}')" ondragover="event.preventDefault()" ondrop="handleListGroupDrop(event, '${jsString(level)}', '${jsString(value)}')"` : '';
|
||||||
html += `<tr ${dragAttr} class="list-header-row lvl-${depth} ${isCollapsed ? 'collapsed' : ''} ${isAnyParentCollapsed ? 'hidden-row' : ''}"><td colspan="${(isAdmin ? 10 : 9) + 1}" onclick="toggleUnitCollapse('${jsString(level)}', '${jsString(value)}')" style="padding-left: 15px !important;"><span class="collapse-icon">▼</span> ${value}</td></tr>`;
|
html += `<tr ${dragAttr} class="list-header-row lvl-${depth} ${isCollapsed ? 'collapsed' : ''} ${isAnyParentCollapsed ? 'hidden-row' : ''}"><td colspan="${groupColumnCount}" onclick="toggleUnitCollapse('${jsString(level)}', '${jsString(value)}')" style="padding-left: 15px !important;"><span class="collapse-icon">▼</span> ${escapeHtml(value)}</td></tr>`;
|
||||||
lastValues[level] = value;
|
lastValues[level] = value;
|
||||||
levelOrder.slice(depth + 1).forEach((childLevel) => {
|
levelOrder.slice(depth + 1).forEach((childLevel) => {
|
||||||
lastValues[childLevel] = '';
|
lastValues[childLevel] = '';
|
||||||
@@ -1409,20 +1588,25 @@ function renderListViewTable() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const hidden = levelOrder.some((level) => member[level] && collapsedUnits.has(`${level}_${member[level].trim()}`)) || isAnyParentCollapsed;
|
const hidden = levelOrder.some((level) => member[level] && collapsedUnits.has(`${level}_${member[level].trim()}`)) || isAnyParentCollapsed;
|
||||||
const rowDragAttr = isAdmin ? `draggable="true" ondragstart="handleListDragStart(event, ${index})" ondragover="event.preventDefault()" ondrop="handleListDrop(event, ${index})"` : '';
|
const rowDragAttr = editable ? `draggable="true" ondragstart="handleListDragStart(event, ${index})" ondragover="event.preventDefault()" ondrop="handleListDrop(event, ${index})"` : '';
|
||||||
|
const actionCell = editable
|
||||||
|
? `<div class="flex gap-1 justify-center"><span class="list-action-btn btn-edit" onclick="openModal('${member._id}')">수정</span><span class="list-action-btn btn-delete" onclick="deleteMember('${member._id}')">삭제</span></div>`
|
||||||
|
: inspectable
|
||||||
|
? `<span class="list-action-btn btn-edit bg-indigo-50 text-indigo-600 border border-indigo-100" onclick="openModal('${member._id}')">조회</span>`
|
||||||
|
: '<span class="text-slate-300">-</span>';
|
||||||
html += `
|
html += `
|
||||||
<tr id="list-row-${member._id}" ${rowDragAttr} class="${hidden ? 'hidden-row' : ''}">
|
<tr id="list-row-${member._id}" ${rowDragAttr} class="${hidden ? 'hidden-row' : ''}">
|
||||||
${isAdmin ? '<td class="text-slate-300 cursor-move">☰</td>' : ''}
|
${editable ? '<td class="text-slate-300 cursor-move">☰</td>' : ''}
|
||||||
<td class="font-black text-slate-700">${member['이름']}</td>
|
<td class="font-black text-slate-700">${escapeHtml(member['이름'] || '-')}</td>
|
||||||
<td>${member['직급'] || '-'}</td>
|
<td>${escapeHtml(member['직급'] || '-')}</td>
|
||||||
<td>${member['근무상태'] === '휴직' ? '<span class="text-red-500 font-black">휴직</span>' : (member['직책'] || '-')}</td>
|
<td>${member['근무상태'] === '휴직' ? '<span class="text-red-500 font-black">휴직</span>' : escapeHtml(member['직책'] || '-')}</td>
|
||||||
<td>${member['셀'] || '-'}</td>
|
<td>${escapeHtml(member['셀'] || '-')}</td>
|
||||||
<td>${member['팀'] || '-'}</td>
|
<td>${escapeHtml(member['팀'] || '-')}</td>
|
||||||
<td>${member['디비전'] || '-'}</td>
|
<td>${escapeHtml(member['디비전'] || '-')}</td>
|
||||||
<td>${member['그룹'] || '-'}</td>
|
<td>${escapeHtml(member['그룹'] || '-')}</td>
|
||||||
<td>${member['부서'] || '-'}</td>
|
<td>${escapeHtml(member['부서'] || '-')}</td>
|
||||||
<td>${member['소속회사'] || '-'}</td>
|
<td>${escapeHtml(member['소속회사'] || '-')}</td>
|
||||||
<td>${isAdmin ? `<div class="flex gap-1 justify-center"><span class="list-action-btn btn-edit" onclick="openModal('${member._id}')">수정</span><span class="list-action-btn btn-delete" onclick="deleteMember('${member._id}')">삭제</span></div>` : `<span class="list-action-btn btn-edit bg-indigo-50 text-indigo-600 border border-indigo-100" onclick="openModal('${member._id}')">조회</span>`}</td>
|
<td>${actionCell}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
@@ -1472,15 +1656,14 @@ function handleListSearch(value) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.querySelectorAll('.list-search-target').forEach((element) => element.classList.remove('list-search-target'));
|
document.querySelectorAll('.list-search-target').forEach((element) => element.classList.remove('list-search-target'));
|
||||||
const targetMember = editingMembers.find((member) => (
|
const targetEntry = getListSearchEntries().find((entry) => (
|
||||||
(member['이름'] || '').toLowerCase().includes(query)
|
entry.values.some((candidate) => String(candidate || '').toLowerCase().includes(query))
|
||||||
|| levelOrder.some((level) => (member[level] || '').toLowerCase().includes(query))
|
|
||||||
));
|
));
|
||||||
if (!targetMember) {
|
if (!targetEntry) {
|
||||||
alert('검색 결과가 없습니다.');
|
alert('검색 결과가 없습니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const row = document.getElementById(`list-row-${targetMember._id}`);
|
const row = document.getElementById(targetEntry.rowId);
|
||||||
if (row) {
|
if (row) {
|
||||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
row.classList.add('list-search-target');
|
row.classList.add('list-search-target');
|
||||||
|
|||||||
Reference in New Issue
Block a user