--- @fox/core/components/fox-email/fox-email.tsx
+++ @fox/core/components/fox-email/fox-email.tsx
... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 |
"use client"; |
| 2 | 2 |
|
| 3 | 3 |
import { useId, useState, type ReactNode } from "react";
|
| 4 |
-import { FoxFormLabel } from "../fox-form-label";
|
|
| 4 |
+import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label";
|
|
| 5 | 5 |
import { cx } from "../../utils";
|
| 6 | 6 |
import { FoxButton } from "../fox-button";
|
| 7 | 7 |
import { FoxInput } from "../fox-input";
|
... | ... | @@ -29,6 +29,8 @@ |
| 29 | 29 |
verifyLabel?: string; |
| 30 | 30 |
|
| 31 | 31 |
label?: string; |
| 32 |
+ /** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다(`FoxInput`·`FoxSelect`와 같은 규약). */ |
|
| 33 |
+ requirement?: FoxFormLabelRequirement; |
|
| 32 | 34 |
/** 헬퍼 메시지. 오류일 때 색이 바뀐다. */ |
| 33 | 35 |
message?: string; |
| 34 | 36 |
messageIcon?: ReactNode; |
... | ... | @@ -88,6 +90,7 @@ |
| 88 | 90 |
onVerifyFail, |
| 89 | 91 |
verifyLabel = "확인", |
| 90 | 92 |
label, |
| 93 |
+ requirement, |
|
| 91 | 94 |
message, |
| 92 | 95 |
messageIcon, |
| 93 | 96 |
localPlaceholder = "아이디", |
... | ... | @@ -163,12 +166,16 @@ |
| 163 | 166 |
aria-labelledby={label ? `${autoId}-label` : undefined}
|
| 164 | 167 |
> |
| 165 | 168 |
{label && (
|
| 166 |
- <FoxFormLabel as="span" id={`${autoId}-label`}>
|
|
| 169 |
+ <FoxFormLabel as="span" id={`${autoId}-label`} requirement={requirement}>
|
|
| 167 | 170 |
{label}
|
| 168 | 171 |
</FoxFormLabel> |
| 169 | 172 |
)} |
| 170 | 173 |
|
| 171 |
- <div className="fox-email__row"> |
|
| 174 |
+ {/* 시안의 form-email 알맹이 — form-group / select / btn이 4px 간격으로 쌓인다.
|
|
| 175 |
+ 라벨·헬퍼와의 간격은 바깥(`field.root`)이 갖는다. */} |
|
| 176 |
+ <div className="fox-email__body"> |
|
| 177 |
+ {/* 시안의 form-group — 아이디·@·도메인이 한 줄이다. 도메인 목록은 아래 줄에 따로 선다. */}
|
|
| 178 |
+ <div className="fox-email__group"> |
|
| 172 | 179 |
<div className="fox-email__part"> |
| 173 | 180 |
<FoxInput |
| 174 | 181 |
size="md" |
... | ... | @@ -186,18 +193,6 @@ |
| 186 | 193 |
@ |
| 187 | 194 |
</span> |
| 188 | 195 |
|
| 189 |
- <div className="fox-email__part"> |
|
| 190 |
- <FoxSelect |
|
| 191 |
- size="md" |
|
| 192 |
- options={domainItems}
|
|
| 193 |
- value={parts.custom ? CUSTOM_DOMAIN : parts.domain}
|
|
| 194 |
- onValueChange={handleDomainPick}
|
|
| 195 |
- placeholder={domainPlaceholder}
|
|
| 196 |
- error={invalid}
|
|
| 197 |
- disabled={disabled}
|
|
| 198 |
- /> |
|
| 199 |
- </div> |
|
| 200 |
- |
|
| 201 | 196 |
{/* 목록에서 고른 값이 여기에 채워지고 잠긴다. "직접입력"일 때만 풀린다. */}
|
| 202 | 197 |
<div className="fox-email__part"> |
| 203 | 198 |
<FoxInput |
... | ... | @@ -211,17 +206,28 @@ |
| 211 | 206 |
disabled={disabled}
|
| 212 | 207 |
/> |
| 213 | 208 |
</div> |
| 209 |
+ </div> |
|
| 214 | 210 |
|
| 215 |
- {onVerify && (
|
|
| 216 |
- <FoxButton |
|
| 217 |
- size="md" |
|
| 218 |
- type="primary" |
|
| 219 |
- label={verifyLabel}
|
|
| 220 |
- loading={verifying}
|
|
| 221 |
- disabled={disabled}
|
|
| 222 |
- onAction={handleVerify}
|
|
| 223 |
- /> |
|
| 224 |
- )} |
|
| 211 |
+ <FoxSelect |
|
| 212 |
+ size="md" |
|
| 213 |
+ options={domainItems}
|
|
| 214 |
+ value={parts.custom ? CUSTOM_DOMAIN : parts.domain}
|
|
| 215 |
+ onValueChange={handleDomainPick}
|
|
| 216 |
+ placeholder={domainPlaceholder}
|
|
| 217 |
+ error={invalid}
|
|
| 218 |
+ disabled={disabled}
|
|
| 219 |
+ /> |
|
| 220 |
+ |
|
| 221 |
+ {onVerify && (
|
|
| 222 |
+ <FoxButton |
|
| 223 |
+ size="md" |
|
| 224 |
+ type="primary" |
|
| 225 |
+ label={verifyLabel}
|
|
| 226 |
+ loading={verifying}
|
|
| 227 |
+ disabled={disabled}
|
|
| 228 |
+ onAction={handleVerify}
|
|
| 229 |
+ /> |
|
| 230 |
+ )} |
|
| 225 | 231 |
</div> |
| 226 | 232 |
|
| 227 | 233 |
{message && (
|
--- @fox/core/components/fox-phone-number/fox-phone-number.tsx
+++ @fox/core/components/fox-phone-number/fox-phone-number.tsx
... | ... | @@ -184,47 +184,52 @@ |
| 184 | 184 |
const body = |
| 185 | 185 |
type === "unit" ? ( |
| 186 | 186 |
<div className="fox-phone-number__row"> |
| 187 |
- <div className="fox-phone-number__part"> |
|
| 188 |
- <FoxSelect |
|
| 189 |
- size="md" |
|
| 190 |
- options={prefixItems}
|
|
| 191 |
- value={parts[0]}
|
|
| 192 |
- onValueChange={(next) => {
|
|
| 193 |
- commit(0, next); |
|
| 194 |
- if (autoAdvance) {
|
|
| 195 |
- focusSegment(1); |
|
| 196 |
- } |
|
| 197 |
- }} |
|
| 198 |
- placeholder={placeholders[0]}
|
|
| 199 |
- error={errored}
|
|
| 200 |
- disabled={disabled || state === "disabled"}
|
|
| 201 |
- readOnly={readOnly || state === "view"}
|
|
| 202 |
- /> |
|
| 203 |
- </div> |
|
| 204 |
- |
|
| 205 |
- {[1, 2].map((index) => (
|
|
| 187 |
+ {[0, 1, 2].map((index) => (
|
|
| 206 | 188 |
<Fragment key={index}>
|
| 207 |
- <span className="fox-phone-number__separator" aria-hidden="true"> |
|
| 208 |
- - |
|
| 209 |
- </span> |
|
| 189 |
+ {index > 0 && (
|
|
| 190 |
+ <span className="fox-phone-number__separator" aria-hidden="true"> |
|
| 191 |
+ - |
|
| 192 |
+ </span> |
|
| 193 |
+ )} |
|
| 210 | 194 |
<div className="fox-phone-number__part"> |
| 211 |
- <FoxInput |
|
| 212 |
- size="md" |
|
| 213 |
- inputMode="numeric" |
|
| 214 |
- aria-label={SEGMENT_LABELS[index]}
|
|
| 215 |
- value={parts[index]}
|
|
| 216 |
- onChange={handleSegmentChange(index)}
|
|
| 217 |
- onKeyDown={handleSegmentKeyDown(index)}
|
|
| 218 |
- placeholder={placeholders[index]}
|
|
| 219 |
- maxLength={maxLengths[index]}
|
|
| 220 |
- invalid={invalid}
|
|
| 221 |
- disabled={disabled}
|
|
| 222 |
- readOnly={readOnly}
|
|
| 223 |
- state={state}
|
|
| 224 |
- ref={(node) => {
|
|
| 225 |
- tailRefs.current[index] = node; |
|
| 226 |
- }} |
|
| 227 |
- /> |
|
| 195 |
+ {/* 앞자리는 고를 목록이 있을 때만 셀렉트다. 목록이 비었는데 셀렉트를 그리면
|
|
| 196 |
+ placeholder("010")가 이미 고른 값처럼 보이는데 실제 값은 비어 있어, 뒷자리만
|
|
| 197 |
+ 채운 번호가 조용히 만들어진다 — 그때는 나머지 칸과 같은 입력 칸이 된다. */} |
|
| 198 |
+ {index === 0 && prefixItems.length > 0 ? (
|
|
| 199 |
+ <FoxSelect |
|
| 200 |
+ size="md" |
|
| 201 |
+ options={prefixItems}
|
|
| 202 |
+ value={parts[0]}
|
|
| 203 |
+ onValueChange={(next) => {
|
|
| 204 |
+ commit(0, next); |
|
| 205 |
+ if (autoAdvance) {
|
|
| 206 |
+ focusSegment(1); |
|
| 207 |
+ } |
|
| 208 |
+ }} |
|
| 209 |
+ placeholder={placeholders[0]}
|
|
| 210 |
+ error={errored}
|
|
| 211 |
+ disabled={disabled || state === "disabled"}
|
|
| 212 |
+ readOnly={readOnly || state === "view"}
|
|
| 213 |
+ /> |
|
| 214 |
+ ) : ( |
|
| 215 |
+ <FoxInput |
|
| 216 |
+ size="md" |
|
| 217 |
+ inputMode="numeric" |
|
| 218 |
+ aria-label={SEGMENT_LABELS[index]}
|
|
| 219 |
+ value={parts[index]}
|
|
| 220 |
+ onChange={handleSegmentChange(index)}
|
|
| 221 |
+ onKeyDown={handleSegmentKeyDown(index)}
|
|
| 222 |
+ placeholder={placeholders[index]}
|
|
| 223 |
+ maxLength={maxLengths[index]}
|
|
| 224 |
+ invalid={invalid}
|
|
| 225 |
+ disabled={disabled}
|
|
| 226 |
+ readOnly={readOnly}
|
|
| 227 |
+ state={state}
|
|
| 228 |
+ ref={(node) => {
|
|
| 229 |
+ tailRefs.current[index] = node; |
|
| 230 |
+ }} |
|
| 231 |
+ /> |
|
| 232 |
+ )} |
|
| 228 | 233 |
</div> |
| 229 | 234 |
</Fragment> |
| 230 | 235 |
))} |
--- @fox/core/components/fox-text-area/fox-text-area.tsx
+++ @fox/core/components/fox-text-area/fox-text-area.tsx
... | ... | @@ -9,7 +9,7 @@ |
| 9 | 9 |
type ReactNode, |
| 10 | 10 |
} from "react"; |
| 11 | 11 |
import { cx } from "../../utils";
|
| 12 |
-import { FoxFormLabel } from "../fox-form-label";
|
|
| 12 |
+import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label";
|
|
| 13 | 13 |
|
| 14 | 14 |
export type FoxTextAreaState = "default" | "focused" | "completed" | "error" | "disabled" | "view"; |
| 15 | 15 |
|
... | ... | @@ -27,6 +27,8 @@ |
| 27 | 27 |
onChange?: (value: string, event: ChangeEvent<HTMLTextAreaElement>) => void; |
| 28 | 28 |
onInput?: (value: string, event: FormEvent<HTMLTextAreaElement>) => void; |
| 29 | 29 |
label?: string; |
| 30 |
+ /** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다. */ |
|
| 31 |
+ requirement?: FoxFormLabelRequirement; |
|
| 30 | 32 |
/** 헬퍼 메시지. 색은 상태를 따라간다. */ |
| 31 | 33 |
message?: string; |
| 32 | 34 |
/** 헬퍼 메시지 앞 아이콘. `currentColor`로 그려야 상태 색을 따라간다. */ |
... | ... | @@ -53,6 +55,7 @@ |
| 53 | 55 |
onChange, |
| 54 | 56 |
onInput, |
| 55 | 57 |
label, |
| 58 |
+ requirement, |
|
| 56 | 59 |
message, |
| 57 | 60 |
messageIcon, |
| 58 | 61 |
counter = false, |
... | ... | @@ -96,7 +99,9 @@ |
| 96 | 99 |
return ( |
| 97 | 100 |
<div className={cx("fox-text-area", className)} data-state={state}>
|
| 98 | 101 |
{label && (
|
| 99 |
- <FoxFormLabel htmlFor={fieldId}>{label}</FoxFormLabel>
|
|
| 102 |
+ <FoxFormLabel htmlFor={fieldId} requirement={requirement}>
|
|
| 103 |
+ {label}
|
|
| 104 |
+ </FoxFormLabel> |
|
| 100 | 105 |
)} |
| 101 | 106 |
|
| 102 | 107 |
<div className="fox-text-area__box"> |
--- @fox/styles/_fox-email.scss
+++ @fox/styles/_fox-email.scss
... | ... | @@ -1,25 +1,27 @@ |
| 1 | 1 |
// FoxEmail — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) |
| 2 | 2 |
// Figma form-email (136:3092 · 섹션 392:5820) |
| 3 | 3 |
// |
| 4 |
-// [아이디] @ [도메인 드롭다운] [도메인 직접입력] + 선택적 확인 버튼. |
|
| 5 |
-// 시안에서 세 칸이 모두 input·select 인스턴스이고 버튼도 btn 인스턴스라, 이 파일은 줄 |
|
| 6 |
-// 배치와 `@` 구분자만 갖는다 — 상자 모양과 상태는 FoxInput·FoxSelect가 가진 것을 쓴다. |
|
| 4 |
+// 시안 구조는 **줄이 나뉜다** — [아이디] @ [도메인] 한 줄, 그 아래 도메인 드롭다운, 그 아래 |
|
| 5 |
+// 선택적 확인 버튼(form-email 5543:9940: form-group 320×40 · select 320×40 · btn). |
|
| 6 |
+// 종전에는 넷을 한 줄에 두고 넘치면 접히게 했는데, 그러면 좁은 곳에서 드롭다운이 도메인 칸보다 |
|
| 7 |
+// 먼저 접혀 시안과 순서가 어긋났다. |
|
| 8 |
+// |
|
| 9 |
+// 세 칸이 모두 input·select 인스턴스이고 버튼도 btn 인스턴스라, 이 파일은 줄 배치와 `@` |
|
| 10 |
+// 구분자만 갖는다 — 상자 모양과 상태는 FoxInput·FoxSelect가 가진 것을 쓴다. |
|
| 7 | 11 |
// |
| 8 | 12 |
// 마크업 계약 (React 밖 소비자용): |
| 9 | 13 |
// <div class="fox-email"> |
| 10 |
-// <div class="fox-email__row"> |
|
| 11 |
-// <div class="fox-email__part">…fox-input…</div> |
|
| 14 |
+// <div class="fox-email__body"> |
|
| 15 |
+// <div class="fox-email__group"> |
|
| 16 |
+// <div class="fox-email__part">…fox-input(아이디)…</div> |
|
| 12 | 17 |
// <span class="fox-email__at">@</span> |
| 13 |
-// <div class="fox-email__part">…fox-select…</div> |
|
| 14 |
-// <div class="fox-email__part">…fox-input…</div> |
|
| 15 |
-// <button class="fox-button fox-button--primary fox-button--md">확인</button> |
|
| 18 |
+// <div class="fox-email__part">…fox-input(도메인)…</div> |
|
| 16 | 19 |
// </div> |
| 20 |
+// …fox-select(도메인 목록)… |
|
| 21 |
+// <button class="fox-button …">확인</button> <!-- 선택 --> |
|
| 17 | 22 |
// </div> |
| 18 | 23 |
// |
| 19 | 24 |
// 토큰이 아닌 값(사용자 승인): |
| 20 |
-// - 칸의 `min-inline-size: 24rem`(240px): 시안이 세 칸을 240px로 고정했는데 토큰 |
|
| 21 |
-// 스케일에 240이 없다. 고정 대신 최소폭으로 두어 넓은 곳에서는 나눠 갖고 좁아지면 |
|
| 22 |
-// 줄이 넘어가게 했다. |
|
| 23 | 25 |
// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 |
| 24 | 26 |
// export하지 않는다. |
| 25 | 27 |
|
... | ... | @@ -30,18 +32,27 @@ |
| 30 | 32 |
@include field.root; |
| 31 | 33 |
|
| 32 | 34 |
|
| 33 |
- // 좁아지면 칸이 찌그러지는 대신 줄이 넘어간다. |
|
| 34 |
- &__row {
|
|
| 35 |
+ // 세 줄이 4px 간격으로 쌓인다(시안 form-group 0-40 · select 44-84). |
|
| 36 |
+ &__body {
|
|
| 35 | 37 |
display: flex; |
| 36 |
- flex-wrap: wrap; |
|
| 38 |
+ inline-size: 100%; |
|
| 39 |
+ flex-direction: column; |
|
| 40 |
+ gap: fox.gap(2); |
|
| 41 |
+ } |
|
| 42 |
+ |
|
| 43 |
+ // 아이디·@·도메인이 한 줄을 나눠 갖는다(시안 149 + @ + 149). |
|
| 44 |
+ &__group {
|
|
| 45 |
+ display: flex; |
|
| 37 | 46 |
align-items: center; |
| 38 | 47 |
gap: fox.gap(2); |
| 39 | 48 |
inline-size: 100%; |
| 40 | 49 |
} |
| 41 | 50 |
|
| 51 |
+ // 두 칸이 남는 폭을 똑같이 나눈다. `min-inline-size: 0`이 없으면 입력의 기본 폭 때문에 |
|
| 52 |
+ // 좁은 곳에서 넘친다. |
|
| 42 | 53 |
&__part {
|
| 43 | 54 |
flex: 1 1 0; |
| 44 |
- min-inline-size: 24rem; |
|
| 55 |
+ min-inline-size: 0; |
|
| 45 | 56 |
} |
| 46 | 57 |
|
| 47 | 58 |
&__at {
|
--- app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
... | ... | @@ -9,9 +9,8 @@ |
| 9 | 9 |
} from 'react'; |
| 10 | 10 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 11 | 11 |
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
| 12 |
-import { FoxFormLabel } from '@fox/core/components/fox-form-label';
|
|
| 12 |
+import { FoxEmail } from '@fox/core/components/fox-email';
|
|
| 13 | 13 |
import { FoxInput } from '@fox/core/components/fox-input';
|
| 14 |
-import { FoxSelect } from '@fox/core/components/fox-select';
|
|
| 15 | 14 |
import { FoxModal } from '@fox/core/components/fox-modal';
|
| 16 | 15 |
import {
|
| 17 | 16 |
foxPasswordValidator, |
... | ... | @@ -46,8 +45,6 @@ |
| 46 | 45 |
onClose: () => void; |
| 47 | 46 |
} |
| 48 | 47 |
|
| 49 |
-const CUSTOM_EMAIL_DOMAIN = '__custom__'; |
|
| 50 |
- |
|
| 51 | 48 |
const EMAIL_DOMAIN_OPTIONS = [ |
| 52 | 49 |
'naver.com', |
| 53 | 50 |
'gmail.com', |
... | ... | @@ -73,9 +70,7 @@ |
| 73 | 70 |
const [isChecking, startChecking] = useTransition(); |
| 74 | 71 |
|
| 75 | 72 |
const [phoneNumber, setPhoneNumber] = useState(DEFAULT_MOBILE_PHONE_PREFIX); |
| 76 |
- const [emailLocal, setEmailLocal] = useState('');
|
|
| 77 |
- const [emailDomain, setEmailDomain] = useState(EMAIL_DOMAIN_OPTIONS[0]); |
|
| 78 |
- const [isCustomDomain, setIsCustomDomain] = useState(false); |
|
| 73 |
+ const [emailValue, setEmailValue] = useState(`@${EMAIL_DOMAIN_OPTIONS[0]}`);
|
|
| 79 | 74 |
const [isEmailTouched, setIsEmailTouched] = useState(false); |
| 80 | 75 |
const [roleCode, setRoleCode] = useState<string>(DEFAULT_ADMIN_ROLE_CODE); |
| 81 | 76 |
const [menuCodes, setMenuCodes] = useState<string[]>([]); |
... | ... | @@ -92,7 +87,8 @@ |
| 92 | 87 |
|
| 93 | 88 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 94 | 89 |
|
| 95 |
- const email = emailLocal && emailDomain ? `${emailLocal}@${emailDomain}` : '';
|
|
| 90 |
+ const [emailLocal = '', emailDomain = ''] = emailValue.split('@');
|
|
| 91 |
+ const email = emailLocal && emailDomain ? emailValue : ''; |
|
| 96 | 92 |
|
| 97 | 93 |
const emailMessage = |
| 98 | 94 |
errors.email ?? |
... | ... | @@ -206,65 +202,19 @@ |
| 206 | 202 |
errors={errors}
|
| 207 | 203 |
emailField={
|
| 208 | 204 |
<> |
| 209 |
- <div className={styles.field}>
|
|
| 210 |
- <FoxFormLabel as="span" requirement="required"> |
|
| 211 |
- 이메일 |
|
| 212 |
- </FoxFormLabel> |
|
| 213 |
- <div className={styles.emailRow}>
|
|
| 214 |
- <span className={styles.emailPart}>
|
|
| 215 |
- <FoxInput |
|
| 216 |
- size="md" |
|
| 217 |
- aria-label="이메일 아이디" |
|
| 218 |
- placeholder="1234-5678" |
|
| 219 |
- value={emailLocal}
|
|
| 220 |
- onChange={setEmailLocal}
|
|
| 221 |
- onBlur={() => setIsEmailTouched(true)}
|
|
| 222 |
- invalid={Boolean(emailMessage)}
|
|
| 223 |
- /> |
|
| 224 |
- </span> |
|
| 225 |
- <span aria-hidden="true">@</span> |
|
| 226 |
- <span className={styles.emailPart}>
|
|
| 227 |
- {isCustomDomain ? (
|
|
| 228 |
- <FoxInput |
|
| 229 |
- size="md" |
|
| 230 |
- aria-label="이메일 도메인" |
|
| 231 |
- placeholder="직접입력" |
|
| 232 |
- value={emailDomain}
|
|
| 233 |
- onChange={setEmailDomain}
|
|
| 234 |
- onBlur={() => setIsEmailTouched(true)}
|
|
| 235 |
- invalid={Boolean(emailMessage)}
|
|
| 236 |
- /> |
|
| 237 |
- ) : ( |
|
| 238 |
- <FoxSelect |
|
| 239 |
- size="md" |
|
| 240 |
- options={[
|
|
| 241 |
- ...EMAIL_DOMAIN_OPTIONS.map((domain) => ({
|
|
| 242 |
- value: domain, |
|
| 243 |
- label: domain, |
|
| 244 |
- })), |
|
| 245 |
- { value: CUSTOM_EMAIL_DOMAIN, label: '직접입력' },
|
|
| 246 |
- ]} |
|
| 247 |
- value={emailDomain}
|
|
| 248 |
- onValueChange={(next) => {
|
|
| 249 |
- setIsEmailTouched(true); |
|
| 250 |
- if (next === CUSTOM_EMAIL_DOMAIN) {
|
|
| 251 |
- setIsCustomDomain(true); |
|
| 252 |
- setEmailDomain('');
|
|
| 253 |
- return; |
|
| 254 |
- } |
|
| 255 |
- setEmailDomain(next); |
|
| 256 |
- }} |
|
| 257 |
- error={Boolean(emailMessage)}
|
|
| 258 |
- /> |
|
| 259 |
- )} |
|
| 260 |
- </span> |
|
| 261 |
- </div> |
|
| 262 |
- {emailMessage && (
|
|
| 263 |
- <p className={styles.fieldError} role="alert">
|
|
| 264 |
- {emailMessage}
|
|
| 265 |
- </p> |
|
| 266 |
- )} |
|
| 267 |
- </div> |
|
| 205 |
+ <FoxEmail |
|
| 206 |
+ label="이메일" |
|
| 207 |
+ requirement="required" |
|
| 208 |
+ domainOptions={[...EMAIL_DOMAIN_OPTIONS]}
|
|
| 209 |
+ localPlaceholder="ID" |
|
| 210 |
+ value={emailValue}
|
|
| 211 |
+ onChange={(next) => {
|
|
| 212 |
+ setEmailValue(next); |
|
| 213 |
+ setIsEmailTouched(true); |
|
| 214 |
+ }} |
|
| 215 |
+ message={emailMessage}
|
|
| 216 |
+ invalid={Boolean(emailMessage)}
|
|
| 217 |
+ /> |
|
| 268 | 218 |
<input type="hidden" name="email" value={email} />
|
| 269 | 219 |
</> |
| 270 | 220 |
} |
--- app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
... | ... | @@ -49,7 +49,7 @@ |
| 49 | 49 |
actions: ( |
| 50 | 50 |
<FoxButtonGroup size="md"> |
| 51 | 51 |
<FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
|
| 52 |
- <FoxButton type="error" size="md" label="삭제" onAction={runDelete} />
|
|
| 52 |
+ <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
|
|
| 53 | 53 |
</FoxButtonGroup> |
| 54 | 54 |
), |
| 55 | 55 |
}); |
--- app/(protected)/(basic)/boards/_components/board-filter-bar.tsx
... | ... | @@ -1,144 +0,0 @@ |
| 1 | -import Link from 'next/link'; | |
| 2 | -import { Button } from '@/components/ui/button'; | |
| 3 | -import { Field } from '@/components/ui/field'; | |
| 4 | -import { Input } from '@/components/ui/input'; | |
| 5 | -import { Select } from '@/components/ui/select'; | |
| 6 | -import { BOARD_CATEGORY_OPTIONS, type BoardType } from '@/lib/domain/board-post'; | |
| 7 | -import { | |
| 8 | - ANSWERED_FILTER_OPTIONS, | |
| 9 | - BOARD_PATHS, | |
| 10 | - BOARD_QUERY_FIELDS, | |
| 11 | - BOARD_SEARCH_FIELDS, | |
| 12 | - BOARD_TYPE_OPTIONS, | |
| 13 | - VISIBILITY_FILTER_OPTIONS, | |
| 14 | - buildBoardPostHref, | |
| 15 | - type BoardPostQuery, | |
| 16 | -} from '@/lib/domain/board-post-query'; | |
| 17 | - | |
| 18 | -interface BoardFilterBarProps { | |
| 19 | - boardType: BoardType; | |
| 20 | - query: BoardPostQuery; | |
| 21 | -} | |
| 22 | - | |
| 23 | -/** | |
| 24 | - * 검색·필터 영역 — 세 게시판이 공유한다. 어떤 필터를 그릴지는 `BOARD_QUERY_FIELDS`가 정한다 | |
| 25 | - * (공지사항: 구분·사용여부 / 1:1문의: 구분·질문유형·답변여부 / FAQ: 구분·유형). | |
| 26 | - * | |
| 27 | - * 순수 `<form method="get">` GET 제출로 URL을 갱신한다(자바스크립트 없이도 동작). `pageSize`는 | |
| 28 | - * hidden으로 함께 제출해 검색 후에도 유지되며, 검색 자체는 항상 1페이지로 돌아간다(hidden page를 | |
| 29 | - * 두지 않아 `parseBoardPostQuery`의 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼 제출이 아니라 | |
| 30 | - * 기본값 링크라 현재 입력값과 무관하게 동작한다. | |
| 31 | - * | |
| 32 | - * 상호작용이 전부 네이티브 폼 제출/링크라 클라이언트 자바스크립트가 필요 없다 — Server Component. | |
| 33 | - */ | |
| 34 | -export function BoardFilterBar({ boardType, query }: BoardFilterBarProps) { | |
| 35 | - const fields = BOARD_QUERY_FIELDS[boardType]; | |
| 36 | - const searchFields = BOARD_SEARCH_FIELDS[boardType]; | |
| 37 | - const typeOptions = BOARD_TYPE_OPTIONS[boardType]; | |
| 38 | - | |
| 39 | - const resetHref = buildBoardPostHref(boardType, query, { | |
| 40 | - category: '', | |
| 41 | - type: '', | |
| 42 | - visibility: '', | |
| 43 | - answered: '', | |
| 44 | - searchField: searchFields[0].value, | |
| 45 | - keyword: '', | |
| 46 | - page: 1, | |
| 47 | - }); | |
| 48 | - | |
| 49 | - return ( | |
| 50 | - <form | |
| 51 | - method="get" | |
| 52 | - action={BOARD_PATHS[boardType]} | |
| 53 | - className="flex flex-wrap items-end gap-3" | |
| 54 | - > | |
| 55 | - <input type="hidden" name="pageSize" value={query.pageSize} /> | |
| 56 | - | |
| 57 | - {fields.category && ( | |
| 58 | - <Field label="구분"> | |
| 59 | - <Select name="category" defaultValue={query.category}> | |
| 60 | - <option value="">전체</option> | |
| 61 | - {BOARD_CATEGORY_OPTIONS.map((option) => ( | |
| 62 | - <option key={option.value} value={option.value}> | |
| 63 | - {option.label} | |
| 64 | - </option> | |
| 65 | - ))} | |
| 66 | - </Select> | |
| 67 | - </Field> | |
| 68 | - )} | |
| 69 | - | |
| 70 | - {fields.type && ( | |
| 71 | - <Field label={boardType === 'inquiry' ? '질문유형' : '유형'}> | |
| 72 | - <Select name="type" defaultValue={query.type}> | |
| 73 | - <option value="">전체</option> | |
| 74 | - {typeOptions.map((option) => ( | |
| 75 | - <option key={option.value} value={option.value}> | |
| 76 | - {option.label} | |
| 77 | - </option> | |
| 78 | - ))} | |
| 79 | - </Select> | |
| 80 | - </Field> | |
| 81 | - )} | |
| 82 | - | |
| 83 | - {fields.visibility && ( | |
| 84 | - <Field label="사용여부"> | |
| 85 | - <Select name="visibility" defaultValue={query.visibility}> | |
| 86 | - <option value="">전체</option> | |
| 87 | - {VISIBILITY_FILTER_OPTIONS.map((option) => ( | |
| 88 | - <option key={option.value} value={option.value}> | |
| 89 | - {option.label} | |
| 90 | - </option> | |
| 91 | - ))} | |
| 92 | - </Select> | |
| 93 | - </Field> | |
| 94 | - )} | |
| 95 | - | |
| 96 | - {fields.answered && ( | |
| 97 | - <Field label="답변여부"> | |
| 98 | - <Select name="answered" defaultValue={query.answered}> | |
| 99 | - <option value="">전체</option> | |
| 100 | - {ANSWERED_FILTER_OPTIONS.map((option) => ( | |
| 101 | - <option key={option.value} value={option.value}> | |
| 102 | - {option.label} | |
| 103 | - </option> | |
| 104 | - ))} | |
| 105 | - </Select> | |
| 106 | - </Field> | |
| 107 | - )} | |
| 108 | - | |
| 109 | - {/* 검색 대상이 하나뿐인 화면(공지사항)은 select를 그리지 않고 hidden으로만 싣는다. */} | |
| 110 | - {searchFields.length > 1 ? ( | |
| 111 | - <Field label="검색 대상"> | |
| 112 | - <Select name="searchField" defaultValue={query.searchField}> | |
| 113 | - {searchFields.map((option) => ( | |
| 114 | - <option key={option.value} value={option.value}> | |
| 115 | - {option.label} | |
| 116 | - </option> | |
| 117 | - ))} | |
| 118 | - </Select> | |
| 119 | - </Field> | |
| 120 | - ) : ( | |
| 121 | - <input type="hidden" name="searchField" value={searchFields[0].value} /> | |
| 122 | - )} | |
| 123 | - | |
| 124 | - <Field label="검색어"> | |
| 125 | - <Input | |
| 126 | - type="text" | |
| 127 | - name="keyword" | |
| 128 | - defaultValue={query.keyword} | |
| 129 | - placeholder="검색어를 입력하세요." | |
| 130 | - /> | |
| 131 | - </Field> | |
| 132 | - | |
| 133 | - <Button type="submit" variant="secondary"> | |
| 134 | - 검색 | |
| 135 | - </Button> | |
| 136 | - <Link | |
| 137 | - href={resetHref} | |
| 138 | - className="text-body-md text-foreground-muted underline-offset-2 hover:underline" | |
| 139 | - > | |
| 140 | - 초기화 | |
| 141 | - </Link> | |
| 142 | - </form> | |
| 143 | - ); | |
| 144 | -} |
--- app/(protected)/(basic)/boards/_components/board-list-toolbar.tsx
... | ... | @@ -1,78 +0,0 @@ |
| 1 | -'use client'; | |
| 2 | - | |
| 3 | -import { useRouter } from 'next/navigation'; | |
| 4 | -import { useState, type ChangeEvent } from 'react'; | |
| 5 | -import { Button } from '@/components/ui/button'; | |
| 6 | -import { Select } from '@/components/ui/select'; | |
| 7 | -import type { BoardType } from '@/lib/domain/board-post'; | |
| 8 | -import { | |
| 9 | - BOARD_PAGE_SIZE_OPTIONS, | |
| 10 | - buildBoardPostHref, | |
| 11 | - type BoardPageSize, | |
| 12 | - type BoardPostQuery, | |
| 13 | -} from '@/lib/domain/board-post-query'; | |
| 14 | -import { BoardPostFormModal } from './board-post-form-modal'; | |
| 15 | - | |
| 16 | -interface BoardListToolbarProps { | |
| 17 | - boardType: BoardType; | |
| 18 | - query: BoardPostQuery; | |
| 19 | - /** 1:1문의는 관리자가 글을 등록하지 않으므로 false다(사용자 확인 사항). */ | |
| 20 | - canCreate: boolean; | |
| 21 | -} | |
| 22 | - | |
| 23 | -/** | |
| 24 | - * 목록 툴바 — [신규 등록]과 페이지 크기 select. 페이지 크기가 바뀌면 1페이지로 되돌린다 | |
| 25 | - * (기존 페이지 번호가 새 크기 기준으로는 의미가 달라지므로). | |
| 26 | - * | |
| 27 | - * 시안의 "최근등록순" 정렬 select는 두지 않았다 — 백엔드 목록 SQL의 정렬이 최신순으로 고정돼 | |
| 28 | - * 있고 시안의 select에도 다른 선택지가 없어, 선택지가 하나뿐인 컨트롤이 되기 때문이다. | |
| 29 | - * 정렬 기준이 추가되면 그때 select를 붙인다. | |
| 30 | - */ | |
| 31 | -export function BoardListToolbar({ | |
| 32 | - boardType, | |
| 33 | - query, | |
| 34 | - canCreate, | |
| 35 | -}: BoardListToolbarProps) { | |
| 36 | - const router = useRouter(); | |
| 37 | - const [isCreateOpen, setIsCreateOpen] = useState(false); | |
| 38 | - | |
| 39 | - function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) { | |
| 40 | - const pageSize = Number(event.target.value) as BoardPageSize; | |
| 41 | - router.replace(buildBoardPostHref(boardType, query, { pageSize, page: 1 })); | |
| 42 | - } | |
| 43 | - | |
| 44 | - return ( | |
| 45 | - <div className="flex flex-wrap items-center justify-end gap-3"> | |
| 46 | - {canCreate && ( | |
| 47 | - <Button | |
| 48 | - type="button" | |
| 49 | - variant="primary" | |
| 50 | - onClick={() => setIsCreateOpen(true)} | |
| 51 | - > | |
| 52 | - + 신규 등록 | |
| 53 | - </Button> | |
| 54 | - )} | |
| 55 | - | |
| 56 | - <Select | |
| 57 | - name="pageSize" | |
| 58 | - aria-label="페이지 크기" | |
| 59 | - defaultValue={query.pageSize} | |
| 60 | - onChange={handlePageSizeChange} | |
| 61 | - > | |
| 62 | - {BOARD_PAGE_SIZE_OPTIONS.map((size) => ( | |
| 63 | - <option key={size} value={size}> | |
| 64 | - {size}개씩 | |
| 65 | - </option> | |
| 66 | - ))} | |
| 67 | - </Select> | |
| 68 | - | |
| 69 | - {isCreateOpen && ( | |
| 70 | - <BoardPostFormModal | |
| 71 | - boardType={boardType} | |
| 72 | - mode="create" | |
| 73 | - onClose={() => setIsCreateOpen(false)} | |
| 74 | - /> | |
| 75 | - )} | |
| 76 | - </div> | |
| 77 | - ); | |
| 78 | -} |
+++ app/(protected)/(basic)/boards/_components/board-post-columns.tsx
... | ... | @@ -0,0 +1,186 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { FoxBadge } from '@fox/core/components/fox-badge'; | |
| 4 | +import { FoxIconButton } from '@fox/core/components/fox-icon-button'; | |
| 5 | +import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator'; | |
| 6 | +import type { FoxListColumn } from '@fox/core/components/fox-list-container'; | |
| 7 | +import { FoxPaperclipIcon } from '@fox/core/icons'; | |
| 8 | +import { formatPhoneNumber } from '@/lib/domain/phone-number'; | |
| 9 | +import { | |
| 10 | + formatAnswerStatusLabel, | |
| 11 | + formatBoardCategoryLabel, | |
| 12 | + formatFaqTypeLabel, | |
| 13 | + formatInquiryTypeLabel, | |
| 14 | + formatOptionalValue, | |
| 15 | + formatVisibilityLabel, | |
| 16 | + type BoardPost, | |
| 17 | + type BoardType, | |
| 18 | +} from '@/lib/domain/board-post'; | |
| 19 | +import { BoardRowActions } from './board-row-actions'; | |
| 20 | + | |
| 21 | +function numberColumn(page: number, pageSize: number): FoxListColumn<BoardPost> { | |
| 22 | + return { | |
| 23 | + key: 'no', | |
| 24 | + header: '번호', | |
| 25 | + width: 80, | |
| 26 | + render: (_row, index) => (page - 1) * pageSize + index + 1, | |
| 27 | + }; | |
| 28 | +} | |
| 29 | + | |
| 30 | +function visibilityColumn(): FoxListColumn<BoardPost> { | |
| 31 | + return { | |
| 32 | + key: 'isVisible', | |
| 33 | + header: '사용여부', | |
| 34 | + width: 120, | |
| 35 | + render: (row) => ( | |
| 36 | + <FoxStatusIndicator | |
| 37 | + type={row.isVisible ? 'success' : 'danger'} | |
| 38 | + label={formatVisibilityLabel(row.isVisible)} | |
| 39 | + /> | |
| 40 | + ), | |
| 41 | + }; | |
| 42 | +} | |
| 43 | + | |
| 44 | +function actionsColumn(boardType: BoardType): FoxListColumn<BoardPost> { | |
| 45 | + return { | |
| 46 | + key: 'actions', | |
| 47 | + header: '관리', | |
| 48 | + width: 120, | |
| 49 | + render: (row) => ( | |
| 50 | + <span onClick={(event) => event.stopPropagation()}> | |
| 51 | + <BoardRowActions boardType={boardType} post={row} /> | |
| 52 | + </span> | |
| 53 | + ), | |
| 54 | + }; | |
| 55 | +} | |
| 56 | + | |
| 57 | +export function noticeColumns(page: number, pageSize: number): FoxListColumn<BoardPost>[] { | |
| 58 | + return [ | |
| 59 | + numberColumn(page, pageSize), | |
| 60 | + { | |
| 61 | + key: 'category', | |
| 62 | + header: '구분', | |
| 63 | + width: 120, | |
| 64 | + render: (row) => ( | |
| 65 | + <FoxBadge type="pastel" color="neutral" size="md"> | |
| 66 | + {formatBoardCategoryLabel(row.categoryCode)} | |
| 67 | + </FoxBadge> | |
| 68 | + ), | |
| 69 | + }, | |
| 70 | + { key: 'title', header: '제목', width: 320, emphasis: true }, | |
| 71 | + { | |
| 72 | + key: 'attachment', | |
| 73 | + header: '첨부', | |
| 74 | + width: 100, | |
| 75 | + render: (row) => | |
| 76 | + row.attachmentId ? ( | |
| 77 | + <FoxIconButton | |
| 78 | + type="default" | |
| 79 | + size="sm" | |
| 80 | + icon={<FoxPaperclipIcon />} | |
| 81 | + label={`${row.title} 첨부파일`} | |
| 82 | + /> | |
| 83 | + ) : null, | |
| 84 | + }, | |
| 85 | + { | |
| 86 | + key: 'authorName', | |
| 87 | + header: '작성자', | |
| 88 | + width: 160, | |
| 89 | + render: (row) => formatOptionalValue(row.authorName), | |
| 90 | + }, | |
| 91 | + visibilityColumn(), | |
| 92 | + { | |
| 93 | + key: 'createdAt', | |
| 94 | + header: '작성일', | |
| 95 | + width: 140, | |
| 96 | + render: (row) => formatOptionalValue(row.createdAt), | |
| 97 | + }, | |
| 98 | + actionsColumn('notice'), | |
| 99 | + ]; | |
| 100 | +} | |
| 101 | + | |
| 102 | +export function faqColumns(page: number, pageSize: number): FoxListColumn<BoardPost>[] { | |
| 103 | + return [ | |
| 104 | + numberColumn(page, pageSize), | |
| 105 | + { | |
| 106 | + key: 'category', | |
| 107 | + header: '구분', | |
| 108 | + width: 120, | |
| 109 | + render: (row) => ( | |
| 110 | + <FoxBadge type="pastel" color="neutral" size="md"> | |
| 111 | + {formatBoardCategoryLabel(row.categoryCode)} | |
| 112 | + </FoxBadge> | |
| 113 | + ), | |
| 114 | + }, | |
| 115 | + { | |
| 116 | + key: 'type', | |
| 117 | + header: '유형', | |
| 118 | + width: 120, | |
| 119 | + render: (row) => formatFaqTypeLabel(row.typeCode), | |
| 120 | + }, | |
| 121 | + { key: 'title', header: '제목', width: 320, emphasis: true }, | |
| 122 | + { | |
| 123 | + key: 'authorName', | |
| 124 | + header: '작성자', | |
| 125 | + width: 160, | |
| 126 | + render: (row) => formatOptionalValue(row.authorName), | |
| 127 | + }, | |
| 128 | + { | |
| 129 | + key: 'createdAt', | |
| 130 | + header: '등록일', | |
| 131 | + width: 140, | |
| 132 | + render: (row) => formatOptionalValue(row.createdAt), | |
| 133 | + }, | |
| 134 | + visibilityColumn(), | |
| 135 | + actionsColumn('faq'), | |
| 136 | + ]; | |
| 137 | +} | |
| 138 | + | |
| 139 | +export function inquiryColumns(page: number, pageSize: number): FoxListColumn<BoardPost>[] { | |
| 140 | + return [ | |
| 141 | + numberColumn(page, pageSize), | |
| 142 | + { | |
| 143 | + key: 'type', | |
| 144 | + header: '질문유형', | |
| 145 | + width: 120, | |
| 146 | + render: (row) => formatInquiryTypeLabel(row.typeCode), | |
| 147 | + }, | |
| 148 | + { key: 'title', header: '질문제목', width: 280, emphasis: true }, | |
| 149 | + { | |
| 150 | + key: 'authorName', | |
| 151 | + header: '질문자명', | |
| 152 | + width: 140, | |
| 153 | + render: (row) => formatOptionalValue(row.authorName), | |
| 154 | + }, | |
| 155 | + { | |
| 156 | + key: 'authorPhoneNumber', | |
| 157 | + header: '전화번호', | |
| 158 | + width: 160, | |
| 159 | + render: (row) => formatOptionalValue(formatPhoneNumber(row.authorPhoneNumber)), | |
| 160 | + }, | |
| 161 | + { | |
| 162 | + key: 'createdAt', | |
| 163 | + header: '질문일', | |
| 164 | + width: 140, | |
| 165 | + render: (row) => formatOptionalValue(row.createdAt), | |
| 166 | + }, | |
| 167 | + { | |
| 168 | + key: 'answerStatus', | |
| 169 | + header: '상태', | |
| 170 | + width: 120, | |
| 171 | + render: (row) => ( | |
| 172 | + <FoxStatusIndicator | |
| 173 | + type={row.answeredAt ? 'success' : 'warning'} | |
| 174 | + label={formatAnswerStatusLabel(row.answerStatusCode)} | |
| 175 | + /> | |
| 176 | + ), | |
| 177 | + }, | |
| 178 | + { | |
| 179 | + key: 'answeredAt', | |
| 180 | + header: '답변일', | |
| 181 | + width: 140, | |
| 182 | + render: (row) => formatOptionalValue(row.answeredAt), | |
| 183 | + }, | |
| 184 | + actionsColumn('inquiry'), | |
| 185 | + ]; | |
| 186 | +} |
--- app/(protected)/(basic)/boards/_components/board-post-form-modal.tsx
+++ app/(protected)/(basic)/boards/_components/board-post-form-modal.tsx
... | ... | @@ -1,17 +1,21 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useActionState, useEffect } from 'react';
|
|
| 4 |
-import { Button } from '@/components/ui/button';
|
|
| 5 |
-import { Field } from '@/components/ui/field';
|
|
| 6 |
-import { Input } from '@/components/ui/input';
|
|
| 7 |
-import { Modal } from '@/components/ui/modal';
|
|
| 8 |
-import { RadioGroup } from '@/components/ui/radio-group';
|
|
| 9 |
-import { Select } from '@/components/ui/select';
|
|
| 3 |
+import { useActionState, useEffect, useRef, useState } from 'react';
|
|
| 4 |
+import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 5 |
+import { FoxFileUpload } from '@fox/core/components/fox-file-upload';
|
|
| 6 |
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
|
|
| 7 |
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
|
|
| 8 |
+import { FoxInput } from '@fox/core/components/fox-input';
|
|
| 9 |
+import { FoxModal } from '@fox/core/components/fox-modal';
|
|
| 10 |
+import { FoxSelect } from '@fox/core/components/fox-select';
|
|
| 11 |
+import { FoxTextArea } from '@fox/core/components/fox-text-area';
|
|
| 12 |
+import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
|
|
| 13 |
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
|
|
| 10 | 14 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 11 | 15 |
import {
|
| 12 | 16 |
BOARD_CATEGORY_OPTIONS, |
| 13 |
- formatBoardCategoryLabel, |
|
| 14 |
- formatOptionalValue, |
|
| 17 |
+ FAQ_TYPE_OPTIONS, |
|
| 18 |
+ INQUIRY_TYPE_OPTIONS, |
|
| 15 | 19 |
type BoardPost, |
| 16 | 20 |
type BoardType, |
| 17 | 21 |
} from '@/lib/domain/board-post'; |
... | ... | @@ -20,34 +24,15 @@ |
| 20 | 24 |
INITIAL_BOARD_POST_FORM_STATE, |
| 21 | 25 |
TITLE_MAX_LENGTH, |
| 22 | 26 |
} from '@/lib/domain/board-post-form'; |
| 23 |
-import { BOARD_TYPE_OPTIONS } from '@/lib/domain/board-post-query';
|
|
| 24 | 27 |
import { createBoardPostAction, updateBoardPostAction } from '../_actions';
|
| 25 |
-import { BoardAttachmentField } from './board-attachment-field';
|
|
| 26 |
-import { BoardContentField } from './board-content-field';
|
|
| 27 |
-import { submitFormAction } from '@/app/_hooks/submit-form-action';
|
|
| 28 |
+import styles from './board-post-form.module.scss'; |
|
| 28 | 29 |
|
| 29 | 30 |
interface BoardPostFormModalProps {
|
| 30 | 31 |
boardType: BoardType; |
| 31 | 32 |
mode: 'create' | 'edit'; |
| 32 |
- /** 수정 모드의 기존 값. 등록 모드는 넘기지 않는다. */ |
|
| 33 | 33 |
post?: BoardPost; |
| 34 | 34 |
onClose: () => void; |
| 35 | 35 |
} |
| 36 |
- |
|
| 37 |
-const FORM_ID = 'board-post-form'; |
|
| 38 |
- |
|
| 39 |
-const PINNED_OPTIONS = [ |
|
| 40 |
- { value: 'N', label: '사용안함' },
|
|
| 41 |
- { value: 'Y', label: '사용' },
|
|
| 42 |
-]; |
|
| 43 |
-const VISIBILITY_OPTIONS = [ |
|
| 44 |
- { value: 'Y', label: '노출' },
|
|
| 45 |
- { value: 'N', label: '미노출' },
|
|
| 46 |
-]; |
|
| 47 |
-const PUSH_OPTIONS = [ |
|
| 48 |
- { value: 'Y', label: '발송' },
|
|
| 49 |
- { value: 'N', label: '미발송' },
|
|
| 50 |
-]; |
|
| 51 | 36 |
|
| 52 | 37 |
const BOARD_TITLES: Record<BoardType, string> = {
|
| 53 | 38 |
notice: '공지사항', |
... | ... | @@ -55,32 +40,32 @@ |
| 55 | 40 |
faq: 'FAQ', |
| 56 | 41 |
}; |
| 57 | 42 |
|
| 58 |
-/** |
|
| 59 |
- * 공지사항·FAQ의 등록/수정 팝업 — 시안 A_BOA_007_p·008_p(공지사항), 015_p·016_p(FAQ). |
|
| 60 |
- * |
|
| 61 |
- * 네 시안이 같은 폼의 변형이라 한 컴포넌트가 게시판 종류(`boardType`)와 모드(`mode`)로 분기한다. |
|
| 62 |
- * 게시판별 차이는 두 가지뿐이다 — 공지사항에만 상단고정·노출기간·앱푸쉬가, FAQ에만 유형이 있다. |
|
| 63 |
- * |
|
| 64 |
- * **수정 시 구분은 읽기 전용이다**(시안 ①). 값 자체를 제출하지 않고, Server Action도 수정 |
|
| 65 |
- * 경로에서는 구분을 검증하지 않는다 — 직접 POST로 다른 값을 보내도 반영되지 않는다. |
|
| 66 |
- * |
|
| 67 |
- * 첨부파일이 있어 폼은 multipart로 제출된다(Server Action은 이를 자동으로 처리한다). |
|
| 68 |
- * 저장 버튼은 footer에서 `form={FORM_ID}`로 연결한다 — 네이티브 HTML의 `form` 속성은 버튼이
|
|
| 69 |
- * DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 form을 제출한다. |
|
| 70 |
- */ |
|
| 43 |
+const TYPE_OPTIONS: Partial<Record<BoardType, readonly { value: string; label: string }[]>> = {
|
|
| 44 |
+ faq: FAQ_TYPE_OPTIONS, |
|
| 45 |
+ inquiry: INQUIRY_TYPE_OPTIONS, |
|
| 46 |
+}; |
|
| 47 |
+ |
|
| 71 | 48 |
export function BoardPostFormModal({
|
| 72 | 49 |
boardType, |
| 73 | 50 |
mode, |
| 74 | 51 |
post, |
| 75 | 52 |
onClose, |
| 76 | 53 |
}: BoardPostFormModalProps) {
|
| 77 |
- const { showToast } = useFeedback();
|
|
| 78 | 54 |
const isCreate = mode === 'create'; |
| 79 |
- |
|
| 55 |
+ const { showToast } = useFeedback();
|
|
| 56 |
+ const formRef = useRef<HTMLFormElement>(null); |
|
| 80 | 57 |
const [state, formAction, isPending] = useActionState( |
| 81 | 58 |
isCreate ? createBoardPostAction : updateBoardPostAction, |
| 82 | 59 |
INITIAL_BOARD_POST_FORM_STATE |
| 83 | 60 |
); |
| 61 |
+ |
|
| 62 |
+ const [categoryCode, setCategoryCode] = useState( |
|
| 63 |
+ post?.categoryCode || BOARD_CATEGORY_OPTIONS[0].value |
|
| 64 |
+ ); |
|
| 65 |
+ const [typeCode, setTypeCode] = useState(post?.typeCode ?? ''); |
|
| 66 |
+ const [isPinned, setIsPinned] = useState(post?.isPinned ?? false); |
|
| 67 |
+ const [isVisible, setIsVisible] = useState(post?.isVisible ?? true); |
|
| 68 |
+ const [isPushEnabled, setIsPushEnabled] = useState(post?.isPushEnabled ?? false); |
|
| 84 | 69 |
|
| 85 | 70 |
useEffect(() => {
|
| 86 | 71 |
if (state.status === 'success') {
|
... | ... | @@ -93,164 +78,190 @@ |
| 93 | 78 |
}, [state, showToast, onClose, isCreate]); |
| 94 | 79 |
|
| 95 | 80 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 96 |
- const typeOptions = BOARD_TYPE_OPTIONS[boardType]; |
|
| 81 |
+ const typeOptions = TYPE_OPTIONS[boardType]; |
|
| 97 | 82 |
const hasNoticeOnlyFields = boardType === 'notice'; |
| 98 | 83 |
|
| 99 | 84 |
return ( |
| 100 |
- <Modal |
|
| 85 |
+ <FoxModal |
|
| 86 |
+ open |
|
| 87 |
+ size="md" |
|
| 101 | 88 |
title={`${BOARD_TITLES[boardType]} ${isCreate ? '신규 등록' : '수정'}`}
|
| 102 | 89 |
onClose={onClose}
|
| 103 |
- footer={
|
|
| 90 |
+ actions={
|
|
| 104 | 91 |
<> |
| 105 |
- <Button type="button" variant="ghost" onClick={onClose}>
|
|
| 106 |
- 취소 |
|
| 107 |
- </Button> |
|
| 108 |
- <Button |
|
| 109 |
- type="submit" |
|
| 110 |
- form={FORM_ID}
|
|
| 111 |
- variant="primary" |
|
| 92 |
+ <FoxButton type="default" size="md" label="취소" onAction={onClose} />
|
|
| 93 |
+ <FoxButton |
|
| 94 |
+ type="primary" |
|
| 95 |
+ size="md" |
|
| 96 |
+ label={isPending ? '저장 중...' : isCreate ? '등록' : '수정'}
|
|
| 112 | 97 |
disabled={isPending}
|
| 113 |
- > |
|
| 114 |
- {isPending ? '저장 중...' : isCreate ? '등록' : '수정'}
|
|
| 115 |
- </Button> |
|
| 98 |
+ onAction={() => formRef.current?.requestSubmit()}
|
|
| 99 |
+ /> |
|
| 116 | 100 |
</> |
| 117 | 101 |
} |
| 118 | 102 |
> |
| 119 |
- <form id={FORM_ID} onSubmit={(event) => submitFormAction(event, formAction)} className="flex flex-col gap-4">
|
|
| 103 |
+ <form |
|
| 104 |
+ ref={formRef}
|
|
| 105 |
+ onSubmit={(event) => submitFormAction(event, formAction)}
|
|
| 106 |
+ className={styles.fields}
|
|
| 107 |
+ > |
|
| 120 | 108 |
<input type="hidden" name="boardType" value={boardType} />
|
| 121 | 109 |
{!isCreate && post && <input type="hidden" name="id" value={post.id} />}
|
| 122 | 110 |
|
| 123 |
- <p className="text-right text-body-sm text-danger"> |
|
| 124 |
- * 는 필수 항목입니다. |
|
| 125 |
- </p> |
|
| 111 |
+ <div className={styles.field}>
|
|
| 112 |
+ <FoxSelect |
|
| 113 |
+ size="md" |
|
| 114 |
+ label="구분" |
|
| 115 |
+ options={BOARD_CATEGORY_OPTIONS.map((option) => ({
|
|
| 116 |
+ value: option.value, |
|
| 117 |
+ label: option.label, |
|
| 118 |
+ }))} |
|
| 119 |
+ value={categoryCode}
|
|
| 120 |
+ onValueChange={setCategoryCode}
|
|
| 121 |
+ error={Boolean(errors.categoryCode)}
|
|
| 122 |
+ hint={errors.categoryCode}
|
|
| 123 |
+ /> |
|
| 124 |
+ <input type="hidden" name="categoryCode" value={categoryCode} />
|
|
| 125 |
+ </div> |
|
| 126 | 126 |
|
| 127 |
- <Field label={isCreate ? '구분 *' : '구분'}>
|
|
| 128 |
- {isCreate ? (
|
|
| 129 |
- <Select name="categoryCode" defaultValue={BOARD_CATEGORY_OPTIONS[0].value}>
|
|
| 130 |
- {BOARD_CATEGORY_OPTIONS.map((option) => (
|
|
| 131 |
- <option key={option.value} value={option.value}>
|
|
| 132 |
- {option.label}
|
|
| 133 |
- </option> |
|
| 134 |
- ))} |
|
| 135 |
- </Select> |
|
| 136 |
- ) : ( |
|
| 137 |
- <Input |
|
| 138 |
- value={formatBoardCategoryLabel(post?.categoryCode ?? null)}
|
|
| 139 |
- readOnly |
|
| 127 |
+ {typeOptions && (
|
|
| 128 |
+ <div className={styles.field}>
|
|
| 129 |
+ <FoxSelect |
|
| 130 |
+ size="md" |
|
| 131 |
+ label="유형" |
|
| 132 |
+ options={typeOptions.map((option) => ({
|
|
| 133 |
+ value: option.value, |
|
| 134 |
+ label: option.label, |
|
| 135 |
+ }))} |
|
| 136 |
+ value={typeCode}
|
|
| 137 |
+ onValueChange={setTypeCode}
|
|
| 138 |
+ placeholder="유형을 선택하세요." |
|
| 139 |
+ error={Boolean(errors.typeCode)}
|
|
| 140 |
+ hint={errors.typeCode}
|
|
| 140 | 141 |
/> |
| 141 |
- )} |
|
| 142 |
- </Field> |
|
| 143 |
- <FieldError message={errors.categoryCode} />
|
|
| 144 |
- |
|
| 145 |
- {typeOptions.length > 0 && (
|
|
| 146 |
- <> |
|
| 147 |
- <Field label="유형 *"> |
|
| 148 |
- <Select name="typeCode" defaultValue={post?.typeCode ?? ''}>
|
|
| 149 |
- <option value="">선택</option> |
|
| 150 |
- {typeOptions.map((option) => (
|
|
| 151 |
- <option key={option.value} value={option.value}>
|
|
| 152 |
- {option.label}
|
|
| 153 |
- </option> |
|
| 154 |
- ))} |
|
| 155 |
- </Select> |
|
| 156 |
- </Field> |
|
| 157 |
- <FieldError message={errors.typeCode} />
|
|
| 158 |
- </> |
|
| 142 |
+ <input type="hidden" name="typeCode" value={typeCode} />
|
|
| 143 |
+ </div> |
|
| 159 | 144 |
)} |
| 160 | 145 |
|
| 161 |
- <Field label="제목 *"> |
|
| 162 |
- <Input |
|
| 163 |
- type="text" |
|
| 146 |
+ <div className={styles.field}>
|
|
| 147 |
+ <FoxInput |
|
| 148 |
+ size="md" |
|
| 164 | 149 |
name="title" |
| 150 |
+ label="제목" |
|
| 151 |
+ requirement="required" |
|
| 165 | 152 |
defaultValue={post?.title ?? ''}
|
| 166 |
- maxLength={TITLE_MAX_LENGTH}
|
|
| 167 | 153 |
placeholder="제목을 입력하세요." |
| 154 |
+ invalid={Boolean(errors.title)}
|
|
| 155 |
+ message={errors.title}
|
|
| 156 |
+ maxLength={TITLE_MAX_LENGTH}
|
|
| 168 | 157 |
/> |
| 169 |
- </Field> |
|
| 170 |
- <FieldError message={errors.title} />
|
|
| 158 |
+ </div> |
|
| 171 | 159 |
|
| 172 |
- <BoardContentField |
|
| 173 |
- name="content" |
|
| 174 |
- label="내용 *" |
|
| 175 |
- defaultValue={post?.content ?? ''}
|
|
| 176 |
- maxLength={CONTENT_MAX_LENGTH}
|
|
| 177 |
- placeholder="내용을 입력하세요." |
|
| 178 |
- /> |
|
| 179 |
- <FieldError message={errors.content} />
|
|
| 160 |
+ <div className={styles.field}>
|
|
| 161 |
+ <FoxTextArea |
|
| 162 |
+ name="content" |
|
| 163 |
+ label="내용" |
|
| 164 |
+ requirement="required" |
|
| 165 |
+ defaultValue={post?.content ?? ''}
|
|
| 166 |
+ placeholder="내용을 입력하세요." |
|
| 167 |
+ rows={8}
|
|
| 168 |
+ counter |
|
| 169 |
+ maxLength={CONTENT_MAX_LENGTH}
|
|
| 170 |
+ invalid={Boolean(errors.content)}
|
|
| 171 |
+ message={errors.content}
|
|
| 172 |
+ /> |
|
| 173 |
+ </div> |
|
| 180 | 174 |
|
| 181 |
- <BoardAttachmentField attachmentId={post?.attachmentId} />
|
|
| 175 |
+ <div className={styles.field}>
|
|
| 176 |
+ <FoxFileUpload |
|
| 177 |
+ mode="default" |
|
| 178 |
+ name="attachment" |
|
| 179 |
+ label="첨부파일" |
|
| 180 |
+ selectLabel="파일선택" |
|
| 181 |
+ placeholder="파일을 선택해 주세요." |
|
| 182 |
+ /> |
|
| 183 |
+ <input |
|
| 184 |
+ type="hidden" |
|
| 185 |
+ name="attachmentId" |
|
| 186 |
+ defaultValue={post?.attachmentId ?? ''}
|
|
| 187 |
+ /> |
|
| 188 |
+ </div> |
|
| 182 | 189 |
|
| 183 | 190 |
{hasNoticeOnlyFields && (
|
| 184 |
- <> |
|
| 185 |
- <Field label="상단고정"> |
|
| 186 |
- <RadioGroup |
|
| 187 |
- name="isPinned" |
|
| 188 |
- options={PINNED_OPTIONS}
|
|
| 189 |
- defaultValue={post?.isPinned ? 'Y' : 'N'}
|
|
| 190 |
- /> |
|
| 191 |
- </Field> |
|
| 192 |
- |
|
| 193 |
- <Field label="노출기간"> |
|
| 194 |
- <div className="flex items-center gap-2"> |
|
| 195 |
- <Input |
|
| 196 |
- type="date" |
|
| 197 |
- name="startDate" |
|
| 198 |
- defaultValue={post?.startDate ?? ''}
|
|
| 199 |
- aria-label="게시시작일시" |
|
| 200 |
- /> |
|
| 201 |
- <span className="text-foreground-muted">~</span> |
|
| 202 |
- <Input |
|
| 203 |
- type="date" |
|
| 204 |
- name="endDate" |
|
| 205 |
- defaultValue={post?.endDate ?? ''}
|
|
| 206 |
- aria-label="게시종료일시" |
|
| 207 |
- /> |
|
| 208 |
- </div> |
|
| 209 |
- </Field> |
|
| 210 |
- <FieldError message={errors.startDate} />
|
|
| 211 |
- <FieldError message={errors.endDate} />
|
|
| 212 |
- </> |
|
| 213 |
- )} |
|
| 214 |
- |
|
| 215 |
- <Field label="사용여부 *"> |
|
| 216 |
- <RadioGroup |
|
| 217 |
- name="isVisible" |
|
| 218 |
- options={VISIBILITY_OPTIONS}
|
|
| 219 |
- defaultValue={post?.isVisible === false ? 'N' : 'Y'}
|
|
| 220 |
- /> |
|
| 221 |
- </Field> |
|
| 222 |
- |
|
| 223 |
- {hasNoticeOnlyFields && (
|
|
| 224 |
- <Field label="앱푸쉬 설정 *"> |
|
| 225 |
- <RadioGroup |
|
| 226 |
- name="isPushEnabled" |
|
| 227 |
- options={PUSH_OPTIONS}
|
|
| 228 |
- defaultValue={post?.isPushEnabled === false ? 'N' : 'Y'}
|
|
| 191 |
+ <div className={styles.field}>
|
|
| 192 |
+ <FoxFormLabel as="span">상단고정</FoxFormLabel> |
|
| 193 |
+ <FoxToggleSwitch |
|
| 194 |
+ size="md" |
|
| 195 |
+ name="isPinned" |
|
| 196 |
+ value="true" |
|
| 197 |
+ label={isPinned ? '고정' : '미고정'}
|
|
| 198 |
+ checked={isPinned}
|
|
| 199 |
+ onChange={setIsPinned}
|
|
| 229 | 200 |
/> |
| 230 |
- </Field> |
|
| 201 |
+ </div> |
|
| 231 | 202 |
)} |
| 232 | 203 |
|
| 233 |
- {!isCreate && post && (
|
|
| 234 |
- <Field label="작성자 / 작성일"> |
|
| 235 |
- <div className="flex items-center gap-2"> |
|
| 236 |
- <Input value={formatOptionalValue(post.authorName)} readOnly />
|
|
| 237 |
- <Input value={formatOptionalValue(post.createdAt)} readOnly />
|
|
| 204 |
+ {hasNoticeOnlyFields && (
|
|
| 205 |
+ <div className={styles.field}>
|
|
| 206 |
+ <FoxFormLabel as="span">게시기간</FoxFormLabel> |
|
| 207 |
+ <div className={styles.period}>
|
|
| 208 |
+ <FoxInput |
|
| 209 |
+ size="md" |
|
| 210 |
+ type="date" |
|
| 211 |
+ name="startDate" |
|
| 212 |
+ aria-label="게시 시작일" |
|
| 213 |
+ defaultValue={post?.startDate ?? ''}
|
|
| 214 |
+ invalid={Boolean(errors.startDate)}
|
|
| 215 |
+ /> |
|
| 216 |
+ <span aria-hidden="true">~</span> |
|
| 217 |
+ <FoxInput |
|
| 218 |
+ size="md" |
|
| 219 |
+ type="date" |
|
| 220 |
+ name="endDate" |
|
| 221 |
+ aria-label="게시 종료일" |
|
| 222 |
+ defaultValue={post?.endDate ?? ''}
|
|
| 223 |
+ invalid={Boolean(errors.endDate)}
|
|
| 224 |
+ /> |
|
| 238 | 225 |
</div> |
| 239 |
- </Field> |
|
| 226 |
+ {(errors.startDate ?? errors.endDate) && (
|
|
| 227 |
+ <FoxHelperText |
|
| 228 |
+ type="danger" |
|
| 229 |
+ message={errors.startDate ?? errors.endDate ?? ''}
|
|
| 230 |
+ /> |
|
| 231 |
+ )} |
|
| 232 |
+ </div> |
|
| 233 |
+ )} |
|
| 234 |
+ |
|
| 235 |
+ <div className={styles.field}>
|
|
| 236 |
+ <FoxFormLabel as="span">사용여부</FoxFormLabel> |
|
| 237 |
+ <FoxToggleSwitch |
|
| 238 |
+ size="md" |
|
| 239 |
+ name="isVisible" |
|
| 240 |
+ value="true" |
|
| 241 |
+ label={isVisible ? '노출' : '미노출'}
|
|
| 242 |
+ checked={isVisible}
|
|
| 243 |
+ onChange={setIsVisible}
|
|
| 244 |
+ /> |
|
| 245 |
+ </div> |
|
| 246 |
+ |
|
| 247 |
+ {hasNoticeOnlyFields && (
|
|
| 248 |
+ <div className={styles.field}>
|
|
| 249 |
+ <FoxFormLabel as="span">앱푸쉬 설정</FoxFormLabel> |
|
| 250 |
+ <FoxToggleSwitch |
|
| 251 |
+ size="md" |
|
| 252 |
+ name="isPushEnabled" |
|
| 253 |
+ value="true" |
|
| 254 |
+ label={isPushEnabled ? '발송' : '미발송'}
|
|
| 255 |
+ checked={isPushEnabled}
|
|
| 256 |
+ onChange={setIsPushEnabled}
|
|
| 257 |
+ /> |
|
| 258 |
+ </div> |
|
| 240 | 259 |
)} |
| 241 | 260 |
|
| 242 | 261 |
{state.status === 'error' && state.message && (
|
| 243 |
- <p className="text-body-sm text-danger">{state.message}</p>
|
|
| 262 |
+ <FoxHelperText type="danger" message={state.message} />
|
|
| 244 | 263 |
)} |
| 245 | 264 |
</form> |
| 246 |
- </Modal> |
|
| 265 |
+ </FoxModal> |
|
| 247 | 266 |
); |
| 248 |
-} |
|
| 249 |
- |
|
| 250 |
-/** 필드 하단 오류 문구. 값이 없으면 아무것도 그리지 않아 레이아웃을 차지하지 않는다. */ |
|
| 251 |
-export function FieldError({ message }: { message?: string }) {
|
|
| 252 |
- if (!message) {
|
|
| 253 |
- return null; |
|
| 254 |
- } |
|
| 255 |
- return <p className="text-body-sm text-danger">{message}</p>;
|
|
| 256 | 267 |
} |
+++ app/(protected)/(basic)/boards/_components/board-post-form.module.scss
... | ... | @@ -0,0 +1,28 @@ |
| 1 | +@use "@fox/styles/abstracts" as fox; | |
| 2 | + | |
| 3 | +.fields { | |
| 4 | + display: flex; | |
| 5 | + inline-size: 100%; | |
| 6 | + flex-direction: column; | |
| 7 | +} | |
| 8 | + | |
| 9 | +.field { | |
| 10 | + display: flex; | |
| 11 | + inline-size: 100%; | |
| 12 | + flex-direction: column; | |
| 13 | + gap: fox.gap(3); | |
| 14 | + padding-block: fox.padding(6); | |
| 15 | + border-block-end: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 16 | +} | |
| 17 | + | |
| 18 | +.period { | |
| 19 | + display: flex; | |
| 20 | + align-items: center; | |
| 21 | + gap: fox.gap(3); | |
| 22 | + | |
| 23 | + > :first-child, | |
| 24 | + > :last-child { | |
| 25 | + flex: 1 1 0; | |
| 26 | + min-inline-size: 0; | |
| 27 | + } | |
| 28 | +} |
+++ app/(protected)/(basic)/boards/_components/board-post-list.module.scss
... | ... | @@ -0,0 +1,7 @@ |
| 1 | +@use "@fox/styles/abstracts" as fox; | |
| 2 | + | |
| 3 | +.filter { | |
| 4 | + display: flex; | |
| 5 | + align-items: center; | |
| 6 | + gap: fox.gap(3); | |
| 7 | +} |
+++ app/(protected)/(basic)/boards/_components/board-post-list.tsx
... | ... | @@ -0,0 +1,168 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { useState, useTransition, type ReactNode } from 'react'; | |
| 5 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 6 | +import { FoxIconButton } from '@fox/core/components/fox-icon-button'; | |
| 7 | +import { | |
| 8 | + FoxListContainer, | |
| 9 | + type FoxListColumn, | |
| 10 | +} from '@fox/core/components/fox-list-container'; | |
| 11 | +import { FoxSelectText } from '@fox/core/components/fox-select-text'; | |
| 12 | +import { FoxTab } from '@fox/core/components/fox-tab'; | |
| 13 | +import { FoxTabItem } from '@fox/core/components/fox-tab-item'; | |
| 14 | +import { FoxArrowsClockwiseIcon, FoxPlusIcon } from '@fox/core/icons'; | |
| 15 | +import type { BoardPost, BoardType } from '@/lib/domain/board-post'; | |
| 16 | +import { | |
| 17 | + BOARD_PAGE_SIZE_OPTIONS, | |
| 18 | + BOARD_SEARCH_FIELDS, | |
| 19 | + FILTER_ALL, | |
| 20 | + VISIBILITY_FILTER_OPTIONS, | |
| 21 | + buildBoardPostHref, | |
| 22 | + type BoardPageSize, | |
| 23 | + type BoardPostQuery, | |
| 24 | +} from '@/lib/domain/board-post-query'; | |
| 25 | +import { BoardPostFormModal } from './board-post-form-modal'; | |
| 26 | +import styles from './board-post-list.module.scss'; | |
| 27 | + | |
| 28 | +interface BoardPostListProps { | |
| 29 | + boardType: BoardType; | |
| 30 | + title: string; | |
| 31 | + breadcrumb: { label: string; href?: string }[]; | |
| 32 | + columns: FoxListColumn<BoardPost>[]; | |
| 33 | + items: BoardPost[]; | |
| 34 | + query: BoardPostQuery; | |
| 35 | + currentPage: number; | |
| 36 | + totalPages: number; | |
| 37 | + totalCount: number; | |
| 38 | + /** 1:1문의처럼 관리자가 글을 쓰지 않는 게시판은 끈다. */ | |
| 39 | + canCreate?: boolean; | |
| 40 | + /** 행의 "관리" 열을 화면마다 다르게 그려야 할 때. */ | |
| 41 | + extraToolbar?: ReactNode; | |
| 42 | +} | |
| 43 | + | |
| 44 | +const SORT_OPTIONS = [{ value: 'recent', label: '최근등록순' }]; | |
| 45 | + | |
| 46 | +export function BoardPostList({ | |
| 47 | + boardType, | |
| 48 | + title, | |
| 49 | + breadcrumb, | |
| 50 | + columns, | |
| 51 | + items, | |
| 52 | + query, | |
| 53 | + currentPage, | |
| 54 | + totalPages, | |
| 55 | + totalCount, | |
| 56 | + canCreate = true, | |
| 57 | + extraToolbar, | |
| 58 | +}: BoardPostListProps) { | |
| 59 | + const router = useRouter(); | |
| 60 | + const [isCreateOpen, setIsCreateOpen] = useState(false); | |
| 61 | + const [, startRefresh] = useTransition(); | |
| 62 | + | |
| 63 | + function go(patch: Partial<BoardPostQuery>) { | |
| 64 | + router.replace(buildBoardPostHref(boardType, query, { ...patch, page: 1 })); | |
| 65 | + } | |
| 66 | + | |
| 67 | + return ( | |
| 68 | + <> | |
| 69 | + <FoxListContainer<BoardPost> | |
| 70 | + title={title} | |
| 71 | + breadcrumb={breadcrumb} | |
| 72 | + caption={title} | |
| 73 | + columns={columns} | |
| 74 | + rows={items} | |
| 75 | + rowKey={(row) => row.id} | |
| 76 | + totalCount={totalCount} | |
| 77 | + emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요." | |
| 78 | + sorts={ | |
| 79 | + <> | |
| 80 | + <FoxSelectText | |
| 81 | + size="sm" | |
| 82 | + ariaLabel="정렬" | |
| 83 | + options={SORT_OPTIONS} | |
| 84 | + value="recent" | |
| 85 | + /> | |
| 86 | + <FoxSelectText | |
| 87 | + size="sm" | |
| 88 | + ariaLabel="페이지 크기" | |
| 89 | + options={BOARD_PAGE_SIZE_OPTIONS.map((size) => ({ | |
| 90 | + value: String(size), | |
| 91 | + label: `${size}개씩 보기`, | |
| 92 | + }))} | |
| 93 | + value={String(query.pageSize)} | |
| 94 | + onValueChange={(value) => | |
| 95 | + go({ pageSize: Number(value) as BoardPageSize }) | |
| 96 | + } | |
| 97 | + /> | |
| 98 | + </> | |
| 99 | + } | |
| 100 | + searchFields={BOARD_SEARCH_FIELDS[boardType].map((field) => ({ | |
| 101 | + value: field.value, | |
| 102 | + label: field.label, | |
| 103 | + }))} | |
| 104 | + searchField={query.searchField} | |
| 105 | + keyword={query.keyword} | |
| 106 | + toolbarActions={ | |
| 107 | + canCreate ? ( | |
| 108 | + <FoxButton | |
| 109 | + type="primary" | |
| 110 | + size="md" | |
| 111 | + leadingIcon={<FoxPlusIcon />} | |
| 112 | + label="신규 등록" | |
| 113 | + onAction={() => setIsCreateOpen(true)} | |
| 114 | + /> | |
| 115 | + ) : ( | |
| 116 | + extraToolbar | |
| 117 | + ) | |
| 118 | + } | |
| 119 | + filter={ | |
| 120 | + <div className={styles.filter}> | |
| 121 | + <FoxIconButton | |
| 122 | + type="default" | |
| 123 | + size="md" | |
| 124 | + icon={<FoxArrowsClockwiseIcon />} | |
| 125 | + label="목록 새로고침" | |
| 126 | + onAction={() => startRefresh(() => router.refresh())} | |
| 127 | + /> | |
| 128 | + <FoxTab | |
| 129 | + type="fill" | |
| 130 | + size="md" | |
| 131 | + label="노출 여부" | |
| 132 | + value={query.visibility} | |
| 133 | + onValueChange={(value) => go({ visibility: value })} | |
| 134 | + > | |
| 135 | + <FoxTabItem value={FILTER_ALL}>전체</FoxTabItem> | |
| 136 | + {VISIBILITY_FILTER_OPTIONS.map((option) => ( | |
| 137 | + <FoxTabItem key={option.value} value={option.value}> | |
| 138 | + {option.label} | |
| 139 | + </FoxTabItem> | |
| 140 | + ))} | |
| 141 | + </FoxTab> | |
| 142 | + </div> | |
| 143 | + } | |
| 144 | + page={currentPage} | |
| 145 | + totalPages={totalPages} | |
| 146 | + buildHref={(patch) => | |
| 147 | + buildBoardPostHref(boardType, query, { page: Number(patch.page ?? 1) }) | |
| 148 | + } | |
| 149 | + onQueryChange={(patch) => { | |
| 150 | + if (patch.keyword !== undefined || patch.searchField !== undefined) { | |
| 151 | + go({ | |
| 152 | + keyword: String(patch.keyword ?? ''), | |
| 153 | + searchField: String(patch.searchField ?? query.searchField), | |
| 154 | + }); | |
| 155 | + } | |
| 156 | + }} | |
| 157 | + /> | |
| 158 | + | |
| 159 | + {isCreateOpen && ( | |
| 160 | + <BoardPostFormModal | |
| 161 | + boardType={boardType} | |
| 162 | + mode="create" | |
| 163 | + onClose={() => setIsCreateOpen(false)} | |
| 164 | + /> | |
| 165 | + )} | |
| 166 | + </> | |
| 167 | + ); | |
| 168 | +} |
--- app/(protected)/(basic)/boards/_components/board-row-actions.tsx
+++ app/(protected)/(basic)/boards/_components/board-row-actions.tsx
... | ... | @@ -1,7 +1,8 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 | 3 |
import { useState, useTransition } from 'react';
|
| 4 |
-import { Button } from '@/components/ui/button';
|
|
| 4 |
+import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 5 |
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
|
| 5 | 6 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
| 6 | 7 |
import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
|
| 7 | 8 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
... | ... | @@ -52,14 +53,10 @@ |
| 52 | 53 |
title: '게시물을 삭제하시겠습니까?', |
| 53 | 54 |
message: `"${post.title}" 게시물을 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
|
| 54 | 55 |
actions: ( |
| 55 |
- <> |
|
| 56 |
- <Button type="button" variant="ghost" onClick={hideAlert}>
|
|
| 57 |
- 취소 |
|
| 58 |
- </Button> |
|
| 59 |
- <Button type="button" variant="primary" onClick={runDelete}>
|
|
| 60 |
- 삭제 |
|
| 61 |
- </Button> |
|
| 62 |
- </> |
|
| 56 |
+ <FoxButtonGroup size="md"> |
|
| 57 |
+ <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
|
|
| 58 |
+ <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
|
|
| 59 |
+ </FoxButtonGroup> |
|
| 63 | 60 |
), |
| 64 | 61 |
}); |
| 65 | 62 |
} |
... | ... | @@ -67,14 +64,14 @@ |
| 67 | 64 |
return ( |
| 68 | 65 |
<div className="flex items-center gap-1"> |
| 69 | 66 |
<FoxIconButton |
| 70 |
- type="ghost" |
|
| 67 |
+ type="default" |
|
| 71 | 68 |
size="sm" |
| 72 | 69 |
icon={<FoxPencilSimpleIcon />}
|
| 73 | 70 |
label="수정" |
| 74 | 71 |
onAction={() => setIsEditOpen(true)}
|
| 75 | 72 |
/> |
| 76 | 73 |
<FoxIconButton |
| 77 |
- type="ghost" |
|
| 74 |
+ type="default" |
|
| 78 | 75 |
size="sm" |
| 79 | 76 |
icon={<FoxTrashIcon />}
|
| 80 | 77 |
label={isDeleting ? '삭제 중' : '삭제'}
|
--- app/(protected)/(basic)/boards/faqs/_components/faq-table.tsx
... | ... | @@ -1,73 +0,0 @@ |
| 1 | -import { | |
| 2 | - Table, | |
| 3 | - TableBody, | |
| 4 | - TableCell, | |
| 5 | - TableHead, | |
| 6 | - TableHeaderCell, | |
| 7 | - TableRow, | |
| 8 | -} from '@/components/ui/table'; | |
| 9 | -import { | |
| 10 | - formatBoardCategoryLabel, | |
| 11 | - formatFaqTypeLabel, | |
| 12 | - formatOptionalValue, | |
| 13 | - formatVisibilityLabel, | |
| 14 | - type BoardPost, | |
| 15 | -} from '@/lib/domain/board-post'; | |
| 16 | -import { BoardRowActions } from '../../_components/board-row-actions'; | |
| 17 | - | |
| 18 | -interface FaqTableProps { | |
| 19 | - items: BoardPost[]; | |
| 20 | - page: number; | |
| 21 | - pageSize: number; | |
| 22 | - totalCount: number; | |
| 23 | -} | |
| 24 | - | |
| 25 | -const COLUMN_HEADERS = [ | |
| 26 | - '번호', | |
| 27 | - '구분', | |
| 28 | - '유형', | |
| 29 | - '제목', | |
| 30 | - '작성자', | |
| 31 | - '등록일', | |
| 32 | - '사용여부', | |
| 33 | - '관리', | |
| 34 | -]; | |
| 35 | - | |
| 36 | -/** | |
| 37 | - * FAQ 목록 테이블 — 시안 A_BOA_014 ③. | |
| 38 | - * | |
| 39 | - * "유형"은 현재 항상 `-`다 — 백엔드에 유형 저장 필드가 아직 없어 조회 응답에 값이 없다 | |
| 40 | - * (`board-post.ts`의 `BOARD_TYPE_CODE_PARAM` 주석). 열은 유지해 두었으므로 백엔드에 필드가 | |
| 41 | - * 추가되면 Repository 매핑만 늘리면 그대로 채워진다. | |
| 42 | - */ | |
| 43 | -export function FaqTable({ items, page, pageSize, totalCount }: FaqTableProps) { | |
| 44 | - const offset = (page - 1) * pageSize; | |
| 45 | - | |
| 46 | - return ( | |
| 47 | - <Table> | |
| 48 | - <TableHead> | |
| 49 | - <TableRow> | |
| 50 | - {COLUMN_HEADERS.map((header) => ( | |
| 51 | - <TableHeaderCell key={header}>{header}</TableHeaderCell> | |
| 52 | - ))} | |
| 53 | - </TableRow> | |
| 54 | - </TableHead> | |
| 55 | - <TableBody> | |
| 56 | - {items.map((post, index) => ( | |
| 57 | - <TableRow key={post.id}> | |
| 58 | - <TableCell>{totalCount - offset - index}</TableCell> | |
| 59 | - <TableCell>{formatBoardCategoryLabel(post.categoryCode)}</TableCell> | |
| 60 | - <TableCell>{formatFaqTypeLabel(post.typeCode)}</TableCell> | |
| 61 | - <TableCell>{formatOptionalValue(post.title)}</TableCell> | |
| 62 | - <TableCell>{formatOptionalValue(post.authorName)}</TableCell> | |
| 63 | - <TableCell>{formatOptionalValue(post.createdAt)}</TableCell> | |
| 64 | - <TableCell>{formatVisibilityLabel(post.isVisible)}</TableCell> | |
| 65 | - <TableCell> | |
| 66 | - <BoardRowActions boardType="faq" post={post} /> | |
| 67 | - </TableCell> | |
| 68 | - </TableRow> | |
| 69 | - ))} | |
| 70 | - </TableBody> | |
| 71 | - </Table> | |
| 72 | - ); | |
| 73 | -} |
--- app/(protected)/(basic)/boards/faqs/page.tsx
+++ app/(protected)/(basic)/boards/faqs/page.tsx
... | ... | @@ -1,26 +1,17 @@ |
| 1 | 1 |
import type { Metadata } from 'next';
|
| 2 |
-import Link from 'next/link'; |
|
| 3 |
-import { Alert } from '@/components/ui/alert';
|
|
| 4 |
-import { Pagination } from '@/components/ui/pagination';
|
|
| 5 | 2 |
import { verifySession } from '@/lib/auth/dal';
|
| 6 | 3 |
import { fetchBoardPosts } from '@/lib/data/repositories/board-post-repository';
|
| 7 |
-import {
|
|
| 8 |
- buildBoardPostHref, |
|
| 9 |
- parseBoardPostQuery, |
|
| 10 |
-} from '@/lib/domain/board-post-query'; |
|
| 11 |
-import { BoardFilterBar } from '../_components/board-filter-bar';
|
|
| 12 |
-import { BoardListToolbar } from '../_components/board-list-toolbar';
|
|
| 13 |
-import { FaqTable } from './_components/faq-table';
|
|
| 4 |
+import { parseBoardPostQuery } from '@/lib/domain/board-post-query';
|
|
| 5 |
+import { faqColumns } from '../_components/board-post-columns';
|
|
| 6 |
+import { BoardPostList } from '../_components/board-post-list';
|
|
| 14 | 7 |
|
| 15 |
-export const metadata: Metadata = {
|
|
| 16 |
- title: 'FAQ', |
|
| 17 |
-}; |
|
| 8 |
+export const metadata: Metadata = { title: 'FAQ' };
|
|
| 9 |
+export const dynamic = 'force-dynamic'; |
|
| 18 | 10 |
|
| 19 | 11 |
interface PageProps {
|
| 20 | 12 |
searchParams: Promise<Record<string, string | string[] | undefined>>; |
| 21 | 13 |
} |
| 22 | 14 |
|
| 23 |
-/** FAQ 목록 — 시안 A_BOA_014. 구조는 공지사항과 같고 컬럼·필터만 다르다. */ |
|
| 24 | 15 |
export default async function Page({ searchParams }: PageProps) {
|
| 25 | 16 |
await verifySession(); |
| 26 | 17 |
|
... | ... | @@ -31,48 +22,20 @@ |
| 31 | 22 |
const currentPage = Math.min(query.page, totalPages); |
| 32 | 23 |
|
| 33 | 24 |
return ( |
| 34 |
- <section className="flex flex-1 flex-col gap-6 p-8"> |
|
| 35 |
- <h1 className="text-headline-sm text-foreground">FAQ 목록</h1> |
|
| 36 |
- |
|
| 37 |
- <BoardFilterBar boardType="faq" query={query} />
|
|
| 38 |
- |
|
| 39 |
- <BoardListToolbar boardType="faq" query={query} canCreate />
|
|
| 40 |
- |
|
| 41 |
- <p className="text-body-md text-foreground-muted"> |
|
| 42 |
- 총 {totalCount}개 | 현재페이지 {currentPage}/{totalPages}
|
|
| 43 |
- </p> |
|
| 44 |
- |
|
| 45 |
- {items.length === 0 ? (
|
|
| 46 |
- <Alert |
|
| 47 |
- title="검색 결과가 없습니다." |
|
| 48 |
- actions={
|
|
| 49 |
- <Link |
|
| 50 |
- href={buildBoardPostHref('faq', query, { keyword: '', page: 1 })}
|
|
| 51 |
- className="bg-foreground px-4 py-2 text-background" |
|
| 52 |
- > |
|
| 53 |
- 확인 |
|
| 54 |
- </Link> |
|
| 55 |
- } |
|
| 56 |
- > |
|
| 57 |
- 다른 검색어로 다시 시도해 주세요. |
|
| 58 |
- </Alert> |
|
| 59 |
- ) : ( |
|
| 60 |
- <> |
|
| 61 |
- <FaqTable |
|
| 62 |
- items={items}
|
|
| 63 |
- page={currentPage}
|
|
| 64 |
- pageSize={query.pageSize}
|
|
| 65 |
- totalCount={totalCount}
|
|
| 66 |
- /> |
|
| 67 |
- <Pagination |
|
| 68 |
- currentPage={currentPage}
|
|
| 69 |
- totalPages={totalPages}
|
|
| 70 |
- buildHref={(page: number) =>
|
|
| 71 |
- buildBoardPostHref('faq', query, { page })
|
|
| 72 |
- } |
|
| 73 |
- /> |
|
| 74 |
- </> |
|
| 75 |
- )} |
|
| 76 |
- </section> |
|
| 25 |
+ <BoardPostList |
|
| 26 |
+ boardType="faq" |
|
| 27 |
+ title="FAQ 목록" |
|
| 28 |
+ breadcrumb={[
|
|
| 29 |
+ { label: '홈', href: '/' },
|
|
| 30 |
+ { label: '게시판 관리(고객센터)' },
|
|
| 31 |
+ { label: 'FAQ' },
|
|
| 32 |
+ ]} |
|
| 33 |
+ columns={faqColumns(currentPage, query.pageSize)}
|
|
| 34 |
+ items={items}
|
|
| 35 |
+ query={query}
|
|
| 36 |
+ currentPage={currentPage}
|
|
| 37 |
+ totalPages={totalPages}
|
|
| 38 |
+ totalCount={totalCount}
|
|
| 39 |
+ /> |
|
| 77 | 40 |
); |
| 78 | 41 |
} |
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
... | ... | @@ -20,7 +20,7 @@ |
| 20 | 20 |
import { saveInquiryAnswerAction } from '../../_actions';
|
| 21 | 21 |
import { BoardAttachmentField } from '../../_components/board-attachment-field';
|
| 22 | 22 |
import { BoardContentField } from '../../_components/board-content-field';
|
| 23 |
-import { FieldError } from '../../_components/board-post-form-modal';
|
|
| 23 |
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
|
|
| 24 | 24 |
import { submitFormAction } from '@/app/_hooks/submit-form-action';
|
| 25 | 25 |
|
| 26 | 26 |
interface InquiryAnswerModalProps {
|
... | ... | @@ -139,7 +139,9 @@ |
| 139 | 139 |
<p className="text-body-sm text-foreground-muted"> |
| 140 | 140 |
답변완료를 선택한 경우에만 사용자 화면에 답변 내용이 노출됩니다. |
| 141 | 141 |
</p> |
| 142 |
- <FieldError message={errors.answerStatusCode} />
|
|
| 142 |
+ {errors.answerStatusCode && (
|
|
| 143 |
+ <FoxHelperText type="danger" message={errors.answerStatusCode} />
|
|
| 144 |
+ )} |
|
| 143 | 145 |
|
| 144 | 146 |
<BoardContentField |
| 145 | 147 |
name="answerContent" |
... | ... | @@ -148,7 +150,9 @@ |
| 148 | 150 |
maxLength={ANSWER_MAX_LENGTH}
|
| 149 | 151 |
placeholder="답변 내용을 입력하세요." |
| 150 | 152 |
/> |
| 151 |
- <FieldError message={errors.answerContent} />
|
|
| 153 |
+ {errors.answerContent && (
|
|
| 154 |
+ <FoxHelperText type="danger" message={errors.answerContent} />
|
|
| 155 |
+ )} |
|
| 152 | 156 |
|
| 153 | 157 |
<BoardAttachmentField |
| 154 | 158 |
label="답변 첨부파일" |
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 | 3 |
import { useState, useTransition } from 'react';
|
| 4 |
+import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 5 |
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
|
| 4 | 6 |
import { Button } from '@/components/ui/button';
|
| 5 | 7 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 6 | 8 |
import type { BoardPost } from '@/lib/domain/board-post';
|
... | ... | @@ -43,14 +45,10 @@ |
| 43 | 45 |
title: '문의를 삭제하시겠습니까?', |
| 44 | 46 |
message: `"${post.title}" 문의를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
|
| 45 | 47 |
actions: ( |
| 46 |
- <> |
|
| 47 |
- <Button type="button" variant="ghost" onClick={hideAlert}>
|
|
| 48 |
- 취소 |
|
| 49 |
- </Button> |
|
| 50 |
- <Button type="button" variant="primary" onClick={runDelete}>
|
|
| 51 |
- 삭제 |
|
| 52 |
- </Button> |
|
| 53 |
- </> |
|
| 48 |
+ <FoxButtonGroup size="md"> |
|
| 49 |
+ <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
|
|
| 50 |
+ <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
|
|
| 51 |
+ </FoxButtonGroup> |
|
| 54 | 52 |
), |
| 55 | 53 |
}); |
| 56 | 54 |
} |
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-table.tsx
... | ... | @@ -1,84 +0,0 @@ |
| 1 | -import { | |
| 2 | - Table, | |
| 3 | - TableBody, | |
| 4 | - TableCell, | |
| 5 | - TableHead, | |
| 6 | - TableHeaderCell, | |
| 7 | - TableRow, | |
| 8 | -} from '@/components/ui/table'; | |
| 9 | -import { | |
| 10 | - formatAnswerStatusLabel, | |
| 11 | - formatInquiryTypeLabel, | |
| 12 | - formatOptionalValue, | |
| 13 | - maskPhoneNumber, | |
| 14 | - type BoardPost, | |
| 15 | -} from '@/lib/domain/board-post'; | |
| 16 | -import { InquiryRowActions } from './inquiry-row-actions'; | |
| 17 | - | |
| 18 | -interface InquiryTableProps { | |
| 19 | - items: BoardPost[]; | |
| 20 | - page: number; | |
| 21 | - pageSize: number; | |
| 22 | - totalCount: number; | |
| 23 | -} | |
| 24 | - | |
| 25 | -const COLUMN_HEADERS = [ | |
| 26 | - '번호', | |
| 27 | - '질문유형', | |
| 28 | - '질문제목', | |
| 29 | - '질문자명', | |
| 30 | - '전화번호', | |
| 31 | - '질문일', | |
| 32 | - '상태', | |
| 33 | - '답변일', | |
| 34 | - '관리', | |
| 35 | -]; | |
| 36 | - | |
| 37 | -/** | |
| 38 | - * 1:1문의 목록 테이블 — 시안 A_BOA_012 ②. | |
| 39 | - * | |
| 40 | - * **전화번호는 마스킹해서 표시한다**(사용자 확인 사항 — 시안의 `010-12**-**78`). 상세 팝업에서는 | |
| 41 | - * 답변을 위해 연락처를 확인해야 하므로 원본을 보여준다. | |
| 42 | - * | |
| 43 | - * "답변일"은 답변이 실제로 달린 경우에만 값이 있고 그 외에는 `-`다(시안 ② "답변일은 답변완료 | |
| 44 | - * 시에만 표시"). | |
| 45 | - */ | |
| 46 | -export function InquiryTable({ | |
| 47 | - items, | |
| 48 | - page, | |
| 49 | - pageSize, | |
| 50 | - totalCount, | |
| 51 | -}: InquiryTableProps) { | |
| 52 | - const offset = (page - 1) * pageSize; | |
| 53 | - | |
| 54 | - return ( | |
| 55 | - <Table> | |
| 56 | - <TableHead> | |
| 57 | - <TableRow> | |
| 58 | - {COLUMN_HEADERS.map((header) => ( | |
| 59 | - <TableHeaderCell key={header}>{header}</TableHeaderCell> | |
| 60 | - ))} | |
| 61 | - </TableRow> | |
| 62 | - </TableHead> | |
| 63 | - <TableBody> | |
| 64 | - {items.map((post, index) => ( | |
| 65 | - <TableRow key={post.id}> | |
| 66 | - <TableCell>{totalCount - offset - index}</TableCell> | |
| 67 | - <TableCell>{formatInquiryTypeLabel(post.typeCode)}</TableCell> | |
| 68 | - <TableCell>{formatOptionalValue(post.title)}</TableCell> | |
| 69 | - <TableCell>{formatOptionalValue(post.authorName)}</TableCell> | |
| 70 | - <TableCell>{maskPhoneNumber(post.authorPhoneNumber)}</TableCell> | |
| 71 | - <TableCell>{formatOptionalValue(post.createdAt)}</TableCell> | |
| 72 | - <TableCell> | |
| 73 | - {formatAnswerStatusLabel(post.answerStatusCode)} | |
| 74 | - </TableCell> | |
| 75 | - <TableCell>{formatOptionalValue(post.answeredAt)}</TableCell> | |
| 76 | - <TableCell> | |
| 77 | - <InquiryRowActions post={post} /> | |
| 78 | - </TableCell> | |
| 79 | - </TableRow> | |
| 80 | - ))} | |
| 81 | - </TableBody> | |
| 82 | - </Table> | |
| 83 | - ); | |
| 84 | -} |
--- app/(protected)/(basic)/boards/inquiries/page.tsx
+++ app/(protected)/(basic)/boards/inquiries/page.tsx
... | ... | @@ -1,31 +1,17 @@ |
| 1 | 1 |
import type { Metadata } from 'next';
|
| 2 |
-import Link from 'next/link'; |
|
| 3 |
-import { Alert } from '@/components/ui/alert';
|
|
| 4 |
-import { Pagination } from '@/components/ui/pagination';
|
|
| 5 | 2 |
import { verifySession } from '@/lib/auth/dal';
|
| 6 | 3 |
import { fetchBoardPosts } from '@/lib/data/repositories/board-post-repository';
|
| 7 |
-import {
|
|
| 8 |
- buildBoardPostHref, |
|
| 9 |
- parseBoardPostQuery, |
|
| 10 |
-} from '@/lib/domain/board-post-query'; |
|
| 11 |
-import { BoardFilterBar } from '../_components/board-filter-bar';
|
|
| 12 |
-import { BoardListToolbar } from '../_components/board-list-toolbar';
|
|
| 13 |
-import { InquiryTable } from './_components/inquiry-table';
|
|
| 4 |
+import { parseBoardPostQuery } from '@/lib/domain/board-post-query';
|
|
| 5 |
+import { inquiryColumns } from '../_components/board-post-columns';
|
|
| 6 |
+import { BoardPostList } from '../_components/board-post-list';
|
|
| 14 | 7 |
|
| 15 |
-export const metadata: Metadata = {
|
|
| 16 |
- title: '1:1문의', |
|
| 17 |
-}; |
|
| 8 |
+export const metadata: Metadata = { title: '1:1문의' };
|
|
| 9 |
+export const dynamic = 'force-dynamic'; |
|
| 18 | 10 |
|
| 19 | 11 |
interface PageProps {
|
| 20 | 12 |
searchParams: Promise<Record<string, string | string[] | undefined>>; |
| 21 | 13 |
} |
| 22 | 14 |
|
| 23 |
-/** |
|
| 24 |
- * 1:1문의 목록 — 시안 A_BOA_012. |
|
| 25 |
- * |
|
| 26 |
- * 관리자는 글을 등록하지 않고 **조회·답변·삭제만** 한다(사용자 확인 사항) — 그래서 툴바의 |
|
| 27 |
- * [신규 등록]을 끈다. 등록을 막는 것은 화면뿐 아니라 Server Action도 마찬가지다. |
|
| 28 |
- */ |
|
| 29 | 15 |
export default async function Page({ searchParams }: PageProps) {
|
| 30 | 16 |
await verifySession(); |
| 31 | 17 |
|
... | ... | @@ -36,55 +22,21 @@ |
| 36 | 22 |
const currentPage = Math.min(query.page, totalPages); |
| 37 | 23 |
|
| 38 | 24 |
return ( |
| 39 |
- <section className="flex flex-1 flex-col gap-6 p-8"> |
|
| 40 |
- <h1 className="text-headline-sm text-foreground">1:1문의 목록</h1> |
|
| 41 |
- |
|
| 42 |
- <BoardFilterBar boardType="inquiry" query={query} />
|
|
| 43 |
- |
|
| 44 |
- <BoardListToolbar |
|
| 45 |
- boardType="inquiry" |
|
| 46 |
- query={query}
|
|
| 47 |
- canCreate={false}
|
|
| 48 |
- /> |
|
| 49 |
- |
|
| 50 |
- <p className="text-body-md text-foreground-muted"> |
|
| 51 |
- 총 {totalCount}개 | 현재페이지 {currentPage}/{totalPages}
|
|
| 52 |
- </p> |
|
| 53 |
- |
|
| 54 |
- {items.length === 0 ? (
|
|
| 55 |
- <Alert |
|
| 56 |
- title="검색 결과가 없습니다." |
|
| 57 |
- actions={
|
|
| 58 |
- <Link |
|
| 59 |
- href={buildBoardPostHref('inquiry', query, {
|
|
| 60 |
- keyword: '', |
|
| 61 |
- page: 1, |
|
| 62 |
- })} |
|
| 63 |
- className="bg-foreground px-4 py-2 text-background" |
|
| 64 |
- > |
|
| 65 |
- 확인 |
|
| 66 |
- </Link> |
|
| 67 |
- } |
|
| 68 |
- > |
|
| 69 |
- 다른 검색어로 다시 시도해 주세요. |
|
| 70 |
- </Alert> |
|
| 71 |
- ) : ( |
|
| 72 |
- <> |
|
| 73 |
- <InquiryTable |
|
| 74 |
- items={items}
|
|
| 75 |
- page={currentPage}
|
|
| 76 |
- pageSize={query.pageSize}
|
|
| 77 |
- totalCount={totalCount}
|
|
| 78 |
- /> |
|
| 79 |
- <Pagination |
|
| 80 |
- currentPage={currentPage}
|
|
| 81 |
- totalPages={totalPages}
|
|
| 82 |
- buildHref={(page: number) =>
|
|
| 83 |
- buildBoardPostHref('inquiry', query, { page })
|
|
| 84 |
- } |
|
| 85 |
- /> |
|
| 86 |
- </> |
|
| 87 |
- )} |
|
| 88 |
- </section> |
|
| 25 |
+ <BoardPostList |
|
| 26 |
+ boardType="inquiry" |
|
| 27 |
+ title="1:1문의 목록" |
|
| 28 |
+ breadcrumb={[
|
|
| 29 |
+ { label: '홈', href: '/' },
|
|
| 30 |
+ { label: '게시판 관리(고객센터)' },
|
|
| 31 |
+ { label: '1:1문의' },
|
|
| 32 |
+ ]} |
|
| 33 |
+ columns={inquiryColumns(currentPage, query.pageSize)}
|
|
| 34 |
+ items={items}
|
|
| 35 |
+ query={query}
|
|
| 36 |
+ currentPage={currentPage}
|
|
| 37 |
+ totalPages={totalPages}
|
|
| 38 |
+ totalCount={totalCount}
|
|
| 39 |
+ canCreate={false}
|
|
| 40 |
+ /> |
|
| 89 | 41 |
); |
| 90 | 42 |
} |
--- app/(protected)/(basic)/boards/notices/_components/notice-table.tsx
... | ... | @@ -1,83 +0,0 @@ |
| 1 | -import { | |
| 2 | - Table, | |
| 3 | - TableBody, | |
| 4 | - TableCell, | |
| 5 | - TableHead, | |
| 6 | - TableHeaderCell, | |
| 7 | - TableRow, | |
| 8 | -} from '@/components/ui/table'; | |
| 9 | -import { | |
| 10 | - EMPTY_FIELD_PLACEHOLDER, | |
| 11 | - formatBoardCategoryLabel, | |
| 12 | - formatOptionalValue, | |
| 13 | - formatVisibilityLabel, | |
| 14 | - type BoardPost, | |
| 15 | -} from '@/lib/domain/board-post'; | |
| 16 | -import { BoardRowActions } from '../../_components/board-row-actions'; | |
| 17 | - | |
| 18 | -interface NoticeTableProps { | |
| 19 | - items: BoardPost[]; | |
| 20 | - page: number; | |
| 21 | - pageSize: number; | |
| 22 | - totalCount: number; | |
| 23 | -} | |
| 24 | - | |
| 25 | -const COLUMN_HEADERS = [ | |
| 26 | - '번호', | |
| 27 | - '구분', | |
| 28 | - '제목', | |
| 29 | - '첨부', | |
| 30 | - '작성자', | |
| 31 | - '사용여부', | |
| 32 | - '작성일', | |
| 33 | - '관리', | |
| 34 | -]; | |
| 35 | - | |
| 36 | -/** | |
| 37 | - * 공지사항 목록 테이블 — 시안 A_BOA_006 ③. | |
| 38 | - * | |
| 39 | - * "번호"는 저장된 값이 아니라 전체 건수에서 거꾸로 세는 표시 순번이다(시안이 2·1처럼 내림차순으로 | |
| 40 | - * 표기하고 목록이 최신순이라 가장 최근 글이 가장 큰 번호가 된다). 정확한 전체 건수를 전제하는데, | |
| 41 | - * Repository가 전체를 받아 세기 때문에 그 전제가 성립한다. | |
| 42 | - * | |
| 43 | - * "첨부"는 파일 유무만 표시한다 — 백엔드에 atchFileId로 파일 정보를 조회하는 API가 없어 파일명· | |
| 44 | - * 용량을 알 수 없다(`board-attachment-field.tsx` 주석 참조). | |
| 45 | - */ | |
| 46 | -export function NoticeTable({ | |
| 47 | - items, | |
| 48 | - page, | |
| 49 | - pageSize, | |
| 50 | - totalCount, | |
| 51 | -}: NoticeTableProps) { | |
| 52 | - const offset = (page - 1) * pageSize; | |
| 53 | - | |
| 54 | - return ( | |
| 55 | - <Table> | |
| 56 | - <TableHead> | |
| 57 | - <TableRow> | |
| 58 | - {COLUMN_HEADERS.map((header) => ( | |
| 59 | - <TableHeaderCell key={header}>{header}</TableHeaderCell> | |
| 60 | - ))} | |
| 61 | - </TableRow> | |
| 62 | - </TableHead> | |
| 63 | - <TableBody> | |
| 64 | - {items.map((post, index) => ( | |
| 65 | - <TableRow key={post.id}> | |
| 66 | - <TableCell>{totalCount - offset - index}</TableCell> | |
| 67 | - <TableCell>{formatBoardCategoryLabel(post.categoryCode)}</TableCell> | |
| 68 | - <TableCell>{formatOptionalValue(post.title)}</TableCell> | |
| 69 | - <TableCell> | |
| 70 | - {post.attachmentId ? '있음' : EMPTY_FIELD_PLACEHOLDER} | |
| 71 | - </TableCell> | |
| 72 | - <TableCell>{formatOptionalValue(post.authorName)}</TableCell> | |
| 73 | - <TableCell>{formatVisibilityLabel(post.isVisible)}</TableCell> | |
| 74 | - <TableCell>{formatOptionalValue(post.createdAt)}</TableCell> | |
| 75 | - <TableCell> | |
| 76 | - <BoardRowActions boardType="notice" post={post} /> | |
| 77 | - </TableCell> | |
| 78 | - </TableRow> | |
| 79 | - ))} | |
| 80 | - </TableBody> | |
| 81 | - </Table> | |
| 82 | - ); | |
| 83 | -} |
--- app/(protected)/(basic)/boards/notices/page.tsx
+++ app/(protected)/(basic)/boards/notices/page.tsx
... | ... | @@ -1,31 +1,17 @@ |
| 1 | 1 |
import type { Metadata } from 'next';
|
| 2 |
-import Link from 'next/link'; |
|
| 3 |
-import { Alert } from '@/components/ui/alert';
|
|
| 4 |
-import { Pagination } from '@/components/ui/pagination';
|
|
| 5 | 2 |
import { verifySession } from '@/lib/auth/dal';
|
| 6 | 3 |
import { fetchBoardPosts } from '@/lib/data/repositories/board-post-repository';
|
| 7 |
-import {
|
|
| 8 |
- buildBoardPostHref, |
|
| 9 |
- parseBoardPostQuery, |
|
| 10 |
-} from '@/lib/domain/board-post-query'; |
|
| 11 |
-import { BoardFilterBar } from '../_components/board-filter-bar';
|
|
| 12 |
-import { BoardListToolbar } from '../_components/board-list-toolbar';
|
|
| 13 |
-import { NoticeTable } from './_components/notice-table';
|
|
| 4 |
+import { parseBoardPostQuery } from '@/lib/domain/board-post-query';
|
|
| 5 |
+import { noticeColumns } from '../_components/board-post-columns';
|
|
| 6 |
+import { BoardPostList } from '../_components/board-post-list';
|
|
| 14 | 7 |
|
| 15 |
-export const metadata: Metadata = {
|
|
| 16 |
- title: '공지사항', |
|
| 17 |
-}; |
|
| 8 |
+export const metadata: Metadata = { title: '공지사항' };
|
|
| 9 |
+export const dynamic = 'force-dynamic'; |
|
| 18 | 10 |
|
| 19 | 11 |
interface PageProps {
|
| 20 | 12 |
searchParams: Promise<Record<string, string | string[] | undefined>>; |
| 21 | 13 |
} |
| 22 | 14 |
|
| 23 |
-/** |
|
| 24 |
- * 공지사항 목록 — 시안 A_BOA_006. |
|
| 25 |
- * |
|
| 26 |
- * 전체 건수가 **확정값**이라 페이지 수를 그대로 계산한다 — Repository가 백엔드 페이징을 쓰지 않고 |
|
| 27 |
- * 전체를 받아 직접 세기 때문이다(백엔드 `totalCount`는 현재 페이지 행 수라 신뢰할 수 없다). |
|
| 28 |
- */ |
|
| 29 | 15 |
export default async function Page({ searchParams }: PageProps) {
|
| 30 | 16 |
await verifySession(); |
| 31 | 17 |
|
... | ... | @@ -33,56 +19,23 @@ |
| 33 | 19 |
const { items, totalCount } = await fetchBoardPosts('notice', query);
|
| 34 | 20 |
|
| 35 | 21 |
const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); |
| 36 |
- // 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐) 마지막 페이지로 맞춘다 — |
|
| 37 |
- // 표의 순번 계산도 이 값을 기준으로 해야 헤더의 "현재페이지"와 어긋나지 않는다. |
|
| 38 | 22 |
const currentPage = Math.min(query.page, totalPages); |
| 39 | 23 |
|
| 40 | 24 |
return ( |
| 41 |
- <section className="flex flex-1 flex-col gap-6 p-8"> |
|
| 42 |
- <h1 className="text-headline-sm text-foreground">공지사항 목록</h1> |
|
| 43 |
- |
|
| 44 |
- <BoardFilterBar boardType="notice" query={query} />
|
|
| 45 |
- |
|
| 46 |
- <BoardListToolbar boardType="notice" query={query} canCreate />
|
|
| 47 |
- |
|
| 48 |
- <p className="text-body-md text-foreground-muted"> |
|
| 49 |
- 총 {totalCount}개 | 현재페이지 {currentPage}/{totalPages}
|
|
| 50 |
- </p> |
|
| 51 |
- |
|
| 52 |
- {items.length === 0 ? (
|
|
| 53 |
- <Alert |
|
| 54 |
- title="검색 결과가 없습니다." |
|
| 55 |
- actions={
|
|
| 56 |
- <Link |
|
| 57 |
- href={buildBoardPostHref('notice', query, {
|
|
| 58 |
- keyword: '', |
|
| 59 |
- page: 1, |
|
| 60 |
- })} |
|
| 61 |
- className="bg-foreground px-4 py-2 text-background" |
|
| 62 |
- > |
|
| 63 |
- 확인 |
|
| 64 |
- </Link> |
|
| 65 |
- } |
|
| 66 |
- > |
|
| 67 |
- 다른 검색어로 다시 시도해 주세요. |
|
| 68 |
- </Alert> |
|
| 69 |
- ) : ( |
|
| 70 |
- <> |
|
| 71 |
- <NoticeTable |
|
| 72 |
- items={items}
|
|
| 73 |
- page={currentPage}
|
|
| 74 |
- pageSize={query.pageSize}
|
|
| 75 |
- totalCount={totalCount}
|
|
| 76 |
- /> |
|
| 77 |
- <Pagination |
|
| 78 |
- currentPage={currentPage}
|
|
| 79 |
- totalPages={totalPages}
|
|
| 80 |
- buildHref={(page: number) =>
|
|
| 81 |
- buildBoardPostHref('notice', query, { page })
|
|
| 82 |
- } |
|
| 83 |
- /> |
|
| 84 |
- </> |
|
| 85 |
- )} |
|
| 86 |
- </section> |
|
| 25 |
+ <BoardPostList |
|
| 26 |
+ boardType="notice" |
|
| 27 |
+ title="공지사항 목록" |
|
| 28 |
+ breadcrumb={[
|
|
| 29 |
+ { label: '홈', href: '/' },
|
|
| 30 |
+ { label: '게시판 관리(고객센터)' },
|
|
| 31 |
+ { label: '공지사항' },
|
|
| 32 |
+ ]} |
|
| 33 |
+ columns={noticeColumns(currentPage, query.pageSize)}
|
|
| 34 |
+ items={items}
|
|
| 35 |
+ query={query}
|
|
| 36 |
+ currentPage={currentPage}
|
|
| 37 |
+ totalPages={totalPages}
|
|
| 38 |
+ totalCount={totalCount}
|
|
| 39 |
+ /> |
|
| 87 | 40 |
); |
| 88 | 41 |
} |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
... | ... | @@ -57,7 +57,7 @@ |
| 57 | 57 |
actions: ( |
| 58 | 58 |
<> |
| 59 | 59 |
<FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
|
| 60 |
- <FoxButton type="error" size="md" label="삭제" onAction={runDelete} />
|
|
| 60 |
+ <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
|
|
| 61 | 61 |
</> |
| 62 | 62 |
), |
| 63 | 63 |
}); |
--- app/(protected)/(basic)/system/codes/_components/code-management.tsx
+++ app/(protected)/(basic)/system/codes/_components/code-management.tsx
... | ... | @@ -115,7 +115,7 @@ |
| 115 | 115 |
<> |
| 116 | 116 |
<FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
|
| 117 | 117 |
<FoxButton |
| 118 |
- type="error" |
|
| 118 |
+ type="primary" |
|
| 119 | 119 |
size="md" |
| 120 | 120 |
label="삭제" |
| 121 | 121 |
onAction={() => {
|
--- app/_components/feedback-host.tsx
+++ app/_components/feedback-host.tsx
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { Alert } from '@/components/ui/alert';
|
|
| 3 |
+import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 4 |
+import { FoxModal } from '@fox/core/components/fox-modal';
|
|
| 4 | 5 |
import { Spinner } from '@/components/ui/spinner';
|
| 5 | 6 |
import { Toast } from '@/components/ui/toast';
|
| 6 | 7 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
... | ... | @@ -25,23 +26,24 @@ |
| 25 | 26 |
)} |
| 26 | 27 |
|
| 27 | 28 |
{alert && (
|
| 28 |
- <Alert |
|
| 29 |
- variant={alert.variant}
|
|
| 29 |
+ <FoxModal |
|
| 30 |
+ open |
|
| 31 |
+ size="sm" |
|
| 30 | 32 |
title={alert.title}
|
| 33 |
+ onClose={hideAlert}
|
|
| 31 | 34 |
actions={
|
| 32 | 35 |
alert.actions ?? ( |
| 33 |
- <button |
|
| 34 |
- type="button" |
|
| 35 |
- onClick={hideAlert}
|
|
| 36 |
- className="rounded-md bg-primary px-4 py-2 text-body-md text-on-primary" |
|
| 37 |
- > |
|
| 38 |
- 확인 |
|
| 39 |
- </button> |
|
| 36 |
+ <FoxButton |
|
| 37 |
+ type="primary" |
|
| 38 |
+ size="md" |
|
| 39 |
+ label="확인" |
|
| 40 |
+ onAction={hideAlert}
|
|
| 41 |
+ /> |
|
| 40 | 42 |
) |
| 41 | 43 |
} |
| 42 | 44 |
> |
| 43 | 45 |
{alert.message}
|
| 44 |
- </Alert> |
|
| 46 |
+ </FoxModal> |
|
| 45 | 47 |
)} |
| 46 | 48 |
|
| 47 | 49 |
{spinnerCount > 0 && (
|
--- app/layout.tsx
+++ app/layout.tsx
... | ... | @@ -2,27 +2,7 @@ |
| 2 | 2 |
import { Geist_Mono } from "next/font/google";
|
| 3 | 3 |
import { FeedbackHost } from "@/app/_components/feedback-host";
|
| 4 | 4 |
import { FeedbackProvider } from "@/app/_components/feedback-provider";
|
| 5 |
-import {
|
|
| 6 |
- THEME_ATTRIBUTE_NAME, |
|
| 7 |
- THEME_STORAGE_KEY, |
|
| 8 |
-} from "@/lib/constants/theme"; |
|
| 9 | 5 |
import "./globals.scss"; |
| 10 |
- |
|
| 11 |
-// 첫 페인트 전 테마 적용 — <head>는 <body>보다 먼저 파싱되므로 <html>만 조작 가능하다 |
|
| 12 |
-// (preventing-flash-before-hydration.md의 Themes 패턴). localStorage에 명시 선택("light"
|
|
| 13 |
-// /"dark")이 저장돼 있으면 첫 페인트 전에 <html data-theme>을 부여해 FOUC를 막는다. 저장값이 |
|
| 14 |
-// 없거나("system") 손상된 값이면 속성을 부여하지 않는다 — globals.css의
|
|
| 15 |
-// `prefers-color-scheme` 미디어 쿼리가 그대로 OS 설정을 따르는 fail-safe 경로다. |
|
| 16 |
-// |
|
| 17 |
-// THEME_STORAGE_KEY/THEME_ATTRIBUTE_NAME은 반드시 'use client' 파일이 아닌 |
|
| 18 |
-// lib/constants/theme에서 import한다 — 'use client' 모듈의 일반 값(문자열) export를 |
|
| 19 |
-// Server Component가 직접 import하면 클라이언트 레퍼런스 프록시 특성상 undefined로 평가된다 |
|
| 20 |
-// (컴포넌트 export만 정상적으로 프록시된다). |
|
| 21 |
-const THEME_BOOTSTRAP_SCRIPT = `(function(){try{var t=localStorage.getItem(${JSON.stringify(
|
|
| 22 |
- THEME_STORAGE_KEY |
|
| 23 |
-)});if(t==="light"||t==="dark")document.documentElement.setAttribute(${JSON.stringify(
|
|
| 24 |
- THEME_ATTRIBUTE_NAME |
|
| 25 |
-)},t)}catch(e){}})()`;
|
|
| 26 | 6 |
|
| 27 | 7 |
// 앱 기본 서체는 여기서 선언하지 않는다 — @fox 토큰이 패밀리명을 리터럴 `Pretendard`로 못 |
| 28 | 8 |
// 박고 있어 `next/font`(패밀리명을 해시로 바꾼다)를 쓸 수 없고, globals.scss의 `@font-face`가 |
... | ... | @@ -63,11 +43,15 @@ |
| 63 | 43 |
return ( |
| 64 | 44 |
<html |
| 65 | 45 |
lang="ko" |
| 46 |
+ // 테마를 바꾸는 기획이 아직 없어 라이트로 못박는다. @fox 토큰은 `color-scheme`으로 |
|
| 47 |
+ // 갈리므로(`@fox/styles/_root.scss`) 이 속성 하나가 OS 다크 설정까지 덮는다. |
|
| 48 |
+ // 전환이 기획되면 이 값을 저장된 선택으로 되돌리고 `_hooks/use-theme-preference`와 |
|
| 49 |
+ // `_components/theme-toggle-button`을 헤더에 다시 붙이면 된다(둘 다 그대로 남아 있다). |
|
| 50 |
+ data-theme="light" |
|
| 66 | 51 |
suppressHydrationWarning |
| 67 | 52 |
className={geistMono.variable}
|
| 68 | 53 |
> |
| 69 | 54 |
<head> |
| 70 |
- <script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
|
|
| 71 | 55 |
{/* @font-face는 CSS를 다 읽은 뒤에야 발견돼 본문이 한 번 폴백 서체로 그려진다.
|
| 72 | 56 |
거의 모든 화면이 쓰는 regular만 미리 받아 그 깜빡임을 없앤다 — 나머지 굵기는 |
| 73 | 57 |
빈도가 낮아 선점 비용이 이득보다 크다. */} |
--- lib/domain/board-post.ts
+++ lib/domain/board-post.ts
... | ... | @@ -17,19 +17,13 @@ |
| 17 | 17 |
export type BoardType = 'notice' | 'inquiry' | 'faq'; |
| 18 | 18 |
|
| 19 | 19 |
/** |
| 20 |
- * 백엔드 `stngId`(게시판 설정 ID) — **임시값이다.** |
|
| 21 |
- * |
|
| 22 |
- * 백엔드에 게시판 설정 목록 API도 테이블도 없고 소스에는 예시값 하나 |
|
| 23 |
- * (`5e9ae040df1b41c1b03ce11d1bafcc60`)만 있어 세 게시판 중 무엇인지 알 수 없다. 사용자 확인 결과 |
|
| 24 |
- * **"추후 백엔드에 추가 예정, 지금은 임의값으로 개발"** 이라 아래 값을 쓴다. |
|
| 25 |
- * |
|
| 26 |
- * 실제 값이 나오면 이 표만 고치면 된다 — 나머지 코드는 `BoardType`만 다루고 이 값을 직접 알지 |
|
| 27 |
- * 못한다. 그때까지 목록 조회는 빈 결과가 정상이다(존재하지 않는 stngId라 매칭되는 행이 없다). |
|
| 20 |
+ * TODO: 추후 메뉴 데이터에 실려 오면 이 상수를 지우고 그 값을 쓴다. |
|
| 21 |
+ * `GET /api/v1/common/bbs/stng/list`의 실제 값(typeSe는 셋 다 'NOT'이라 구분에 쓸 수 없다). |
|
| 28 | 22 |
*/ |
| 29 | 23 |
export const BOARD_SETTING_IDS: Record<BoardType, string> = {
|
| 30 |
- notice: 'TEMP_STNG_ID_NOTICE', |
|
| 31 |
- inquiry: 'TEMP_STNG_ID_INQUIRY', |
|
| 32 |
- faq: 'TEMP_STNG_ID_FAQ', |
|
| 24 |
+ notice: '5e9ae040df1b41c1b03ce11d1bafcc60', |
|
| 25 |
+ inquiry: '4538fa2b88cc458da5cae972f39a1ebd', |
|
| 26 |
+ faq: '9f3d4c7b2a3e4b7d8c1f6a9e2b5c7d10', |
|
| 33 | 27 |
}; |
| 34 | 28 |
|
| 35 | 29 |
export type BoardPost = {
|
--- lib/domain/file-module.ts
+++ lib/domain/file-module.ts
... | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 |
// 이슈: 틀린 값이어도 업로드가 실패하지 않는다. 백엔드가 저장 설정을 못 찾으면 기본 저장소로 |
| 3 | 3 |
// 폴백해 조용히 다른 곳에 저장한다. |
| 4 | 4 |
export const FILE_MODULE_ID = {
|
| 5 |
- decorationItem: 'MODULE_ITEM', |
|
| 5 |
+ decorationItem: 'MODULE_USER_ITEM', |
|
| 6 | 6 |
board: 'MODULE_BBS', |
| 7 | 7 |
} as const; |
| 8 | 8 |
|
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?