Skip to content

Commit 550b5db

Browse files
authored
Merge pull request #251 from kw-coms/codex/calendar-quick-add-and-hub-cards
[codex] Add calendar quick-add and activity hub cards
2 parents a5a05b9 + ac43113 commit 550b5db

3 files changed

Lines changed: 253 additions & 2 deletions

File tree

src/App.jsx

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
X,
2121
} from 'lucide-react'
2222
import { listNotices } from './services/noticeApi.js'
23-
import { listClubActivities } from './services/clubActivityApi.js'
23+
import { createClubActivity, listClubActivities } from './services/clubActivityApi.js'
2424
import { getNotificationSummary, listNotifications, markAllNotificationsRead, markNotificationRead } from './services/notificationApi.js'
2525
import { listFonts } from './services/fontApi.js'
2626
import { BUILT_IN_FONTS, buildFontFaceCss, fontFamilyValue, injectBuiltinFontStylesheets } from './services/fontPreferences.js'
@@ -111,6 +111,18 @@ const showcaseItems = [
111111
]
112112

113113
const activityHubItems = [
114+
{
115+
title: 'Activity log',
116+
body: '관리자가 등록한 실제 활동 기록과 사진을 회원에게 보여줍니다.',
117+
route: '/activities',
118+
cta: '활동 기록 보기',
119+
},
120+
{
121+
title: 'Monthly calendar',
122+
body: '정기 회의, 세미나, 발표, 모집 마감을 월별 일정으로 확인합니다.',
123+
route: '/activities',
124+
cta: '일정 보기',
125+
},
114126
{
115127
title: '공지사항',
116128
body: '모집, 세미나, 운영 안내를 빠르게 확인합니다.',
@@ -390,6 +402,18 @@ function categoryLabel(value) {
390402
return labels[value] || value || '일반'
391403
}
392404

405+
const clubActivityCategories = [
406+
['GENERAL', '일반'],
407+
['SEMINAR', '세미나'],
408+
['STUDY', '스터디'],
409+
['PROJECT', '프로젝트'],
410+
['MEETING', '회의'],
411+
['RECRUIT', '모집'],
412+
['EVENT', '행사'],
413+
['MT', 'MT'],
414+
['ACHIEVEMENT', '성과'],
415+
]
416+
393417
const projectsDetailCards = [
394418
{
395419
title: 'Official Website',
@@ -1619,6 +1643,14 @@ function ClubCalendarSection({ compact = false }) {
16191643
const navigate = useNavigate()
16201644
const [records, setRecords] = useState(null)
16211645
const [error, setError] = useState('')
1646+
const [scheduleForm, setScheduleForm] = useState({
1647+
title: '',
1648+
eventDate: '',
1649+
category: 'MEETING',
1650+
description: '',
1651+
})
1652+
const [savingSchedule, setSavingSchedule] = useState(false)
1653+
const [scheduleNotice, setScheduleNotice] = useState('')
16221654

16231655
useEffect(() => {
16241656
if (authLoading || !user) {
@@ -1651,6 +1683,31 @@ function ClubCalendarSection({ compact = false }) {
16511683
return acc
16521684
}, {})
16531685
const isLocked = !authLoading && !user
1686+
const isAdmin = user?.role === 'ADMIN'
1687+
1688+
const submitSchedule = async (event) => {
1689+
event.preventDefault()
1690+
if (!scheduleForm.title.trim() || !scheduleForm.eventDate) return
1691+
setSavingSchedule(true)
1692+
setScheduleNotice('')
1693+
setError('')
1694+
try {
1695+
const created = await createClubActivity({
1696+
kind: 'SCHEDULE',
1697+
category: scheduleForm.category,
1698+
title: scheduleForm.title.trim(),
1699+
description: scheduleForm.description.trim(),
1700+
eventDate: scheduleForm.eventDate,
1701+
})
1702+
setRecords((prev) => [created, ...(Array.isArray(prev) ? prev : [])])
1703+
setScheduleNotice('일정을 추가했습니다.')
1704+
setScheduleForm((prev) => ({ ...prev, title: '', description: '', eventDate: '' }))
1705+
} catch (err) {
1706+
setError(err.message || '일정을 추가하지 못했습니다.')
1707+
} finally {
1708+
setSavingSchedule(false)
1709+
}
1710+
}
16541711

16551712
return (
16561713
<section className={`club-calendar-section ${compact ? 'club-calendar-section-compact' : ''} bg-[#f5f5f7] px-5 py-12 sm:py-16`}>
@@ -1669,6 +1726,57 @@ function ClubCalendarSection({ compact = false }) {
16691726
</div>
16701727
</div>
16711728

1729+
{isAdmin && !isLocked && (
1730+
<form onSubmit={submitSchedule} className="calendar-admin-composer mt-8" aria-label="캘린더 일정 추가">
1731+
<div>
1732+
<p className="calendar-admin-composer-title">관리자 일정 추가</p>
1733+
<p className="calendar-admin-composer-copy">캘린더에 바로 표시할 정기 회의, 세미나, 발표, 모집 마감 일정을 등록합니다.</p>
1734+
</div>
1735+
<label>
1736+
<span>일정 제목</span>
1737+
<input
1738+
value={scheduleForm.title}
1739+
onChange={(event) => setScheduleForm((prev) => ({ ...prev, title: event.target.value }))}
1740+
maxLength={120}
1741+
/>
1742+
</label>
1743+
<label>
1744+
<span>일정 날짜</span>
1745+
<input
1746+
type="date"
1747+
value={scheduleForm.eventDate}
1748+
onChange={(event) => setScheduleForm((prev) => ({ ...prev, eventDate: event.target.value }))}
1749+
/>
1750+
</label>
1751+
<label>
1752+
<span>일정 분류</span>
1753+
<select
1754+
value={scheduleForm.category}
1755+
onChange={(event) => setScheduleForm((prev) => ({ ...prev, category: event.target.value }))}
1756+
>
1757+
{clubActivityCategories.map(([value, label]) => (
1758+
<option key={value} value={value}>{label}</option>
1759+
))}
1760+
</select>
1761+
</label>
1762+
<label className="calendar-admin-composer-wide">
1763+
<span>일정 설명</span>
1764+
<input
1765+
value={scheduleForm.description}
1766+
onChange={(event) => setScheduleForm((prev) => ({ ...prev, description: event.target.value }))}
1767+
maxLength={500}
1768+
/>
1769+
</label>
1770+
<button
1771+
type="submit"
1772+
disabled={savingSchedule || !scheduleForm.title.trim() || !scheduleForm.eventDate}
1773+
>
1774+
{savingSchedule ? '추가 중...' : '일정 추가'}
1775+
</button>
1776+
{scheduleNotice && <p className="calendar-admin-composer-notice">{scheduleNotice}</p>}
1777+
</form>
1778+
)}
1779+
16721780
<div className="club-calendar-shell mt-8">
16731781
<div className="club-calendar-weekdays" aria-hidden="true">
16741782
{calendarWeekdays.map((weekday) => (
@@ -2195,7 +2303,7 @@ function HomeView() {
21952303
</div>
21962304
<button type="button" onClick={() => goPageTop('/notices')} className={ghostActionBtnClass}>최근 공지 보기</button>
21972305
</div>
2198-
<div className="mt-8 grid gap-3 lg:grid-cols-3">
2306+
<div className="mt-8 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
21992307
{activityHubItems.map((item, index) => (
22002308
<button
22012309
key={item.title}

src/index.css

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,91 @@ select {
769769
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
770770
}
771771

772+
.calendar-admin-composer {
773+
display: grid;
774+
grid-template-columns: minmax(14rem, 1.25fr) minmax(10rem, 1fr) minmax(9rem, 0.72fr) minmax(8rem, 0.72fr) auto;
775+
align-items: end;
776+
gap: 0.75rem;
777+
border: 1px solid var(--app-hairline);
778+
border-radius: 0.5rem;
779+
background: var(--app-surface);
780+
padding: 1rem;
781+
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.08);
782+
}
783+
784+
.calendar-admin-composer-title {
785+
margin: 0;
786+
color: var(--app-text);
787+
font-size: 0.95rem;
788+
font-weight: 850;
789+
}
790+
791+
.calendar-admin-composer-copy {
792+
margin: 0.35rem 0 0;
793+
color: var(--app-muted);
794+
font-size: 0.78rem;
795+
font-weight: 650;
796+
line-height: 1.5;
797+
}
798+
799+
.calendar-admin-composer label {
800+
display: grid;
801+
gap: 0.35rem;
802+
color: var(--app-muted);
803+
font-size: 0.74rem;
804+
font-weight: 850;
805+
}
806+
807+
.calendar-admin-composer input,
808+
.calendar-admin-composer select {
809+
width: 100%;
810+
min-height: 2.45rem;
811+
border: 1px solid var(--app-hairline);
812+
border-radius: 0.45rem;
813+
background: var(--app-surface-soft);
814+
padding: 0 0.75rem;
815+
color: var(--app-text);
816+
font-size: 0.9rem;
817+
font-weight: 750;
818+
outline: none;
819+
}
820+
821+
.calendar-admin-composer input:focus,
822+
.calendar-admin-composer select:focus {
823+
border-color: color-mix(in srgb, var(--app-accent) 64%, var(--app-hairline));
824+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-accent) 18%, transparent);
825+
}
826+
827+
.calendar-admin-composer button {
828+
min-height: 2.45rem;
829+
border: 0;
830+
border-radius: 9999px;
831+
background: var(--app-text);
832+
padding: 0 1rem;
833+
color: var(--app-bg);
834+
font-size: 0.86rem;
835+
font-weight: 850;
836+
white-space: nowrap;
837+
cursor: pointer;
838+
}
839+
840+
.calendar-admin-composer button:disabled {
841+
cursor: not-allowed;
842+
opacity: 0.5;
843+
}
844+
845+
.calendar-admin-composer-wide {
846+
grid-column: 2 / span 2;
847+
}
848+
849+
.calendar-admin-composer-notice {
850+
grid-column: 1 / -1;
851+
margin: 0;
852+
color: var(--app-accent-text);
853+
font-size: 0.8rem;
854+
font-weight: 800;
855+
}
856+
772857
.club-calendar-shell {
773858
overflow: hidden;
774859
border: 1px solid var(--app-hairline);
@@ -1750,6 +1835,14 @@ select {
17501835
justify-content: center;
17511836
}
17521837

1838+
.calendar-admin-composer {
1839+
grid-template-columns: 1fr;
1840+
}
1841+
1842+
.calendar-admin-composer-wide {
1843+
grid-column: auto;
1844+
}
1845+
17531846
.club-calendar-weekdays span {
17541847
padding: 0.65rem 0.25rem;
17551848
font-size: 0.72rem;

tests/e2e/app-smoke.spec.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,8 @@ test('activity hub points members back to notices community and archive', async
768768
await page.goto('/')
769769

770770
await expect(page.getByRole('heading', { name: '활동 허브' })).toBeVisible()
771+
await expect(page.getByRole('button', { name: /Activity log/ })).toBeVisible()
772+
await expect(page.getByRole('button', { name: /Monthly calendar/ })).toBeVisible()
771773
await expect(page.getByRole('heading', { name: '공지사항' })).toBeVisible()
772774
await expect(page.getByRole('heading', { name: '커뮤니티' })).toBeVisible()
773775
await expect(page.getByRole('heading', { name: '자료실' })).toBeVisible()
@@ -827,6 +829,54 @@ test('signed-in members see real activity records and schedule events', async ({
827829
await expect(page.getByText('로그인 하세요')).toHaveCount(0)
828830
})
829831

832+
test('admin can add a schedule directly from the monthly calendar', async ({ page }) => {
833+
await mockAdminApis(page)
834+
let createdPayload = null
835+
await page.route('**/api/club-activities', async (route) => {
836+
if (route.request().method() === 'POST') {
837+
const body = route.request().postData() || ''
838+
const form = {
839+
title: multipartField(body, 'title'),
840+
kind: multipartField(body, 'kind'),
841+
category: multipartField(body, 'category'),
842+
eventDate: multipartField(body, 'eventDate'),
843+
description: multipartField(body, 'description'),
844+
}
845+
createdPayload = form
846+
await route.fulfill({
847+
status: 200,
848+
json: {
849+
id: 20,
850+
kind: form.kind,
851+
category: form.category,
852+
title: form.title,
853+
description: form.description,
854+
eventDate: form.eventDate,
855+
imageUrl: null,
856+
imageOriginalName: null,
857+
createdByName: '관리자',
858+
},
859+
})
860+
return
861+
}
862+
await route.fulfill({ status: 200, json: [] })
863+
})
864+
865+
await page.goto('/activities')
866+
await page.getByLabel('일정 제목').fill('캘린더 직접 등록 회의')
867+
await page.getByLabel('일정 날짜').fill('2026-06-24')
868+
await page.getByLabel('일정 분류').selectOption('MEETING')
869+
await page.getByRole('button', { name: '일정 추가' }).click()
870+
871+
await expect.poll(() => createdPayload).toMatchObject({
872+
title: '캘린더 직접 등록 회의',
873+
kind: 'SCHEDULE',
874+
category: 'MEETING',
875+
eventDate: '2026-06-24',
876+
})
877+
await expect(page.getByText('캘린더 직접 등록 회의')).toBeVisible()
878+
})
879+
830880
test('admin can register a club activity record', async ({ page }) => {
831881
await mockAdminApis(page)
832882
let createdPayload = null

0 commit comments

Comments
 (0)