style: 대시보드·과제 페이지 디자인 개편 — 히어로·진행률 통계·일차 그룹핑

- 대시보드: 인사+응원 문구 히어로, 진행률 % 큰 숫자+바, 일차별 완료 카운트
- 과제: 제출률 히어로, 일차별 그룹핑, 제출 완료 카드 청록 띠, 제출 폼 접기(details)
- global.css: compact 히어로·stat·day-head·assign-card·fold 클래스 추가 (기존 클래스 무변경)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-16 08:36:43 +09:00
parent c6d723824b
commit b834afad4b
3 changed files with 300 additions and 96 deletions

View File

@ -1,8 +1,7 @@
// : ,
// : () ,
// ( + ) · .
import { useEffect, useState } from 'react';
import client from '../api/client';
import Card from '../components/Card';
import Badge from '../components/Badge';
const WEEKS = [1, 2, 3, 4, 5, 6, 7, 8];
@ -43,67 +42,67 @@ function AssignmentCard({ assignment, submission, onSubmitted }) {
}
return (
<Card>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 8 }}>
// (.assign-card.submitted global.css )
<div className={`card assign-card${submission ? ' submitted' : ''}`}>
<div className="assign-title-row">
<Badge value={assignment.kind} />
<span className="badge badge-primary">{assignment.day}일차</span>
{submission && <Badge value={submission.status} />}
{submission ? (
<Badge value={submission.status} />
) : (
<span className="badge badge-rose">미제출</span>
)}
</div>
<div style={{ fontWeight: 800, fontSize: 16 }}>{assignment.title}</div>
<p className="muted" style={{ margin: '6px 0 14px', whiteSpace: 'pre-line' }}>
<div className="assign-title">{assignment.title}</div>
<p className="muted" style={{ margin: '6px 0 12px', whiteSpace: 'pre-line', fontSize: 14 }}>
{assignment.summary}
</p>
{/* 멘토 피드백이 있으면 눈에 띄게 보여준다 */}
{submission?.feedback && (
<div
style={{
background: 'var(--primary-soft)',
borderRadius: 10,
padding: '10px 14px',
marginBottom: 14,
}}
>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--primary)' }}>
멘토 피드백
</div>
<div style={{ whiteSpace: 'pre-line' }}>{submission.feedback}</div>
<div className="tip" style={{ marginBottom: 12 }}>
<b>멘토 피드백</b>
<div style={{ whiteSpace: 'pre-line', marginTop: 4 }}>{submission.feedback}</div>
</div>
)}
<form onSubmit={handleSubmit}>
<div className="field">
<label>제출 내용</label>
<textarea
className="textarea"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="무엇을 했고, 무엇을 배웠는지 적어 주세요."
/>
</div>
<div className="field">
<label>링크 (선택)</label>
<input
className="input"
value={link}
onChange={(e) => setLink(e.target.value)}
placeholder="예: Gitea 저장소 주소, 결과물 링크"
/>
</div>
{error && <p className="error-text">{error}</p>}
{savedMessage && (
<p style={{ color: 'var(--teal)', fontSize: 14, marginBottom: 8 }}>{savedMessage}</p>
)}
<button className="btn btn-primary" type="submit" disabled={saving}>
{saving ? '제출 중...' : submission ? '다시 제출하기' : '제출하기'}
</button>
{submission && (
<span className="muted" style={{ fontSize: 13, marginLeft: 10 }}>
마지막 제출: {new Date(submission.submittedAt).toLocaleString('ko-KR')}
</span>
)}
</form>
</Card>
{/* : <details> JS / HTML .
제출 완료된 과제는 폼을 접어두고, 미제출 과제는 펼쳐서 바로 쓰게 한다. */}
<details className="fold" open={!submission}>
<summary>{submission ? '다시 제출하기' : '제출하기'}</summary>
<form onSubmit={handleSubmit}>
<div className="field">
<label>제출 내용</label>
<textarea
className="textarea"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="무엇을 했고, 무엇을 배웠는지 적어 주세요."
/>
</div>
<div className="field">
<label>링크 (선택)</label>
<input
className="input"
value={link}
onChange={(e) => setLink(e.target.value)}
placeholder="예: Gitea 저장소 주소, 결과물 링크"
/>
</div>
{error && <p className="error-text">{error}</p>}
{savedMessage && (
<p style={{ color: 'var(--teal)', fontSize: 14, marginBottom: 8 }}>{savedMessage}</p>
)}
<button className="btn btn-primary" type="submit" disabled={saving}>
{saving ? '제출 중...' : submission ? '다시 제출하기' : '제출하기'}
</button>
{submission && (
<span className="muted" style={{ fontSize: 13, marginLeft: 10 }}>
마지막 제출: {new Date(submission.submittedAt).toLocaleString('ko-KR')}
</span>
)}
</form>
</details>
</div>
);
}
@ -144,10 +143,40 @@ export default function AssignmentsPage() {
return mySubmissions.find((s) => s.assignmentId === assignmentId) || null;
}
// ( )
const submittedCount = assignments.filter((a) => findSubmission(a.id)).length;
const percent =
assignments.length === 0 ? 0 : Math.round((submittedCount / assignments.length) * 100);
// () .
const groups = new Map();
for (const assignment of assignments) {
const key = `${assignment.day}일차`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(assignment);
}
return (
<div>
<h1 className="page-title">과제</h1>
<p className="page-desc">주차를 선택해 과제를 확인하고 제출하세요.</p>
<div className="hero compact">
<div className="eyebrow">Assignments</div>
<h1>{week}주차 과제</h1>
<p>
매일 16:30까지 제출해요. 과제가 일찍 끝나면 예비 과제로
예비 과제도 평가에 반영됩니다.
</p>
<div className="chip-row" style={{ alignItems: 'center' }}>
<div className="stat">
<span className="stat-num">{percent}%</span>
<span className="stat-label">
{submittedCount} / {assignments.length} 제출
</span>
</div>
<div className="progress-track" style={{ flex: 1, minWidth: 160 }}>
<div className="progress-fill" style={{ width: `${percent}%` }} />
</div>
</div>
</div>
<div className="tabs">
{WEEKS.map((w) => (
@ -164,18 +193,37 @@ export default function AssignmentsPage() {
{loading ? (
<p className="empty">불러오는 ...</p>
) : assignments.length === 0 ? (
<p className="empty"> 주차에는 과제가 없어요.</p>
<div className="card">
<p className="empty">
주차에는 등록된 과제가 없어요.
<br />
<span style={{ fontSize: 13 }}>5주차부터는 실전 티켓이 과제를 대신해요.</span>
</p>
</div>
) : (
assignments.map((assignment) => (
<AssignmentCard
// : key "id-" ,
// ( ) .
key={`${assignment.id}-${findSubmission(assignment.id)?.id ?? 'none'}`}
assignment={assignment}
submission={findSubmission(assignment.id)}
onSubmitted={loadMySubmissions}
/>
))
[...groups.entries()].map(([groupName, groupAssignments]) => {
const groupSubmitted = groupAssignments.filter((a) => findSubmission(a.id)).length;
return (
<div key={groupName}>
<div className="day-head">
<span className="day-badge">{groupName}</span>
<span className="day-count">
{groupSubmitted} / {groupAssignments.length} 제출
</span>
</div>
{groupAssignments.map((assignment) => (
<AssignmentCard
// : key "id-" ,
// ( ) .
key={`${assignment.id}-${findSubmission(assignment.id)?.id ?? 'none'}`}
assignment={assignment}
submission={findSubmission(assignment.id)}
onSubmitted={loadMySubmissions}
/>
))}
</div>
);
})
)}
</div>
);

View File

@ -1,13 +1,24 @@
// : (1~8) ,
// . .
// . .
import { useEffect, useState } from 'react';
import client from '../api/client';
import ProgressBar from '../components/ProgressBar';
import { useAuth } from '../AuthContext';
import Card from '../components/Card';
const WEEKS = [1, 2, 3, 4, 5, 6, 7, 8];
// (%) .
// : " " .
function cheerMessage(percent) {
if (percent === 100) return '이번 주 완주! 정말 수고했어요 🎉';
if (percent >= 70) return '거의 다 왔어요, 조금만 더!';
if (percent >= 30) return '좋은 흐름이에요. 이대로 쭉!';
if (percent > 0) return '시작이 반이에요. 하나씩 채워봐요.';
return '이번 주 첫 체크를 눌러볼까요?';
}
export default function DashboardPage() {
const { user } = useAuth();
const [week, setWeek] = useState(1);
const [items, setItems] = useState([]); //
const [checkedIds, setCheckedIds] = useState(new Set()); // id
@ -53,6 +64,7 @@ export default function DashboardPage() {
}
const doneCount = items.filter((item) => checkedIds.has(item.id)).length;
const percent = items.length === 0 ? 0 : Math.round((doneCount / items.length) * 100);
// day (day null " ").
const groups = new Map();
@ -64,8 +76,25 @@ export default function DashboardPage() {
return (
<div>
<h1 className="page-title">대시보드</h1>
<p className="page-desc">주차를 선택하고, 완료한 항목을 체크하세요.</p>
{/* 히어로: 인사 + 이번 주 진행률을 한눈에 */}
<div className="hero compact">
<div className="eyebrow">Dashboard</div>
<h1>
{user?.name}, {week}주차 진행 상황이에요
</h1>
<p>{cheerMessage(percent)}</p>
<div className="chip-row" style={{ alignItems: 'center' }}>
<div className="stat">
<span className="stat-num">{percent}%</span>
<span className="stat-label">
{doneCount} / {items.length} 완료
</span>
</div>
<div className="progress-track" style={{ flex: 1, minWidth: 160 }}>
<div className="progress-fill" style={{ width: `${percent}%` }} />
</div>
</div>
</div>
<div className="tabs">
{WEEKS.map((w) => (
@ -79,36 +108,46 @@ export default function DashboardPage() {
))}
</div>
<Card>
<ProgressBar value={doneCount} total={items.length} />
</Card>
{loading ? (
<p className="empty">불러오는 ...</p>
) : items.length === 0 ? (
<p className="empty"> 주차에는 체크리스트가 없어요.</p>
<Card>
<p className="empty">
주차에는 체크리스트가 없어요.
<br />
<span style={{ fontSize: 13 }}>궁금하면 멘토에게 물어보세요!</span>
</p>
</Card>
) : (
[...groups.entries()].map(([groupName, groupItems]) => (
<div key={groupName}>
<h2 className="section-title">{groupName}</h2>
<Card style={{ padding: '8px 16px' }}>
{groupItems.map((item) => {
const done = checkedIds.has(item.id);
return (
// : label ().
<label key={item.id} className={`check-row${done ? ' done' : ''}`}>
<input
type="checkbox"
checked={done}
onChange={() => toggle(item.id)}
/>
<span className="check-label">{item.label}</span>
</label>
);
})}
</Card>
</div>
))
[...groups.entries()].map(([groupName, groupItems]) => {
const groupDone = groupItems.filter((i) => checkedIds.has(i.id)).length;
return (
<div key={groupName}>
<div className="day-head">
<span className="day-badge">{groupName}</span>
<span className="day-count">
{groupDone} / {groupItems.length} 완료
</span>
</div>
<Card style={{ padding: '8px 16px' }}>
{groupItems.map((item) => {
const done = checkedIds.has(item.id);
return (
// : label ().
<label key={item.id} className={`check-row${done ? ' done' : ''}`}>
<input
type="checkbox"
checked={done}
onChange={() => toggle(item.id)}
/>
<span className="check-label">{item.label}</span>
</label>
);
})}
</Card>
</div>
);
})
)}
</div>
);

View File

@ -626,3 +626,120 @@ inline-code, .icode {
border-radius: 5px;
padding: 1px 6px;
}
/* 컴팩트 히어로 — 대시보드·과제처럼 매일 보는 페이지용 (가이드 히어로보다 낮게) */
.hero.compact {
padding: 22px 24px 20px;
margin-bottom: 18px;
}
.hero.compact h1 {
font-size: clamp(21px, 3.2vw, 26px);
margin: 8px 0 6px;
}
/* 큰 숫자 통계 (진행률 등) */
.stat-row {
display: flex;
align-items: center;
gap: 22px;
flex-wrap: wrap;
}
.stat {
display: flex;
align-items: baseline;
gap: 6px;
}
.stat .stat-num {
font-size: 34px;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--primary);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.stat .stat-label {
font-size: 13px;
font-weight: 600;
color: var(--muted);
}
/* 요일(일차) 섹션 헤더 — 배지 + 제목 */
.day-head {
display: flex;
align-items: center;
gap: 10px;
margin: 22px 0 10px;
}
.day-head .day-badge {
flex: none;
padding: 3px 12px;
border-radius: 8px;
background: var(--primary);
color: #fff;
font-size: 12.5px;
font-weight: 800;
letter-spacing: 0.02em;
}
.day-head .day-count {
font-size: 13px;
color: var(--muted);
font-weight: 600;
}
/* 제출 완료된 과제 카드 표시 — 왼쪽에 청록 띠 */
.assign-card {
position: relative;
overflow: hidden;
}
.assign-card.submitted::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--teal);
}
.assign-title-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.assign-title {
font-weight: 800;
font-size: 16px;
letter-spacing: -0.01em;
}
/* 접었다 펴는 상세 — 학습 포인트: <details>는 JS 없이도 접기/펼치기가 되는 HTML 기본 기능 */
.fold summary {
cursor: pointer;
font-size: 13.5px;
font-weight: 700;
color: var(--primary);
list-style: none;
user-select: none;
}
.fold summary::before {
content: "▸ ";
}
.fold[open] summary::before {
content: "▾ ";
}
.fold[open] summary {
margin-bottom: 10px;
}