From cf72281c6d37e7aa86e53d0837017bc83bd5d92c Mon Sep 17 00:00:00 2001 From: EENE Dashboard Date: Mon, 8 Jun 2026 22:09:46 +0900 Subject: [PATCH] feat: quarter board theme, hub column, and team panel UX Apply preview-style 4-dept layout with center hub, PM/assignee team status linking, task type label updates, and remove task keywords. Co-authored-by: Cursor --- backend/prisma/mapHrProjects.ts | 2 - .../migration.sql | 3 + backend/prisma/schema.prisma | 1 - backend/scripts/sync-from-remote.ts | 2 - backend/scripts/sync-to-remote.ts | 1 - backend/src/routes/tasks.ts | 6 +- .../public/quarter-dashboard-preview.html | 1951 +++++++++++++++++ frontend/src/components/common/TaskModal.tsx | 20 +- .../components/dashboard/BoardConnectors.tsx | 11 + .../components/dashboard/DepartmentColumn.tsx | 165 +- .../src/components/dashboard/DonutGauge.tsx | 19 + .../src/components/dashboard/HubColumn.tsx | 402 ++++ .../dashboard/HubScheduleCarousel.tsx | 95 + .../dashboard/MemberTaskTooltip.tsx | 14 +- .../src/components/dashboard/TaskCard.tsx | 112 +- frontend/src/hooks/useBoardConnectors.ts | 234 ++ frontend/src/index.css | 111 +- frontend/src/lib/boardLayout.ts | 74 + frontend/src/lib/hubConfig.ts | 90 + frontend/src/lib/hubSchedule.ts | 130 ++ frontend/src/lib/taskFormPayload.ts | 1 - frontend/src/lib/taskStatusVisual.ts | 66 + frontend/src/lib/taskType.ts | 11 + frontend/src/lib/teamStatus.ts | 34 +- frontend/src/main.tsx | 1 + frontend/src/pages/DashboardPage.tsx | 222 +- frontend/src/styles/quarter-board.css | 1278 +++++++++++ frontend/src/types/index.ts | 1 - 28 files changed, 4743 insertions(+), 314 deletions(-) create mode 100644 backend/prisma/migrations/20260608120000_remove_task_keywords/migration.sql create mode 100644 frontend/public/quarter-dashboard-preview.html create mode 100644 frontend/src/components/dashboard/BoardConnectors.tsx create mode 100644 frontend/src/components/dashboard/DonutGauge.tsx create mode 100644 frontend/src/components/dashboard/HubColumn.tsx create mode 100644 frontend/src/components/dashboard/HubScheduleCarousel.tsx create mode 100644 frontend/src/hooks/useBoardConnectors.ts create mode 100644 frontend/src/lib/boardLayout.ts create mode 100644 frontend/src/lib/hubConfig.ts create mode 100644 frontend/src/lib/hubSchedule.ts create mode 100644 frontend/src/lib/taskStatusVisual.ts create mode 100644 frontend/src/styles/quarter-board.css diff --git a/backend/prisma/mapHrProjects.ts b/backend/prisma/mapHrProjects.ts index 2a8023a..9df5c19 100644 --- a/backend/prisma/mapHrProjects.ts +++ b/backend/prisma/mapHrProjects.ts @@ -41,7 +41,6 @@ export interface MappedTask { issueNote: string | null; startDate: Date | null; dueDate: Date | null; - keywords: string | null; showDate: boolean; showDescription: boolean; showStatus: boolean; @@ -194,7 +193,6 @@ export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTas issueNote: pickIssueNote(p), startDate: parseDate(p.startDate), dueDate: parseDate(p.endDate), - keywords: p.keywords?.length ? p.keywords.join(', ') : null, showDate: visible, showDescription: visible, showStatus: visible, diff --git a/backend/prisma/migrations/20260608120000_remove_task_keywords/migration.sql b/backend/prisma/migrations/20260608120000_remove_task_keywords/migration.sql new file mode 100644 index 0000000..e2babb6 --- /dev/null +++ b/backend/prisma/migrations/20260608120000_remove_task_keywords/migration.sql @@ -0,0 +1,3 @@ +-- 키워드 필드 제거 (데이터 삭제 후 컬럼 drop) +UPDATE "tasks" SET "keywords" = NULL WHERE "keywords" IS NOT NULL; +ALTER TABLE "tasks" DROP COLUMN IF EXISTS "keywords"; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 465d61c..23ad08a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -80,7 +80,6 @@ model Task { showStatus Boolean @default(true) showIssue Boolean @default(true) showProgress Boolean @default(true) - keywords String? creatorId String assigneeId String? pmMemberId String? diff --git a/backend/scripts/sync-from-remote.ts b/backend/scripts/sync-from-remote.ts index 9fb2658..9d23468 100644 --- a/backend/scripts/sync-from-remote.ts +++ b/backend/scripts/sync-from-remote.ts @@ -39,7 +39,6 @@ type RemoteTask = { showStatus: boolean; showIssue: boolean; showProgress: boolean; - keywords?: string | null; creatorId: string; assigneeId?: string | null; pmMemberId?: string | null; @@ -314,7 +313,6 @@ async function main() { showStatus: remote.showStatus, showIssue: remote.showIssue, showProgress: remote.showProgress, - keywords: remote.keywords ?? null, creatorId, assigneeId, pmMemberId, diff --git a/backend/scripts/sync-to-remote.ts b/backend/scripts/sync-to-remote.ts index 58610bf..aa05e0f 100644 --- a/backend/scripts/sync-to-remote.ts +++ b/backend/scripts/sync-to-remote.ts @@ -189,7 +189,6 @@ async function syncTasks(memberIdMap: Map) { showStatus: task.showStatus, showIssue: task.showIssue, showProgress: task.showProgress, - keywords: task.keywords, pmMemberId, assigneeMemberIds, }); diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index dc2cca8..4bd436c 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -56,7 +56,7 @@ router.post('/', async (req, res, next) => { const body = req.body as Record; const { title, description, status, priority, quarter, category, section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate, - showDescription, showStatus, showIssue, showProgress, keywords, pmMemberId } = body; + showDescription, showStatus, showIssue, showProgress, pmMemberId } = body; if (!title || !quarter) { throw new AppError(400, '제목과 분기는 필수입니다.'); @@ -85,7 +85,6 @@ router.post('/', async (req, res, next) => { showStatus: showStatus !== undefined ? showStatus === 'true' || showStatus === true : true, showIssue: showIssue !== undefined ? showIssue === 'true' || showIssue === true : true, showProgress: showProgress !== undefined ? showProgress === 'true' || showProgress === true : true, - keywords: keywords || null, assigneeId: assigneeId || null, pmMemberId: pmMemberId || null, creatorId, @@ -118,7 +117,7 @@ router.patch('/:id', async (req, res, next) => { const body = req.body as Record; const { title, description, status, priority, quarter, category, section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate, - showDescription, showStatus, showIssue, showProgress, keywords, pmMemberId } = body; + showDescription, showStatus, showIssue, showProgress, pmMemberId } = body; const assigneeMemberIds = parseMemberIds(body); @@ -145,7 +144,6 @@ router.patch('/:id', async (req, res, next) => { ...(showStatus !== undefined && { showStatus: showStatus === true || showStatus === 'true' }), ...(showIssue !== undefined && { showIssue: showIssue === true || showIssue === 'true' }), ...(showProgress !== undefined && { showProgress: showProgress === true || showProgress === 'true' }), - ...(keywords !== undefined && { keywords: keywords || null }), }, }); diff --git a/frontend/public/quarter-dashboard-preview.html b/frontend/public/quarter-dashboard-preview.html new file mode 100644 index 0000000..f059fd3 --- /dev/null +++ b/frontend/public/quarter-dashboard-preview.html @@ -0,0 +1,1951 @@ + + + + + + 2026년 2분기 인사총무 프로젝트 현황 — Preview + + + + + +
+
+ + 총괄기획실 + | + People Growth Hub + + +
+ +
+
+ 2026 2분기 업무 + · + 전체 22 + + 진행 16 + 보류 4 + 완료 2 + + 이슈 5 +
+
+ +
+ + +
+
+ +
+
+ +
+
+
+
+ +
+
+
+

인사관리

+ HRM +
+
+
+
+ 2 +
+
+
+
+
+
+
상반기 채용 운영
+
+ 작업 기간 + 2026.04 ~ 2026.06 +
+
+ 주요 내용 + 채용공고, 서류검토, 면접, 사후협의 진행 +
+
+ +
+
75%
+
+
+
+
+
+
+
평가제도 개선
+
+ 작업 기간 + 2026.03 ~ 2026.07 +
+
+ 주요 내용 + 평가항목 정비, 부서 의견수렴, 피드백 방식 개선 +
+
+ +
+
60%
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
+

인재육성

+ HRD +
+
+
+
+ 2 +
+
+
+
+
+
+
신규입사자 온보딩 프로그램
+
+ 작업 기간 + 2026.04 ~ 2026.06 +
+
+ 주요 내용 + 신규입사자 교육, 조직 적응 프로그램 운영 +
+
+ +
+
85%
+
+
+
+
+
+
+
팀장 리더십 교육
+
+ 작업 기간 + 2026.05 ~ 2026.06 +
+
+ 주요 내용 + 팀장 대상 리더십·코칭·피드백 교육 실시 +
+
+ +
+
100%
+
+
+
+
+
+ + +
+ +
+
+ +
+ + +
+
+
+ +
분기 슬로건
+
+
+
+

+ 인사육성문화총무 +

+

개선과제

+

정상 추진

+
+
+
+
+
+ + +
+
+
+
+ +
상시업무
+
+ +
+ + + + + + +
+
+
+
+ + +
+
+
+ +
분기 주요 일정
+
+
    +
  • + 4월 + 평가제도 시행 +
  • +
  • + 5월 + 상반기 채용 마감 +
  • +
  • + 6월 + 복지·안전 점검 +
  • +
+
+
+ +
+ + +
+
+
+
+ +
+
+
+

조직문화

+ EX +
+
+
+
+ 2 +
+
+
+
+
+
+
조직문화 진단
+
+ 작업 기간 + 2026.04 ~ 2026.05 +
+
+ 주요 내용 + 만족도 조사, 직원 의견수렴, 개선과제 도출 +
+
+ +
+
100%
+
+
+
+
+
+
+
복리후생제도 개선
+
+ 작업 기간 + 2026.05 ~ 2026.08 +
+
+ 주요 내용 + 복지제도 이용현황 분석, 개선안 검토 +
+
+ +
+
50%
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
+

총무관리

+ GA +
+
+
+
+ 2 +
+
+
+
+
+
+
사무공간 재배치
+
+ 작업 기간 + 2026.04 ~ 2026.07 +
+
+ 주요 내용 + 좌석 재배치, 회의실 운영 개선 +
+
+ +
+
70%
+
+
+
+
+
+
+
안전·보안 점검 강화
+
+ 작업 기간 + 2026.04 ~ 2026.06 +
+
+ 주요 내용 + 출입통제, 보안카드, 시설안전, 소방점검 관리 +
+
+ +
+
85%
+
+
+
+
+
+ + + +
+
+ + + + diff --git a/frontend/src/components/common/TaskModal.tsx b/frontend/src/components/common/TaskModal.tsx index dac053c..c4bf83a 100644 --- a/frontend/src/components/common/TaskModal.tsx +++ b/frontend/src/components/common/TaskModal.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { createPortal } from 'react-dom'; import type { Task, TeamMember } from '../../types'; -import { normalizeTaskType, displayFlagsForTaskType } from '../../lib/taskType'; +import { normalizeTaskType, displayFlagsForTaskType, TASK_TYPE_OPTIONS } from '../../lib/taskType'; const STATUS_OPTIONS = [ { value: 'TODO', label: '대기' }, @@ -27,7 +27,6 @@ export interface TaskFormData { showStatus: boolean; showIssue: boolean; showProgress: boolean; - keywords: string; pmMemberId: string; assigneeMemberIds: string[]; } @@ -76,7 +75,6 @@ export function TaskModal({ showStatus: task?.showStatus ?? true, showIssue: task?.showIssue ?? true, showProgress: task?.showProgress ?? true, - keywords: task?.keywords ?? '', pmMemberId: task?.pmMember?.id ?? task?.pmMemberId ?? '', assigneeMemberIds: task?.assigneeMembers?.map((m) => m.id) ?? [], }); @@ -168,8 +166,9 @@ export function TaskModal({ }} className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition bg-white" > - - + {TASK_TYPE_OPTIONS.map((opt) => ( + + ))} @@ -299,17 +298,6 @@ export function TaskModal({ )} - {/* 키워드 */} -
- - set('keywords', e.target.value)} - className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition" - placeholder="예: 인사정보, ERP, 입퇴사 분석" - /> -
- {/* 프로젝트 기간 */}
diff --git a/frontend/src/components/dashboard/BoardConnectors.tsx b/frontend/src/components/dashboard/BoardConnectors.tsx new file mode 100644 index 0000000..811a195 --- /dev/null +++ b/frontend/src/components/dashboard/BoardConnectors.tsx @@ -0,0 +1,11 @@ +import { useBoardConnectors } from '../../hooks/useBoardConnectors'; + +export function BoardConnectors({ enabled = true }: { enabled?: boolean }) { + const { svgRef, lineGroupRef } = useBoardConnectors(enabled); + + return ( + + ); +} diff --git a/frontend/src/components/dashboard/DepartmentColumn.tsx b/frontend/src/components/dashboard/DepartmentColumn.tsx index 15c443b..2fc67ca 100644 --- a/frontend/src/components/dashboard/DepartmentColumn.tsx +++ b/frontend/src/components/dashboard/DepartmentColumn.tsx @@ -1,11 +1,11 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useDroppable } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { apiClient, getApiErrorMessage } from '../../lib/apiClient'; -import { isProjectTask, isRoutineTask } from '../../lib/taskType'; -import { COLUMN_META, type SectionKey } from '../../lib/sections'; +import { isProjectTask } from '../../lib/taskType'; +import { type BoardSlotConfig, slotSectionLabel } from '../../lib/boardLayout'; import { SortableTaskCard } from './TaskCard'; import { ContextMenu } from '../common/ContextMenu'; import { TaskModal } from '../common/TaskModal'; @@ -13,13 +13,29 @@ import type { TaskFormData } from '../common/TaskModal'; import { taskFormToApiPayload } from '../../lib/taskFormPayload'; import type { Task, TeamMember } from '../../types'; +const DUMMY_HEADER_KEY = 'eene-board-slot-headers-v1'; + +type DummyHeaders = Record; + +function loadDummyHeaders(): DummyHeaders { + try { + const raw = localStorage.getItem(DUMMY_HEADER_KEY); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +function saveDummyHeader(slotId: string, data: { title: string; titleEn: string; subtitle: string }) { + const all = loadDummyHeaders(); + all[slotId] = data; + localStorage.setItem(DUMMY_HEADER_KEY, JSON.stringify(all)); +} + interface DepartmentColumnProps { - title: string; - titleEn?: string; - accent?: string; + slot: BoardSlotConfig; tasks: Task[]; orderedIds: string[]; - section: SectionKey; quarter: string; onSelectTask?: (task: Task) => void; sectionOptions?: { value: string; label: string }[]; @@ -59,7 +75,7 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP autoFocus value={draftTitle} onChange={(e) => setDraftTitle(e.target.value)} - className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-lg font-bold outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition" + className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-lg font-bold outline-none focus:border-emerald-400" />
@@ -67,7 +83,7 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP setDraftTitleEn(e.target.value)} - className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition" + className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-emerald-400" placeholder="HRM" />
@@ -76,12 +92,12 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP setDraftSubtitle(e.target.value)} - className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition" + className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-emerald-400" />
- - + +
@@ -91,36 +107,44 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP } export function DepartmentColumn({ - title: initialTitle, - titleEn, - accent, + slot, tasks, orderedIds, - section, quarter, onSelectTask, sectionOptions: externalSectionOptions, teamMembers = [], }: DepartmentColumnProps) { const queryClient = useQueryClient(); - const meta = COLUMN_META[section]; + const section = slotSectionLabel(slot); + const isDummySlot = !slot.sectionKey; const { data: colConfig } = useQuery({ - queryKey: ['columns', section], - queryFn: () => apiClient.get(`/columns/${encodeURIComponent(section)}`).then((r) => r.data), + queryKey: ['columns', slot.sectionKey], + queryFn: () => apiClient.get(`/columns/${encodeURIComponent(slot.sectionKey!)}`).then((r) => r.data), + enabled: !!slot.sectionKey, staleTime: 0, }); + const [dummyHeader, setDummyHeader] = useState(() => loadDummyHeaders()[slot.id]); + + useEffect(() => { + setDummyHeader(loadDummyHeaders()[slot.id]); + }, [slot.id]); + const patchColumn = useMutation({ mutationFn: (data: Record) => - apiClient.patch(`/columns/${encodeURIComponent(section)}`, data), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['columns', section] }), + apiClient.patch(`/columns/${encodeURIComponent(slot.sectionKey!)}`, data), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['columns', slot.sectionKey] }), }); - const title = colConfig?.title ?? initialTitle; - const titleEnState = colConfig?.titleEn ?? (titleEn ?? meta.titleEn); - const subtitle = colConfig?.subtitle ?? ''; - const accentColor = accent ?? meta.accent; + const title = isDummySlot + ? dummyHeader?.title ?? slot.defaultTitle + : colConfig?.title ?? slot.defaultTitle; + const titleEnState = isDummySlot + ? dummyHeader?.titleEn ?? slot.defaultTitleEn + : colConfig?.titleEn ?? slot.defaultTitleEn; + const subtitle = isDummySlot ? dummyHeader?.subtitle ?? '' : colConfig?.subtitle ?? ''; const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; type: 'header' | 'list' } | null>(null); const [cardMenu, setCardMenu] = useState<{ x: number; y: number; task: Task } | null>(null); @@ -129,8 +153,9 @@ export function DepartmentColumn({ const [showHeaderModal, setShowHeaderModal] = useState(false); const [editingTask, setEditingTask] = useState(null); - const { setNodeRef: setProjectDropRef, isOver: isProjectOver } = useDroppable({ id: `drop::project::${section}` }); - const { setNodeRef: setRoutineDropRef, isOver: isRoutineOver } = useDroppable({ id: `drop::routine::${section}` }); + const { setNodeRef: setProjectDropRef, isOver: isProjectOver } = useDroppable({ + id: `drop::project::${section}`, + }); const orderedTasks = [...tasks].sort((a, b) => { const ai = orderedIds.indexOf(a.id); @@ -142,9 +167,6 @@ export function DepartmentColumn({ }); const projectTasks = orderedTasks.filter((t) => isProjectTask(t.taskType)); - const routineTasks = orderedTasks.filter((t) => isRoutineTask(t.taskType)); - - const patchColumnField = (field: string, value: string) => patchColumn.mutate({ [field]: value }); const create = useMutation({ mutationFn: (data: Partial) => apiClient.post('/tasks', data), @@ -211,33 +233,41 @@ export function DepartmentColumn({ } }; + const saveHeader = (t: string, te: string, s: string) => { + if (isDummySlot) { + saveDummyHeader(slot.id, { title: t, titleEn: te, subtitle: s }); + setDummyHeader({ title: t, titleEn: te, subtitle: s }); + } else { + patchColumn.mutate({ title: t, titleEn: te, subtitle: s }); + } + setShowHeaderModal(false); + }; + return ( <> -
{ e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, type: 'header' }); }} + className="dept-head" + onContextMenu={(e) => { + e.preventDefault(); + setCtxMenu({ x: e.clientX, y: e.clientY, type: 'header' }); + }} > -
-
-

- {title.replace(/\s*부문$/, '')} -

- {titleEnState && ( - - {titleEnState} - - )} +
+
+
+

{title.replace(/\s*부문$/, '')}

+ {titleEnState && {titleEnState}} +
+ {subtitle &&

{subtitle}

}
- {subtitle &&

{subtitle}

}
-
- {tasks.length} - +
+ {projectTasks.length} +
@@ -262,35 +292,7 @@ export function DepartmentColumn({ )}
- -
-
- {routineTasks.length === 0 ? ( -
- ) : ( - t.id)} strategy={verticalListSortingStrategy}> - {routineTasks.map((task) => ( - - ))} - - )} -
-
-
+ {cardMenu && ( { - patchColumnField('title', t); - patchColumnField('titleEn', te); - patchColumnField('subtitle', s); - setShowHeaderModal(false); - }} + onSave={saveHeader} onClose={() => setShowHeaderModal(false)} /> )} diff --git a/frontend/src/components/dashboard/DonutGauge.tsx b/frontend/src/components/dashboard/DonutGauge.tsx new file mode 100644 index 0000000..c406448 --- /dev/null +++ b/frontend/src/components/dashboard/DonutGauge.tsx @@ -0,0 +1,19 @@ +import type { Task } from '../../types'; +import { getDonutDisplay } from '../../lib/taskStatusVisual'; + +export function DonutGauge({ task }: { task: Pick }) { + const display = getDonutDisplay(task); + + return ( +
+ {display.label} +
+ ); +} diff --git a/frontend/src/components/dashboard/HubColumn.tsx b/frontend/src/components/dashboard/HubColumn.tsx new file mode 100644 index 0000000..e1f3c29 --- /dev/null +++ b/frontend/src/components/dashboard/HubColumn.tsx @@ -0,0 +1,402 @@ +import { createPortal } from 'react-dom'; +import { useState } from 'react'; +import { useDroppable } from '@dnd-kit/core'; +import type { Task } from '../../types'; +import { useHubConfig, type HubConfig, type HubScheduleItem } from '../../lib/hubConfig'; +import { quarterDateBounds, sortScheduleItems, todayIso } from '../../lib/hubSchedule'; +import { HubScheduleCarousel } from './HubScheduleCarousel'; +import { ContextMenu } from '../common/ContextMenu'; + +interface HubColumnProps { + routineTasks: Task[]; + quarter?: string; + onSelectRoutine?: (task: Task) => void; +} + +type HubEditSection = 'slogan' | 'routine' | 'schedule'; + +function ModalShell({ + title, + onClose, + children, + onSubmit, +}: { + title: string; + onClose: () => void; + children: React.ReactNode; + onSubmit: (e: React.FormEvent) => void; +}) { + return createPortal( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+
+ {children} +
+ + +
+
+
+
, + document.body, + ); +} + +function SloganEditModal({ + config, + onSave, + onClose, +}: { + config: HubConfig; + onSave: (patch: Pick) => void; + onClose: () => void; +}) { + const [title, setTitle] = useState(config.sloganTitle); + const [lines, setLines] = useState(config.sloganLines.join('\n')); + + return ( + { + e.preventDefault(); + onSave({ sloganTitle: title.trim() || '분기 슬로건', sloganLines: lines.split('\n') }); + onClose(); + }} + > +
+ + setTitle(e.target.value)} + className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400" + /> +
+
+ +