Refine process cost navigation and unify section layouts
This commit is contained in:
@@ -4473,6 +4473,54 @@ def save_project_quick_links(session_id: str | None, codes: list[str]) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_process_cost_quick_links() -> list[str]:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT support_dept_code
|
||||||
|
FROM project_quick_links
|
||||||
|
WHERE page_key = 'process_cost'
|
||||||
|
ORDER BY sort_order, updated_at DESC, support_dept_code
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).mappings().all()
|
||||||
|
return [normalize_text(row["support_dept_code"]) for row in rows if normalize_text(row["support_dept_code"])]
|
||||||
|
|
||||||
|
|
||||||
|
def save_process_cost_quick_links(codes: list[str]) -> None:
|
||||||
|
normalized_codes: list[str] = []
|
||||||
|
for code in codes:
|
||||||
|
normalized_code = normalize_text(code)
|
||||||
|
if normalized_code and normalized_code not in normalized_codes:
|
||||||
|
normalized_codes.append(normalized_code)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
DELETE FROM project_quick_links
|
||||||
|
WHERE page_key = 'process_cost'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for sort_order, support_dept_code in enumerate(normalized_codes):
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO project_quick_links (
|
||||||
|
page_key, support_dept_code, sort_order, updated_at
|
||||||
|
) VALUES (
|
||||||
|
'process_cost', :support_dept_code, :sort_order, CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"support_dept_code": support_dept_code,
|
||||||
|
"sort_order": sort_order,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_project_uncontracted_classification_map() -> dict[str, str]:
|
def get_project_uncontracted_classification_map() -> dict[str, str]:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -5332,34 +5380,53 @@ def get_process_cost_related_codes(support_dept_code: str | None) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def get_process_cost_available_years(source: str) -> list[int]:
|
def get_process_cost_available_years(source: str) -> list[int]:
|
||||||
normalized_source = normalize_text(source).lower()
|
contract_meta = _get_project_contract_meta()
|
||||||
if normalized_source == "wehago":
|
detected_years = {
|
||||||
init_wehago_compare_db(engine)
|
year
|
||||||
with engine.begin() as conn:
|
for code in contract_meta.keys()
|
||||||
rows = conn.execute(
|
for year in [_extract_year_from_project_code(code)]
|
||||||
text(
|
if year is not None
|
||||||
"""
|
}
|
||||||
SELECT DISTINCT fiscal_year
|
max_year = max(detected_years) if detected_years else datetime.now().year
|
||||||
FROM wehago_voucher_rows
|
min_year = 1994
|
||||||
WHERE fiscal_year IS NOT NULL
|
if max_year < min_year:
|
||||||
ORDER BY fiscal_year
|
max_year = min_year
|
||||||
"""
|
return list(range(min_year, max_year + 1))
|
||||||
)
|
|
||||||
).fetchall()
|
|
||||||
return [int(row[0]) for row in rows if row and row[0] is not None]
|
|
||||||
|
|
||||||
with engine.begin() as conn:
|
|
||||||
rows = conn.execute(
|
def _extract_year_from_project_code(value: Any) -> int | None:
|
||||||
text(
|
code = normalize_text(value).upper()
|
||||||
"""
|
if len(code) < 3:
|
||||||
SELECT DISTINCT year
|
return None
|
||||||
FROM transactions
|
digits = "".join(ch for ch in code if ch.isdigit())
|
||||||
WHERE year IS NOT NULL
|
if len(digits) < 2:
|
||||||
ORDER BY year
|
return None
|
||||||
"""
|
year_2d = digits[:2]
|
||||||
)
|
if not year_2d.isdigit():
|
||||||
).fetchall()
|
return None
|
||||||
return [int(row[0]) for row in rows if row and row[0] is not None]
|
year_value = int(year_2d)
|
||||||
|
if year_value >= 94:
|
||||||
|
return 1900 + year_value
|
||||||
|
return 2000 + year_value
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_process_cost_project_options(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
def sort_key(item: dict[str, Any]) -> str:
|
||||||
|
return normalize_text(item.get("support_dept_code")).upper()
|
||||||
|
|
||||||
|
return sorted(items, key=sort_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_process_cost_project_kind(code: Any) -> tuple[str, str]:
|
||||||
|
normalized = normalize_text(code).upper()
|
||||||
|
prefix = normalized[:1]
|
||||||
|
if prefix == "X":
|
||||||
|
return "X", "사전사업"
|
||||||
|
if prefix == "Y":
|
||||||
|
return "Y", "설계"
|
||||||
|
if prefix == "Z":
|
||||||
|
return "Z", "감리"
|
||||||
|
return "", "기타"
|
||||||
|
|
||||||
|
|
||||||
def _get_project_contract_meta() -> dict[str, dict[str, Any]]:
|
def _get_project_contract_meta() -> dict[str, dict[str, Any]]:
|
||||||
@@ -5385,7 +5452,9 @@ def _get_project_contract_meta() -> dict[str, dict[str, Any]]:
|
|||||||
COALESCE(c.support_dept_name, p.support_dept_name, b.support_dept_name, '') AS support_dept_name,
|
COALESCE(c.support_dept_name, p.support_dept_name, b.support_dept_name, '') AS support_dept_name,
|
||||||
COALESCE(c.hanmac_contract_amount, 0) AS hanmac_contract_amount,
|
COALESCE(c.hanmac_contract_amount, 0) AS hanmac_contract_amount,
|
||||||
COALESCE(c.client_name, '') AS client_name,
|
COALESCE(c.client_name, '') AS client_name,
|
||||||
COALESCE(p.expected_as_cost, b.expected_as_cost, 0) AS expected_as_cost
|
COALESCE(p.expected_as_cost, b.expected_as_cost, 0) AS expected_as_cost,
|
||||||
|
COALESCE(p.expected_sga_budget, b.expected_sga_budget, 0) AS expected_sga_budget,
|
||||||
|
COALESCE(p.project_start_date, b.project_start_date, '') AS project_start_date
|
||||||
FROM code_universe AS u
|
FROM code_universe AS u
|
||||||
LEFT JOIN project_contract_info AS c
|
LEFT JOIN project_contract_info AS c
|
||||||
ON c.support_dept_code = u.support_dept_code
|
ON c.support_dept_code = u.support_dept_code
|
||||||
@@ -5405,12 +5474,117 @@ def _get_project_contract_meta() -> dict[str, dict[str, Any]]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _get_project_actual_sga_summary(codes: list[str]) -> dict[str, dict[str, Any]]:
|
||||||
|
normalized_codes = [normalize_text(code) for code in codes if normalize_text(code)]
|
||||||
|
if not normalized_codes:
|
||||||
|
return {}
|
||||||
|
in_clause, params = build_in_clause("actual_sga_code", normalized_codes)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
text(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
support_dept_code,
|
||||||
|
COALESCE(label, '') AS label,
|
||||||
|
COALESCE(reference, '') AS reference,
|
||||||
|
COALESCE(note, '') AS note,
|
||||||
|
COALESCE(grade, '') AS grade,
|
||||||
|
COALESCE(minutes, '') AS minutes,
|
||||||
|
COALESCE(amount, 0) AS amount
|
||||||
|
FROM project_actual_input_entries
|
||||||
|
WHERE support_dept_code IN ({in_clause})
|
||||||
|
AND COALESCE(group_name, '') = 'sga'
|
||||||
|
ORDER BY position, id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
).mappings().all()
|
||||||
|
|
||||||
|
grouped: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for row in rows:
|
||||||
|
code = normalize_text(row.get("support_dept_code"))
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
grouped.setdefault(code, []).append(dict(row))
|
||||||
|
|
||||||
|
result: dict[str, dict[str, Any]] = {}
|
||||||
|
for code, code_rows in grouped.items():
|
||||||
|
total_amount = sum(normalize_amount(item.get("amount")) for item in code_rows)
|
||||||
|
has_detail_trace = any(
|
||||||
|
normalize_text(item.get("reference"))
|
||||||
|
or normalize_text(item.get("note"))
|
||||||
|
or normalize_text(item.get("grade"))
|
||||||
|
or normalize_text(item.get("minutes"))
|
||||||
|
for item in code_rows
|
||||||
|
)
|
||||||
|
distinct_labels = {
|
||||||
|
normalize_text(item.get("label"))
|
||||||
|
for item in code_rows
|
||||||
|
if normalize_text(item.get("label"))
|
||||||
|
}
|
||||||
|
result[code] = {
|
||||||
|
"rows": code_rows,
|
||||||
|
"amount": total_amount,
|
||||||
|
"has_detail_trace": has_detail_trace,
|
||||||
|
"distinct_labels": distinct_labels,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _get_project_exec_budget_summary(codes: list[str]) -> dict[str, dict[str, float]]:
|
||||||
|
normalized_codes = [normalize_text(code) for code in codes if normalize_text(code)]
|
||||||
|
if not normalized_codes:
|
||||||
|
return {}
|
||||||
|
in_clause, params = build_in_clause("exec_budget_code", normalized_codes)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
text(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
support_dept_code,
|
||||||
|
COALESCE(group_name, '') AS group_name,
|
||||||
|
SUM(COALESCE(amount, 0)) AS amount
|
||||||
|
FROM project_exec_budget_entries
|
||||||
|
WHERE support_dept_code IN ({in_clause})
|
||||||
|
GROUP BY support_dept_code, group_name
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
).mappings().all()
|
||||||
|
|
||||||
|
result: dict[str, dict[str, float]] = {}
|
||||||
|
for row in rows:
|
||||||
|
code = normalize_text(row.get("support_dept_code"))
|
||||||
|
group_name = normalize_text(row.get("group_name"))
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
current = result.setdefault(code, {"labor": 0.0, "outsource": 0.0, "cost_plan": 0.0})
|
||||||
|
current[group_name or "labor"] = normalize_amount(row.get("amount"))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _is_real_project_actual_sga(
|
||||||
|
actual_sga_summary: dict[str, Any] | None,
|
||||||
|
expected_sga_budget: float,
|
||||||
|
) -> bool:
|
||||||
|
if not actual_sga_summary:
|
||||||
|
return False
|
||||||
|
amount = normalize_amount(actual_sga_summary.get("amount"))
|
||||||
|
if amount <= 0:
|
||||||
|
return False
|
||||||
|
if actual_sga_summary.get("has_detail_trace"):
|
||||||
|
return True
|
||||||
|
distinct_labels = set(actual_sga_summary.get("distinct_labels") or [])
|
||||||
|
if len(distinct_labels) > 1:
|
||||||
|
return True
|
||||||
|
if distinct_labels and distinct_labels != {"판관비"}:
|
||||||
|
return True
|
||||||
|
if expected_sga_budget > 0 and abs(amount - expected_sga_budget) <= 0.5:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _get_hanmac_process_cost_tx_by_code(selected_year: int | None) -> dict[str, dict[str, Any]]:
|
def _get_hanmac_process_cost_tx_by_code(selected_year: int | None) -> dict[str, dict[str, Any]]:
|
||||||
year_clause = ""
|
|
||||||
params: dict[str, Any] = {}
|
|
||||||
if selected_year:
|
|
||||||
year_clause = "AND year = :selected_year"
|
|
||||||
params["selected_year"] = selected_year
|
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
text(
|
text(
|
||||||
@@ -5427,11 +5601,10 @@ def _get_hanmac_process_cost_tx_by_code(selected_year: int | None) -> dict[str,
|
|||||||
AND support_dept_code NOT IN ('ZZZZZZ')
|
AND support_dept_code NOT IN ('ZZZZZZ')
|
||||||
AND COALESCE(support_dept_name, '') <> ''
|
AND COALESCE(support_dept_name, '') <> ''
|
||||||
AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실')
|
AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실')
|
||||||
{year_clause}
|
|
||||||
GROUP BY support_dept_code
|
GROUP BY support_dept_code
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
params,
|
{},
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
result: dict[str, dict[str, Any]] = {}
|
result: dict[str, dict[str, Any]] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
@@ -5448,18 +5621,18 @@ def _get_hanmac_process_cost_tx_by_code(selected_year: int | None) -> dict[str,
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def get_process_cost_project_options(source: str, selected_year: int | None, include_related: bool = False) -> list[dict[str, Any]]:
|
def get_process_cost_project_options(
|
||||||
|
source: str,
|
||||||
|
start_year: int | None,
|
||||||
|
end_year: int | None,
|
||||||
|
include_related: bool = False,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
normalized_source = normalize_text(source).lower()
|
normalized_source = normalize_text(source).lower()
|
||||||
contract_meta = _get_project_contract_meta()
|
contract_meta = _get_project_contract_meta()
|
||||||
billing_summary_map = get_project_billing_summary_map() if normalized_source != "wehago" else {}
|
billing_summary_map = get_project_billing_summary_map() if normalized_source != "wehago" else {}
|
||||||
|
|
||||||
if normalized_source == "wehago":
|
if normalized_source == "wehago":
|
||||||
init_wehago_compare_db(engine)
|
init_wehago_compare_db(engine)
|
||||||
year_clause = ""
|
|
||||||
params: dict[str, Any] = {}
|
|
||||||
if selected_year:
|
|
||||||
year_clause = "AND fiscal_year = :selected_year"
|
|
||||||
params["selected_year"] = selected_year
|
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
text(
|
text(
|
||||||
@@ -5480,7 +5653,6 @@ def get_process_cost_project_options(source: str, selected_year: int | None, inc
|
|||||||
FROM wehago_voucher_rows
|
FROM wehago_voucher_rows
|
||||||
WHERE COALESCE(support_dept_code, '') <> ''
|
WHERE COALESCE(support_dept_code, '') <> ''
|
||||||
AND support_dept_code NOT IN ('ZZZZZZ')
|
AND support_dept_code NOT IN ('ZZZZZZ')
|
||||||
{year_clause}
|
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
support_dept_code,
|
support_dept_code,
|
||||||
@@ -5494,10 +5666,10 @@ def get_process_cost_project_options(source: str, selected_year: int | None, inc
|
|||||||
ORDER BY expense_amount DESC, support_dept_code
|
ORDER BY expense_amount DESC, support_dept_code
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
params,
|
{},
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
else:
|
else:
|
||||||
tx_by_code = _get_hanmac_process_cost_tx_by_code(selected_year)
|
tx_by_code = _get_hanmac_process_cost_tx_by_code(None)
|
||||||
universe_codes = sorted(set(contract_meta) | set(billing_summary_map) | set(tx_by_code))
|
universe_codes = sorted(set(contract_meta) | set(billing_summary_map) | set(tx_by_code))
|
||||||
rows = []
|
rows = []
|
||||||
for code in universe_codes:
|
for code in universe_codes:
|
||||||
@@ -5525,6 +5697,14 @@ def get_process_cost_project_options(source: str, selected_year: int | None, inc
|
|||||||
continue
|
continue
|
||||||
contract_row = contract_meta.get(code, {})
|
contract_row = contract_meta.get(code, {})
|
||||||
billing_row = billing_summary_map.get(code, {})
|
billing_row = billing_summary_map.get(code, {})
|
||||||
|
project_start_date = normalize_text(contract_row.get("project_start_date"))
|
||||||
|
project_start_year = _extract_year_from_project_code(code)
|
||||||
|
if start_year and project_start_year and project_start_year < start_year:
|
||||||
|
continue
|
||||||
|
if end_year and project_start_year and project_start_year > end_year:
|
||||||
|
continue
|
||||||
|
if (start_year or end_year) and not project_start_year:
|
||||||
|
continue
|
||||||
contract_amount = normalize_amount(contract_row.get("hanmac_contract_amount"))
|
contract_amount = normalize_amount(contract_row.get("hanmac_contract_amount"))
|
||||||
revenue_amount = normalize_amount(row.get("revenue_amount"))
|
revenue_amount = normalize_amount(row.get("revenue_amount"))
|
||||||
expense_amount = normalize_amount(row.get("expense_amount"))
|
expense_amount = normalize_amount(row.get("expense_amount"))
|
||||||
@@ -5541,10 +5721,12 @@ def get_process_cost_project_options(source: str, selected_year: int | None, inc
|
|||||||
"profit_rate": _safe_ratio(profit_amount, revenue_amount),
|
"profit_rate": _safe_ratio(profit_amount, revenue_amount),
|
||||||
"voucher_count": int(row.get("voucher_count") or 0),
|
"voucher_count": int(row.get("voucher_count") or 0),
|
||||||
"last_posting_date": normalize_text(row.get("last_posting_date")),
|
"last_posting_date": normalize_text(row.get("last_posting_date")),
|
||||||
|
"project_start_date": project_start_date,
|
||||||
|
"project_kind_code": _get_process_cost_project_kind(code)[0],
|
||||||
|
"project_kind_label": _get_process_cost_project_kind(code)[1],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
result.sort(key=lambda item: (-normalize_amount(item.get("expense_amount")), -normalize_amount(item.get("revenue_amount")), item.get("support_dept_code", "")))
|
return _sort_process_cost_project_options(result)
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def get_process_cost_project_detail(
|
def get_process_cost_project_detail(
|
||||||
@@ -5568,14 +5750,12 @@ def get_process_cost_project_detail(
|
|||||||
contract_meta = contract_meta_map.get(code, {})
|
contract_meta = contract_meta_map.get(code, {})
|
||||||
related_clusters = _build_process_cost_related_clusters() if include_related else {}
|
related_clusters = _build_process_cost_related_clusters() if include_related else {}
|
||||||
cluster_codes = related_clusters.get(code, [code]) if include_related else [code]
|
cluster_codes = related_clusters.get(code, [code]) if include_related else [code]
|
||||||
|
actual_sga_summary_map = _get_project_actual_sga_summary(cluster_codes)
|
||||||
|
exec_budget_summary_map = _get_project_exec_budget_summary(cluster_codes)
|
||||||
if normalized_source == "wehago":
|
if normalized_source == "wehago":
|
||||||
init_wehago_compare_db(engine)
|
init_wehago_compare_db(engine)
|
||||||
in_clause, code_params = build_in_clause("process_cost_wehago_code", cluster_codes)
|
in_clause, code_params = build_in_clause("process_cost_wehago_code", cluster_codes)
|
||||||
year_clause = ""
|
|
||||||
params: dict[str, Any] = dict(code_params)
|
params: dict[str, Any] = dict(code_params)
|
||||||
if selected_year:
|
|
||||||
year_clause = "AND fiscal_year = :selected_year"
|
|
||||||
params["selected_year"] = selected_year
|
|
||||||
amount_expr = (
|
amount_expr = (
|
||||||
"CASE "
|
"CASE "
|
||||||
"WHEN ABS(COALESCE(compare_amount, 0)) > 0 THEN ABS(COALESCE(compare_amount, 0)) "
|
"WHEN ABS(COALESCE(compare_amount, 0)) > 0 THEN ABS(COALESCE(compare_amount, 0)) "
|
||||||
@@ -5599,7 +5779,6 @@ def get_process_cost_project_detail(
|
|||||||
MAX(COALESCE(proof_date, '')) AS last_posting_date
|
MAX(COALESCE(proof_date, '')) AS last_posting_date
|
||||||
FROM wehago_voucher_rows
|
FROM wehago_voucher_rows
|
||||||
WHERE support_dept_code IN ({in_clause})
|
WHERE support_dept_code IN ({in_clause})
|
||||||
{year_clause}
|
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
params,
|
params,
|
||||||
@@ -5616,7 +5795,6 @@ def get_process_cost_project_detail(
|
|||||||
FROM wehago_voucher_rows
|
FROM wehago_voucher_rows
|
||||||
WHERE support_dept_code IN ({in_clause})
|
WHERE support_dept_code IN ({in_clause})
|
||||||
AND (account_code LIKE '5%' OR account_code LIKE '6%')
|
AND (account_code LIKE '5%' OR account_code LIKE '6%')
|
||||||
{year_clause}
|
|
||||||
GROUP BY account_code, account_name
|
GROUP BY account_code, account_name
|
||||||
ORDER BY amount DESC, account_code
|
ORDER BY amount DESC, account_code
|
||||||
LIMIT 14
|
LIMIT 14
|
||||||
@@ -5633,7 +5811,6 @@ def get_process_cost_project_detail(
|
|||||||
SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN {amount_expr} ELSE 0 END) AS expense_amount
|
SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN {amount_expr} ELSE 0 END) AS expense_amount
|
||||||
FROM wehago_voucher_rows
|
FROM wehago_voucher_rows
|
||||||
WHERE support_dept_code IN ({in_clause})
|
WHERE support_dept_code IN ({in_clause})
|
||||||
{year_clause}
|
|
||||||
AND LENGTH(COALESCE(proof_date, '')) >= 7
|
AND LENGTH(COALESCE(proof_date, '')) >= 7
|
||||||
GROUP BY month_label
|
GROUP BY month_label
|
||||||
ORDER BY month_label DESC
|
ORDER BY month_label DESC
|
||||||
@@ -5644,11 +5821,7 @@ def get_process_cost_project_detail(
|
|||||||
).mappings().all()
|
).mappings().all()
|
||||||
else:
|
else:
|
||||||
in_clause, code_params = build_in_clause("process_cost_code", cluster_codes)
|
in_clause, code_params = build_in_clause("process_cost_code", cluster_codes)
|
||||||
year_clause = ""
|
|
||||||
params = dict(code_params)
|
params = dict(code_params)
|
||||||
if selected_year:
|
|
||||||
year_clause = "AND year = :selected_year"
|
|
||||||
params["selected_year"] = selected_year
|
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
summary = conn.execute(
|
summary = conn.execute(
|
||||||
text(
|
text(
|
||||||
@@ -5665,7 +5838,6 @@ def get_process_cost_project_detail(
|
|||||||
MAX(COALESCE(posting_date, '')) AS last_posting_date
|
MAX(COALESCE(posting_date, '')) AS last_posting_date
|
||||||
FROM transactions
|
FROM transactions
|
||||||
WHERE support_dept_code IN ({in_clause})
|
WHERE support_dept_code IN ({in_clause})
|
||||||
{year_clause}
|
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
params,
|
params,
|
||||||
@@ -5682,7 +5854,6 @@ def get_process_cost_project_detail(
|
|||||||
FROM transactions
|
FROM transactions
|
||||||
WHERE support_dept_code IN ({in_clause})
|
WHERE support_dept_code IN ({in_clause})
|
||||||
AND (account_code LIKE '5%' OR account_code LIKE '6%')
|
AND (account_code LIKE '5%' OR account_code LIKE '6%')
|
||||||
{year_clause}
|
|
||||||
GROUP BY account_code, account_name
|
GROUP BY account_code, account_name
|
||||||
ORDER BY amount DESC, account_code
|
ORDER BY amount DESC, account_code
|
||||||
LIMIT 14
|
LIMIT 14
|
||||||
@@ -5699,7 +5870,6 @@ def get_process_cost_project_detail(
|
|||||||
SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN amount ELSE 0 END) AS expense_amount
|
SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN amount ELSE 0 END) AS expense_amount
|
||||||
FROM transactions
|
FROM transactions
|
||||||
WHERE support_dept_code IN ({in_clause})
|
WHERE support_dept_code IN ({in_clause})
|
||||||
{year_clause}
|
|
||||||
AND year IS NOT NULL
|
AND year IS NOT NULL
|
||||||
AND month IS NOT NULL
|
AND month IS NOT NULL
|
||||||
GROUP BY year, month
|
GROUP BY year, month
|
||||||
@@ -5714,24 +5884,46 @@ def get_process_cost_project_detail(
|
|||||||
if include_related:
|
if include_related:
|
||||||
contract_amount = sum(normalize_amount(contract_meta_map.get(member, {}).get("hanmac_contract_amount")) for member in cluster_codes)
|
contract_amount = sum(normalize_amount(contract_meta_map.get(member, {}).get("hanmac_contract_amount")) for member in cluster_codes)
|
||||||
as_cost_amount = sum(normalize_amount(contract_meta_map.get(member, {}).get("expected_as_cost")) for member in cluster_codes)
|
as_cost_amount = sum(normalize_amount(contract_meta_map.get(member, {}).get("expected_as_cost")) for member in cluster_codes)
|
||||||
|
expected_sga_budget = sum(normalize_amount(contract_meta_map.get(member, {}).get("expected_sga_budget")) for member in cluster_codes)
|
||||||
|
planned_labor_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("labor")) for member in cluster_codes)
|
||||||
|
planned_outsource_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("outsource")) for member in cluster_codes)
|
||||||
|
planned_cost_plan_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("cost_plan")) for member in cluster_codes)
|
||||||
else:
|
else:
|
||||||
contract_amount = normalize_amount(contract_meta.get("hanmac_contract_amount"))
|
contract_amount = normalize_amount(contract_meta.get("hanmac_contract_amount"))
|
||||||
as_cost_amount = normalize_amount(contract_meta.get("expected_as_cost"))
|
as_cost_amount = normalize_amount(contract_meta.get("expected_as_cost"))
|
||||||
|
expected_sga_budget = normalize_amount(contract_meta.get("expected_sga_budget"))
|
||||||
|
planned_labor_amount = normalize_amount((exec_budget_summary_map.get(code) or {}).get("labor"))
|
||||||
|
planned_outsource_amount = normalize_amount((exec_budget_summary_map.get(code) or {}).get("outsource"))
|
||||||
|
planned_cost_plan_amount = normalize_amount((exec_budget_summary_map.get(code) or {}).get("cost_plan"))
|
||||||
revenue_amount = normalize_amount(summary_row.get("revenue_amount"))
|
revenue_amount = normalize_amount(summary_row.get("revenue_amount"))
|
||||||
expense_amount = normalize_amount(summary_row.get("expense_amount"))
|
ledger_expense_amount = normalize_amount(summary_row.get("expense_amount"))
|
||||||
labor_amount = normalize_amount(summary_row.get("labor_amount"))
|
labor_amount = normalize_amount(summary_row.get("labor_amount"))
|
||||||
outsourcing_amount = normalize_amount(summary_row.get("outsourcing_amount"))
|
outsourcing_amount = normalize_amount(summary_row.get("outsourcing_amount"))
|
||||||
sga_amount = normalize_amount(summary_row.get("sga_amount"))
|
ledger_sga_amount = normalize_amount(summary_row.get("sga_amount"))
|
||||||
design_cost_amount = max(expense_amount - labor_amount - outsourcing_amount - sga_amount, 0.0)
|
real_project_actual_sga_amount = 0.0
|
||||||
|
for member in cluster_codes:
|
||||||
|
member_expected_sga_budget = normalize_amount(contract_meta_map.get(member, {}).get("expected_sga_budget"))
|
||||||
|
actual_summary = actual_sga_summary_map.get(member)
|
||||||
|
if _is_real_project_actual_sga(actual_summary, member_expected_sga_budget):
|
||||||
|
real_project_actual_sga_amount += normalize_amount((actual_summary or {}).get("amount"))
|
||||||
|
sga_amount = ledger_sga_amount + real_project_actual_sga_amount
|
||||||
|
expense_amount = ledger_expense_amount + real_project_actual_sga_amount
|
||||||
|
design_cost_amount = max(ledger_expense_amount - labor_amount - outsourcing_amount - ledger_sga_amount, 0.0)
|
||||||
profit_amount = revenue_amount - expense_amount
|
profit_amount = revenue_amount - expense_amount
|
||||||
|
|
||||||
target_base = contract_amount * 0.85 if contract_amount > 0 else max(revenue_amount * 0.85, expense_amount)
|
target_base = (
|
||||||
|
planned_labor_amount
|
||||||
|
+ planned_outsource_amount
|
||||||
|
+ planned_cost_plan_amount
|
||||||
|
+ as_cost_amount
|
||||||
|
+ expected_sga_budget
|
||||||
|
)
|
||||||
phase_rows = [
|
phase_rows = [
|
||||||
{"phase": "직접인건비", "target_amount": target_base * 0.30, "actual_amount": labor_amount},
|
{"phase": "직접인건비", "target_amount": planned_labor_amount, "actual_amount": labor_amount},
|
||||||
{"phase": "외주비", "target_amount": target_base * 0.32, "actual_amount": outsourcing_amount},
|
{"phase": "외주비", "target_amount": planned_outsource_amount, "actual_amount": outsourcing_amount},
|
||||||
{"phase": "제경비", "target_amount": target_base * 0.23, "actual_amount": design_cost_amount},
|
{"phase": "제경비", "target_amount": planned_cost_plan_amount, "actual_amount": design_cost_amount},
|
||||||
{"phase": "A/S비", "target_amount": as_cost_amount, "actual_amount": as_cost_amount},
|
{"phase": "A/S비", "target_amount": as_cost_amount, "actual_amount": as_cost_amount},
|
||||||
{"phase": "판관비", "target_amount": target_base * 0.15, "actual_amount": sga_amount},
|
{"phase": "판관비", "target_amount": expected_sga_budget, "actual_amount": sga_amount},
|
||||||
]
|
]
|
||||||
for row in phase_rows:
|
for row in phase_rows:
|
||||||
row["gap_amount"] = row["target_amount"] - row["actual_amount"]
|
row["gap_amount"] = row["target_amount"] - row["actual_amount"]
|
||||||
@@ -5750,6 +5942,19 @@ def get_process_cost_project_detail(
|
|||||||
"last_posting_date": normalize_text(row.get("last_posting_date")),
|
"last_posting_date": normalize_text(row.get("last_posting_date")),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if real_project_actual_sga_amount > 0:
|
||||||
|
normalized_accounts.append(
|
||||||
|
{
|
||||||
|
"account_code": "PROJECT-SGA",
|
||||||
|
"account_name": "판관비(프로젝트 정보)",
|
||||||
|
"amount": real_project_actual_sga_amount,
|
||||||
|
"row_count": sum(len((actual_sga_summary_map.get(member) or {}).get("rows") or []) for member in cluster_codes),
|
||||||
|
"share_rate": _safe_ratio(real_project_actual_sga_amount, expense_amount),
|
||||||
|
"last_posting_date": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
normalized_accounts.sort(key=lambda item: (-normalize_amount(item.get("amount")), normalize_text(item.get("account_code"))))
|
||||||
|
normalized_accounts = normalized_accounts[:14]
|
||||||
|
|
||||||
normalized_monthly = []
|
normalized_monthly = []
|
||||||
for row in monthly_rows:
|
for row in monthly_rows:
|
||||||
@@ -5781,6 +5986,9 @@ def get_process_cost_project_detail(
|
|||||||
"voucher_count": int(summary_row.get("voucher_count") or 0),
|
"voucher_count": int(summary_row.get("voucher_count") or 0),
|
||||||
"row_count": int(summary_row.get("row_count") or 0),
|
"row_count": int(summary_row.get("row_count") or 0),
|
||||||
"included_codes": cluster_codes if include_related else [code],
|
"included_codes": cluster_codes if include_related else [code],
|
||||||
|
"expected_sga_budget": expected_sga_budget,
|
||||||
|
"ledger_sga_amount": ledger_sga_amount,
|
||||||
|
"project_actual_sga_amount": real_project_actual_sga_amount,
|
||||||
},
|
},
|
||||||
"phase_rows": phase_rows,
|
"phase_rows": phase_rows,
|
||||||
"account_rows": normalized_accounts,
|
"account_rows": normalized_accounts,
|
||||||
@@ -5798,7 +6006,8 @@ def get_process_cost_project_detail(
|
|||||||
def render_process_cost_page(
|
def render_process_cost_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
source: str | None = None,
|
source: str | None = None,
|
||||||
year: int | None = None,
|
start_year: int | None = None,
|
||||||
|
end_year: int | None = None,
|
||||||
code: str | None = None,
|
code: str | None = None,
|
||||||
include_related: bool = False,
|
include_related: bool = False,
|
||||||
message: str = "",
|
message: str = "",
|
||||||
@@ -5810,17 +6019,39 @@ def render_process_cost_page(
|
|||||||
normalized_source = "hanmac"
|
normalized_source = "hanmac"
|
||||||
|
|
||||||
years = get_process_cost_available_years(normalized_source)
|
years = get_process_cost_available_years(normalized_source)
|
||||||
selected_year = year if year in years else None
|
selected_start_year = start_year if start_year in years else None
|
||||||
project_options = get_process_cost_project_options(normalized_source, selected_year, include_related=include_related)
|
selected_end_year = end_year if end_year in years else None
|
||||||
|
if not selected_start_year and not selected_end_year and years:
|
||||||
|
selected_start_year = years[0]
|
||||||
|
selected_end_year = years[-1]
|
||||||
|
elif selected_start_year and selected_end_year and selected_start_year > selected_end_year:
|
||||||
|
selected_end_year = selected_start_year
|
||||||
|
elif selected_end_year and not selected_start_year:
|
||||||
|
selected_start_year = years[0] if years else None
|
||||||
|
if selected_start_year and selected_start_year > selected_end_year:
|
||||||
|
selected_start_year = selected_end_year
|
||||||
|
elif selected_start_year and not selected_end_year:
|
||||||
|
selected_end_year = years[-1] if years else None
|
||||||
|
if selected_end_year and selected_end_year < selected_start_year:
|
||||||
|
selected_end_year = selected_start_year
|
||||||
|
|
||||||
|
project_options = get_process_cost_project_options(
|
||||||
|
normalized_source,
|
||||||
|
selected_start_year,
|
||||||
|
selected_end_year,
|
||||||
|
include_related=include_related,
|
||||||
|
)
|
||||||
selected_code = normalize_text(code)
|
selected_code = normalize_text(code)
|
||||||
if selected_code and not any(item["support_dept_code"] == selected_code for item in project_options):
|
if selected_code and not any(item["support_dept_code"] == selected_code for item in project_options):
|
||||||
selected_code = ""
|
selected_code = ""
|
||||||
if not selected_code and project_options:
|
selected_project = next(
|
||||||
selected_code = project_options[0]["support_dept_code"]
|
(item for item in project_options if normalize_text(item.get("support_dept_code")) == selected_code),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
detail = get_process_cost_project_detail(
|
detail = get_process_cost_project_detail(
|
||||||
normalized_source,
|
normalized_source,
|
||||||
selected_year,
|
None,
|
||||||
selected_code,
|
selected_code,
|
||||||
include_related=include_related,
|
include_related=include_related,
|
||||||
)
|
)
|
||||||
@@ -5828,12 +6059,16 @@ def render_process_cost_page(
|
|||||||
**base_context(request, message),
|
**base_context(request, message),
|
||||||
"process_cost_source": normalized_source,
|
"process_cost_source": normalized_source,
|
||||||
"process_cost_years": years,
|
"process_cost_years": years,
|
||||||
"process_cost_selected_year": selected_year,
|
"process_cost_years_desc": sorted(years, reverse=True),
|
||||||
|
"process_cost_selected_start_year": selected_start_year,
|
||||||
|
"process_cost_selected_end_year": selected_end_year,
|
||||||
"process_cost_selected_code": selected_code,
|
"process_cost_selected_code": selected_code,
|
||||||
|
"process_cost_selected_project": selected_project,
|
||||||
"process_cost_include_related": include_related,
|
"process_cost_include_related": include_related,
|
||||||
"process_cost_projects": project_options,
|
"process_cost_projects": project_options,
|
||||||
"process_cost_detail": detail,
|
"process_cost_detail": detail,
|
||||||
"process_cost_related_codes": get_process_cost_related_codes(selected_code),
|
"process_cost_related_codes": get_process_cost_related_codes(selected_code),
|
||||||
|
"process_cost_quick_link_codes": get_process_cost_quick_links(),
|
||||||
}
|
}
|
||||||
return templates.TemplateResponse(request, "process_cost.html", context)
|
return templates.TemplateResponse(request, "process_cost.html", context)
|
||||||
|
|
||||||
@@ -6832,6 +7067,31 @@ async def project_quick_links_save(request: Request):
|
|||||||
return JSONResponse(content={"error": str(exc)}, status_code=500)
|
return JSONResponse(content={"error": str(exc)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/process-cost/quick-links")
|
||||||
|
async def process_cost_quick_links_load():
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"codes": get_process_cost_quick_links()})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("프로세스 원가 바로가기 조회 에러: %s", exc)
|
||||||
|
return JSONResponse(content={"error": str(exc)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/process-cost/quick-links")
|
||||||
|
async def process_cost_quick_links_save(request: Request):
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("잘못된 바로가기 형식입니다.")
|
||||||
|
codes = payload.get("codes") or []
|
||||||
|
if not isinstance(codes, list):
|
||||||
|
raise ValueError("바로가기 목록 형식이 잘못되었습니다.")
|
||||||
|
save_process_cost_quick_links([normalize_text(code) for code in codes if isinstance(code, str)])
|
||||||
|
return JSONResponse(content={"status": "ok", "codes": get_process_cost_quick_links()})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("프로세스 원가 바로가기 저장 에러: %s", exc)
|
||||||
|
return JSONResponse(content={"error": str(exc)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/projects/uncontracted-category")
|
@app.post("/projects/uncontracted-category")
|
||||||
async def project_uncontracted_category_save(request: Request):
|
async def project_uncontracted_category_save(request: Request):
|
||||||
try:
|
try:
|
||||||
@@ -7214,7 +7474,8 @@ async def annual_summary(request: Request):
|
|||||||
async def process_cost(
|
async def process_cost(
|
||||||
request: Request,
|
request: Request,
|
||||||
source: str | None = None,
|
source: str | None = None,
|
||||||
year: str | None = None,
|
start_year: str | None = None,
|
||||||
|
end_year: str | None = None,
|
||||||
code: str | None = None,
|
code: str | None = None,
|
||||||
include_related: str | None = None,
|
include_related: str | None = None,
|
||||||
):
|
):
|
||||||
@@ -7222,7 +7483,8 @@ async def process_cost(
|
|||||||
return render_process_cost_page(
|
return render_process_cost_page(
|
||||||
request,
|
request,
|
||||||
source=source,
|
source=source,
|
||||||
year=parse_optional_year(year),
|
start_year=parse_optional_year(start_year),
|
||||||
|
end_year=parse_optional_year(end_year),
|
||||||
code=code,
|
code=code,
|
||||||
include_related=normalize_text(include_related) in {"1", "true", "y", "yes", "on"},
|
include_related=normalize_text(include_related) in {"1", "true", "y", "yes", "on"},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,13 +54,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chart-box {
|
.chart-box {
|
||||||
background:
|
background: transparent;
|
||||||
linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,247,249,0.98)),
|
border: 0;
|
||||||
radial-gradient(circle at top left, rgba(17, 17, 17, 0.045), transparent 36%);
|
border-top: 1px solid var(--line);
|
||||||
border: 1px solid var(--line);
|
border-radius: 0;
|
||||||
border-radius: 16px;
|
padding: 18px 0 0;
|
||||||
padding: 18px 20px 20px;
|
box-shadow: none;
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
|
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
contain-intrinsic-size: 620px;
|
contain-intrinsic-size: 620px;
|
||||||
}
|
}
|
||||||
@@ -96,6 +95,8 @@
|
|||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
contain-intrinsic-size: 760px;
|
contain-intrinsic-size: 760px;
|
||||||
|
padding-right: 14px;
|
||||||
|
border-right: 1px solid rgba(217, 221, 227, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
@@ -128,8 +129,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.summary-overview .stat-card {
|
.summary-overview .stat-card {
|
||||||
padding: 14px 14px 15px;
|
padding: 0 8px 12px 0;
|
||||||
border-radius: 12px;
|
border-radius: 0;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +175,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body[data-view-mode="dual"] .chart-box {
|
body[data-view-mode="dual"] .chart-box {
|
||||||
padding: 12px 12px 14px;
|
padding: 12px 0 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +192,13 @@
|
|||||||
.metric-grid {
|
.metric-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-overview {
|
||||||
|
padding-right: 0;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid rgba(217, 221, 227, 0.9);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+20
-17
@@ -23,6 +23,7 @@
|
|||||||
--page-frame-width-dual: min(2560px, calc(100vw - (var(--page-gutter) * 2)));
|
--page-frame-width-dual: min(2560px, calc(100vw - (var(--page-gutter) * 2)));
|
||||||
--page-frame-width-single: min(1520px, calc(100vw - (var(--page-gutter) * 2)));
|
--page-frame-width-single: min(1520px, calc(100vw - (var(--page-gutter) * 2)));
|
||||||
--status-widget-height: 34px;
|
--status-widget-height: 34px;
|
||||||
|
--toolbar-row-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -116,12 +117,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--panel);
|
background: transparent;
|
||||||
border: 1px solid var(--line);
|
border: 0;
|
||||||
border-radius: 18px;
|
border-radius: 0;
|
||||||
padding: var(--panel-pad);
|
padding: 0;
|
||||||
box-shadow: 0 10px 24px rgba(21, 24, 29, 0.045);
|
box-shadow: none;
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
@@ -165,11 +166,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: var(--white);
|
background: transparent;
|
||||||
border: 1px solid var(--line);
|
border: 0;
|
||||||
border-radius: 14px;
|
border-radius: 0;
|
||||||
padding: 14px 15px 13px;
|
padding: 0 0 14px;
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.7);
|
box-shadow: none;
|
||||||
|
border-bottom: 1px solid rgba(217, 221, 227, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card .label {
|
.stat-card .label {
|
||||||
@@ -402,9 +404,10 @@
|
|||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid var(--line);
|
border: 0;
|
||||||
border-radius: 14px;
|
border-top: 1px solid var(--line);
|
||||||
background: var(--white);
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
@@ -431,7 +434,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:nth-child(even) td {
|
tbody tr:nth-child(even) td {
|
||||||
background: var(--table-alt);
|
background: rgba(245, 246, 248, 0.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
@@ -614,9 +617,9 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">대시보드</a>
|
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">대시보드</a>
|
||||||
<a href="/projects" class="{% if request.url.path == '/projects' %}active{% endif %}">프로젝트 정보</a>
|
|
||||||
<a href="/annual-summary" class="{% if request.url.path == '/annual-summary' %}active{% endif %}">연도별 수익/비용</a>
|
<a href="/annual-summary" class="{% if request.url.path == '/annual-summary' %}active{% endif %}">연도별 수익/비용</a>
|
||||||
<a href="/process-cost" class="{% if request.url.path == '/process-cost' %}active{% endif %}">프로세스 원가</a>
|
<a href="/process-cost" class="{% if request.url.path == '/process-cost' %}active{% endif %}">프로젝트 원가</a>
|
||||||
|
<a href="/projects" class="{% if request.url.path == '/projects' %}active{% endif %}">프로젝트 정보</a>
|
||||||
<a href="/wehago-compare" class="{% if request.url.path == '/wehago-compare' %}active{% endif %}">전표비교</a>
|
<a href="/wehago-compare" class="{% if request.url.path == '/wehago-compare' %}active{% endif %}">전표비교</a>
|
||||||
<div class="nav-spacer"></div>
|
<div class="nav-spacer"></div>
|
||||||
<button type="button" class="view-mode-switch" id="viewModeSwitch" data-mode="dual" aria-label="화면 구성 전환">
|
<button type="button" class="view-mode-switch" id="viewModeSwitch" data-mode="dual" aria-label="화면 구성 전환">
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-topbar-actions {
|
.dashboard-topbar-actions {
|
||||||
@@ -80,6 +82,8 @@
|
|||||||
.dashboard-status-panel {
|
.dashboard-status-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
border-right: 1px solid rgba(217, 221, 227, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-status-grid {
|
.dashboard-status-grid {
|
||||||
@@ -90,6 +94,7 @@
|
|||||||
|
|
||||||
.dashboard-status-grid .stat-card {
|
.dashboard-status-grid .stat-card {
|
||||||
min-height: 84px;
|
min-height: 84px;
|
||||||
|
padding-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-status-grid .stat-card .value {
|
.dashboard-status-grid .stat-card .value {
|
||||||
@@ -131,13 +136,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chart-shell {
|
.chart-shell {
|
||||||
background:
|
background: transparent;
|
||||||
linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,247,249,0.98)),
|
border: 0;
|
||||||
radial-gradient(circle at top left, rgba(24, 24, 27, 0.045), transparent 38%);
|
border-top: 1px solid var(--line);
|
||||||
border: 1px solid var(--line);
|
border-radius: 0;
|
||||||
border-radius: 16px;
|
padding: 14px 0 0;
|
||||||
padding: 14px 16px 16px;
|
box-shadow: none;
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
@@ -214,7 +218,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body[data-view-mode="dual"] .chart-shell {
|
body[data-view-mode="dual"] .chart-shell {
|
||||||
padding: 10px 12px 12px;
|
padding: 10px 0 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +231,13 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-status-panel {
|
||||||
|
padding-right: 0;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid rgba(217, 221, 227, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-chart-stack {
|
.dashboard-chart-stack {
|
||||||
grid-template-rows: auto;
|
grid-template-rows: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
+766
-107
File diff suppressed because it is too large
Load Diff
+106
-38
@@ -71,10 +71,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.metric-note {
|
.metric-note {
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: transparent;
|
||||||
border: 1px solid var(--line);
|
border: 0;
|
||||||
border-radius: 16px;
|
border-top: 1px solid var(--line);
|
||||||
padding: 14px 16px;
|
border-radius: 0;
|
||||||
|
padding: 14px 0 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -85,13 +86,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chart-stage {
|
.chart-stage {
|
||||||
background:
|
background: transparent;
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(246, 247, 249, 0.98)),
|
border: 0;
|
||||||
radial-gradient(circle at top left, rgba(17, 17, 17, 0.045), transparent 34%);
|
border-top: 1px solid var(--line);
|
||||||
border: 1px solid var(--line);
|
border-radius: 0;
|
||||||
border-radius: 16px;
|
padding: 16px 0 0;
|
||||||
padding: 16px;
|
box-shadow: none;
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-header {
|
.chart-header {
|
||||||
@@ -189,7 +189,20 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 10px;
|
min-height: var(--toolbar-row-height);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 0 0 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-head h3,
|
||||||
|
.toolbar-head h4 {
|
||||||
|
margin: 0;
|
||||||
|
height: var(--toolbar-row-height);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-title-group {
|
.toolbar-title-group {
|
||||||
@@ -210,6 +223,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
min-height: var(--toolbar-row-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-quick-link-bar {
|
.project-quick-link-bar {
|
||||||
@@ -300,9 +314,10 @@
|
|||||||
|
|
||||||
.project-search-controls {
|
.project-search-controls {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 170px 210px;
|
grid-template-columns: minmax(0, 1fr) 180px 236px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: end;
|
align-items: center;
|
||||||
|
min-height: var(--toolbar-row-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-search-field {
|
.project-search-field {
|
||||||
@@ -312,6 +327,16 @@
|
|||||||
z-index: 180;
|
z-index: 180;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-search-field > input,
|
||||||
|
.project-search-controls .field > select,
|
||||||
|
.uncontracted-toolbar .field > select {
|
||||||
|
height: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
line-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.project-search-dropdown {
|
.project-search-dropdown {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -452,10 +477,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.analysis-stat {
|
.analysis-stat {
|
||||||
border: 1px solid #dfe6ee;
|
border: 0;
|
||||||
border-radius: 14px;
|
border-bottom: 1px solid rgba(217, 221, 227, 0.9);
|
||||||
background: rgba(255,255,255,0.94);
|
border-radius: 0;
|
||||||
padding: 14px 16px;
|
background: transparent;
|
||||||
|
padding: 0 0 12px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
@@ -471,14 +497,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.analysis-panel {
|
.analysis-panel {
|
||||||
background: rgba(255,255,255,0.98);
|
background: transparent;
|
||||||
border: 1px solid var(--line);
|
border: 0;
|
||||||
border-radius: 18px;
|
border-top: 1px solid var(--line);
|
||||||
padding: 18px;
|
border-radius: 0;
|
||||||
|
padding: 18px 0 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.analysis-panel:empty {
|
||||||
|
display: none;
|
||||||
|
border-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.analysis-panel h4 {
|
.analysis-panel h4 {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
@@ -1016,7 +1049,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#projectUncontractedDashboard {
|
#projectUncontractedDashboard {
|
||||||
padding: 20px;
|
padding: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
@@ -1034,13 +1067,29 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
content-visibility: visible;
|
content-visibility: visible;
|
||||||
contain: none;
|
contain: none;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uncontracted-toolbar {
|
.uncontracted-toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 180px) auto;
|
grid-template-columns: 180px 180px 44px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: end;
|
align-items: center;
|
||||||
|
min-height: var(--toolbar-row-height);
|
||||||
|
width: 428px;
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uncontracted-dashboard .toolbar-head {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uncontracted-dashboard .toolbar-head h3 {
|
||||||
|
height: var(--toolbar-row-height);
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uncontracted-summary-grid {
|
.uncontracted-summary-grid {
|
||||||
@@ -1050,10 +1099,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.uncontracted-stat {
|
.uncontracted-stat {
|
||||||
border: 1px solid #dfe6ee;
|
border: 0;
|
||||||
border-radius: 14px;
|
border-bottom: 1px solid rgba(217, 221, 227, 0.9);
|
||||||
padding: 12px;
|
border-radius: 0;
|
||||||
background: #fbfcfd;
|
padding: 0 0 12px;
|
||||||
|
background: transparent;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -1067,14 +1117,14 @@
|
|||||||
|
|
||||||
.uncontracted-stat.is-clickable {
|
.uncontracted-stat.is-clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
|
transition: color 0.16s ease, border-color 0.16s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uncontracted-stat.is-clickable:hover,
|
.uncontracted-stat.is-clickable:hover,
|
||||||
.uncontracted-stat.is-clickable.is-active {
|
.uncontracted-stat.is-clickable.is-active {
|
||||||
border-color: #c8d7e5;
|
border-color: #111827;
|
||||||
box-shadow: 0 12px 22px rgba(22, 33, 50, 0.08);
|
box-shadow: none;
|
||||||
transform: translateY(-1px);
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uncontracted-stat span {
|
.uncontracted-stat span {
|
||||||
@@ -1131,6 +1181,15 @@
|
|||||||
|
|
||||||
.uncontracted-dashboard-grid .detail-block {
|
.uncontracted-dashboard-grid .detail-block {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uncontracted-dashboard-grid .detail-block h4 {
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact-table {
|
.compact-table {
|
||||||
@@ -1152,7 +1211,15 @@
|
|||||||
.compact-table th {
|
.compact-table th {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #547085;
|
color: #547085;
|
||||||
background: #f5f8fb;
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-table td {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-table tbody tr:nth-child(even) td {
|
||||||
|
background: #fcfdff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact-table td:last-child,
|
.compact-table td:last-child,
|
||||||
@@ -2199,7 +2266,7 @@
|
|||||||
|
|
||||||
.metric-grid .stat-card {
|
.metric-grid .stat-card {
|
||||||
min-height: 78px;
|
min-height: 78px;
|
||||||
padding: 11px 12px 10px;
|
padding: 0 8px 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-grid .stat-card .value {
|
.metric-grid .stat-card .value {
|
||||||
@@ -2412,7 +2479,7 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<select id="projectCostYearFilter" aria-label="연도 필터">
|
<select id="projectCostYearFilter" aria-label="연도 필터">
|
||||||
<option value="">전체 연도</option>
|
<option value="">전체 연도</option>
|
||||||
{% for year in project_year_options %}
|
{% for year in project_year_options|sort(reverse=True) %}
|
||||||
<option value="{{ year }}">{{ year }}</option>
|
<option value="{{ year }}">{{ year }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
@@ -6339,8 +6406,9 @@
|
|||||||
function renderUncontractedDashboard() {
|
function renderUncontractedDashboard() {
|
||||||
const data = buildUncontractedDashboardData();
|
const data = buildUncontractedDashboardData();
|
||||||
const allYears = getUncontractedYearRange().allYears;
|
const allYears = getUncontractedYearRange().allYears;
|
||||||
const startOptions = allYears.map((year) => `<option value="${year}"${Number(year) === Number(data.startYear) ? " selected" : ""}>${year}</option>`).join("");
|
const orderedYears = [...allYears].sort((a, b) => Number(b) - Number(a));
|
||||||
const endOptions = allYears.map((year) => `<option value="${year}"${Number(year) === Number(data.endYear) ? " selected" : ""}>${year}</option>`).join("");
|
const startOptions = orderedYears.map((year) => `<option value="${year}"${Number(year) === Number(data.startYear) ? " selected" : ""}>${year}</option>`).join("");
|
||||||
|
const endOptions = orderedYears.map((year) => `<option value="${year}"${Number(year) === Number(data.endYear) ? " selected" : ""}>${year}</option>`).join("");
|
||||||
const detailExpenseTotal = (data.detailRows || []).reduce((sum, row) => sum + Number(row.expense_amount || 0), 0);
|
const detailExpenseTotal = (data.detailRows || []).reduce((sum, row) => sum + Number(row.expense_amount || 0), 0);
|
||||||
const detailRevenueTotal = (data.detailRows || []).reduce((sum, row) => sum + Number(row.revenue_amount || 0), 0);
|
const detailRevenueTotal = (data.detailRows || []).reduce((sum, row) => sum + Number(row.revenue_amount || 0), 0);
|
||||||
latestExcludedDetailRows = data.excludedDetailRows || [];
|
latestExcludedDetailRows = data.excludedDetailRows || [];
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voucher-title-wrap h2 {
|
.voucher-title-wrap h2 {
|
||||||
@@ -164,15 +166,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-shell {
|
.panel-shell {
|
||||||
border: 1px solid var(--line);
|
border: 0;
|
||||||
border-radius: 16px;
|
border-top: 1px solid var(--line);
|
||||||
background: #fff;
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucher-page > .panel-shell:first-of-type {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucher-page > .panel-shell:first-of-type > .panel-header {
|
||||||
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
padding: 12px 16px 8px;
|
padding: 14px 0 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -195,7 +206,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-body {
|
.panel-body {
|
||||||
padding: 0 16px 16px;
|
padding: 0 0 16px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,9 +219,9 @@
|
|||||||
.status-card {
|
.status-card {
|
||||||
min-width: 130px;
|
min-width: 130px;
|
||||||
width: auto;
|
width: auto;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid rgba(217, 221, 227, 0.9);
|
||||||
border-radius: 14px;
|
border-radius: 999px;
|
||||||
background: #fff;
|
background: rgba(255, 255, 255, 0.76);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -259,9 +270,10 @@
|
|||||||
|
|
||||||
.detail-panel {
|
.detail-panel {
|
||||||
display: none;
|
display: none;
|
||||||
border: 1px solid var(--line);
|
border: 0;
|
||||||
border-radius: 14px;
|
border-top: 1px solid var(--line);
|
||||||
background: #fff;
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -271,9 +283,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-head {
|
.detail-head {
|
||||||
padding: 10px 12px;
|
padding: 14px 0 10px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid rgba(217, 221, 227, 0.9);
|
||||||
background: #f8fafc;
|
background: transparent;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -288,7 +300,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 16px;
|
padding: 14px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-panel summary::-webkit-details-marker {
|
.data-panel summary::-webkit-details-marker {
|
||||||
@@ -331,8 +343,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid rgba(217, 221, 227, 0.9);
|
||||||
background: #fff;
|
background: transparent;
|
||||||
max-height: 460px;
|
max-height: 460px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -346,9 +358,9 @@
|
|||||||
.table-footer {
|
.table-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 10px 12px 14px;
|
padding: 10px 0 14px;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid rgba(217, 221, 227, 0.9);
|
||||||
background: #fff;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.load-more-button {
|
.load-more-button {
|
||||||
|
|||||||
Reference in New Issue
Block a user