feat: implement P-WAR system analysis and inquiries sorting functionality
This commit is contained in:
Binary file not shown.
122
js/analysis.js
Normal file
122
js/analysis.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Project Master Analysis JS
|
||||||
|
* P-WAR (Project Performance Above Replacement) 분석 엔진
|
||||||
|
*/
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
console.log("Analysis engine initialized...");
|
||||||
|
loadPWarData();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadPWarData() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/analysis/p-war');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.error) throw new Error(data.error);
|
||||||
|
|
||||||
|
updateSummaryMetrics(data);
|
||||||
|
renderPWarLeaderboard(data);
|
||||||
|
renderRiskSignals(data);
|
||||||
|
|
||||||
|
// 시스템 평균 정보 표시
|
||||||
|
if (data.length > 0 && data[0].avg_info) {
|
||||||
|
const avg = data[0].avg_info;
|
||||||
|
document.getElementById('avg-system-info').textContent =
|
||||||
|
`* 0.0 = 시스템 평균 (파일 ${avg.avg_files.toLocaleString()}개 / 방치 ${avg.avg_stagnant}일 / 리스크 ${avg.avg_risk}건)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("분석 데이터 로딩 실패:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSummaryMetrics(data) {
|
||||||
|
// 1. 평균 P-WAR 산출
|
||||||
|
const avgPWar = data.reduce((acc, cur) => acc + cur.p_war, 0) / data.length;
|
||||||
|
document.querySelector('.metric-card.sra .value').textContent = avgPWar.toFixed(2);
|
||||||
|
|
||||||
|
// 2. 고위험 좀비 프로젝트 비율 (P-WAR < -1.0 기준)
|
||||||
|
const zombieCount = data.filter(p => p.p_war < -1.0).length;
|
||||||
|
const zombieRate = (zombieCount / data.length) * 100;
|
||||||
|
document.querySelector('.metric-card.stability .value').textContent = `${zombieRate.toFixed(1)}%`;
|
||||||
|
|
||||||
|
// 3. 총 활성 리소스 규모
|
||||||
|
const totalActiveFiles = data.filter(p => p.p_war > 0).reduce((acc, cur) => acc + cur.file_count, 0);
|
||||||
|
document.querySelector('.metric-card.piso .value').textContent = (totalActiveFiles / 1000).toFixed(1) + "k";
|
||||||
|
|
||||||
|
// 4. 방치 리스크 총합
|
||||||
|
const totalRisks = data.reduce((acc, cur) => acc + cur.risk_count, 0);
|
||||||
|
document.querySelector('.metric-card.iwar .value').textContent = totalRisks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPWarLeaderboard(data) {
|
||||||
|
const container = document.querySelector('.timeline-analysis .card-body');
|
||||||
|
|
||||||
|
const sortedData = [...data].sort((a, b) => b.p_war - a.p_war);
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="table-scroll-wrapper">
|
||||||
|
<table class="data-table p-war-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="position: sticky; top: 0; z-index: 10; width: 250px;">프로젝트명</th>
|
||||||
|
<th style="position: sticky; top: 0; z-index: 10; width: 140px;">관리 상태</th>
|
||||||
|
<th style="position: sticky; top: 0; z-index: 10;">파일 수</th>
|
||||||
|
<th style="position: sticky; top: 0; z-index: 10;">방치일</th>
|
||||||
|
<th style="position: sticky; top: 0; z-index: 10;">미결리스크</th>
|
||||||
|
<th style="position: sticky; top: 0; z-index: 10;">P-WAR (기여도)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${sortedData.map(p => {
|
||||||
|
let statusBadge = "";
|
||||||
|
if (p.is_auto_delete) {
|
||||||
|
statusBadge = '<span class="badge-system">잠김예정 프로젝트</span>';
|
||||||
|
} else if (p.p_war > 0) {
|
||||||
|
statusBadge = '<span class="badge-active">운영 중</span>';
|
||||||
|
} else if (p.p_war <= -0.3) {
|
||||||
|
statusBadge = '<span class="badge-danger">방치-삭제대상</span>';
|
||||||
|
} else {
|
||||||
|
statusBadge = '<span class="badge-warning">위험군</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="${p.is_auto_delete || p.p_war <= -0.3 ? 'row-danger' : p.p_war < 0 ? 'row-warning' : ''}">
|
||||||
|
<td class="font-bold">${p.project_nm}</td>
|
||||||
|
<td>${statusBadge}</td>
|
||||||
|
<td>${p.file_count.toLocaleString()}개</td>
|
||||||
|
<td>${p.days_stagnant}일</td>
|
||||||
|
<td>${p.risk_count}건</td>
|
||||||
|
<td class="p-war-value ${p.p_war >= 0 ? 'text-plus' : 'text-minus'}">
|
||||||
|
${p.p_war > 0 ? '+' : ''}${p.p_war}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRiskSignals(data) {
|
||||||
|
const container = document.querySelector('.risk-signal-list');
|
||||||
|
|
||||||
|
// 1. 시스템 삭제(잠김예정) 프로젝트 우선 추출
|
||||||
|
const autoDeleted = data.filter(p => p.is_auto_delete).slice(0, 3);
|
||||||
|
// 2. 그 외 P-WAR가 낮은 순(음수)으로 추출
|
||||||
|
const highRiskProjects = data.filter(p => p.p_war < -1.0 && !p.is_auto_delete).slice(0, 5 - autoDeleted.length);
|
||||||
|
|
||||||
|
const combined = [...autoDeleted, ...highRiskProjects];
|
||||||
|
|
||||||
|
container.innerHTML = combined.map(p => `
|
||||||
|
<div class="risk-item high">
|
||||||
|
<div class="risk-project">${p.project_nm} (${p.master})</div>
|
||||||
|
<div class="risk-reason">
|
||||||
|
${p.is_auto_delete ? '[잠김예정] 활동 부재로 인한 시스템 자동 삭제 발생' : `P-WAR ${p.p_war} (대체 수준 이하 정체)`}
|
||||||
|
</div>
|
||||||
|
<div class="risk-status">위험</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// --- 초기화 ---
|
// --- 초기화 ---
|
||||||
|
let allInquiries = [];
|
||||||
|
let currentSort = { field: 'no', direction: 'desc' };
|
||||||
|
|
||||||
async function loadInquiries() {
|
async function loadInquiries() {
|
||||||
initStickyHeader();
|
initStickyHeader();
|
||||||
|
|
||||||
const pmType = document.getElementById('filterPmType').value;
|
const pmType = document.getElementById('filterPmType').value;
|
||||||
const category = document.getElementById('filterCategory').value;
|
const category = document.getElementById('filterCategory').value;
|
||||||
const status = document.getElementById('filterStatus').value;
|
|
||||||
const keyword = document.getElementById('searchKeyword').value;
|
const keyword = document.getElementById('searchKeyword').value;
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -20,17 +22,87 @@ async function loadInquiries() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API.INQUIRIES}?${params}`);
|
const response = await fetch(`${API.INQUIRIES}?${params}`);
|
||||||
const data = await response.json();
|
allInquiries = await response.json();
|
||||||
|
|
||||||
updateStats(data);
|
refreshInquiryBoard();
|
||||||
|
|
||||||
const filteredData = status ? data.filter(item => item.status === status) : data;
|
|
||||||
renderInquiryList(filteredData);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("데이터 로딩 중 오류 발생:", e);
|
console.error("데이터 로딩 중 오류 발생:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshInquiryBoard() {
|
||||||
|
const status = document.getElementById('filterStatus').value;
|
||||||
|
|
||||||
|
// 1. 상태 필터링
|
||||||
|
let filteredData = status ? allInquiries.filter(item => item.status === status) : [...allInquiries];
|
||||||
|
|
||||||
|
// 2. 정렬 적용
|
||||||
|
filteredData = sortData(filteredData);
|
||||||
|
|
||||||
|
// 3. 통계 및 리스트 렌더링
|
||||||
|
updateStats(allInquiries);
|
||||||
|
updateSortUI();
|
||||||
|
renderInquiryList(filteredData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSort(field) {
|
||||||
|
if (currentSort.field === field) {
|
||||||
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
currentSort.field = field;
|
||||||
|
currentSort.direction = 'asc';
|
||||||
|
}
|
||||||
|
refreshInquiryBoard();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortData(data) {
|
||||||
|
const { field, direction } = currentSort;
|
||||||
|
const modifier = direction === 'asc' ? 1 : -1;
|
||||||
|
|
||||||
|
return data.sort((a, b) => {
|
||||||
|
let valA = a[field];
|
||||||
|
let valB = b[field];
|
||||||
|
|
||||||
|
// 숫자형 변환 시도 (No 필드 등)
|
||||||
|
if (field === 'no' || !isNaN(valA)) {
|
||||||
|
valA = Number(valA);
|
||||||
|
valB = Number(valB);
|
||||||
|
}
|
||||||
|
|
||||||
|
// null/undefined 처리
|
||||||
|
if (valA === null || valA === undefined) valA = "";
|
||||||
|
if (valB === null || valB === undefined) valB = "";
|
||||||
|
|
||||||
|
if (valA < valB) return -1 * modifier;
|
||||||
|
if (valA > valB) return 1 * modifier;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSortUI() {
|
||||||
|
// 모든 헤더 클래스 및 아이콘 초기화
|
||||||
|
document.querySelectorAll('.inquiry-table thead th.sortable').forEach(th => {
|
||||||
|
th.classList.remove('active-sort');
|
||||||
|
const icon = th.querySelector('.sort-icon');
|
||||||
|
if (icon) {
|
||||||
|
// 레이아웃 시프트 방지를 위해 투명한 기본 아이콘(또는 공백) 유지
|
||||||
|
icon.textContent = "▲";
|
||||||
|
icon.style.opacity = "0";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 현재 정렬된 헤더 강조 및 아이콘 표시
|
||||||
|
const activeTh = document.querySelector(`.inquiry-table thead th[onclick*="'${currentSort.field}'"]`);
|
||||||
|
if (activeTh) {
|
||||||
|
activeTh.classList.add('active-sort');
|
||||||
|
const icon = activeTh.querySelector('.sort-icon');
|
||||||
|
if (icon) {
|
||||||
|
icon.textContent = currentSort.direction === 'asc' ? "▲" : "▼";
|
||||||
|
icon.style.opacity = "1";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initStickyHeader() {
|
function initStickyHeader() {
|
||||||
const header = document.getElementById('stickyHeader');
|
const header = document.getElementById('stickyHeader');
|
||||||
const thead = document.querySelector('.inquiry-table thead');
|
const thead = document.querySelector('.inquiry-table thead');
|
||||||
|
|||||||
93
server.py
93
server.py
@@ -76,6 +76,10 @@ async def get_mail_test(request: Request):
|
|||||||
async def get_inquiries_page(request: Request):
|
async def get_inquiries_page(request: Request):
|
||||||
return templates.TemplateResponse("inquiries.html", {"request": request})
|
return templates.TemplateResponse("inquiries.html", {"request": request})
|
||||||
|
|
||||||
|
@app.get("/analysis")
|
||||||
|
async def get_analysis_page(request: Request):
|
||||||
|
return templates.TemplateResponse("analysis.html", {"request": request})
|
||||||
|
|
||||||
class InquiryReplyRequest(BaseModel):
|
class InquiryReplyRequest(BaseModel):
|
||||||
reply: str
|
reply: str
|
||||||
status: str
|
status: str
|
||||||
@@ -251,6 +255,95 @@ async def stop_sync():
|
|||||||
crawl_stop_event.set()
|
crawl_stop_event.set()
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
@app.get("/api/analysis/p-war")
|
||||||
|
async def get_p_war_analysis():
|
||||||
|
"""P-WAR(Project Performance Above Replacement) 분석 API - 실제 평균 기반"""
|
||||||
|
try:
|
||||||
|
with get_db_connection() as conn:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
|
||||||
|
last_date = cursor.fetchone()['last_date']
|
||||||
|
|
||||||
|
cursor.execute(DashboardQueries.GET_PROJECT_LIST, (last_date,))
|
||||||
|
projects = cursor.fetchall()
|
||||||
|
|
||||||
|
cursor.execute("SELECT project_nm, COUNT(*) as cnt FROM inquiries WHERE status != '완료' GROUP BY project_nm")
|
||||||
|
inquiry_risks = {row['project_nm']: row['cnt'] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
import math
|
||||||
|
temp_data = []
|
||||||
|
total_files = 0
|
||||||
|
total_stagnant = 0
|
||||||
|
total_risk = 0
|
||||||
|
count = len(projects)
|
||||||
|
|
||||||
|
if count == 0: return []
|
||||||
|
|
||||||
|
# 1. 1차 순회: 전체 합계 계산 (평균 산출용)
|
||||||
|
for p in projects:
|
||||||
|
file_count = int(p['file_count']) if p['file_count'] else 0
|
||||||
|
log = p['recent_log']
|
||||||
|
|
||||||
|
days_stagnant = 10
|
||||||
|
if log and log != "데이터 없음":
|
||||||
|
match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log)
|
||||||
|
if match:
|
||||||
|
log_date = datetime.strptime(match.group(0), "%Y.%m.%d").date()
|
||||||
|
days_stagnant = (last_date - log_date).days
|
||||||
|
|
||||||
|
risk_count = inquiry_risks.get(p['project_nm'], 0)
|
||||||
|
|
||||||
|
total_files += file_count
|
||||||
|
total_stagnant += days_stagnant
|
||||||
|
total_risk += risk_count
|
||||||
|
temp_data.append((p, file_count, days_stagnant, risk_count))
|
||||||
|
|
||||||
|
# 2. 시스템 실제 평균(Mean) 산출
|
||||||
|
avg_files = total_files / count
|
||||||
|
avg_stagnant = 5 # 사용자 요청에 따라 방치 기준을 5일로 강제 고정 (엄격한 판정)
|
||||||
|
avg_risk = total_risk / count
|
||||||
|
|
||||||
|
# 3. 평균 수준의 프로젝트 가치(V_avg) 정의
|
||||||
|
v_rep = ( (1 / (1 + avg_stagnant)) * math.log10(avg_files + 1) ) - (avg_risk * 0.5)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
# 4. 2차 순회: P-WAR 산출 (개별 가치 - 평균 가치)
|
||||||
|
for p, f_cnt, d_stg, r_cnt in temp_data:
|
||||||
|
name = p['short_nm'] or p['project_nm']
|
||||||
|
log = p['recent_log'] or ""
|
||||||
|
is_auto_delete = "폴더자동삭제" in log.replace(" ", "")
|
||||||
|
|
||||||
|
activity_factor = 1 / (1 + d_stg)
|
||||||
|
scale_factor = math.log10(f_cnt + 1)
|
||||||
|
v_project = (activity_factor * scale_factor) - (r_cnt * 0.5)
|
||||||
|
|
||||||
|
# [추가] 폴더 자동 삭제 페널티 부여 (실질적 관리 부재)
|
||||||
|
if is_auto_delete:
|
||||||
|
v_project -= 1.5
|
||||||
|
|
||||||
|
p_war = v_project - v_rep
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"project_nm": name,
|
||||||
|
"file_count": f_cnt,
|
||||||
|
"days_stagnant": d_stg,
|
||||||
|
"risk_count": r_cnt,
|
||||||
|
"p_war": round(p_war, 3),
|
||||||
|
"is_auto_delete": is_auto_delete,
|
||||||
|
"master": p['master'],
|
||||||
|
"dept": p['department'],
|
||||||
|
"avg_info": {
|
||||||
|
"avg_files": round(avg_files, 1),
|
||||||
|
"avg_stagnant": round(avg_stagnant, 1),
|
||||||
|
"avg_risk": round(avg_risk, 2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
results.sort(key=lambda x: x['p_war'])
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
@app.get("/attachments")
|
@app.get("/attachments")
|
||||||
async def get_attachments():
|
async def get_attachments():
|
||||||
path = "sample"
|
path = "sample"
|
||||||
|
|||||||
301
style/analysis.css
Normal file
301
style/analysis.css
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
/* Analysis Page Styles */
|
||||||
|
|
||||||
|
.analysis-content {
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: var(--topbar-h, 36px) auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 10px 0 30px 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--ai-color, linear-gradient(135deg, #6366f1 0%, #a855f7 100%));
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-header h2 { font-size: 24px; font-weight: 800; color: #111; margin: 0; }
|
||||||
|
.analysis-header p { font-size: 13px; color: #666; margin-top: 6px; }
|
||||||
|
|
||||||
|
.btn-refresh {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-refresh:hover { background: #f8f9fa; border-color: #bbb; }
|
||||||
|
|
||||||
|
/* 1. Metrics Grid */
|
||||||
|
.metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
background: #fff;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #eef0f2;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.04);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card .label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #888;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
position: relative; /* 툴팁 배치를 위해 추가 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 툴팁 스타일 추가 */
|
||||||
|
.metric-card .label:hover::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 0;
|
||||||
|
width: 220px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #1e293b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 10;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card .label:hover::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 20px;
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-top-color: #1e293b;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.info-icon { width: 14px; height: 14px; border-radius: 50%; background: #eee; display: inline-flex; align-items: center; justify-content: center; font-size: 9px; cursor: help; font-style: normal; }
|
||||||
|
|
||||||
|
.metric-card .value { font-size: 32px; font-weight: 800; color: #1e5149; margin: 0; }
|
||||||
|
|
||||||
|
.trend { font-size: 11px; font-weight: 700; }
|
||||||
|
.trend.up { color: #d32f2f; }
|
||||||
|
.trend.down { color: #1976d2; }
|
||||||
|
.trend.steady { color: #666; }
|
||||||
|
|
||||||
|
/* 2. Main Grid Layout */
|
||||||
|
.analysis-main-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #eef2f6;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.04);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h4 { margin: 0; font-size: 15px; font-weight: 700; color: #334155; }
|
||||||
|
|
||||||
|
.card-body { padding: 24px; }
|
||||||
|
|
||||||
|
/* 테이블 스크롤 래퍼 */
|
||||||
|
.table-scroll-wrapper {
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #eef2f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 스크롤바 커스텀 */
|
||||||
|
.table-scroll-wrapper::-webkit-scrollbar { width: 6px; }
|
||||||
|
.table-scroll-wrapper::-webkit-scrollbar-track { background: #f8fafc; }
|
||||||
|
.table-scroll-wrapper::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
||||||
|
.table-scroll-wrapper::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||||
|
|
||||||
|
.chart-placeholder {
|
||||||
|
height: 300px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px dashed #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* D-WAR 테이블 스타일 추가 */
|
||||||
|
.d-war-table { width: 100%; border-radius: 12px; overflow: hidden; }
|
||||||
|
.d-war-table th { background: #f1f5f9; color: #475569; font-size: 11px; padding: 12px; }
|
||||||
|
.d-war-table td { padding: 14px 12px; border-bottom: 1px solid #f1f5f9; }
|
||||||
|
.d-war-value { font-weight: 800; color: #1e5149; text-align: center; font-size: 15px; }
|
||||||
|
.p-war-value { font-weight: 800; text-align: center; font-size: 15px; }
|
||||||
|
.text-plus { color: #1d4ed8; }
|
||||||
|
.text-minus { color: #dc2626; }
|
||||||
|
|
||||||
|
/* 관리 상태 배지 스타일 */
|
||||||
|
.badge-system {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
border: 1px solid #7f1d1d;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-active {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #166534;
|
||||||
|
border: 1px solid #dcfce7;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
border: 1px solid #fef3c7;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #991b1b;
|
||||||
|
border: 1px solid #fee2e2;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 행 강조 스타일 수정 */
|
||||||
|
.row-danger { background: #fff1f2 !important; }
|
||||||
|
.row-warning { background: #fffaf0 !important; }
|
||||||
|
.row-success { background: #f0fdf4 !important; }
|
||||||
|
|
||||||
|
.font-bold { font-weight: 700; }
|
||||||
|
|
||||||
|
/* P-WAR 가이드 스타일 */
|
||||||
|
.d-war-guide {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-item {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-item span {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-low span { background: #2563eb; }
|
||||||
|
.warning-mid span { background: #22c55e; }
|
||||||
|
.danger-high span { background: #f59e0b; }
|
||||||
|
.hazard-critical span { background: #ef4444; }
|
||||||
|
|
||||||
|
/* 3. Risk Signal List */
|
||||||
|
.risk-signal-list { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
.risk-item {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 40px;
|
||||||
|
gap: 4px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-project { font-size: 13px; font-weight: 700; color: #1e293b; }
|
||||||
|
.risk-reason { font-size: 11px; color: #64748b; margin-top: 4px; }
|
||||||
|
.risk-status {
|
||||||
|
grid-row: span 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-item.high { background: #fff1f2; border-left: 4px solid #f43f5e; }
|
||||||
|
.risk-item.high .risk-status { color: #f43f5e; }
|
||||||
|
.risk-item.warning { background: #fffbeb; border-left: 4px solid #f59e0b; }
|
||||||
|
.risk-item.warning .risk-status { color: #f59e0b; }
|
||||||
|
.risk-item.safe { background: #f0fdf4; border-left: 4px solid #22c55e; }
|
||||||
|
.risk-item.safe .risk-status { color: #22c55e; }
|
||||||
|
|
||||||
|
/* 4. Factor Section */
|
||||||
|
.factor-grid { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.factor-item { display: grid; grid-template-columns: 200px 1fr 60px; align-items: center; gap: 20px; }
|
||||||
|
.factor-name { font-size: 13px; font-weight: 600; color: #475569; }
|
||||||
|
.factor-bar-wrapper { height: 8px; background: #f1f5f9; border-radius: 4px; overflow: hidden; }
|
||||||
|
.factor-bar { height: 100%; background: var(--ai-color, #6366f1); border-radius: 4px; }
|
||||||
|
.factor-value { font-size: 12px; font-weight: 700; color: #1e5149; text-align: right; }
|
||||||
@@ -108,6 +108,42 @@
|
|||||||
z-index: 900;
|
z-index: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 정렬 가능한 헤더 스타일 추가 */
|
||||||
|
.inquiry-table thead th.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inquiry-table thead th.sortable .header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
font-size: 8px;
|
||||||
|
color: #ccc;
|
||||||
|
line-height: 1;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inquiry-table thead th.active-sort {
|
||||||
|
color: #1e5149;
|
||||||
|
background: #f0f7f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inquiry-table thead th.active-sort .sort-icon {
|
||||||
|
color: #1e5149;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.inquiry-table td {
|
.inquiry-table td {
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
136
templates/analysis.html
Normal file
136
templates/analysis.html
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>데이터 분석 - Project Master Sabermetrics</title>
|
||||||
|
<link rel="stylesheet" as="style" crossorigin
|
||||||
|
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
|
||||||
|
<link rel="stylesheet" href="style/common.css">
|
||||||
|
<link rel="stylesheet" href="style/analysis.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="topbar">
|
||||||
|
<div class="topbar-header">
|
||||||
|
<a href="/">
|
||||||
|
<h2>Project Master Test</h2>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<ul class="nav-list">
|
||||||
|
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
|
||||||
|
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
|
||||||
|
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
|
||||||
|
<li class="nav-item active" onclick="location.href='/analysis'">분석</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="analysis-content">
|
||||||
|
<header class="analysis-header">
|
||||||
|
<div class="title-group">
|
||||||
|
<div class="ai-badge">AI Sabermetrics</div>
|
||||||
|
<h2>시스템 운영 빅데이터 분석</h2>
|
||||||
|
<p>수집된 활동 로그 및 문의사항 데이터를 기반으로 한 통계적 성능 지표 (Beta)</p>
|
||||||
|
</div>
|
||||||
|
<div class="analysis-actions">
|
||||||
|
<button class="btn-refresh" onclick="location.reload()">데이터 갱신</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 핵심 세이버메트릭스 지표 요약 -->
|
||||||
|
<section class="metrics-grid">
|
||||||
|
<div class="metric-card sra">
|
||||||
|
<div class="metric-info">
|
||||||
|
<span class="label" data-tooltip="Avg. P-WAR Score: 시스템 내 모든 프로젝트의 평균 기여도입니다. 양수(+)가 높을수록 시스템이 활발하게 운영되고 있음을 의미합니다.">평균 P-WAR (기여도) <i class="info-icon">?</i></span>
|
||||||
|
<h3 class="value">0.00</h3>
|
||||||
|
<span class="trend up">대체 수준(0.0) 대비</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-chart-mini" id="sraChart"></div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card iwar">
|
||||||
|
<div class="metric-info">
|
||||||
|
<span class="label" data-tooltip="Total Pending Risks: 현재 해결되지 않고 방치된 문의사항의 총합입니다. P-WAR 감점 요인입니다.">미결 리스크 총합 <i class="info-icon">?</i></span>
|
||||||
|
<h3 class="value">0</h3>
|
||||||
|
<span class="trend steady">실시간 집계</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-chart-mini" id="iwarChart"></div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card piso">
|
||||||
|
<div class="metric-info">
|
||||||
|
<span class="label" data-tooltip="Active Resource Scale: P-WAR가 양수(+)인 활성 프로젝트들이 관리 중인 총 파일 규모입니다.">활성 자원 규모 <i class="info-icon">?</i></span>
|
||||||
|
<h3 class="value">0</h3>
|
||||||
|
<span class="trend up">시스템 기여 자원</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-chart-mini" id="pisoChart"></div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card stability">
|
||||||
|
<div class="metric-info">
|
||||||
|
<span class="label" data-tooltip="Zombie Project Rate: P-WAR 점수가 -1.0 이하인 '대체 수준 미달' 프로젝트의 비중입니다.">좀비 프로젝트 비율 <i class="info-icon">?</i></span>
|
||||||
|
<h3 class="value">0%</h3>
|
||||||
|
<span class="trend steady">집중 관리 대상</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-chart-mini" id="stabilityChart"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 메인 분석 영역 -->
|
||||||
|
<div class="analysis-main-grid">
|
||||||
|
<!-- P-WAR 분석 테이블 -->
|
||||||
|
<div class="analysis-card timeline-analysis">
|
||||||
|
<div class="card-header">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<h4>Project Performance Above Replacement (P-WAR Ranking)</h4>
|
||||||
|
<p style="font-size: 11px; color: #888; margin: 0;">대체 수준(Replacement Level) 프로젝트 대비 기여도를 측정합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-tools">
|
||||||
|
<span id="avg-system-info" style="font-size: 11px; color: #888;">* 0.0 = 시스템 평균 계산 중...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- P-WAR 판정 가이드 범례 수정 -->
|
||||||
|
<div class="d-war-guide">
|
||||||
|
<div class="guide-item active-low"><span>양수(+)</span> 운영 중</div>
|
||||||
|
<div class="guide-item warning-mid"><span>음수(-)</span> 위험군</div>
|
||||||
|
<div class="guide-item danger-high"><span>-0.3 이하</span> 방치-삭제대상</div>
|
||||||
|
<div class="guide-item hazard-critical"><span>시스템삭제</span> 잠김예정 프로젝트</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-placeholder">
|
||||||
|
<p>R-Engine 시각화 대기 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 위험 신호 및 예측 -->
|
||||||
|
<div class="analysis-card risk-prediction">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4>Deep Learning 기반 장애 예보 (Risk Signal)</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="risk-signal-list">
|
||||||
|
<div class="risk-item high">
|
||||||
|
<div class="risk-project">프로젝트 A (해외/중동)</div>
|
||||||
|
<div class="risk-reason">파일 급증 대비 활동 정체 (P-ISO 급락)</div>
|
||||||
|
<div class="risk-status">위험</div>
|
||||||
|
</div>
|
||||||
|
<div class="risk-item warning">
|
||||||
|
<div class="risk-project">프로젝트 B (기술개발)</div>
|
||||||
|
<div class="risk-reason">특정 환경(IE/Edge) 문의 집중 발생</div>
|
||||||
|
<div class="risk-status">주의</div>
|
||||||
|
</div>
|
||||||
|
<div class="risk-item safe">
|
||||||
|
<div class="risk-project">프로젝트 C (국내/장헌)</div>
|
||||||
|
<div class="risk-reason">로그 활동성 및 해결률 안정적 유지</div>
|
||||||
|
<div class="risk-status">안전</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="js/common.js"></script>
|
||||||
|
<script src="js/analysis.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -22,10 +22,8 @@
|
|||||||
<ul class="nav-list">
|
<ul class="nav-list">
|
||||||
<li class="nav-item active" onclick="location.href='/dashboard'">대시보드</li>
|
<li class="nav-item active" onclick="location.href='/dashboard'">대시보드</li>
|
||||||
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
|
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
|
||||||
<li class="nav-item" onclick="alert('준비 중입니다.')">로그관리</li>
|
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
|
||||||
<li class="nav-item" onclick="alert('준비 중입니다.')">파일관리</li>
|
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
|
||||||
<li class="nav-item" onclick="alert('준비 중입니다.')">인원관리</li>
|
|
||||||
<li class="nav-item" onclick="alert('준비 중입니다.')">공지사항</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,8 @@
|
|||||||
<ul class="nav-list">
|
<ul class="nav-list">
|
||||||
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
|
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
|
||||||
<li class="nav-item active" onclick="location.href='/inquiries'">문의사항</li>
|
<li class="nav-item active" onclick="location.href='/inquiries'">문의사항</li>
|
||||||
<li class="nav-item" onclick="alert('준비 중입니다.')">로그관리</li>
|
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
|
||||||
<li class="nav-item" onclick="alert('준비 중입니다.')">파일관리</li>
|
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
|
||||||
<li class="nav-item" onclick="alert('준비 중입니다.')">인원관리</li>
|
|
||||||
<li class="nav-item" onclick="alert('준비 중입니다.')">공지사항</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -136,17 +134,33 @@
|
|||||||
<table class="inquiry-table">
|
<table class="inquiry-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th width="50">No</th>
|
<th width="50" class="sortable" onclick="handleSort('no')">
|
||||||
|
<div class="header-content">No <span id="sort-no" class="sort-icon">▼</span></div>
|
||||||
|
</th>
|
||||||
<th width="80">이미지</th>
|
<th width="80">이미지</th>
|
||||||
<th width="120">PM 종류</th>
|
<th width="120" class="sortable" onclick="handleSort('pm_type')">
|
||||||
<th width="100">환경</th>
|
<div class="header-content">PM 종류 <span id="sort-pm_type" class="sort-icon"></span></div>
|
||||||
<th width="150">구분</th>
|
</th>
|
||||||
<th>프로젝트</th>
|
<th width="100" class="sortable" onclick="handleSort('browser')">
|
||||||
|
<div class="header-content">환경 <span id="sort-browser" class="sort-icon"></span></div>
|
||||||
|
</th>
|
||||||
|
<th width="150" class="sortable" onclick="handleSort('category')">
|
||||||
|
<div class="header-content">구분 <span id="sort-category" class="sort-icon"></span></div>
|
||||||
|
</th>
|
||||||
|
<th class="sortable" onclick="handleSort('project_nm')">
|
||||||
|
<div class="header-content">프로젝트 <span id="sort-project_nm" class="sort-icon"></span></div>
|
||||||
|
</th>
|
||||||
<th width="400">문의내용</th>
|
<th width="400">문의내용</th>
|
||||||
<th width="100">작성자</th>
|
<th width="100" class="sortable" onclick="handleSort('author')">
|
||||||
<th width="120">날짜</th>
|
<div class="header-content">작성자 <span id="sort-author" class="sort-icon"></span></div>
|
||||||
|
</th>
|
||||||
|
<th width="120" class="sortable" onclick="handleSort('reg_date')">
|
||||||
|
<div class="header-content">날짜 <span id="sort-reg_date" class="sort-icon"></span></div>
|
||||||
|
</th>
|
||||||
<th width="400">답변내용</th>
|
<th width="400">답변내용</th>
|
||||||
<th width="100">상태</th>
|
<th width="100" class="sortable" onclick="handleSort('status')">
|
||||||
|
<div class="header-content">상태 <span id="sort-status" class="sort-icon"></span></div>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="inquiryList">
|
<tbody id="inquiryList">
|
||||||
|
|||||||
@@ -22,9 +22,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<ul class="nav-list">
|
<ul class="nav-list">
|
||||||
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
|
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
|
||||||
|
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
|
||||||
<li class="nav-item active" onclick="location.href='/mailTest'">메일관리</li>
|
<li class="nav-item active" onclick="location.href='/mailTest'">메일관리</li>
|
||||||
<li class="nav-item">로그관리</li>
|
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
|
||||||
<li class="nav-item">파일관리</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user