diff --git a/budget/services.py b/budget/services.py index b7768a7..099346e 100644 --- a/budget/services.py +++ b/budget/services.py @@ -1,5 +1,12 @@ from decimal import Decimal, ROUND_HALF_UP +from datetime import datetime, time, timedelta +from django.db.models import Sum +from django.utils import timezone +import zoneinfo + +KST = zoneinfo.ZoneInfo("Asia/Seoul") + def calculate_earn_minutes(duration_min, rate): duration_min = Decimal(str(duration_min)) @@ -38,4 +45,166 @@ def convert_earn_to_unit(earn_minutes, conversion_rate): return converted_unit.quantize( Decimal("0.01"), rounding=ROUND_HALF_UP, - ) \ No newline at end of file + ) + + + + + +def get_week_range(reference_dt=None): + """이번 주 (월요일 00:00, 다음 주 월요일 00:00) — Asia/Seoul. + 잔액·마감·결산이 전부 이 함수를 임포트해서 같은 경계를 쓰게 함.""" + if reference_dt is None: + reference_dt = timezone.now() + local = reference_dt.astimezone(KST) + monday_date = (local - timedelta(days=local.weekday())).date() + start = datetime.combine(monday_date, time.min, tzinfo=KST) + end = start + timedelta(days=7) + return start, end + + +def get_today_kst(): + return timezone.now().astimezone(KST).date() + + +def get_current_balance(user, reference_dt=None): + """이번 주 잔액 = weekly_budget_min + Σearn_min - Σduration_min. + 음수 허용. 저장 안 함 — 매번 집계.""" + from ledger.models import EarnRecord, SpendRecord + + start, end = get_week_range(reference_dt) + start_date, end_date = start.date(), end.date() + + earned = EarnRecord.objects.filter( + users=user, + earn_date__gte=start_date, earn_date__lt=end_date, + ).aggregate(total=Sum('earn_min'))['total'] or Decimal('0') + + spent_min = SpendRecord.objects.filter( + users=user, + spend_date__gte=start_date, spend_date__lt=end_date, + ).aggregate(total=Sum('duration_min'))['total'] or 0 + + return user.weekly_budget_min + earned - Decimal(spent_min) + + +def get_week_summary(user, reference_dt=None): + """홈 화면 컨텍스트용. 표시 문자열은 view에서 별도 포맷.""" + from ledger.models import EarnRecord, SpendRecord + + start, end = get_week_range(reference_dt) + start_date, end_date = start.date(), end.date() + + earned = EarnRecord.objects.filter( + users=user, earn_date__gte=start_date, earn_date__lt=end_date, + ).aggregate(total=Sum('earn_min'))['total'] or Decimal('0') + + spent = Decimal( + SpendRecord.objects.filter( + users=user, spend_date__gte=start_date, spend_date__lt=end_date, + ).aggregate(total=Sum('duration_min'))['total'] or 0 + ) + + budget = user.weekly_budget_min + balance = budget + earned - spent + total_available = budget + earned # 게이지 분모: 이번 주에 쓸 수 있었던 총량 + percent = ( + int((spent / total_available) * 100) if total_available > 0 else 0 + ) + return { + 'balance': balance, + 'budget': budget, + 'earned': earned, + 'spent': spent, + 'percent_used': min(percent, 999), # 표시용(초과 시 999까지) + 'percent_used_capped': min(percent, 100), # bar 너비용 + 'is_overspent': balance < 0, + } + + + #def is_overspent(user, reference_dt=None): + # """지출 저장 직후 이걸로 판정해서 True면 크루 피드 이벤트 생성. + # 강성훈이 지출 저장 로직에서 호출.""" + # return get_current_balance(user, reference_dt) < 0 + +# ---------- 표시 헬퍼 ---------- + +def format_minutes_display(m): + """정수 분 → '1시간 30분' / '-30분'. 부호 그대로.""" + m = int(m) + sign = "-" if m < 0 else "" + m = abs(m) + h, r = divmod(m, 60) + if h and r: return f"{sign}{h}시간 {r}분" + if h: return f"{sign}{h}시간" + return f"{sign}{r}분" + + +def format_unit_display(minutes, conversion_base, unit_label): + """분 → 환산 단위 (소수점 1자리). '3.8권' / '-0.5권'. + base가 없거나 0 이하면 시간 표시로 폴백.""" + if not conversion_base or Decimal(str(conversion_base)) <= 0: + return format_minutes_display(minutes) + m = Decimal(str(minutes)) + sign = "-" if m < 0 else "" + val = (abs(m) / Decimal(str(conversion_base))).quantize( + Decimal("0.1"), rounding=ROUND_HALF_UP, + ) + return f"{sign}{val}{unit_label or ''}" + + +# ---------- 표시용 카테고리 매핑 ---------- +# category에 실제 저장되는 영문 값 → 화면에 보여줄 한글 이름 +CATEGORY_DISPLAY_NAMES = { + 'short_form': '숏폼', + # 다른 카테고리 값이 더 있으면 여기에 추가해주세요. +} + + +def get_category_display(category): + """매핑에 없는 값은 원래 값 그대로 반환(누락 방지용 안전장치).""" + return CATEGORY_DISPLAY_NAMES.get(category, category) + + +# ---------- 오늘 기록 ---------- + +def get_today_records(user): + """오늘의 지출·수입 기록을 시작 시각순 리스트로.""" + from ledger.models import EarnRecord, SpendRecord + + today = get_today_kst() + earns = EarnRecord.objects.filter( + users=user, earn_date=today + ).select_related('activity') + spends = SpendRecord.objects.filter(users=user, spend_date=today) + + records = [] + for e in earns: + records.append({ + 'label': e.activity.activity_type, # "독서 30분" → "독서" + 'signed_min': e.earn_min, + 'is_positive': True, + 'started_at': e.earn_start, + }) + for s in spends: + records.append({ + 'label': get_category_display(s.category), # "short_form 지출" → "숏폼" + 'signed_min': Decimal(-s.duration_min), + 'is_positive': False, + 'started_at': s.spend_start, + }) + records.sort(key=lambda r: r['started_at']) + return records + + +def attach_value_displays(records, mode='minutes', conversion_base=None, unit_label=None): + """records에 value_display 붙임. mode='minutes' | 'unit'.""" + for r in records: + if mode == 'unit': + val = format_unit_display(r['signed_min'], conversion_base, unit_label) + else: + val = format_minutes_display(r['signed_min']) + if r['signed_min'] > 0 and not val.startswith('+'): + val = '+' + val + r['value_display'] = val + return records \ No newline at end of file diff --git a/ledger/static/ledger/css/main_convert.css b/ledger/static/ledger/css/main_convert.css index 67ef29d..175079c 100644 --- a/ledger/static/ledger/css/main_convert.css +++ b/ledger/static/ledger/css/main_convert.css @@ -1,56 +1,3 @@ -/* ===== 환산 카드 (도넛 차트 + 환산 값 + 예산 요약) ===== */ -.donut-card { - background: #ffffff; - border-radius: 15px; - padding: 20px 16px; - display: flex; - flex-direction: column; - align-items: center; - gap: 24px; -} - -/* 도넛 차트 + 중앙 텍스트 오버레이 */ -.donut-card__chart-wrap { - position: relative; - width: 186px; - height: 186px; -} - -.donut-card__chart { - width: 186px; - height: 186px; -} - -.donut-card__center { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; -} - -.donut-card__value { - font-size: 24px; - font-weight: 700; - color: #000000; -} - -.donut-card__unit-label { - font-size: 13px; - font-weight: 400; - color: #70737c; -} - -.donut-card__switch { - font-size: 16px; - font-weight: 700; - color: #ff7a00; - text-decoration: none; -} - /* ===== 환산된 기록 리스트 (박스형 카드) ===== */ .converted-record-list { list-style: none; @@ -86,31 +33,4 @@ .converted-record-list__value--negative { color: #ff7a00; -} - -/* ===== 보기 방식 토글 ===== */ -.view-toggle { - background: #f0e5d5; - border-radius: 30px; - padding: 3px 4px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.view-toggle__option { - border: none; - background: none; - text-decoration: none; - font-size: 13px; - font-weight: 600; - color: #70737c; - padding: 13px 37px; - border-radius: 30px; - cursor: pointer; -} - -.view-toggle__option--active { - background: #ff7a00; - color: #ffffff; } \ No newline at end of file diff --git a/ledger/static/ledger/css/main_progress.css b/ledger/static/ledger/css/main_progress.css index 20745f6..d60ba11 100644 --- a/ledger/static/ledger/css/main_progress.css +++ b/ledger/static/ledger/css/main_progress.css @@ -1,27 +1,3 @@ -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - background: #f5f5f5; - font-family: 'Inter', sans-serif; -} - -/* ===== 전체 컨테이너 ===== */ -.main-progress { - background: #fffefc; - border-radius: 28px; - border: 1px solid #70737c; - width: 393px; - min-height: 852px; - margin: 0 auto; - display: flex; - flex-direction: column; - overflow: hidden; -} - /* ===== 상단 헤더 (로고 + 알림) ===== */ .app-header { display: flex; @@ -41,79 +17,97 @@ body { height: 24px; } -/* ===== 본문 영역 ===== */ -.app-content { - flex: 1; - display: flex; - flex-direction: column; - gap: 24px; - padding: 10px 33px 20px; -} - -/* ===== 보기 방식 토글 ===== */ +/* ===== 보기 방식 토글 (카드 + 언더라인) ===== */ .view-toggle { - background: #f0e5d5; - border-radius: 30px; - padding: 3px 4px; + background: #ffffff; + border: 1px solid #c7c7c7; + border-radius: 10px; display: flex; - align-items: center; - justify-content: space-between; + align-items: stretch; + overflow: hidden; } + .view-toggle__option { + flex: 1; + box-sizing: border-box; border: none; background: none; text-decoration: none; font-size: 13px; font-weight: 600; color: #70737c; - padding: 13px 37px; - border-radius: 30px; + padding: 15px 10px; + text-align: center; cursor: pointer; + position: relative; +} + +/* 두 번째 옵션 앞에 세로 구분선 (line-4 대체) */ +.view-toggle__option + .view-toggle__option { + border-left: 1px solid #c7c7c7; } .view-toggle__option--active { + color: #ff7a00; +} + +/* 활성 옵션 하단 언더라인 (rectangle-27 대체) */ +.view-toggle__option--active::after { + content: ''; + position: absolute; + left: 12px; + right: 12px; + bottom: 0; + height: 2px; background: #ff7a00; - color: #ffffff; + border-radius: 10px; } -/* ===== 오늘 남은 시간 ===== */ +/* ===== 이번 주 남은 시간 ===== */ .remaining-time { display: flex; flex-direction: column; align-items: center; - gap: 14px; + gap: 0 } .remaining-time__label { font-size: 13px; font-weight: 400; color: #000000; + padding: 0; + margin : 10 0 0 0; } .remaining-time__value { font-size: 32px; font-weight: 700; color: #000000; + padding: 0; + margin: 10px } /* ===== 예산 진행률 ===== */ -.budget-progress__bar-fill { - background: #ff7a00; - border-radius: 30px; - height: 8px; -} - .budget-progress__header { display: flex; align-items: center; justify-content: space-between; + gap: 10px; + margin-bottom: 8px; } .budget-progress__desc { font-size: 13px; font-weight: 400; color: #70737c; + line-height: 1.45; +} + +/* convert의 둘째 줄 강조 */ +.budget-progress__desc-strong { + font-weight: 700; + color: #70737c; } .budget-progress__percent { @@ -122,32 +116,36 @@ body { color: #ff7a00; } +/* 바깥 트랙: 안 채워진 부분 */ .budget-progress__bar { background: #f0e5d5; border-radius: 30px; width: 100%; height: 8px; + overflow: hidden; /* fill이 혹시라도 넘쳐도 트랙 밖으로 안 삐져나옴 */ } +/* 안쪽 채움: width는 HTML에서 style="width: {{ percent_used_capped }}%"로 주입 */ +/* 안쪽 채움: width는 HTML에서 style="width: {{ percent_used_capped }}%"로 주입 */ +/* 안쪽 채움: width는 extra_js의 스크립트가 data-percent를 읽어서 주입 */ +/* 안쪽 채움: width는 HTML의 인라인 style로 주입됨 */ .budget-progress__bar-fill { background: #ff7a00; border-radius: 30px; - width: 50px; /* 사용률에 따라 JS로 동적 변경 예정 */ height: 8px; + width: 0; /* 기본값. 인라인 style이 덮어씀 */ + transition: width 0.4s ease; /* 값이 바뀔 때 부드럽게 */ } - -/* ===== 오늘 예산 / 사용 / 적립 요약 ===== */ +/* ===== 이번 주 예산 / 사용 / 적립 요약 ===== */ .budget-summary { - border-top: 1px solid #c7c7c7; - border-bottom: 1px solid #c7c7c7; - padding: 10px 0; + padding: 0; } .budget-summary__list { list-style: none; display: flex; - justify-content: center; - gap: 44px; + justify-content: space-evenly; /* 모든 간격(양쪽 끝 포함)이 동일하게 */ + padding: 0; } .budget-summary__item { @@ -155,13 +153,14 @@ body { flex-direction: column; align-items: center; gap: 7px; - width: 70px; + width: auto; /* 고정 70px 제거 */ } .budget-summary__label { font-size: 13px; font-weight: 400; color: #70737c; + white-space: nowrap; } .budget-summary__value { @@ -200,6 +199,7 @@ body { .record-list { list-style: none; + padding: 0; } .record-list__item { @@ -277,58 +277,21 @@ body { color: #ffffff; font-size: 16px; font-weight: 700; + height: 13px; /* 높이 고정: padding만으로는 높이가 일정하지 않을 수 있어서 */ } -/* ===== 하단 네비게이션 ===== */ -.bottom-nav { - border-top: 1px solid #c7c7c7; - padding: 10px 30px; - position: sticky; - bottom: 0; +/* ===== 진행률+요약 감싸는 흰 카드 (main_progress 전용) ===== */ +.budget-card { background: #ffffff; -} - -.bottom-nav__list { - list-style: none; - display: flex; - justify-content: center; - gap: 45px; -} - -.nav-item { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - width: 30px; -} - -.nav-item__icon { - width: 24px; - height: 24px; - display: block; - overflow: hidden; - text-indent: 100%; - white-space: nowrap; - color: transparent; /* 일부 브라우저에서 alt 텍스트 색상에 영향 */ -} - -.nav-item__label { - font-size: 16px; - font-weight: 400; - color: #70737c; -} - -.nav-item__link { + border-radius: 10px; + padding: 20px; display: flex; flex-direction: column; - align-items: center; - gap: 4px; - text-decoration: none; - color: inherit; /* 링크 기본 파란색 상속 차단 */ + gap: 20px; + box-shadow: 0 0 2px rgba(0, 0, 0, 0.25); } -.nav-item--active .nav-item__label { - font-weight: 600; - color: #e2672a; +.app-header__notification-link { + display: inline-flex; /* 이미지 크기에 딱 맞춰서 불필요한 클릭 영역/여백 방지 */ + line-height: 0; /* 인라인 요소 특유의 하단 여백 제거 */ } \ No newline at end of file diff --git a/ledger/static/ledger/record.css b/ledger/static/ledger/css/record.css similarity index 90% rename from ledger/static/ledger/record.css rename to ledger/static/ledger/css/record.css index 1bcc8a5..6c5fc74 100644 --- a/ledger/static/ledger/record.css +++ b/ledger/static/ledger/css/record.css @@ -47,30 +47,30 @@ box-sizing: border-box; } -body.ledger-page { - margin: 0; - background: var(--bg); - font-family: "Pretendard", -apple-system, BlinkMacSystemFont, "Malgun Gothic", sans-serif; +.ledger-screen { + width: 100%; color: var(--text-main); display: flex; - justify-content: center; -} + flex-direction: column; + flex: 1; -.ledger-screen { - width: 100%; - max-width: 420px; - min-height: 100vh; - background: var(--card-bg); - border-left: 1px solid var(--border); - border-right: 1px solid var(--border); - padding: 20px 20px 32px; } .ledger-topbar { display: flex; align-items: center; - gap: 12px; + justify-content: space-between; margin-bottom: 20px; + padding-top: 30px; + position: relative; +} + +.ledger-title { + font-size: var(--fs-md); + font-weight: 700; + position: absolute; + left: 50%; + transform: translateX(-50%); } .ledger-back { @@ -208,18 +208,19 @@ body.ledger-page { .timer-circle-wrap { display: flex; justify-content: center; - margin-bottom: 28px; + margin-bottom: 20px; } .timer-circle { - width: 200px; - height: 200px; + width: 170px; + height: 170px; border-radius: 50%; border: 2px solid var(--border); display: flex; flex-direction: column; align-items: center; justify-content: center; + flex-shrink: 0; } .timer-circle-time { @@ -241,6 +242,17 @@ body.ledger-page { margin-top: 12px; } +#earn-form { + display: flex; + flex-direction: column; + flex: 1; +} + +#result-panel { + flex-direction: column; + flex: 1; +} + .btn { flex: 1; padding: 15px 0; @@ -302,15 +314,6 @@ body.ledger-page { } .manual-time-field input:focus { outline: none; } -.earn-preview { - background: var(--earn-bg); - color: var(--earn-accent-dark); - border-radius: var(--radius-sm); - padding: 10px 12px; - font-size: var(--fs-sm); - font-weight: 600; - margin-bottom: 20px; -} /* 적립 결과 카드 (수입 종료 후 적립 화면) */ .result-summary { @@ -344,6 +347,10 @@ body.ledger-page { margin-bottom: 12px; } +.field-block + .footer-actions { + margin-top: 70px; +} + .field-label { font-size: var(--fs-sm); color: var(--text-sub); @@ -351,26 +358,35 @@ body.ledger-page { display: block; } -.toggle-pair { +.field-block--readonly { + background: var(--bg); /* 카드 배경과 살짝 다르게 -> "조작 불가 영역"이라는 시각적 힌트 */ +} + +.readonly-badge-row { display: flex; gap: 8px; } -.toggle-pill { +.readonly-badge { flex: 1; text-align: center; padding: 8px 0; border-radius: 999px; font-size: var(--fs-sm); - font-weight: 700; - border: 1px solid var(--border); - color: var(--text-sub); + font-weight: 600; + border: 1px solid var(--text-faint); + color: var(--text-faint); + background: transparent; + /* 버튼처럼 안 보이게: 그림자·hover·cursor 전부 제거 */ + cursor: default; + user-select: none; } -.toggle-pill.is-selected { +.readonly-badge.is-selected { background: var(--earn-accent); border-color: var(--earn-accent); color: #fff; + font-weight: 700; } .field-note { diff --git a/ledger/static/ledger/images/icon-bell.svg b/ledger/static/ledger/images/icon-bell.svg new file mode 100644 index 0000000..5dc4146 --- /dev/null +++ b/ledger/static/ledger/images/icon-bell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ledger/static/ledger/images/logo.png b/ledger/static/ledger/images/logo.png new file mode 100644 index 0000000..a54e8f6 Binary files /dev/null and b/ledger/static/ledger/images/logo.png differ diff --git a/ledger/templates/ledger/earn_record_form.html b/ledger/templates/ledger/earn_record_form.html index 2a095f8..13eb775 100644 --- a/ledger/templates/ledger/earn_record_form.html +++ b/ledger/templates/ledger/earn_record_form.html @@ -1,3 +1,4 @@ +{% extends "base.html" %} {% comment %} 와이어프레임 3장(수입기록-타이머 / 수입기록-수동입력 / 수입 종료 후 적립) 통합 화면 동작 방식 (views.py earn_record_create 기준): @@ -7,267 +8,274 @@ 주의: activity