feat(cupang): 달력을 2개월(현재+다음 달) 뷰로 개편

- index 라우터가 두 달치 출고를 합쳐(중복 id 제거) 달 별 격자 2개를 생성
- 상단 바: ‹ › 이동 + 기간 제목 + [오늘], 달마다 머리글/월 출고 합계 배지
- 셀 UI 정리: 오늘=검은 원, 선택=링, 출고일=건수/센터수 태그, 타월은 흐리게
- 범례 추가, 1200px 이하에서는 두 달을 세로로 배치

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 12:20:41 +09:00
parent 0db54b6300
commit 1cf6ba385d
9 changed files with 240 additions and 63 deletions
+35 -14
View File
@@ -129,11 +129,18 @@ async def index(request: Request) -> HTMLResponse:
store, user = guard store, user = guard
year, month = _ym(request) year, month = _ym(request)
next_y, next_m = (year + 1, 1) if month == 12 else (year, month + 1)
prev_y, prev_m = (year - 1, 12) if month == 1 else (year, month - 1)
# 달력은 현재 달 + 다음 달 2개월을 함께 보여준다.
# 취소된 묶음은 달력·목록 어디에도 보이지 않는다(soft delete = 삭제로 취급). # 취소된 묶음은 달력·목록 어디에도 보이지 않는다(soft delete = 삭제로 취급).
shipments = [ merged: dict[Any, dict[str, Any]] = {}
s for s in store.list_shipments(year=year, month=month) for y, m in ((year, month), (next_y, next_m)):
if s.get("status") != "취소" for s in store.list_shipments(year=y, month=m):
] if s.get("status") == "취소":
continue
merged[s["id"]] = s
shipments = list(merged.values())
# 달력 칸에는 출고 건수와 센터 수만 보여준다. # 달력 칸에는 출고 건수와 센터 수만 보여준다.
counts: dict[str, dict[str, Any]] = {} counts: dict[str, dict[str, Any]] = {}
@@ -147,11 +154,12 @@ async def index(request: Request) -> HTMLResponse:
for cell in counts.values(): for cell in counts.values():
cell["centers"] = len(cell["centers"]) cell["centers"] = len(cell["centers"])
# 선택 날짜 (기본: 오늘이 이번 달이면 오늘, 아니면 1일) # 선택 날짜 (기본: 오늘이 보이는 두 달 안이면 오늘, 아니면 첫 달 1일)
sel = request.query_params.get("date") or "" sel = request.query_params.get("date") or ""
today = today_kst() today = today_kst()
if not sel: if not sel:
sel = today.isoformat() if (today.year == year and today.month == month) else f"{year:04d}-{month:02d}-01" in_view = (today.year, today.month) in ((year, month), (next_y, next_m))
sel = today.isoformat() if in_view else f"{year:04d}-{month:02d}-01"
# 선택일의 묶음 — 달력과 같은 기준(출고일) # 선택일의 묶음 — 달력과 같은 기준(출고일)
sel_shipments = [s for s in shipments if s.get("ship_date") == sel] sel_shipments = [s for s in shipments if s.get("ship_date") == sel]
@@ -172,13 +180,14 @@ async def index(request: Request) -> HTMLResponse:
s["total_boxes"] = sum(it["boxes"] for it in s["items"]) s["total_boxes"] = sum(it["boxes"] for it in s["items"])
cal = _calendar.Calendar(firstweekday=6) # 일요일 시작 cal = _calendar.Calendar(firstweekday=6) # 일요일 시작
weeks = cal.monthdatescalendar(year, month)
cal_weeks = [ def _build_month(y: int, m: int) -> dict[str, Any]:
weeks = [
[ [
{ {
"date": d.isoformat(), "date": d.isoformat(),
"day": d.day, "day": d.day,
"in_month": d.month == month, "in_month": d.month == m,
"is_today": d == today, "is_today": d == today,
"is_selected": d.isoformat() == sel, "is_selected": d.isoformat() == sel,
"is_sunday": d.weekday() == 6, "is_sunday": d.weekday() == 6,
@@ -188,11 +197,22 @@ async def index(request: Request) -> HTMLResponse:
} }
for d in week for d in week
] ]
for week in weeks for week in cal.monthdatescalendar(y, m)
] ]
ship_total = sum(
int(c.get("ship") or 0)
for day, c in counts.items()
if day[:7] == f"{y:04d}-{m:02d}"
)
return {
"year": y,
"month": m,
"label": f"{y}{m}",
"weeks": weeks,
"ship_total": ship_total,
}
prev_y, prev_m = (year - 1, 12) if month == 1 else (year, month - 1) months = [_build_month(year, month), _build_month(next_y, next_m)]
next_y, next_m = (year + 1, 1) if month == 12 else (year, month + 1)
return render_template( return render_template(
request, request,
@@ -202,13 +222,14 @@ async def index(request: Request) -> HTMLResponse:
"is_admin": is_admin(user), "is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"), "nav_items": build_erp_nav(user, active="cupang"),
"page_title": "쿠팡 밀크런", "page_title": "쿠팡 밀크런",
"page_subtitle": f"{year}{month}월 출고 일정", "page_subtitle": f"{year}{month} · {next_y}{next_m} 출고 일정",
"year": year, "year": year,
"month": month, "month": month,
"prev_y": prev_y, "prev_m": prev_m, "prev_y": prev_y, "prev_m": prev_m,
"next_y": next_y, "next_m": next_m, "next_y": next_y, "next_m": next_m,
"today_iso": today.isoformat(),
"weekdays": ["", "", "", "", "", "", ""], "weekdays": ["", "", "", "", "", "", ""],
"cal_weeks": cal_weeks, "months": months,
"selected_date": sel, "selected_date": sel,
"sel_shipments": sel_shipments, "sel_shipments": sel_shipments,
}, },
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901f" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901g" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901f" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901g" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901f" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901g" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901f" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901g" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901f" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901g" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
+42 -20
View File
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901f" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901g" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -19,40 +19,62 @@
</div> </div>
<div class="cpg-layout"> <div class="cpg-layout">
<!-- ── 왼쪽: 월간 달력 ── --> <!-- ── 왼쪽: 2개월 월간 달력 ── -->
<div class="erp-card cpg-cal-card"> <div class="erp-card cpg-cal-card">
<div class="cpg-cal-head"> <div class="cpg-mcal-bar">
<a class="erp-btn erp-btn-outline cpg-nav-btn" <div class="cpg-mcal-nav">
<a class="cpg-mcal-navbtn" title="이전 달"
href="/cupang/?year={{ prev_y }}&month={{ prev_m }}"></a> href="/cupang/?year={{ prev_y }}&month={{ prev_m }}"></a>
<h2 class="cpg-cal-title">{{ year }}년 {{ month }}월</h2> <a class="cpg-mcal-navbtn" title="다음 달"
<a class="erp-btn erp-btn-outline cpg-nav-btn"
href="/cupang/?year={{ next_y }}&month={{ next_m }}"></a> href="/cupang/?year={{ next_y }}&month={{ next_m }}"></a>
</div> </div>
<h2 class="cpg-mcal-range">{{ months[0].label }} {{ months[1].label }}</h2>
<a class="cpg-mcal-todaybtn" href="/cupang/">오늘</a>
</div>
<div class="cpg-cal-grid"> <div class="cpg-mcal-months">
{% for m in months %}
<section class="cpg-mcal{% if loop.first %} is-current{% endif %}">
<header class="cpg-mcal-h">
<span class="cpg-mcal-mname">{{ m.month }}월</span>
<span class="cpg-mcal-myear">{{ m.year }}</span>
{% if m.ship_total %}
<span class="cpg-mcal-total">출고 {{ m.ship_total }}건</span>
{% endif %}
</header>
<div class="cpg-mcal-grid">
{% for wd in weekdays %} {% for wd in weekdays %}
<div class="cpg-cal-wd {% if loop.index0 == 0 %}cpg-sun{% elif loop.index0 == 6 %}cpg-sat{% endif %}">{{ wd }}</div> <div class="cpg-mcal-wd {% if loop.index0 == 0 %}is-sun{% elif loop.index0 == 6 %}is-sat{% endif %}">{{ wd }}</div>
{% endfor %} {% endfor %}
{% for week in cal_weeks %} {% for week in m.weeks %}
{% for cell in week %} {% for cell in week %}
<a class="cpg-cal-cell <a class="cpg-mcal-cell
{% if not cell.in_month %}cpg-out{% endif %} {% if not cell.in_month %}is-out{% endif %}
{% if cell.is_today %}cpg-today{% endif %} {% if cell.is_today %}is-today{% endif %}
{% if cell.is_selected %}cpg-selected{% endif %} {% if cell.is_selected %}is-selected{% endif %}
{% if cell.is_sunday or cell.is_holiday %}cpg-red{% elif cell.is_saturday %}cpg-blue{% endif %}" {% if cell.counts.ship %}has-ship{% endif %}
href="/cupang/?year={{ year }}&month={{ month }}&date={{ cell.date }}"> {% if cell.is_sunday or cell.is_holiday %}is-red{% elif cell.is_saturday %}is-blue{% endif %}"
<span class="cpg-cal-day">{{ cell.day }}</span> href="/cupang/?year={{ year }}&month={{ month }}&date={{ cell.date }}"
<span class="cpg-cal-badges"> title="{{ cell.date }}{% if cell.counts.ship %} — 출고 {{ cell.counts.ship }}건 / 센터 {{ cell.counts.centers }}곳{% endif %}">
<span class="cpg-mcal-day">{{ cell.day }}</span>
{% if cell.counts.ship %} {% if cell.counts.ship %}
<span class="erp-badge erp-badge-inverse cpg-mini">출고 {{ cell.counts.ship }}건</span> <span class="cpg-mcal-tag">{{ cell.counts.ship }}건<em>{{ cell.counts.centers }}곳</em></span>
<span class="erp-badge erp-badge-neutral cpg-mini">센터 {{ cell.counts.centers }}곳</span>
{% endif %} {% endif %}
</span>
</a> </a>
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}
</div> </div>
</section>
{% endfor %}
</div>
<div class="cpg-mcal-legend">
<span><i class="cpg-lg-dot"></i>출고 있는 날</span>
<span><i class="cpg-lg-today"></i>오늘</span>
<span><i class="cpg-lg-sel"></i>선택한 날</span>
</div>
</div> </div>
<!-- ── 오른쪽: 선택일 출고 묶음 리스트 ── --> <!-- ── 오른쪽: 선택일 출고 묶음 리스트 ── -->
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901f" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901g" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
+134
View File
@@ -1126,3 +1126,137 @@ body.cpg-dragging { cursor: grabbing; user-select: none; }
.cpg-prod-right .cpg-row-actions { flex-wrap: nowrap; gap: 4px; } .cpg-prod-right .cpg-row-actions { flex-wrap: nowrap; gap: 4px; }
.cpg-prod-right .cpg-row-actions .erp-btn { padding: 3px 8px; font-size: 12px; line-height: 1.5; } .cpg-prod-right .cpg-row-actions .erp-btn { padding: 3px 8px; font-size: 12px; line-height: 1.5; }
.cpg-prod-right .cpg-active-toggle { white-space: nowrap; } .cpg-prod-right .cpg-active-toggle { white-space: nowrap; }
/* ══════════════════════════════════════════════════════════════
메인 달력 — 2개월(현재 달 + 다음 달) 월간 뷰
══════════════════════════════════════════════════════════════ */
.cpg-cal-card { padding: 16px 18px 14px; }
/* 상단 바: ‹ › + 기간 제목 + 오늘 */
.cpg-mcal-bar {
display: flex; align-items: center; gap: 12px;
padding-bottom: 12px; margin-bottom: 14px;
border-bottom: 1px solid var(--color-subtle-ash);
flex-shrink: 0;
}
.cpg-mcal-nav { display: inline-flex; gap: 4px; }
.cpg-mcal-navbtn {
width: 32px; height: 32px;
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid var(--color-subtle-ash); border-radius: 8px;
font-size: 17px; line-height: 1; color: var(--color-rich-black);
text-decoration: none; background: #fff;
transition: background .12s, border-color .12s;
}
.cpg-mcal-navbtn:hover { background: var(--color-ghost-gray); border-color: var(--color-midtone-gray); }
.cpg-mcal-range {
margin: 0; font-size: 19px; font-weight: 600; letter-spacing: -0.4px;
white-space: nowrap;
}
.cpg-mcal-todaybtn {
margin-left: auto;
padding: 6px 14px; border-radius: 999px;
border: 1px solid var(--color-subtle-ash); background: #fff;
font-size: 13px; color: var(--color-rich-black); text-decoration: none;
}
.cpg-mcal-todaybtn:hover { background: var(--color-ghost-gray); border-color: var(--color-midtone-gray); }
/* 두 달 나란히 */
.cpg-mcal-months {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 22px;
flex: 1 1 auto; min-height: 0;
}
@media (max-width: 1200px) {
.cpg-mcal-months { grid-template-columns: minmax(0, 1fr); }
}
.cpg-mcal { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
/* 달 머리글 */
.cpg-mcal-h {
display: flex; align-items: baseline; gap: 6px;
padding: 0 2px 8px;
}
.cpg-mcal-mname { font-size: 15px; font-weight: 700; letter-spacing: -0.2px; }
.cpg-mcal-myear { font-size: 12px; color: var(--color-midtone-gray); font-family: var(--font-geist-mono); }
.cpg-mcal-total {
margin-left: auto;
font-size: 11px; padding: 2px 8px; border-radius: 999px;
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
}
.cpg-mcal.is-current .cpg-mcal-total {
background: var(--color-deep-black); color: #fff;
}
/* 날짜 격자 */
.cpg-mcal-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-rows: minmax(0, 1fr);
gap: 2px;
flex: 1 1 auto; min-height: 0;
}
.cpg-mcal-wd {
text-align: center; padding: 4px 0 6px;
font-size: 11px; font-weight: 600; letter-spacing: .3px;
color: var(--color-midtone-gray);
border-bottom: 1px solid var(--color-subtle-ash);
}
.cpg-mcal-wd.is-sun { color: var(--color-callout-red); }
.cpg-mcal-wd.is-sat { color: #2b5bc2; }
.cpg-mcal-cell {
position: relative;
display: flex; flex-direction: column; align-items: center; gap: 3px;
min-height: 52px; min-width: 0; padding: 5px 4px 6px;
border-radius: 8px;
text-decoration: none; color: var(--color-rich-black);
transition: background .12s, box-shadow .12s;
}
.cpg-mcal-cell:hover { background: var(--color-ghost-gray); }
.cpg-mcal-day {
display: inline-flex; align-items: center; justify-content: center;
min-width: 24px; height: 24px; padding: 0 6px;
border-radius: 999px;
font-size: 13px; font-weight: 500; font-variant-numeric: tabular-nums;
}
.cpg-mcal-cell.is-red .cpg-mcal-day { color: var(--color-callout-red); }
.cpg-mcal-cell.is-blue .cpg-mcal-day { color: #2b5bc2; }
/* 이번 달이 아닌 날 — 흐리게 */
.cpg-mcal-cell.is-out { opacity: .38; }
/* 오늘 — 검은 원 */
.cpg-mcal-cell.is-today .cpg-mcal-day {
background: var(--color-deep-black); color: #fff; font-weight: 700;
}
/* 선택한 날 — 링 강조 */
.cpg-mcal-cell.is-selected {
background: var(--color-ghost-gray);
box-shadow: inset 0 0 0 1.5px var(--color-deep-black);
}
/* 출고 있는 날 — 건수/센터수 태그 */
.cpg-mcal-tag {
display: inline-flex; align-items: center; gap: 4px;
max-width: 100%; padding: 1px 7px;
border-radius: 999px;
background: var(--color-deep-black); color: #fff;
font-size: 11px; line-height: 1.6; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
.cpg-mcal-tag > em { font-style: normal; color: #c9c9c9; font-size: 10px; }
.cpg-mcal-cell.is-out .cpg-mcal-tag { background: var(--color-midtone-gray); }
/* 범례 */
.cpg-mcal-legend {
display: flex; gap: 16px; flex-wrap: wrap;
margin-top: 12px; padding-top: 10px;
border-top: 1px solid var(--color-subtle-ash);
font-size: 11px; color: var(--color-midtone-gray);
flex-shrink: 0;
}
.cpg-mcal-legend span { display: inline-flex; align-items: center; gap: 6px; }
.cpg-mcal-legend i { width: 12px; height: 12px; border-radius: 999px; display: inline-block; }
.cpg-lg-dot { background: var(--color-deep-black); }
.cpg-lg-today { background: var(--color-deep-black); box-shadow: 0 0 0 2px #fff, 0 0 0 3px var(--color-subtle-ash); }
.cpg-lg-sel { background: var(--color-ghost-gray); box-shadow: inset 0 0 0 1.5px var(--color-deep-black); }