From 0014f871a1c39c28eeea26bd6ef3792578e647f8 Mon Sep 17 00:00:00 2001 From: Seungbin Oh Date: Wed, 13 May 2026 01:29:24 +0900 Subject: [PATCH 01/27] feat(footer): add subtle admin link below copyright --- src/components/layout/footer.tsx | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/components/layout/footer.tsx b/src/components/layout/footer.tsx index 15ba403..ed8e98d 100644 --- a/src/components/layout/footer.tsx +++ b/src/components/layout/footer.tsx @@ -99,9 +99,29 @@ export default function Footer() { - - © {dayjs().year()} STDev Nonprofit Corporation. All rights reserved. - + + + © {dayjs().year()} STDev Nonprofit Corporation. All rights + reserved. + + + · + + + 관리자 + + From a44fe2a4e649f979c5090a06d32ee444b178e6ce Mon Sep 17 00:00:00 2001 From: Seungbin Oh Date: Wed, 13 May 2026 01:31:49 +0900 Subject: [PATCH 02/27] feat(cms): add AdminSettings key-value model with helpers --- .../migration.sql | 13 ++ prisma/schema.prisma | 8 + src/utils/admin-settings.test.ts | 150 ++++++++++++++++++ src/utils/admin-settings.ts | 52 ++++++ 4 files changed, 223 insertions(+) create mode 100644 prisma/migrations/20260513000000_add_admin_settings/migration.sql create mode 100644 src/utils/admin-settings.test.ts create mode 100644 src/utils/admin-settings.ts diff --git a/prisma/migrations/20260513000000_add_admin_settings/migration.sql b/prisma/migrations/20260513000000_add_admin_settings/migration.sql new file mode 100644 index 0000000..9a8b2a5 --- /dev/null +++ b/prisma/migrations/20260513000000_add_admin_settings/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "AdminSettings" ( + "id" SERIAL NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AdminSettings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminSettings_key_key" ON "AdminSettings"("key"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1e07386..d6a3c7e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -168,6 +168,14 @@ model Webpage { updatedAt DateTime @updatedAt } +model AdminSettings { + id Int @id @default(autoincrement()) + key String @unique + value String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + enum MarkdownType { articles privacy diff --git a/src/utils/admin-settings.test.ts b/src/utils/admin-settings.test.ts new file mode 100644 index 0000000..981dfd1 --- /dev/null +++ b/src/utils/admin-settings.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { prismaMock, resetPrismaMock } from '@/tests/mocks/prisma' +import { + ADMIN_SETTINGS_KEYS, + deleteAdminSettingByKey, + getAdminSetting, + getAwsCredentials, + listAdminSettings, + upsertAdminSetting, +} from '@/utils/admin-settings' + +beforeEach(() => { + resetPrismaMock() +}) + +describe('ADMIN_SETTINGS_KEYS', () => { + it('exposes AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY constants', () => { + expect(ADMIN_SETTINGS_KEYS.awsAccessKeyId).toBe('AWS_ACCESS_KEY_ID') + expect(ADMIN_SETTINGS_KEYS.awsSecretAccessKey).toBe('AWS_SECRET_ACCESS_KEY') + }) +}) + +describe('getAdminSetting', () => { + it('returns the value when present', async () => { + prismaMock.adminSettings.findUnique.mockResolvedValue({ + id: 1, + key: 'AWS_ACCESS_KEY_ID', + value: 'AKIA123', + createdAt: new Date(), + updatedAt: new Date(), + } as never) + + const value = await getAdminSetting('AWS_ACCESS_KEY_ID') + expect(value).toBe('AKIA123') + expect(prismaMock.adminSettings.findUnique).toHaveBeenCalledWith({ + where: { key: 'AWS_ACCESS_KEY_ID' }, + }) + }) + + it('returns null when not present', async () => { + prismaMock.adminSettings.findUnique.mockResolvedValue(null) + const value = await getAdminSetting('MISSING') + expect(value).toBeNull() + }) +}) + +describe('listAdminSettings', () => { + it('queries with key asc order', async () => { + prismaMock.adminSettings.findMany.mockResolvedValue([]) + await listAdminSettings() + expect(prismaMock.adminSettings.findMany).toHaveBeenCalledWith({ + orderBy: { key: 'asc' }, + }) + }) +}) + +describe('upsertAdminSetting', () => { + it('trims key and value before upserting', async () => { + prismaMock.adminSettings.upsert.mockResolvedValue({ + id: 1, + key: 'FOO', + value: 'bar', + createdAt: new Date(), + updatedAt: new Date(), + } as never) + + await upsertAdminSetting(' FOO ', ' bar ') + expect(prismaMock.adminSettings.upsert).toHaveBeenCalledWith({ + where: { key: 'FOO' }, + create: { key: 'FOO', value: 'bar' }, + update: { value: 'bar' }, + }) + }) + + it('throws when key is empty after trim', async () => { + await expect(upsertAdminSetting(' ', 'value')).rejects.toThrow( + '설정 키는 비어 있을 수 없습니다.', + ) + expect(prismaMock.adminSettings.upsert).not.toHaveBeenCalled() + }) +}) + +describe('deleteAdminSettingByKey', () => { + it('calls prisma delete with key', async () => { + prismaMock.adminSettings.delete.mockResolvedValue({ + id: 1, + key: 'FOO', + value: 'bar', + createdAt: new Date(), + updatedAt: new Date(), + } as never) + await deleteAdminSettingByKey('FOO') + expect(prismaMock.adminSettings.delete).toHaveBeenCalledWith({ + where: { key: 'FOO' }, + }) + }) +}) + +describe('getAwsCredentials', () => { + it('returns credentials when both AWS keys exist', async () => { + prismaMock.adminSettings.findUnique + .mockResolvedValueOnce({ + id: 1, + key: 'AWS_ACCESS_KEY_ID', + value: 'AKIA', + createdAt: new Date(), + updatedAt: new Date(), + } as never) + .mockResolvedValueOnce({ + id: 2, + key: 'AWS_SECRET_ACCESS_KEY', + value: 'secret', + createdAt: new Date(), + updatedAt: new Date(), + } as never) + + const creds = await getAwsCredentials() + expect(creds).toEqual({ accessKeyId: 'AKIA', secretAccessKey: 'secret' }) + }) + + it('returns null when access key is missing', async () => { + prismaMock.adminSettings.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: 2, + key: 'AWS_SECRET_ACCESS_KEY', + value: 'secret', + createdAt: new Date(), + updatedAt: new Date(), + } as never) + + const creds = await getAwsCredentials() + expect(creds).toBeNull() + }) + + it('returns null when secret key is missing', async () => { + prismaMock.adminSettings.findUnique + .mockResolvedValueOnce({ + id: 1, + key: 'AWS_ACCESS_KEY_ID', + value: 'AKIA', + createdAt: new Date(), + updatedAt: new Date(), + } as never) + .mockResolvedValueOnce(null) + + const creds = await getAwsCredentials() + expect(creds).toBeNull() + }) +}) diff --git a/src/utils/admin-settings.ts b/src/utils/admin-settings.ts new file mode 100644 index 0000000..705fff5 --- /dev/null +++ b/src/utils/admin-settings.ts @@ -0,0 +1,52 @@ +'use server' + +import { prisma } from '@/utils/prisma' + +export const ADMIN_SETTINGS_KEYS = { + awsAccessKeyId: 'AWS_ACCESS_KEY_ID', + awsSecretAccessKey: 'AWS_SECRET_ACCESS_KEY', +} as const + +export type AdminSettingsKey = + (typeof ADMIN_SETTINGS_KEYS)[keyof typeof ADMIN_SETTINGS_KEYS] + +export async function getAdminSetting(key: string) { + const setting = await prisma.adminSettings.findUnique({ where: { key } }) + return setting?.value ?? null +} + +export async function listAdminSettings() { + return prisma.adminSettings.findMany({ orderBy: { key: 'asc' } }) +} + +export async function upsertAdminSetting(key: string, value: string) { + const trimmedKey = key.trim() + const trimmedValue = value.trim() + + if (!trimmedKey) { + throw new Error('설정 키는 비어 있을 수 없습니다.') + } + + return prisma.adminSettings.upsert({ + where: { key: trimmedKey }, + create: { key: trimmedKey, value: trimmedValue }, + update: { value: trimmedValue }, + }) +} + +export async function deleteAdminSettingByKey(key: string) { + await prisma.adminSettings.delete({ where: { key } }) +} + +export async function getAwsCredentials() { + const [accessKeyId, secretAccessKey] = await Promise.all([ + getAdminSetting(ADMIN_SETTINGS_KEYS.awsAccessKeyId), + getAdminSetting(ADMIN_SETTINGS_KEYS.awsSecretAccessKey), + ]) + + if (!accessKeyId || !secretAccessKey) { + return null + } + + return { accessKeyId, secretAccessKey } +} From 7d2911f4a730c20d858ced1896e2291e91ef2235 Mon Sep 17 00:00:00 2001 From: Seungbin Oh Date: Wed, 13 May 2026 01:40:13 +0900 Subject: [PATCH 03/27] feat(admin): migrate admin chrome to Chakra UI with hamburger sidebar - Add Chakra UI provider and Toaster to (cms) route group. - Introduce AdminShell with fixed left sidebar on desktop and slide-in Drawer on mobile. - Move admin pages under (cms)/admin/(shell) so sign-in stays outside the shell. - Replace the monolithic dashboard with a Chakra-styled overview that links to per-entity pages. - Refactor sign-in page with Chakra Card and loading state. - Remove obsolete dashboard, section, action-buttons, and inline style files. --- src/app/(cms)/admin/(shell)/layout.tsx | 12 + src/app/(cms)/admin/(shell)/page.tsx | 87 ++ src/app/(cms)/admin/page.tsx | 43 - src/app/(cms)/admin/sign-in/page.tsx | 24 +- src/app/(cms)/admin/sign-in/sign-in-form.tsx | 44 +- src/app/(cms)/layout.tsx | 7 +- src/app/(cms)/providers.tsx | 44 + src/components/admin/action-buttons.test.tsx | 38 - src/components/admin/action-buttons.tsx | 12 - src/components/admin/admin-shell.tsx | 177 ++++ src/components/admin/dashboard.test.tsx | 873 ------------------- src/components/admin/dashboard.tsx | 609 ------------- src/components/admin/menu-items.ts | 18 + src/components/admin/section.test.tsx | 38 - src/components/admin/section.tsx | 16 - src/components/admin/sign-out-button.tsx | 26 + src/components/admin/styles.ts | 3 - src/components/admin/toaster.ts | 8 + 18 files changed, 426 insertions(+), 1653 deletions(-) create mode 100644 src/app/(cms)/admin/(shell)/layout.tsx create mode 100644 src/app/(cms)/admin/(shell)/page.tsx delete mode 100644 src/app/(cms)/admin/page.tsx create mode 100644 src/app/(cms)/providers.tsx delete mode 100644 src/components/admin/action-buttons.test.tsx delete mode 100644 src/components/admin/action-buttons.tsx create mode 100644 src/components/admin/admin-shell.tsx delete mode 100644 src/components/admin/dashboard.test.tsx delete mode 100644 src/components/admin/dashboard.tsx create mode 100644 src/components/admin/menu-items.ts delete mode 100644 src/components/admin/section.test.tsx delete mode 100644 src/components/admin/section.tsx create mode 100644 src/components/admin/sign-out-button.tsx delete mode 100644 src/components/admin/styles.ts create mode 100644 src/components/admin/toaster.ts diff --git a/src/app/(cms)/admin/(shell)/layout.tsx b/src/app/(cms)/admin/(shell)/layout.tsx new file mode 100644 index 0000000..df328ad --- /dev/null +++ b/src/app/(cms)/admin/(shell)/layout.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from 'react' +import { AdminShell } from '@/components/admin/admin-shell' +import { requireAdminPageSession } from '@/utils/admin-auth' + +export default async function AdminShellLayout({ + children, +}: { + children: ReactNode +}) { + const session = await requireAdminPageSession() + return {children} +} diff --git a/src/app/(cms)/admin/(shell)/page.tsx b/src/app/(cms)/admin/(shell)/page.tsx new file mode 100644 index 0000000..aea3ef0 --- /dev/null +++ b/src/app/(cms)/admin/(shell)/page.tsx @@ -0,0 +1,87 @@ +import Link from 'next/link' +import { + Box, + Heading, + SimpleGrid, + Stack, + Stat, + Text, +} from '@chakra-ui/react' +import { prisma } from '@/utils/prisma' + +type StatCardProps = { + label: string + value: number + href: string +} + +function StatCard({ label, value, href }: StatCardProps) { + return ( + + + {label} + {value} + + + ) +} + +export default async function AdminDashboardPage() { + const [ + businesses, + images, + files, + institutions, + markdowns, + webpages, + reports, + histories, + settings, + ] = await Promise.all([ + prisma.business.count(), + prisma.imageAsset.count(), + prisma.fileAsset.count(), + prisma.institution.count(), + prisma.markdown.count(), + prisma.webpage.count(), + prisma.report.count(), + prisma.history.count(), + prisma.adminSettings.count(), + ]) + + return ( + + + 대시보드 + + STDev DIY CMS의 현재 데이터 현황입니다. + + + + + + + + + + + + + + + ) +} diff --git a/src/app/(cms)/admin/page.tsx b/src/app/(cms)/admin/page.tsx deleted file mode 100644 index 8818693..0000000 --- a/src/app/(cms)/admin/page.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { AdminDashboard } from '@/components/admin/dashboard' -import { requireAdminPageSession } from '@/utils/admin-auth' -import { prisma } from '@/utils/prisma' -import * as actions from './actions' - -export default async function AdminPage() { - const session = await requireAdminPageSession() - - const [ - businesses, - images, - files, - institutions, - markdowns, - webpages, - reports, - histories, - ] = await Promise.all([ - prisma.business.findMany({ orderBy: { id: 'asc' } }), - prisma.imageAsset.findMany({ orderBy: { id: 'asc' } }), - prisma.fileAsset.findMany({ orderBy: { id: 'asc' } }), - prisma.institution.findMany({ orderBy: { id: 'asc' } }), - prisma.markdown.findMany({ orderBy: { effectiveDate: 'desc' } }), - prisma.webpage.findMany({ orderBy: { publishedDate: 'desc' } }), - prisma.report.findMany({ orderBy: { publishedDate: 'desc' } }), - prisma.history.findMany({ orderBy: { date: 'desc' } }), - ]) - - return ( - - ) -} diff --git a/src/app/(cms)/admin/sign-in/page.tsx b/src/app/(cms)/admin/sign-in/page.tsx index 6c9e10f..c65d485 100644 --- a/src/app/(cms)/admin/sign-in/page.tsx +++ b/src/app/(cms)/admin/sign-in/page.tsx @@ -1,11 +1,25 @@ +import { Box, Card, Flex, Heading, Stack, Text } from '@chakra-ui/react' import SignInForm from './sign-in-form' export default function SignInPage() { return ( -
-

CMS 로그인

-

STDev Google 계정으로 로그인하세요.

- -
+ + + + + + STDev CMS 로그인 + + STDev Google 계정으로 로그인하세요. + + + + + 관리자는 Google로 연결된 @stdev.kr 계정만 접근할 수 있습니다. + + + + + ) } diff --git a/src/app/(cms)/admin/sign-in/sign-in-form.tsx b/src/app/(cms)/admin/sign-in/sign-in-form.tsx index 9c151c3..4945391 100644 --- a/src/app/(cms)/admin/sign-in/sign-in-form.tsx +++ b/src/app/(cms)/admin/sign-in/sign-in-form.tsx @@ -1,29 +1,43 @@ 'use client' -import { useState } from 'react' +import { Alert, Button, Stack } from '@chakra-ui/react' +import { useState, useTransition } from 'react' import { authClient } from '@/utils/auth-client' export default function SignInForm() { const [error, setError] = useState(null) + const [pending, startTransition] = useTransition() - async function signInWithGoogle() { - const result = await authClient.signIn.social({ - provider: 'google', - callbackURL: '/admin', - errorCallbackURL: '/admin/sign-in', + function signInWithGoogle() { + setError(null) + startTransition(async () => { + const result = await authClient.signIn.social({ + provider: 'google', + callbackURL: '/admin', + errorCallbackURL: '/admin/sign-in', + }) + if (result.error) { + setError(result.error.message ?? '로그인에 실패했습니다.') + } }) - if (result.error) { - setError(result.error.message ?? '로그인에 실패했습니다.') - } } return ( -
- -

관리자는 Google로 연결된 @stdev.kr 계정만 접근할 수 있습니다.

- {error &&

{error}

} -
+ + {error && ( + + + {error} + + )} + ) } diff --git a/src/app/(cms)/layout.tsx b/src/app/(cms)/layout.tsx index 7d49497..01b4d43 100644 --- a/src/app/(cms)/layout.tsx +++ b/src/app/(cms)/layout.tsx @@ -1,9 +1,14 @@ import type { ReactNode } from 'react' +import { Providers } from './providers' + +export const dynamic = 'force-dynamic' export default function CmsLayout({ children }: { children: ReactNode }) { return ( - {children} + + {children} + ) } diff --git a/src/app/(cms)/providers.tsx b/src/app/(cms)/providers.tsx new file mode 100644 index 0000000..55f94e7 --- /dev/null +++ b/src/app/(cms)/providers.tsx @@ -0,0 +1,44 @@ +'use client' + +import { + ChakraProvider, + Portal, + Spinner, + Stack, + Toast, + Toaster as ChakraToaster, + defaultSystem, +} from '@chakra-ui/react' +import type { ReactNode } from 'react' +import { toaster } from '@/components/admin/toaster' + +export function Providers({ children }: { children: ReactNode }) { + return ( + + {children} + + + {(toast) => ( + + {toast.type === 'loading' ? ( + + ) : ( + + )} + + {toast.title && {toast.title}} + {toast.description && ( + {toast.description} + )} + + {toast.action && ( + {toast.action.label} + )} + {toast.closable && } + + )} + + + + ) +} diff --git a/src/components/admin/action-buttons.test.tsx b/src/components/admin/action-buttons.test.tsx deleted file mode 100644 index 0b41e03..0000000 --- a/src/components/admin/action-buttons.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { renderWithChakra, screen } from '@/tests/utils/render' -import { AdminActionButtons } from '@/components/admin/action-buttons' - -describe('', () => { - it('renders a submit button labeled "수정"', () => { - const deleteAction = vi.fn(async () => {}) - renderWithChakra() - const submit = screen.getByRole('button', { name: '수정' }) - expect(submit).toHaveAttribute('type', 'submit') - }) - - it('renders a delete button labeled "삭제"', () => { - const deleteAction = vi.fn(async () => {}) - renderWithChakra() - expect(screen.getByRole('button', { name: '삭제' })).toBeInTheDocument() - }) - - it('renders exactly two buttons', () => { - const deleteAction = vi.fn(async () => {}) - renderWithChakra() - expect(screen.getAllByRole('button')).toHaveLength(2) - }) - - it('wires the delete button with a formAction handler', async () => { - const deleteAction = vi.fn(async () => {}) - const { user } = renderWithChakra( -
- - , - ) - const del = screen.getByRole('button', { - name: '삭제', - }) as HTMLButtonElement - expect(del.type).toBe('submit') - await user.click(del) - }) -}) diff --git a/src/components/admin/action-buttons.tsx b/src/components/admin/action-buttons.tsx deleted file mode 100644 index 6d60261..0000000 --- a/src/components/admin/action-buttons.tsx +++ /dev/null @@ -1,12 +0,0 @@ -type Props = { - deleteAction: (formData: FormData) => Promise -} - -export function AdminActionButtons({ deleteAction }: Props) { - return ( - <> - - - - ) -} diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx new file mode 100644 index 0000000..7564bc4 --- /dev/null +++ b/src/components/admin/admin-shell.tsx @@ -0,0 +1,177 @@ +'use client' + +import { + Box, + Button, + Drawer, + Flex, + Heading, + IconButton, + Portal, + Stack, + Text, +} from '@chakra-ui/react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { useState, type ReactNode } from 'react' +import { adminMenuItems, type AdminMenuItem } from './menu-items' +import { SignOutButton } from './sign-out-button' + +const SIDEBAR_WIDTH = '15rem' + +function HamburgerIcon() { + return ( + + ) +} + +function isItemActive(pathname: string, href: string) { + if (href === '/admin') { + return pathname === '/admin' + } + return pathname === href || pathname.startsWith(`${href}/`) +} + +function SidebarBody({ + pathname, + onNavigate, + sessionEmail, +}: { + pathname: string + onNavigate?: () => void + sessionEmail: string +}) { + return ( + + + STDev CMS + + {sessionEmail} + + + + {adminMenuItems.map((item: AdminMenuItem) => { + const active = isItemActive(pathname, item.href) + return ( + + ) + })} + + + + + + ) +} + +export function AdminShell({ + sessionEmail, + children, +}: { + sessionEmail: string + children: ReactNode +}) { + const [open, setOpen] = useState(false) + const pathname = usePathname() + + return ( + + + + + + setOpen(e.open)} + placement="start" + > + + + + + setOpen(false)} + /> + + + + + + + + setOpen(true)} + > + + + + 관리자 + + + + {children} + + + + ) +} diff --git a/src/components/admin/dashboard.test.tsx b/src/components/admin/dashboard.test.tsx deleted file mode 100644 index 0ca8990..0000000 --- a/src/components/admin/dashboard.test.tsx +++ /dev/null @@ -1,873 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { renderWithChakra, screen, within } from '@/tests/utils/render' -import { AdminDashboard } from '@/components/admin/dashboard' -import { - makeBusiness, - makeFileAsset, - makeHistory, - makeImageAsset, - makeInstitution, - makeMarkdown, - makeReport, - makeWebpage, -} from '@/tests/utils/fixtures' - -function makeActions() { - return { - createBusiness: vi.fn(async () => {}), - createFileAsset: vi.fn(async () => {}), - createHistory: vi.fn(async () => {}), - createImageAsset: vi.fn(async () => {}), - createInstitution: vi.fn(async () => {}), - createMarkdown: vi.fn(async () => {}), - createReport: vi.fn(async () => {}), - createWebpage: vi.fn(async () => {}), - deleteBusiness: vi.fn(async () => {}), - deleteFileAsset: vi.fn(async () => {}), - deleteHistory: vi.fn(async () => {}), - deleteImageAsset: vi.fn(async () => {}), - deleteInstitution: vi.fn(async () => {}), - deleteMarkdown: vi.fn(async () => {}), - deleteReport: vi.fn(async () => {}), - deleteWebpage: vi.fn(async () => {}), - updateBusiness: vi.fn(async () => {}), - updateFileAsset: vi.fn(async () => {}), - updateHistory: vi.fn(async () => {}), - updateImageAsset: vi.fn(async () => {}), - updateInstitution: vi.fn(async () => {}), - updateMarkdown: vi.fn(async () => {}), - updateReport: vi.fn(async () => {}), - updateWebpage: vi.fn(async () => {}), - } -} - -function defaultProps() { - return { - sessionEmail: 'admin@stdev.kr', - businesses: [makeBusiness()], - images: [makeImageAsset()], - files: [makeFileAsset()], - institutions: [makeInstitution()], - markdowns: [makeMarkdown()], - webpages: [makeWebpage()], - reports: [makeReport()], - histories: [makeHistory()], - actions: makeActions(), - } -} - -function getUpdateForm(headingName: string): HTMLFormElement { - const h3 = screen.getByRole('heading', { level: 3, name: headingName }) - const next = h3.nextElementSibling - if (!(next instanceof HTMLFormElement)) { - throw new Error(`Expected a
after h3 "${headingName}"`) - } - return next -} - -function getCreateForm(sectionTitle: string): HTMLFormElement { - const h2 = screen.getByRole('heading', { level: 2, name: sectionTitle }) - const section = h2.closest('section') - if (!section) throw new Error(`No
for "${sectionTitle}"`) - const form = section.querySelector('form') - if (!form) throw new Error(`No inside "${sectionTitle}" section`) - return form -} - -describe('', () => { - describe('header', () => { - it('renders the "STDev DIY CMS" h1 heading', () => { - renderWithChakra() - expect( - screen.getByRole('heading', { level: 1, name: 'STDev DIY CMS' }), - ).toBeInTheDocument() - }) - - it('renders the sessionEmail inside the login-status line', () => { - renderWithChakra() - expect( - screen.getByText('admin@stdev.kr 계정으로 로그인했습니다.'), - ).toBeInTheDocument() - }) - - it('wraps everything inside a
landmark', () => { - const { container } = renderWithChakra( - , - ) - expect(container.querySelector('main')).not.toBeNull() - }) - }) - - describe('데이터 counts summary', () => { - it('renders the "현재 데이터" heading', () => { - renderWithChakra() - expect( - screen.getByRole('heading', { level: 2, name: '현재 데이터' }), - ).toBeInTheDocument() - }) - - it('shows a count list item for every entity type', () => { - renderWithChakra() - expect(screen.getByText('사업: 1')).toBeInTheDocument() - expect(screen.getByText('이미지: 1')).toBeInTheDocument() - expect(screen.getByText('파일: 1')).toBeInTheDocument() - expect(screen.getByText('기관: 1')).toBeInTheDocument() - expect(screen.getByText('마크다운: 1')).toBeInTheDocument() - expect(screen.getByText('웹페이지: 1')).toBeInTheDocument() - expect(screen.getByText('보고서: 1')).toBeInTheDocument() - expect(screen.getByText('연혁: 1')).toBeInTheDocument() - }) - - it('reflects actual array lengths (2 businesses, 0 histories)', () => { - const props = defaultProps() - props.businesses = [ - makeBusiness({ id: 1 }), - makeBusiness({ id: 2, name: 'Second', code: 'second' }), - ] - props.histories = [] - renderWithChakra() - expect(screen.getByText('사업: 2')).toBeInTheDocument() - expect(screen.getByText('연혁: 0')).toBeInTheDocument() - }) - }) - - describe('기존 데이터 관리 section', () => { - it('renders the "기존 데이터 관리" section heading', () => { - renderWithChakra() - expect( - screen.getByRole('heading', { level: 2, name: '기존 데이터 관리' }), - ).toBeInTheDocument() - }) - - it('renders all 8 h3 entity subsection headings', () => { - renderWithChakra() - const expected = [ - '사업', - '이미지', - '파일', - '기관', - '마크다운', - '웹페이지', - '보고서', - '연혁', - ] - for (const name of expected) { - expect( - screen.getByRole('heading', { level: 3, name }), - ).toBeInTheDocument() - } - }) - }) - - describe('existing Business update form', () => { - it('renders a hidden id input equal to the business id', () => { - renderWithChakra() - const form = getUpdateForm('사업') - const hidden = form.querySelector( - 'input[name="id"]', - ) as HTMLInputElement | null - expect(hidden).not.toBeNull() - expect(hidden?.type).toBe('hidden') - expect(hidden?.value).toBe('1') - }) - - it('shows the "#1" id marker inside the form', () => { - renderWithChakra() - const form = getUpdateForm('사업') - expect(within(form).getByText('#1')).toBeInTheDocument() - }) - - it('pre-fills name / code / location / startDate / endDate', () => { - renderWithChakra() - const form = getUpdateForm('사업') - expect( - (form.querySelector('input[name="name"]') as HTMLInputElement).value, - ).toBe('Hackathon 2026') - expect( - (form.querySelector('input[name="code"]') as HTMLInputElement).value, - ).toBe('hack-2026') - expect( - (form.querySelector('input[name="location"]') as HTMLInputElement) - .value, - ).toBe('Seoul') - expect( - (form.querySelector('input[name="startDate"]') as HTMLInputElement) - .value, - ).toBe('2026-06-01') - expect( - (form.querySelector('input[name="endDate"]') as HTMLInputElement).value, - ).toBe('2026-06-02') - }) - - it('marks name/code/dates as required and location as optional', () => { - renderWithChakra() - const form = getUpdateForm('사업') - expect(form.querySelector('input[name="name"]')).toHaveAttribute( - 'required', - ) - expect(form.querySelector('input[name="code"]')).toHaveAttribute( - 'required', - ) - expect(form.querySelector('input[name="startDate"]')).toHaveAttribute( - 'required', - ) - expect(form.querySelector('input[name="endDate"]')).toHaveAttribute( - 'required', - ) - expect(form.querySelector('input[name="location"]')).not.toHaveAttribute( - 'required', - ) - }) - - it('exposes both a 수정 submit button and a 삭제 button', () => { - renderWithChakra() - const form = getUpdateForm('사업') - const update = within(form).getByRole('button', { name: '수정' }) - expect(update).toHaveAttribute('type', 'submit') - expect( - within(form).getByRole('button', { name: '삭제' }), - ).toBeInTheDocument() - }) - - it('renders an empty string when location is null', () => { - const props = defaultProps() - props.businesses = [makeBusiness({ location: null })] - renderWithChakra() - const form = getUpdateForm('사업') - expect( - (form.querySelector('input[name="location"]') as HTMLInputElement) - .value, - ).toBe('') - }) - }) - - describe('existing ImageAsset update form', () => { - it('pre-fills url / filename / alt / mimeType and exposes an image/* file input', () => { - renderWithChakra() - const form = getUpdateForm('이미지') - expect( - (form.querySelector('input[name="url"]') as HTMLInputElement).value, - ).toBe('https://stdev-kr.s3.ap-northeast-2.amazonaws.com/images/logo.png') - expect(form.querySelector('input[name="file"]')).toHaveAttribute( - 'accept', - 'image/*', - ) - expect(form.querySelector('input[name="file"]')).toHaveAttribute( - 'type', - 'file', - ) - expect( - (form.querySelector('input[name="filename"]') as HTMLInputElement) - .value, - ).toBe('logo.png') - expect( - (form.querySelector('input[name="alt"]') as HTMLInputElement).value, - ).toBe('Logo') - expect( - (form.querySelector('input[name="mimeType"]') as HTMLInputElement) - .value, - ).toBe('image/png') - }) - - it('renders empty strings when all nullable image fields are null', () => { - const props = defaultProps() - props.images = [ - makeImageAsset({ - alt: null, - filename: null, - url: null, - mimeType: null, - }), - ] - renderWithChakra() - const form = getUpdateForm('이미지') - expect( - (form.querySelector('input[name="url"]') as HTMLInputElement).value, - ).toBe('') - expect( - (form.querySelector('input[name="filename"]') as HTMLInputElement) - .value, - ).toBe('') - expect( - (form.querySelector('input[name="alt"]') as HTMLInputElement).value, - ).toBe('') - expect( - (form.querySelector('input[name="mimeType"]') as HTMLInputElement) - .value, - ).toBe('') - }) - }) - - describe('existing FileAsset update form', () => { - it('pre-fills url / filename / mimeType and accepts PDFs only', () => { - renderWithChakra() - const form = getUpdateForm('파일') - expect( - (form.querySelector('input[name="url"]') as HTMLInputElement).value, - ).toBe( - 'https://stdev-kr.s3.ap-northeast-2.amazonaws.com/files/report.pdf', - ) - expect(form.querySelector('input[name="file"]')).toHaveAttribute( - 'accept', - 'application/pdf', - ) - expect(form.querySelector('input[name="filename"]')).toHaveAttribute( - 'required', - ) - expect( - (form.querySelector('input[name="filename"]') as HTMLInputElement) - .value, - ).toBe('report.pdf') - expect( - (form.querySelector('input[name="mimeType"]') as HTMLInputElement) - .value, - ).toBe('application/pdf') - }) - }) - - describe('existing Institution update form', () => { - it('pre-fills nameKo / nameEn / url and pre-selects logoId', () => { - renderWithChakra() - const form = getUpdateForm('기관') - expect( - (form.querySelector('input[name="nameKo"]') as HTMLInputElement).value, - ).toBe('기관명') - expect( - (form.querySelector('input[name="nameEn"]') as HTMLInputElement).value, - ).toBe('Institution') - expect( - (form.querySelector('input[name="url"]') as HTMLInputElement).value, - ).toBe('https://example.com') - const logoSelect = form.querySelector( - 'select[name="logoId"]', - ) as HTMLSelectElement - expect(logoSelect.value).toBe('1') - expect(logoSelect).toHaveAttribute('required') - }) - - it('lists every available image as a logoId option', () => { - const props = defaultProps() - props.images = [ - makeImageAsset({ id: 1, filename: 'a.png' }), - makeImageAsset({ id: 2, filename: 'b.png' }), - ] - renderWithChakra() - const form = getUpdateForm('기관') - const select = form.querySelector( - 'select[name="logoId"]', - ) as HTMLSelectElement - const options = Array.from(select.querySelectorAll('option')) - expect(options).toHaveLength(2) - expect(options[0].value).toBe('1') - expect(options[0].textContent).toContain('a.png') - expect(options[1].value).toBe('2') - expect(options[1].textContent).toContain('b.png') - }) - }) - - describe('existing Markdown update form', () => { - it('renders a type select with the three allowed options', () => { - renderWithChakra() - const form = getUpdateForm('마크다운') - const select = form.querySelector( - 'select[name="type"]', - ) as HTMLSelectElement - expect(select.value).toBe('articles') - const entries = Array.from(select.querySelectorAll('option')).map((o) => [ - o.value, - o.textContent, - ]) - expect(entries).toEqual([ - ['articles', '정관'], - ['privacy', '개인정보처리방침'], - ['terms', '이용약관'], - ]) - }) - - it('renders revisionDate/effectiveDate inputs and a content textarea', () => { - renderWithChakra() - const form = getUpdateForm('마크다운') - expect( - (form.querySelector('input[name="revisionDate"]') as HTMLInputElement) - .value, - ).toBe('2026-01-01') - expect( - (form.querySelector('input[name="effectiveDate"]') as HTMLInputElement) - .value, - ).toBe('2026-01-10') - const ta = form.querySelector( - 'textarea[name="content"]', - ) as HTMLTextAreaElement - expect(ta).toBeInTheDocument() - expect(ta.value).toBe('# 정관\n\n본문') - expect(ta).toHaveAttribute('rows', '8') - expect(ta).toHaveAttribute('required') - }) - }) - - describe('existing Webpage update form', () => { - it('renders a type select with the three webpage kinds', () => { - renderWithChakra() - const form = getUpdateForm('웹페이지') - const select = form.querySelector( - 'select[name="type"]', - ) as HTMLSelectElement - expect(select.value).toBe('blog_post') - const entries = Array.from(select.querySelectorAll('option')).map((o) => [ - o.value, - o.textContent, - ]) - expect(entries).toEqual([ - ['blog_post', '블로그 포스트'], - ['news_article', '신문 기사'], - ['press_release', '보도 자료'], - ]) - }) - - it('pre-fills url / title / author / publishedDate', () => { - renderWithChakra() - const form = getUpdateForm('웹페이지') - expect( - (form.querySelector('input[name="url"]') as HTMLInputElement).value, - ).toBe('https://blog.example.com/post') - expect( - (form.querySelector('input[name="title"]') as HTMLInputElement).value, - ).toBe('블로그 글') - expect( - (form.querySelector('input[name="author"]') as HTMLInputElement).value, - ).toBe('홍길동') - expect( - (form.querySelector('input[name="publishedDate"]') as HTMLInputElement) - .value, - ).toBe('2026-05-01') - }) - - it('defaults businessId to empty value when webpage.businessId is null', () => { - renderWithChakra() - const form = getUpdateForm('웹페이지') - const select = form.querySelector( - 'select[name="businessId"]', - ) as HTMLSelectElement - expect(select.value).toBe('') - expect(select.querySelector('option[value=""]')?.textContent).toBe( - '관련 사업 없음', - ) - }) - - it('includes each business as a businessId option by name', () => { - renderWithChakra() - const form = getUpdateForm('웹페이지') - const select = form.querySelector( - 'select[name="businessId"]', - ) as HTMLSelectElement - expect(select.querySelector('option[value="1"]')?.textContent).toBe( - 'Hackathon 2026', - ) - }) - - it('pre-selects businessId when webpage.businessId is set', () => { - const props = defaultProps() - props.webpages = [makeWebpage({ businessId: 1 })] - renderWithChakra() - const form = getUpdateForm('웹페이지') - expect( - (form.querySelector('select[name="businessId"]') as HTMLSelectElement) - .value, - ).toBe('1') - }) - }) - - describe('existing Report update form', () => { - it('pre-fills title and publishedDate', () => { - renderWithChakra() - const form = getUpdateForm('보고서') - expect( - (form.querySelector('input[name="title"]') as HTMLInputElement).value, - ).toBe('2026 1분기 회의록') - expect( - (form.querySelector('input[name="publishedDate"]') as HTMLInputElement) - .value, - ).toBe('2026-03-31') - }) - - it('offers meeting / donation options in the type select', () => { - renderWithChakra() - const form = getUpdateForm('보고서') - const select = form.querySelector( - 'select[name="type"]', - ) as HTMLSelectElement - expect(select.value).toBe('meeting') - const entries = Array.from(select.querySelectorAll('option')).map((o) => [ - o.value, - o.textContent, - ]) - expect(entries).toEqual([ - ['meeting', '총회 및 이사회'], - ['donation', '기부금 모금액 및 활용실적'], - ]) - }) - - it('requires a fileId and lists existing files as options', () => { - renderWithChakra() - const form = getUpdateForm('보고서') - const select = form.querySelector( - 'select[name="fileId"]', - ) as HTMLSelectElement - expect(select).toHaveAttribute('required') - expect(select.value).toBe('1') - expect(select.querySelector('option[value="1"]')?.textContent).toContain( - 'report.pdf', - ) - }) - }) - - describe('existing History update form', () => { - it('pre-fills date / title / content', () => { - renderWithChakra() - const form = getUpdateForm('연혁') - expect( - (form.querySelector('input[name="date"]') as HTMLInputElement).value, - ).toBe('2026-05-01') - expect( - (form.querySelector('input[name="title"]') as HTMLInputElement).value, - ).toBe('첫 이사회') - expect( - (form.querySelector('textarea[name="content"]') as HTMLTextAreaElement) - .value, - ).toBe('정관 채택') - }) - - it('renders an imageId select with an empty option and image options', () => { - renderWithChakra() - const form = getUpdateForm('연혁') - const select = form.querySelector( - 'select[name="imageId"]', - ) as HTMLSelectElement - expect(select.value).toBe('') - expect(select.querySelector('option[value=""]')?.textContent).toBe( - '이미지 없음', - ) - expect(select.querySelector('option[value="1"]')?.textContent).toContain( - 'logo.png', - ) - }) - - it('pre-selects imageId when history.imageId is set', () => { - const props = defaultProps() - props.histories = [makeHistory({ imageId: 1 })] - renderWithChakra() - const form = getUpdateForm('연혁') - expect( - (form.querySelector('select[name="imageId"]') as HTMLSelectElement) - .value, - ).toBe('1') - }) - - it('renders an empty content textarea when history.content is null', () => { - const props = defaultProps() - props.histories = [makeHistory({ content: null })] - renderWithChakra() - const form = getUpdateForm('연혁') - expect( - (form.querySelector('textarea[name="content"]') as HTMLTextAreaElement) - .value, - ).toBe('') - }) - }) - - describe('create forms', () => { - it('renders all 8 create-section h2 headings', () => { - renderWithChakra() - const titles = [ - '사업 추가', - '이미지 추가', - 'PDF 파일 추가', - '기관 추가', - '마크다운 추가', - '웹페이지 추가', - '보고서 추가', - '연혁 추가', - ] - for (const t of titles) { - expect( - screen.getByRole('heading', { level: 2, name: t }), - ).toBeInTheDocument() - } - }) - - it('business create form has placeholders, empty defaults, and a 저장 submit', () => { - renderWithChakra() - const form = getCreateForm('사업 추가') - expect(form.querySelector('input[name="name"]')).toHaveAttribute( - 'placeholder', - '이름', - ) - expect(form.querySelector('input[name="code"]')).toHaveAttribute( - 'placeholder', - '코드', - ) - expect(form.querySelector('input[name="location"]')).toHaveAttribute( - 'placeholder', - '장소', - ) - expect( - (form.querySelector('input[name="name"]') as HTMLInputElement).value, - ).toBe('') - const save = within(form).getByRole('button', { name: '저장' }) - expect(save).toHaveAttribute('type', 'submit') - }) - - it('image create form accepts image/* and has Korean placeholders', () => { - renderWithChakra() - const form = getCreateForm('이미지 추가') - expect(form.querySelector('input[name="file"]')).toHaveAttribute( - 'accept', - 'image/*', - ) - expect(form.querySelector('input[name="url"]')).toHaveAttribute( - 'placeholder', - '기존 S3 이미지 URL (선택)', - ) - expect(form.querySelector('input[name="alt"]')).toHaveAttribute( - 'placeholder', - '대체 텍스트', - ) - expect(form.querySelector('input[name="mimeType"]')).toHaveAttribute( - 'placeholder', - 'MIME 타입', - ) - }) - - it('PDF file create form accepts application/pdf and has S3 URL placeholder', () => { - renderWithChakra() - const form = getCreateForm('PDF 파일 추가') - expect(form.querySelector('input[name="file"]')).toHaveAttribute( - 'accept', - 'application/pdf', - ) - expect(form.querySelector('input[name="url"]')).toHaveAttribute( - 'placeholder', - '기존 S3 PDF URL (선택)', - ) - }) - - it('institution create form has required logoId select with placeholder option', () => { - renderWithChakra() - const form = getCreateForm('기관 추가') - const select = form.querySelector( - 'select[name="logoId"]', - ) as HTMLSelectElement - expect(select).toHaveAttribute('required') - expect(select.querySelector('option[value=""]')?.textContent).toBe( - '로고 이미지 선택', - ) - expect(select.querySelector('option[value="1"]')).not.toBeNull() - }) - - it('markdown create form has required type/dates and a 10-row content textarea', () => { - renderWithChakra() - const form = getCreateForm('마크다운 추가') - expect(form.querySelector('select[name="type"]')).toHaveAttribute( - 'required', - ) - expect(form.querySelector('input[name="revisionDate"]')).toHaveAttribute( - 'required', - ) - expect(form.querySelector('input[name="effectiveDate"]')).toHaveAttribute( - 'required', - ) - const ta = form.querySelector( - 'textarea[name="content"]', - ) as HTMLTextAreaElement - expect(ta).toHaveAttribute('required') - expect(ta).toHaveAttribute('rows', '10') - expect(ta).toHaveAttribute('placeholder', '내용') - }) - - it('webpage create form has 3 type options and an optional businessId select', () => { - renderWithChakra() - const form = getCreateForm('웹페이지 추가') - const typeSel = form.querySelector( - 'select[name="type"]', - ) as HTMLSelectElement - expect(typeSel.querySelectorAll('option')).toHaveLength(3) - expect(typeSel).toHaveAttribute('required') - const bizSel = form.querySelector( - 'select[name="businessId"]', - ) as HTMLSelectElement - expect(bizSel).not.toHaveAttribute('required') - expect(bizSel.querySelector('option[value=""]')?.textContent).toBe( - '관련 사업 없음', - ) - }) - - it('report create form has required type/title/publishedDate/fileId', () => { - renderWithChakra() - const form = getCreateForm('보고서 추가') - expect(form.querySelector('select[name="type"]')).toHaveAttribute( - 'required', - ) - expect(form.querySelector('input[name="title"]')).toHaveAttribute( - 'placeholder', - '제목', - ) - expect(form.querySelector('input[name="publishedDate"]')).toHaveAttribute( - 'required', - ) - const fileSel = form.querySelector( - 'select[name="fileId"]', - ) as HTMLSelectElement - expect(fileSel).toHaveAttribute('required') - expect(fileSel.querySelector('option[value=""]')?.textContent).toBe( - 'PDF 선택', - ) - }) - - it('history create form has required date/title, optional 5-row content textarea, optional imageId', () => { - renderWithChakra() - const form = getCreateForm('연혁 추가') - expect(form.querySelector('input[name="date"]')).toHaveAttribute( - 'required', - ) - expect(form.querySelector('input[name="title"]')).toHaveAttribute( - 'required', - ) - expect(form.querySelector('input[name="title"]')).toHaveAttribute( - 'placeholder', - '제목', - ) - const ta = form.querySelector( - 'textarea[name="content"]', - ) as HTMLTextAreaElement - expect(ta).not.toHaveAttribute('required') - expect(ta).toHaveAttribute('rows', '5') - const imgSel = form.querySelector( - 'select[name="imageId"]', - ) as HTMLSelectElement - expect(imgSel).not.toHaveAttribute('required') - expect(imgSel.querySelector('option[value=""]')?.textContent).toBe( - '이미지 없음', - ) - }) - }) - - describe('empty lists', () => { - it('renders 0 for all counts and no update forms when every array is empty', () => { - const props = { - ...defaultProps(), - businesses: [], - images: [], - files: [], - institutions: [], - markdowns: [], - webpages: [], - reports: [], - histories: [], - } - const { container } = renderWithChakra() - for (const label of [ - '사업', - '이미지', - '파일', - '기관', - '마크다운', - '웹페이지', - '보고서', - '연혁', - ]) { - expect(screen.getByText(`${label}: 0`)).toBeInTheDocument() - } - expect(container.querySelectorAll('form')).toHaveLength(8) - expect(screen.queryAllByRole('button', { name: '수정' })).toHaveLength(0) - expect(screen.queryAllByRole('button', { name: '삭제' })).toHaveLength(0) - }) - - it('renders exactly 16 forms when every entity has a single record', () => { - const { container } = renderWithChakra( - , - ) - expect(container.querySelectorAll('form')).toHaveLength(16) - }) - - it('empty businesses keeps webpage businessId select with only the empty option', () => { - const props = defaultProps() - props.businesses = [] - renderWithChakra() - const createForm = getCreateForm('웹페이지 추가') - const select = createForm.querySelector( - 'select[name="businessId"]', - ) as HTMLSelectElement - expect(select.querySelectorAll('option')).toHaveLength(1) - expect(select.querySelector('option[value=""]')?.textContent).toBe( - '관련 사업 없음', - ) - }) - }) - - describe('aggregated button counts', () => { - it('has exactly 8 수정 submit buttons (one per update form)', () => { - renderWithChakra() - expect(screen.getAllByRole('button', { name: '수정' })).toHaveLength(8) - }) - - it('has exactly 8 삭제 buttons (one per update form)', () => { - renderWithChakra() - expect(screen.getAllByRole('button', { name: '삭제' })).toHaveLength(8) - }) - - it('has exactly 8 저장 submit buttons (one per create form)', () => { - renderWithChakra() - expect(screen.getAllByRole('button', { name: '저장' })).toHaveLength(8) - }) - }) - - describe('user interactions', () => { - it('allows typing into the business create name input', async () => { - const { user } = renderWithChakra() - const form = getCreateForm('사업 추가') - const input = form.querySelector('input[name="name"]') as HTMLInputElement - await user.type(input, '새 사업') - expect(input.value).toBe('새 사업') - }) - - it('allows selecting a different markdown type in the create form', async () => { - const { user } = renderWithChakra() - const form = getCreateForm('마크다운 추가') - const select = form.querySelector( - 'select[name="type"]', - ) as HTMLSelectElement - await user.selectOptions(select, 'privacy') - expect(select.value).toBe('privacy') - }) - - it('allows typing into the history create title input', async () => { - const { user } = renderWithChakra() - const form = getCreateForm('연혁 추가') - const input = form.querySelector( - 'input[name="title"]', - ) as HTMLInputElement - await user.type(input, '새 연혁') - expect(input.value).toBe('새 연혁') - }) - - it('allows overwriting the business update name input', async () => { - const { user } = renderWithChakra() - const form = getUpdateForm('사업') - const input = form.querySelector('input[name="name"]') as HTMLInputElement - await user.clear(input) - await user.type(input, 'Renamed') - expect(input.value).toBe('Renamed') - }) - - it('allows switching a webpage update businessId from empty to the first business', async () => { - const { user } = renderWithChakra() - const form = getUpdateForm('웹페이지') - const select = form.querySelector( - 'select[name="businessId"]', - ) as HTMLSelectElement - expect(select.value).toBe('') - await user.selectOptions(select, '1') - expect(select.value).toBe('1') - }) - }) -}) diff --git a/src/components/admin/dashboard.tsx b/src/components/admin/dashboard.tsx deleted file mode 100644 index 9940c3e..0000000 --- a/src/components/admin/dashboard.tsx +++ /dev/null @@ -1,609 +0,0 @@ -import type { - Business, - FileAsset, - History, - ImageAsset, - Institution, - Markdown, - Report, - Webpage, -} from '@prisma/client' -import { dateValue } from '@/utils/admin-format' -import { AdminActionButtons } from '@/components/admin/action-buttons' -import { AdminSection } from '@/components/admin/section' -import { formStyle, inputStyle, listStyle } from '@/components/admin/styles' - -type ServerAction = (formData: FormData) => Promise - -type Props = { - sessionEmail: string - businesses: Business[] - images: ImageAsset[] - files: FileAsset[] - institutions: Institution[] - markdowns: Markdown[] - webpages: Webpage[] - reports: Report[] - histories: History[] - actions: { - createBusiness: ServerAction - createFileAsset: ServerAction - createHistory: ServerAction - createImageAsset: ServerAction - createInstitution: ServerAction - createMarkdown: ServerAction - createReport: ServerAction - createWebpage: ServerAction - deleteBusiness: ServerAction - deleteFileAsset: ServerAction - deleteHistory: ServerAction - deleteImageAsset: ServerAction - deleteInstitution: ServerAction - deleteMarkdown: ServerAction - deleteReport: ServerAction - deleteWebpage: ServerAction - updateBusiness: ServerAction - updateFileAsset: ServerAction - updateHistory: ServerAction - updateImageAsset: ServerAction - updateInstitution: ServerAction - updateMarkdown: ServerAction - updateReport: ServerAction - updateWebpage: ServerAction - } -} - -export function AdminDashboard({ - sessionEmail, - businesses, - images, - files, - institutions, - markdowns, - webpages, - reports, - histories, - actions, -}: Props) { - return ( -
-

STDev DIY CMS

-

{sessionEmail} 계정으로 로그인했습니다.

- - -
    -
  • 사업: {businesses.length}
  • -
  • 이미지: {images.length}
  • -
  • 파일: {files.length}
  • -
  • 기관: {institutions.length}
  • -
  • 마크다운: {markdowns.length}
  • -
  • 웹페이지: {webpages.length}
  • -
  • 보고서: {reports.length}
  • -
  • 연혁: {histories.length}
  • -
-
- - -
-

사업

- {businesses.map((business) => ( - - - #{business.id} - - - - - - - - ))} -

이미지

- {images.map((image) => ( -
- - #{image.id} - - - - - - - - ))} -

파일

- {files.map((file) => ( -
- - #{file.id} - - - - - - - ))} -

기관

- {institutions.map((institution) => ( -
- - #{institution.id} - - - - - - - ))} -

마크다운

- {markdowns.map((markdown) => ( -
- - #{markdown.id} - - - -