Compare commits
47 Commits
4dc8304a1d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ef3990f1d | ||
|
|
8ec9e4e402 | ||
| 8bdd615ec9 | |||
| 54d763e008 | |||
| 109ca05e23 | |||
| eb7871133d | |||
| 872a88866e | |||
| 91d14fdabf | |||
| 39d21a475b | |||
| 32e9fed5cd | |||
| 5e4337c7e8 | |||
| 5a26d96386 | |||
| 03b4fa108a | |||
| 7db3b7732c | |||
|
|
adae8b130b | ||
|
|
04feb84192 | ||
|
|
c021303182 | ||
|
|
c91dd4a30f | ||
| 0963cfdf5b | |||
| b1e2f6012c | |||
|
|
91c886298c | ||
| e2b7330c5e | |||
| 5a2d770589 | |||
| 5a5cf3e9e6 | |||
| 53a5713ddd | |||
| 47eedf6837 | |||
|
|
6434c580fe | ||
| 6bfa2f367d | |||
|
|
4c52627c2c | ||
|
|
53dea825b0 | ||
|
|
71feecb8d1 | ||
| e74061057d | |||
| 12413795f4 | |||
| 7e122453bb | |||
| eaec13e386 | |||
| 74eee0a3c0 | |||
| 071a686ffa | |||
| 6a546b6fcd | |||
| 24f17b1dd1 | |||
| 33d738f7d0 | |||
| f1515cdd72 | |||
| dbac109f5b | |||
| c94316f8ce | |||
| e768f267d3 | |||
| aca6fa93ea | |||
|
|
84486ba7f9 | ||
|
|
f4196167c7 |
2
.gitignore
vendored
@@ -39,3 +39,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|||||||
937
package-lock.json
generated
17
package.json
@@ -7,10 +7,24 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "biome check",
|
"lint": "biome check",
|
||||||
"format": "biome format --write"
|
"format": "biome format --write",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:migrate:deploy": "prisma migrate deploy",
|
||||||
|
"db:migrate:reset": "prisma migrate reset",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:pull": "prisma db pull"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.19.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
|
"prisma": "^6.19.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0"
|
"react-dom": "19.2.0"
|
||||||
},
|
},
|
||||||
@@ -21,6 +35,7 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
public/imgs/arrows-diagrams-arrow.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Group">
|
||||||
|
<g id="Group_2">
|
||||||
|
<path id="Path" fill-rule="evenodd" clip-rule="evenodd" d="M16 4V4C22.628 4 28 9.372 28 16V16C28 22.628 22.628 28 16 28V28C9.372 28 4 22.628 4 16V16C4 9.372 9.372 4 16 4Z" fill="var(--fill-0, #8C95A1)" stroke="var(--stroke-0, #8C95A1)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Path_2" d="M10.6667 16H21.3333" stroke="var(--stroke-0, white)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Path_3" d="M14.6667 20L10.6667 16L14.6667 12" stroke="var(--stroke-0, white)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<g id="Path_4">
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 836 B |
3
public/imgs/asset-base.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path id="Icon (Stroke)" fill-rule="evenodd" clip-rule="evenodd" d="M0.292893 0.292893C0.683417 -0.0976311 1.31658 -0.0976311 1.70711 0.292893L7.70711 6.29289C8.09763 6.68342 8.09763 7.31658 7.70711 7.70711L1.70711 13.7071C1.31658 14.0976 0.683417 14.0976 0.292893 13.7071C-0.0976311 13.3166 -0.0976311 12.6834 0.292893 12.2929L5.58579 7L0.292893 1.70711C-0.0976311 1.31658 -0.0976311 0.683418 0.292893 0.292893Z" fill="var(--fill-0, white)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 620 B |
9
public/imgs/certificate-asset-1.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Group">
|
||||||
|
<path id="Path" d="M12.9684 9.26294L10.0038 12.2275L7.03923 9.26294" stroke="var(--stroke-0, #4C5561)" stroke-width="1.5625" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Path_2" d="M10.0041 3.33439V12.2273" stroke="var(--stroke-0, #4C5561)" stroke-width="1.5625" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Path_3" d="M16.6737 13.7087C16.6737 15.346 15.3464 16.6733 13.7091 16.6733H6.29937C4.66208 16.6733 3.3348 15.346 3.3348 13.7087" stroke="var(--stroke-0, #4C5561)" stroke-width="1.5625" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<g id="Rectangle">
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 791 B |
3
public/imgs/certificate-asset.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path id="Icon (Stroke)" fill-rule="evenodd" clip-rule="evenodd" d="M0.317301 0.317301C0.740369 -0.105767 1.4263 -0.105767 1.84937 0.317301L7.58333 6.05127L13.3173 0.317301C13.7404 -0.105767 14.4263 -0.105767 14.8494 0.317301C15.2724 0.740369 15.2724 1.4263 14.8494 1.84937L9.1154 7.58333L14.8494 13.3173C15.2724 13.7404 15.2724 14.4263 14.8494 14.8494C14.4263 15.2724 13.7404 15.2724 13.3173 14.8494L7.58333 9.1154L1.84937 14.8494C1.4263 15.2724 0.740369 15.2724 0.317301 14.8494C-0.105767 14.4263 -0.105767 13.7404 0.317301 13.3173L6.05127 7.58333L0.317301 1.84937C-0.105767 1.4263 -0.105767 0.740369 0.317301 0.317301Z" fill="var(--fill-0, #333C47)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 832 B |
BIN
public/imgs/certificate-container.png
Normal file
|
After Width: | Height: | Size: 713 B |
3
public/imgs/ellipse-2.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle id="Ellipse 2" cx="6" cy="6" r="6" fill="var(--fill-0, white)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 250 B |
4
public/imgs/feedback-asset-1.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id=" Min Width">
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 202 B |
3
public/imgs/feedback-asset.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path id="Icon (Stroke)" fill-rule="evenodd" clip-rule="evenodd" d="M0.317301 0.317301C0.740369 -0.105767 1.4263 -0.105767 1.84937 0.317301L7.58333 6.05127L13.3173 0.317301C13.7404 -0.105767 14.4263 -0.105767 14.8494 0.317301C15.2724 0.740369 15.2724 1.4263 14.8494 1.84937L9.1154 7.58333L14.8494 13.3173C15.2724 13.7404 15.2724 14.4263 14.8494 14.8494C14.4263 15.2724 13.7404 15.2724 13.3173 14.8494L7.58333 9.1154L1.84937 14.8494C1.4263 15.2724 0.740369 15.2724 0.317301 14.8494C-0.105767 14.4263 -0.105767 13.7404 0.317301 13.3173L6.05127 7.58333L0.317301 1.84937C-0.105767 1.4263 -0.105767 0.740369 0.317301 0.317301Z" fill="var(--fill-0, #333C47)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 832 B |
8
public/imgs/group.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Group">
|
||||||
|
<g id="Path">
|
||||||
|
</g>
|
||||||
|
<path id="Path_2" fill-rule="evenodd" clip-rule="evenodd" d="M105 60V60C105 84.855 84.855 105 60 105V105C35.145 105 15 84.855 15 60V60C15 35.145 35.145 15 60 15V15C84.855 15 105 35.145 105 60Z" stroke="var(--stroke-0, white)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Path_3" fill-rule="evenodd" clip-rule="evenodd" d="M54.703 45.2904L74.113 56.7703C76.568 58.2203 76.568 61.7754 74.113 63.2254L54.703 74.7054C52.203 76.1854 49.043 74.3804 49.043 71.4754V48.5204C49.043 45.6154 52.203 43.8104 54.703 45.2904V45.2904Z" fill="var(--fill-0, white)" stroke="var(--stroke-0, white)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 896 B |
7
public/imgs/icon-1.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M8.25 3.52652C8.24985 3.42206 8.21876 3.31998 8.16065 3.23316C8.10254 3.14635 8.02001 3.0787 7.92349 3.03874C7.82697 2.99878 7.72077 2.98831 7.61831 3.00865C7.51584 3.02899 7.42169 3.07923 7.34775 3.15302L4.80975 5.69027C4.7118 5.7888 4.59528 5.86692 4.46692 5.92009C4.33856 5.97326 4.20093 6.00043 4.062 6.00002H2.25C2.05109 6.00002 1.86032 6.07904 1.71967 6.21969C1.57902 6.36034 1.5 6.55111 1.5 6.75002V11.25C1.5 11.4489 1.57902 11.6397 1.71967 11.7804C1.86032 11.921 2.05109 12 2.25 12H4.062C4.20093 11.9996 4.33856 12.0268 4.46692 12.08C4.59528 12.1331 4.7118 12.2112 4.80975 12.3098L7.347 14.8478C7.42095 14.9219 7.51523 14.9723 7.61789 14.9928C7.72056 15.0133 7.82699 15.0028 7.92369 14.9627C8.0204 14.9226 8.10303 14.8548 8.16112 14.7677C8.21921 14.6806 8.25015 14.5782 8.25 14.4735V3.52652Z" stroke="var(--stroke-0, white)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M12 6.75C12.4868 7.39911 12.75 8.18861 12.75 9C12.75 9.81139 12.4868 10.6009 12 11.25" stroke="var(--stroke-0, white)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_3" d="M14.5234 13.7731C15.1502 13.1463 15.6475 12.4021 15.9867 11.5832C16.3259 10.7642 16.5005 9.88648 16.5005 9.00005C16.5005 8.11362 16.3259 7.23587 15.9867 6.41692C15.6475 5.59797 15.1502 4.85385 14.5234 4.22705" stroke="var(--stroke-0, white)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
5
public/imgs/icon-2.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M10 12L6 8L10 4" stroke="var(--stroke-0, white)" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 338 B |
5
public/imgs/icon-3.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M10 12L6 8L10 4" stroke="var(--stroke-0, white)" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 338 B |
8
public/imgs/icon-4.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M11.25 2.25H15.75V6.75" stroke="var(--stroke-0, white)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M15.75 2.25L10.5 7.5" stroke="var(--stroke-0, white)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_3" d="M2.25 15.75L7.5 10.5" stroke="var(--stroke-0, white)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_4" d="M6.75 15.75H2.25V11.25" stroke="var(--stroke-0, white)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 778 B |
5
public/imgs/icon.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M9 18L15 12L9 6" stroke="var(--stroke-0, #B1B8C0)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 336 B |
BIN
public/imgs/image-1.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
public/imgs/image-2.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
3
public/imgs/line-58.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 16 1" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line id="Line 58" y1="0.25" x2="16" y2="0.25" stroke="var(--stroke-0, #B1B8C0)" stroke-width="0.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 278 B |
7
public/imgs/music-audio-play.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Group">
|
||||||
|
<g id="Path">
|
||||||
|
</g>
|
||||||
|
<path id="Path_2" fill-rule="evenodd" clip-rule="evenodd" d="M12.5735 8.27696L22.7652 14.3048C24.0543 15.0662 24.0543 16.9328 22.7652 17.6942L12.5735 23.7221C11.2608 24.4992 9.60156 23.5514 9.60156 22.0261V9.97295C9.60156 8.4476 11.2608 7.49984 12.5735 8.27696V8.27696Z" fill="var(--fill-0, white)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 517 B |
8
public/imgs/play.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Group">
|
||||||
|
<g id="Path">
|
||||||
|
</g>
|
||||||
|
<path id="Path_2" fill-rule="evenodd" clip-rule="evenodd" d="M14 8V8C14 11.314 11.314 14 8 14V14C4.686 14 2 11.314 2 8V8C2 4.686 4.686 2 8 2V2C11.314 2 14 4.686 14 8Z" fill="var(--fill-0, #B1B8C0)" stroke="var(--stroke-0, #B1B8C0)" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Path_3" fill-rule="evenodd" clip-rule="evenodd" d="M7.29373 6.03871L9.88173 7.56938C10.2091 7.76271 10.2091 8.23671 9.88173 8.43005L7.29373 9.96071C6.9604 10.158 6.53906 9.91738 6.53906 9.53005V6.46938C6.53906 6.08205 6.9604 5.84138 7.29373 6.03871V6.03871Z" fill="var(--fill-0, white)" stroke="var(--stroke-0, white)" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 875 B |
BIN
public/imgs/thumb-a.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/imgs/thumb-b.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
public/imgs/thumb-c.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
public/imgs/thumb-d.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import MainLogoSvg from "./svgs/mainlogosvg";
|
import MainLogoSvg from "./svgs/mainlogosvg";
|
||||||
import ChevronDownSvg from "./svgs/chevrondownsvg";
|
import ChevronDownSvg from "./svgs/chevrondownsvg";
|
||||||
|
import apiService from "./lib/apiService";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ label: "교육 과정 목록", href: "/course-list" },
|
{ label: "교육 과정 목록", href: "/course-list" },
|
||||||
@@ -12,11 +13,94 @@ const NAV_ITEMS = [
|
|||||||
{ label: "공지사항", href: "/notices" },
|
{ label: "공지사항", href: "/notices" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const INSTRUCTOR_NAV_ITEMS = [
|
||||||
|
{ label: "강좌 현황", href: "/instructor/courses" },
|
||||||
|
{ label: "학습 자료실", href: "/admin/resources" },
|
||||||
|
{ label: "공지사항", href: "/admin/notices" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function NavBar() {
|
export default function NavBar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||||
|
const [userName, setUserName] = useState<string>('');
|
||||||
|
const [userRole, setUserRole] = useState<string>('');
|
||||||
const userMenuRef = useRef<HTMLDivElement | null>(null);
|
const userMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const userButtonRef = useRef<HTMLButtonElement | null>(null);
|
const userButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const hideCenterNav = /^\/[^/]+\/review$/.test(pathname);
|
||||||
|
const isAdminPage = pathname.startsWith('/admin');
|
||||||
|
const isInstructorPage = pathname.startsWith('/instructor');
|
||||||
|
|
||||||
|
// 사용자 정보 가져오기 및 비활성화 계정 체크
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function fetchUserInfo() {
|
||||||
|
try {
|
||||||
|
// localStorage와 쿠키 모두에서 토큰 확인
|
||||||
|
const localStorageToken = localStorage.getItem('token');
|
||||||
|
const cookieToken = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('token='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
const token = localStorageToken || cookieToken;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// localStorage에 토큰이 없고 쿠키에만 있으면 localStorage에도 저장 (동기화)
|
||||||
|
if (!localStorageToken && cookieToken) {
|
||||||
|
localStorage.setItem('token', cookieToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiService.getCurrentUser();
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
// 토큰이 만료되었거나 유효하지 않은 경우
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
// 로그인 페이지가 아닐 때만 리다이렉트
|
||||||
|
if (isMounted && pathname !== '/login') {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// 계정 상태 확인
|
||||||
|
const userStatus = data.status || data.userStatus;
|
||||||
|
if (userStatus === 'INACTIVE' || userStatus === 'inactive') {
|
||||||
|
// 비활성화된 계정인 경우 로그아웃 처리
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
// 로그인 페이지가 아닐 때만 리다이렉트
|
||||||
|
if (isMounted && pathname !== '/login') {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
const role = data.role || data.userRole || '';
|
||||||
|
setUserRole(role);
|
||||||
|
if (data.name) {
|
||||||
|
setUserName(data.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('사용자 정보 조회 오류:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchUserInfo();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [router, pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isUserMenuOpen) return;
|
if (!isUserMenuOpen) return;
|
||||||
@@ -46,67 +130,147 @@ export default function NavBar() {
|
|||||||
<header className="bg-[#060958] h-20">
|
<header className="bg-[#060958] h-20">
|
||||||
<div className="mx-auto flex h-full w-full max-w-[1440px] items-center justify-between px-8">
|
<div className="mx-auto flex h-full w-full max-w-[1440px] items-center justify-between px-8">
|
||||||
<div className="flex flex-1 items-center gap-9">
|
<div className="flex flex-1 items-center gap-9">
|
||||||
<Link href="/" aria-label="XR LMS 홈" className="flex items-center gap-2">
|
<Link
|
||||||
|
href={(userRole === 'ADMIN' || userRole === 'admin') ? "/instructor" : "/"}
|
||||||
|
aria-label="XR LMS 홈"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
<MainLogoSvg width={46.703} height={36} />
|
<MainLogoSvg width={46.703} height={36} />
|
||||||
<span className="text-2xl font-extrabold leading-[1.45] text-white">XR LMS</span>
|
<span className="text-2xl font-extrabold leading-[1.45] text-white">XR LMS</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex h-full items-center">
|
{!hideCenterNav && !isAdminPage && isInstructorPage && (
|
||||||
{NAV_ITEMS.map((item) => {
|
<nav className="flex h-full items-center">
|
||||||
return (
|
{INSTRUCTOR_NAV_ITEMS.map((item) => {
|
||||||
<Link
|
return (
|
||||||
key={item.href}
|
<Link
|
||||||
href={item.href}
|
key={item.href}
|
||||||
className={[
|
href={item.href}
|
||||||
"px-4 py-2 text-[16px] font-semibold text-white",
|
className={["px-4 py-2 text-[16px] font-semibold text-white"].join(" ")}
|
||||||
].join(" ")}
|
>
|
||||||
>
|
{item.label}
|
||||||
{item.label}
|
</Link>
|
||||||
</Link>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
<Link
|
||||||
</nav>
|
href="/admin"
|
||||||
|
className={["px-4 py-2 text-[16px] font-semibold text-white"].join(" ")}
|
||||||
|
>
|
||||||
|
관리자페이지
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
{!hideCenterNav && !isAdminPage && !isInstructorPage && (
|
||||||
|
<nav className="flex h-full items-center">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={["px-4 py-2 text-[16px] font-semibold text-white"].join(" ")}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex items-center gap-2">
|
<div className="relative flex items-center gap-2">
|
||||||
<Link href="/menu/courses" className="px-4 py-2 text-[16px] font-semibold text-white">
|
{(isAdminPage || isInstructorPage) ? (
|
||||||
내 강좌실
|
<>
|
||||||
</Link>
|
<button
|
||||||
<button
|
ref={userButtonRef}
|
||||||
ref={userButtonRef}
|
type="button"
|
||||||
type="button"
|
onClick={() => setIsUserMenuOpen((v) => !v)}
|
||||||
onClick={() => setIsUserMenuOpen((v) => !v)}
|
aria-haspopup="menu"
|
||||||
aria-haspopup="menu"
|
aria-expanded={isUserMenuOpen}
|
||||||
aria-expanded={isUserMenuOpen}
|
className="flex items-center gap-1 px-4 py-2 text-[16px] font-semibold text-white cursor-pointer"
|
||||||
className="flex items-center gap-1 px-4 py-2 text-[16px] font-semibold text-white cursor-pointer"
|
|
||||||
>
|
|
||||||
김이름
|
|
||||||
<ChevronDownSvg
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className={["transition-transform", isUserMenuOpen ? "rotate-180" : "rotate-0"].join(" ")}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{isUserMenuOpen && (
|
|
||||||
<div
|
|
||||||
ref={userMenuRef}
|
|
||||||
role="menu"
|
|
||||||
aria-label="사용자 메뉴"
|
|
||||||
className="absolute right-0 top-full mt-2 bg-white rounded-lg shadow-[0_0_8px_0_rgba(0,0,0,0.25)] p-3 z-50"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
role="menuitem"
|
|
||||||
href="/menu/account"
|
|
||||||
className="block w-full h-10 px-2 rounded-lg text-left text-[#333C47] text-[16px] font-medium leading-normal hover:bg-[rgba(236,240,255,0.5)] focus:bg-[rgba(236,240,255,0.5)] outline-none"
|
|
||||||
onClick={() => setIsUserMenuOpen(false)}
|
|
||||||
>
|
>
|
||||||
내 정보 수정
|
{userName || '사용자'}
|
||||||
|
<ChevronDownSvg
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className={["transition-transform", isUserMenuOpen ? "rotate-180" : "rotate-0"].join(" ")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{isUserMenuOpen && (
|
||||||
|
<div
|
||||||
|
ref={userMenuRef}
|
||||||
|
role="menu"
|
||||||
|
aria-label="사용자 메뉴"
|
||||||
|
className="absolute right-0 top-full mt-2 bg-white rounded-lg shadow-[0_0_8px_0_rgba(0,0,0,0.25)] p-3 z-50"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
role="menuitem"
|
||||||
|
className="flex items-center w-[136px] h-10 px-2 rounded-lg text-left text-[#333C47] text-[16px] font-medium leading-normal hover:bg-[rgba(236,240,255,0.5)] focus:bg-[rgba(236,240,255,0.5)] outline-none"
|
||||||
|
onClick={() => {
|
||||||
|
// 로컬 스토리지에서 토큰 제거
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
// 쿠키에서 토큰 제거
|
||||||
|
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
// 로그인 페이지로 리다이렉트
|
||||||
|
window.location.href = '/login';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link href="/menu/courses" className="px-4 py-2 text-[16px] font-semibold text-white">
|
||||||
|
내 강좌실
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
role="menuitem"
|
ref={userButtonRef}
|
||||||
className="w-full h-10 px-2 rounded-lg text-left text-[#333C47] text-[16px] font-medium leading-normal hover:bg-[rgba(236,240,255,0.5)] focus:bg-[rgba(236,240,255,0.5)] outline-none"
|
type="button"
|
||||||
|
onClick={() => setIsUserMenuOpen((v) => !v)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isUserMenuOpen}
|
||||||
|
className="flex items-center gap-1 px-4 py-2 text-[16px] font-semibold text-white cursor-pointer"
|
||||||
>
|
>
|
||||||
로그아웃
|
{userName || '사용자'}
|
||||||
|
<ChevronDownSvg
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className={["transition-transform", isUserMenuOpen ? "rotate-180" : "rotate-0"].join(" ")}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{isUserMenuOpen && (
|
||||||
|
<div
|
||||||
|
ref={userMenuRef}
|
||||||
|
role="menu"
|
||||||
|
aria-label="사용자 메뉴"
|
||||||
|
className="absolute right-0 top-full mt-2 bg-white rounded-lg shadow-[0_0_8px_0_rgba(0,0,0,0.25)] p-3 z-50"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
role="menuitem"
|
||||||
|
href="/menu/account"
|
||||||
|
className="flex items-center w-[136px] h-10 px-2 rounded-lg text-left text-[#333C47] text-[16px] font-medium leading-normal hover:bg-[rgba(236,240,255,0.5)] focus:bg-[rgba(236,240,255,0.5)] outline-nonq"
|
||||||
|
onClick={() => setIsUserMenuOpen(false)}
|
||||||
|
>
|
||||||
|
내 정보 수정
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
role="menuitem"
|
||||||
|
className="flex items-center w-[136px] h-10 px-2 rounded-lg text-left text-[#333C47] text-[16px] font-medium leading-normal hover:bg-[rgba(236,240,255,0.5)] focus:bg-[rgba(236,240,255,0.5)] outline-none"
|
||||||
|
onClick={() => {
|
||||||
|
// 로컬 스토리지에서 토큰 제거
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
// 쿠키에서 토큰 제거 (미들웨어에서 확인하므로)
|
||||||
|
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
// 로그인 페이지로 리다이렉트
|
||||||
|
window.location.href = '/login';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
9
src/app/[lessonCode]/review/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import FigmaSelectedLessonPage from "../../menu/courses/lessons/FigmaSelectedLessonPage";
|
||||||
|
|
||||||
|
export default function SimpleReviewPage() {
|
||||||
|
return <FigmaSelectedLessonPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
606
src/app/admin/banner/BannerRegistrationModal.tsx
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import ModalCloseSvg from "@/app/svgs/closexsvg";
|
||||||
|
import CloseXOSvg from "@/app/svgs/closexo";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
|
export type Banner = {
|
||||||
|
id: number;
|
||||||
|
order: number;
|
||||||
|
imageUrl: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
registeredDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave?: (title: string, description: string, imageKey?: string) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
editingBanner?: Banner | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BannerRegistrationModal({ open, onClose, onSave, onDelete, editingBanner }: Props) {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
|
||||||
|
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [isImageDeleted, setIsImageDeleted] = useState(false);
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// previewUrl 변경 시 이전 Blob URL 정리
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewUrl && previewUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [previewUrl]);
|
||||||
|
|
||||||
|
// 수정 모드일 때 기존 데이터 채우기
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && editingBanner) {
|
||||||
|
setTitle(editingBanner.title);
|
||||||
|
setDescription(editingBanner.description);
|
||||||
|
|
||||||
|
// 수정 모드일 때 이미지 로드
|
||||||
|
if (editingBanner.imageUrl) {
|
||||||
|
setIsImageDeleted(false);
|
||||||
|
setSelectedImage(null);
|
||||||
|
setPreviewUrl(editingBanner.imageUrl);
|
||||||
|
} else {
|
||||||
|
setIsImageDeleted(false);
|
||||||
|
setSelectedImage(null);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
}
|
||||||
|
} else if (!open) {
|
||||||
|
setTitle("");
|
||||||
|
setDescription("");
|
||||||
|
setErrors({});
|
||||||
|
setIsDeleteConfirmOpen(false);
|
||||||
|
setSelectedImage(null);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setIsDragging(false);
|
||||||
|
setIsImageDeleted(false);
|
||||||
|
}
|
||||||
|
}, [open, editingBanner]);
|
||||||
|
|
||||||
|
// 모달 클릭 시 이벤트 전파 방지
|
||||||
|
const handleModalClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장 버튼 클릭 핸들러
|
||||||
|
const handleSave = async () => {
|
||||||
|
const nextErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!title.trim()) {
|
||||||
|
nextErrors.title = "배너 제목을 입력해 주세요.";
|
||||||
|
}
|
||||||
|
if (!description.trim()) {
|
||||||
|
nextErrors.description = "배너 설명을 입력해 주세요.";
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(nextErrors);
|
||||||
|
|
||||||
|
if (Object.keys(nextErrors).length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSaving) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.submit;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let imageKey: string | null = null;
|
||||||
|
|
||||||
|
// 새 이미지가 선택된 경우 업로드
|
||||||
|
if (selectedImage) {
|
||||||
|
try {
|
||||||
|
const uploadResponse = await apiService.uploadFile(selectedImage);
|
||||||
|
|
||||||
|
if (uploadResponse.data) {
|
||||||
|
imageKey = uploadResponse.data.imageKey
|
||||||
|
|| uploadResponse.data.key
|
||||||
|
|| uploadResponse.data.id
|
||||||
|
|| uploadResponse.data.fileKey
|
||||||
|
|| uploadResponse.data.fileId
|
||||||
|
|| (uploadResponse.data.data && (uploadResponse.data.data.imageKey || uploadResponse.data.data.key))
|
||||||
|
|| null;
|
||||||
|
}
|
||||||
|
} catch (uploadError) {
|
||||||
|
const errorMessage = uploadError instanceof Error ? uploadError.message : '이미지 업로드 중 오류가 발생했습니다.';
|
||||||
|
console.error('이미지 업로드 오류:', errorMessage);
|
||||||
|
setErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
image: '이미지 업로드 중 오류가 발생했습니다. 이미지 없이 계속 진행됩니다.',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 배너 API가 추가되면 아래 주석을 해제하고 실제 API 호출
|
||||||
|
/*
|
||||||
|
if (editingBanner && editingBanner.id) {
|
||||||
|
// 수정 모드
|
||||||
|
await apiService.updateBanner(editingBanner.id, {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
imageKey: isImageDeleted ? "null" : (imageKey || editingBanner.imageUrl || "null"),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 등록 모드
|
||||||
|
await apiService.createBanner({
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
imageKey: imageKey || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 성공 시 onSave 콜백 호출 및 모달 닫기
|
||||||
|
if (onSave) {
|
||||||
|
onSave(title.trim(), description.trim(), imageKey || undefined);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
|
||||||
|
console.error('배너 저장 오류:', errorMessage);
|
||||||
|
setErrors({ submit: errorMessage });
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 버튼 클릭 핸들러
|
||||||
|
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDeleteConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 확인 핸들러
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!editingBanner || !editingBanner.id) {
|
||||||
|
console.error('삭제할 배너 정보가 없습니다.');
|
||||||
|
setErrors({ submit: '삭제할 배너 정보가 없습니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDeleting) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.submit;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: 배너 API가 추가되면 아래 주석을 해제하고 실제 API 호출
|
||||||
|
// await apiService.deleteBanner(editingBanner.id);
|
||||||
|
|
||||||
|
// 성공 시 모달 닫기 및 콜백 호출
|
||||||
|
setIsDeleteConfirmOpen(false);
|
||||||
|
if (onDelete) {
|
||||||
|
onDelete();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '배너 삭제 중 오류가 발생했습니다.';
|
||||||
|
console.error('배너 삭제 실패:', errorMessage);
|
||||||
|
setErrors({ submit: errorMessage });
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 취소 핸들러
|
||||||
|
const handleDeleteCancel = () => {
|
||||||
|
setIsDeleteConfirmOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 유효성 검사
|
||||||
|
const validateImageFile = (file: File): string | null => {
|
||||||
|
const maxSize = 30 * 1024 * 1024; // 30MB
|
||||||
|
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg'];
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
return "PNG 또는 JPG 파일만 업로드 가능합니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return "파일 크기는 30MB 미만이어야 합니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이미지 파일 처리
|
||||||
|
const handleImageFile = (file: File) => {
|
||||||
|
const error = validateImageFile(file);
|
||||||
|
if (error) {
|
||||||
|
setErrors((prev) => ({ ...prev, image: error }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedImage(file);
|
||||||
|
setIsImageDeleted(false);
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.image;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 기존 previewUrl이 Blob URL인 경우 메모리 해제
|
||||||
|
if (previewUrl && previewUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미리보기 URL 생성
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setPreviewUrl(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 선택 핸들러
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleImageFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 클릭으로 파일 선택
|
||||||
|
const handleImageAreaClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드래그 오버 핸들러
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드래그 리브 핸들러
|
||||||
|
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드롭 핸들러
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleImageFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이미지 삭제 핸들러
|
||||||
|
const handleRemoveImage = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedImage(null);
|
||||||
|
// previewUrl이 Blob URL인 경우 메모리 해제
|
||||||
|
if (previewUrl && previewUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setIsImageDeleted(true);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.image;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open && !isDeleteConfirmOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 메인 모달 */}
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
aria-hidden={!open}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className="relative z-10 shadow-xl"
|
||||||
|
onClick={handleModalClick}
|
||||||
|
>
|
||||||
|
<div className="bg-white border border-[var(--color-neutral-40)] rounded-[12px] w-full min-w-[480px] max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-[10px] p-6">
|
||||||
|
<h2 className="text-[20px] font-bold leading-normal text-[var(--color-neutral-700)]">
|
||||||
|
{editingBanner ? "배너 수정" : "배너 등록"}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-6 h-6 flex items-center justify-center cursor-pointer hover:opacity-80 shrink-0"
|
||||||
|
aria-label="닫기"
|
||||||
|
>
|
||||||
|
<ModalCloseSvg />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Container */}
|
||||||
|
<div className="px-6 py-0">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* 배너 제목 */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-[15px] font-semibold leading-normal text-[var(--color-text-label)] w-[100px]">
|
||||||
|
배너 제목<span className="text-[var(--color-error)]">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTitle(e.target.value);
|
||||||
|
if (errors.title) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.title;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="배너 제목을 입력해 주세요."
|
||||||
|
className={`h-[40px] px-3 py-2 border rounded-[8px] bg-white text-[16px] font-normal leading-normal text-[var(--color-text-title)] placeholder:text-[var(--color-text-placeholder-alt)] focus:outline-none ${
|
||||||
|
errors.title
|
||||||
|
? "border-[var(--color-error)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
|
||||||
|
: "border-[var(--color-neutral-40)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{errors.title && (
|
||||||
|
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.title}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 배너 설명 */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-[15px] font-semibold leading-normal text-[var(--color-text-label)] w-[100px]">
|
||||||
|
배너 설명<span className="text-[var(--color-error)]">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDescription(e.target.value);
|
||||||
|
if (errors.description) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.description;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="배너 설명을 입력해 주세요."
|
||||||
|
rows={4}
|
||||||
|
className={`px-3 py-2 border rounded-[8px] bg-white text-[16px] font-normal leading-normal text-[var(--color-text-title)] placeholder:text-[var(--color-text-placeholder-alt)] focus:outline-none resize-none ${
|
||||||
|
errors.description
|
||||||
|
? "border-[var(--color-error)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
|
||||||
|
: "border-[var(--color-neutral-40)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{errors.description && (
|
||||||
|
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 배너 이미지 */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-[15px] font-semibold leading-normal text-[var(--color-text-label)] whitespace-pre">
|
||||||
|
배너 이미지
|
||||||
|
</label>
|
||||||
|
<span className="text-[13px] font-normal leading-[1.4] text-[var(--color-text-meta)]">
|
||||||
|
30MB 미만의 PNG, JPG
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/jpg"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
aria-label="이미지 파일 선택"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
onClick={handleImageAreaClick}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={`border border-dashed min-h-[192px] rounded-[8px] flex flex-col items-center justify-center gap-3 cursor-pointer transition-colors px-0 relative overflow-hidden ${
|
||||||
|
isDragging
|
||||||
|
? "bg-blue-50 border-blue-300"
|
||||||
|
: previewUrl
|
||||||
|
? "bg-white border-[var(--color-neutral-40)]"
|
||||||
|
: "bg-gray-50 border-[var(--color-neutral-40)] hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{previewUrl ? (
|
||||||
|
<>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="relative flex items-start">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="미리보기"
|
||||||
|
className="h-[160px] w-auto object-contain"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveImage}
|
||||||
|
className="w-[18px] h-[18px] flex items-center justify-center ml-1 z-10 hover:opacity-80 transition-opacity shrink-0"
|
||||||
|
aria-label="이미지 삭제"
|
||||||
|
>
|
||||||
|
<CloseXOSvg width={18} height={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-black/0 hover:bg-black/20 transition-colors flex items-center justify-center my-4">
|
||||||
|
<div className="text-center opacity-0 hover:opacity-100 transition-opacity">
|
||||||
|
<p className="text-[14px] font-normal leading-normal text-white">
|
||||||
|
클릭하여 이미지 변경
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-10 h-10 flex items-center justify-center shrink-0">
|
||||||
|
<svg
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 40 40"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M20 13.3333V26.6667M13.3333 20H26.6667"
|
||||||
|
stroke="var(--color-text-meta)"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-[14px] font-normal leading-normal text-[var(--color-text-meta)] whitespace-pre">
|
||||||
|
(클릭하여 이미지 업로드)
|
||||||
|
<br aria-hidden="true" />
|
||||||
|
미첨부 시 기본 이미지가 노출됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.image && (
|
||||||
|
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.image}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 표시 */}
|
||||||
|
{errors.submit && (
|
||||||
|
<div className="px-6 pb-2">
|
||||||
|
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.submit}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions Container */}
|
||||||
|
<div className="flex flex-col gap-8 h-[96px] items-center p-6">
|
||||||
|
<div className="flex items-center justify-center gap-3 w-full">
|
||||||
|
{editingBanner && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
className="h-[48px] px-4 rounded-[10px] bg-[#fef2f2] text-[16px] font-semibold leading-normal text-[var(--color-error)] w-[136px] hover:bg-[#fae6e6] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-[48px] px-4 rounded-[10px] bg-[var(--color-bg-gray-light)] text-[16px] font-semibold leading-normal text-[var(--color-basic-text)] w-[136px] hover:bg-[var(--color-bg-gray-hover)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="h-[48px] px-4 rounded-[10px] bg-[var(--color-active-button)] text-[16px] font-semibold leading-normal text-white w-[136px] hover:bg-[var(--color-active-button-hover)] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSaving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
{isDeleteConfirmOpen && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={handleDeleteCancel}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end">
|
||||||
|
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
|
||||||
|
<div className="flex gap-[8px] items-start w-full">
|
||||||
|
<p className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">
|
||||||
|
배너를 삭제하시겠습니까?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-start w-full">
|
||||||
|
<div className="text-[15px] font-normal leading-[1.5] text-[#4c5561]">
|
||||||
|
<p className="mb-0">삭제 버튼을 누르면 배너 정보가 삭제됩니다.</p>
|
||||||
|
<p>정말 삭제하시겠습니까?</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{errors.submit && (
|
||||||
|
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.submit}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center justify-end shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteCancel}
|
||||||
|
className="bg-[#f1f3f5] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e9ecef] cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-[#4c5561] text-center whitespace-pre">
|
||||||
|
취소
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-[#f64c4c] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e63939] cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-white text-center whitespace-pre">
|
||||||
|
{isDeleting ? '삭제 중...' : '삭제'}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
180
src/app/admin/banner/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import AdminSidebar from "@/app/components/AdminSidebar";
|
||||||
|
import BannerRegistrationModal, { type Banner } from "./BannerRegistrationModal";
|
||||||
|
|
||||||
|
export default function AdminBannerPage() {
|
||||||
|
// TODO: 나중에 실제 데이터로 교체
|
||||||
|
const [banners, setBanners] = useState<Banner[]>([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
order: 1,
|
||||||
|
imageUrl: "http://localhost:3845/assets/43be88ae6f992fc221d0d9c29e82073e7b202f46.png",
|
||||||
|
title: "XR 교육 플랫폼에 오신 것을 환영합니다",
|
||||||
|
description: "다양한 강좌와 함께 성장하는 학습 경험을 시작하세요.",
|
||||||
|
registeredDate: "2025-09-10",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingBanner, setEditingBanner] = useState<Banner | null>(null);
|
||||||
|
|
||||||
|
const handleRegister = () => {
|
||||||
|
setEditingBanner(null);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalClose = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingBanner(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveBanner = (title: string, description: string, imageKey?: string) => {
|
||||||
|
// TODO: API가 추가되면 실제로 배너를 저장하고 리스트를 새로고침
|
||||||
|
console.log('배너 저장:', { title, description, imageKey });
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingBanner(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteBanner = () => {
|
||||||
|
// TODO: API가 추가되면 실제로 배너를 삭제하고 리스트를 새로고침
|
||||||
|
console.log('배너 삭제');
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingBanner(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowClick = (banner: Banner) => {
|
||||||
|
setEditingBanner(banner);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
{/* 메인 레이아웃 */}
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col px-8">
|
||||||
|
{/* 제목 영역 */}
|
||||||
|
<div className="h-[100px] flex items-center">
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
배너 관리
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 콘텐츠 영역 */}
|
||||||
|
<div className="flex-1 pt-8 flex flex-col gap-4">
|
||||||
|
{/* 상단 정보 및 버튼 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[15px] font-medium leading-[1.5] text-[#333c47]">
|
||||||
|
총 {banners.length}건
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRegister}
|
||||||
|
className="bg-[#1f2b91] text-white text-[16px] font-semibold leading-[1.5] px-4 py-2 rounded-lg hover:bg-[#1a2478] transition-colors"
|
||||||
|
>
|
||||||
|
등록하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
{banners.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
|
||||||
|
현재 관리할 수 있는 항목이 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border border-[#dee1e6] rounded-lg overflow-hidden">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{/* 테이블 헤더 */}
|
||||||
|
<div className="bg-gray-50 flex h-12">
|
||||||
|
<div className="border-r border-[#dee1e6] flex items-center justify-center px-4 py-3 shrink-0 w-[57px]">
|
||||||
|
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">
|
||||||
|
순서
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-r border-[#dee1e6] flex items-center px-4 py-3 shrink-0 w-[240px]">
|
||||||
|
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">
|
||||||
|
배너 이미지
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-r border-[#dee1e6] flex items-center px-4 py-3 flex-1 min-w-0">
|
||||||
|
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">
|
||||||
|
배너 문구
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4 py-3 shrink-0 w-[140px]">
|
||||||
|
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">
|
||||||
|
등록일
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 바디 */}
|
||||||
|
{banners.map((banner) => (
|
||||||
|
<div
|
||||||
|
key={banner.id}
|
||||||
|
className="border-t border-[#dee1e6] flex cursor-pointer hover:bg-[#F5F7FF] transition-colors"
|
||||||
|
onClick={() => handleRowClick(banner)}
|
||||||
|
>
|
||||||
|
<div className="border-r border-[#dee1e6] flex items-center justify-center px-4 py-3 shrink-0 w-[57px]">
|
||||||
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">
|
||||||
|
{banner.order}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-r border-[#dee1e6] flex items-center justify-center px-4 py-3 shrink-0 w-[240px]">
|
||||||
|
<div className="h-[120px] w-[208px] rounded overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={banner.imageUrl}
|
||||||
|
alt={banner.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-r border-[#dee1e6] flex items-center px-4 py-3 flex-1 min-w-0">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-[#1b2027]">
|
||||||
|
{banner.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[14px] font-medium leading-[1.5] text-[#333c47]">
|
||||||
|
{banner.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4 py-3 shrink-0 w-[140px]">
|
||||||
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">
|
||||||
|
{banner.registeredDate}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BannerRegistrationModal
|
||||||
|
open={isModalOpen}
|
||||||
|
onClose={handleModalClose}
|
||||||
|
onSave={handleSaveBanner}
|
||||||
|
onDelete={handleDeleteBanner}
|
||||||
|
editingBanner={editingBanner}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
145
src/app/admin/certificates/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import AdminSidebar from "@/app/components/AdminSidebar";
|
||||||
|
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
||||||
|
|
||||||
|
export default function AdminCertificatesPage() {
|
||||||
|
// TODO: 나중에 실제 데이터로 교체
|
||||||
|
const items: any[] = [];
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const totalPages = Math.ceil(items.length / ITEMS_PER_PAGE);
|
||||||
|
const paginatedItems = useMemo(() => {
|
||||||
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||||
|
return items.slice(startIndex, endIndex);
|
||||||
|
}, [items, currentPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
{/* 메인 레이아웃 */}
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col px-8">
|
||||||
|
{/* 제목 영역 */}
|
||||||
|
<div className="h-[100px] flex items-center">
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
수료증 발급/검증키 관리
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 콘텐츠 영역 */}
|
||||||
|
<div className="flex-1 pt-8 flex flex-col">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
|
||||||
|
현재 관리할 수 있는 항목이 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* TODO: 테이블 또는 리스트를 여기에 추가 */}
|
||||||
|
|
||||||
|
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
|
||||||
|
{items.length > ITEMS_PER_PAGE && (() => {
|
||||||
|
// 페이지 번호를 10단위로 표시
|
||||||
|
const pageGroup = Math.floor((currentPage - 1) / 10);
|
||||||
|
const startPage = pageGroup * 10 + 1;
|
||||||
|
const endPage = Math.min(startPage + 9, totalPages);
|
||||||
|
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-8 pb-[32px] flex items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center gap-[8px]">
|
||||||
|
{/* First (맨 앞으로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
aria-label="맨 앞 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
aria-label="이전 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Numbers */}
|
||||||
|
{visiblePages.map((n) => {
|
||||||
|
const active = n === currentPage;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(n)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={[
|
||||||
|
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||||
|
active ? 'bg-[#ecf0ff]' : 'bg-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
aria-label="다음 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Last (맨 뒤로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
aria-label="맨 뒤 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
850
src/app/admin/courses/CourseRegistrationModal.tsx
Normal file
@@ -0,0 +1,850 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect, useMemo } from "react";
|
||||||
|
import ModalCloseSvg from "@/app/svgs/closexsvg";
|
||||||
|
import DropdownIcon from "@/app/svgs/dropdownicon";
|
||||||
|
import CloseXOSvg from "@/app/svgs/closexo";
|
||||||
|
import { type UserRow } from "@/app/admin/id/mockData";
|
||||||
|
import { type Course } from "./mockData";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave?: (courseName: string, instructorName: string) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
editingCourse?: Course | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CourseRegistrationModal({ open, onClose, onSave, onDelete, editingCourse }: Props) {
|
||||||
|
const [courseName, setCourseName] = useState("");
|
||||||
|
const [instructorId, setInstructorId] = useState<string>("");
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
|
||||||
|
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [isImageDeleted, setIsImageDeleted] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 강사 목록 가져오기
|
||||||
|
const [instructors, setInstructors] = useState<UserRow[]>([]);
|
||||||
|
const [isLoadingInstructors, setIsLoadingInstructors] = useState(false);
|
||||||
|
|
||||||
|
// 강사 목록 로드 함수
|
||||||
|
const loadInstructors = async () => {
|
||||||
|
if (isLoadingInstructors) return; // 이미 로딩 중이면 중복 호출 방지
|
||||||
|
|
||||||
|
setIsLoadingInstructors(true);
|
||||||
|
try {
|
||||||
|
// 외부 API 호출 - type이 'ADMIN'인 사용자만 조회
|
||||||
|
const response = await apiService.getUsersCompact({ type: 'ADMIN', limit: 10 });
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||||
|
let usersArray: any[] = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
usersArray = data;
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
usersArray = data.items || data.users || data.data || data.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답 데이터를 UserRow 형식으로 변환
|
||||||
|
const transformedUsers: UserRow[] = usersArray.length > 0
|
||||||
|
? usersArray.map((user: any) => {
|
||||||
|
// 가입일을 YYYY-MM-DD 형식으로 변환
|
||||||
|
const formatDate = (dateString: string | null | undefined): string => {
|
||||||
|
if (!dateString) return new Date().toISOString().split('T')[0];
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
} catch {
|
||||||
|
return new Date().toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// null 값을 명시적으로 처리
|
||||||
|
const getValue = (value: any, fallback: string = '-') => {
|
||||||
|
if (value === null || value === undefined) return fallback;
|
||||||
|
if (typeof value === 'string' && value.trim() === '') return fallback;
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// status가 "ACTIVE"이면 활성화, 아니면 비활성화
|
||||||
|
const accountStatus: 'active' | 'inactive' =
|
||||||
|
user.status === 'ACTIVE' || user.status === 'active' ? 'active' : 'inactive';
|
||||||
|
|
||||||
|
// role 데이터 처리
|
||||||
|
let userRole: 'learner' | 'instructor' | 'admin' = 'learner'; // 기본값
|
||||||
|
if (user.role) {
|
||||||
|
const roleLower = String(user.role).toLowerCase();
|
||||||
|
if (roleLower === 'instructor' || roleLower === '강사') {
|
||||||
|
userRole = 'instructor';
|
||||||
|
} else if (roleLower === 'admin' || roleLower === '관리자') {
|
||||||
|
userRole = 'admin';
|
||||||
|
} else {
|
||||||
|
userRole = 'learner';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(user.id || user.userId || Math.random()),
|
||||||
|
joinDate: formatDate(user.createdAt || user.joinDate || user.join_date),
|
||||||
|
name: getValue(user.name || user.userName, '-'),
|
||||||
|
email: getValue(user.email || user.userEmail, '-'),
|
||||||
|
role: userRole,
|
||||||
|
status: accountStatus,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
setInstructors(transformedUsers);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('강사 목록 로드 오류:', error);
|
||||||
|
setInstructors([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingInstructors(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 선택된 강사 정보
|
||||||
|
const selectedInstructor = useMemo(() => {
|
||||||
|
return instructors.find(inst => inst.id === instructorId);
|
||||||
|
}, [instructors, instructorId]);
|
||||||
|
|
||||||
|
// previewUrl 변경 시 이전 Blob URL 정리
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewUrl && previewUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [previewUrl]);
|
||||||
|
|
||||||
|
// 수정 모드일 때 기존 데이터 채우기
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && editingCourse) {
|
||||||
|
setCourseName(editingCourse.courseName);
|
||||||
|
// 수정 모드일 때 강사 목록 자동 로드
|
||||||
|
loadInstructors();
|
||||||
|
|
||||||
|
// 수정 모드일 때 이미지 로드
|
||||||
|
if (editingCourse.imageKey) {
|
||||||
|
setIsImageDeleted(false); // 초기화
|
||||||
|
setSelectedImage(null); // 새 이미지 선택 초기화
|
||||||
|
const loadImage = async () => {
|
||||||
|
try {
|
||||||
|
const imageUrl = await apiService.getFile(editingCourse.imageKey!);
|
||||||
|
// 이미지가 있으면 previewUrl 설정, 없으면 null
|
||||||
|
if (imageUrl) {
|
||||||
|
setPreviewUrl(imageUrl);
|
||||||
|
} else {
|
||||||
|
setPreviewUrl(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('이미지 로드 오류:', error);
|
||||||
|
// 이미지 로드 실패 시 null로 설정
|
||||||
|
setPreviewUrl(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadImage();
|
||||||
|
} else {
|
||||||
|
setIsImageDeleted(false); // 초기화
|
||||||
|
setSelectedImage(null); // 새 이미지 선택 초기화
|
||||||
|
setPreviewUrl(null); // 이미지가 없으면 명시적으로 null 설정
|
||||||
|
}
|
||||||
|
} else if (!open) {
|
||||||
|
setCourseName("");
|
||||||
|
setInstructorId("");
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
setErrors({});
|
||||||
|
setIsDeleteConfirmOpen(false);
|
||||||
|
setSelectedImage(null);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setIsDragging(false);
|
||||||
|
setIsImageDeleted(false);
|
||||||
|
setInstructors([]); // 모달 닫을 때 강사 목록 초기화
|
||||||
|
}
|
||||||
|
}, [open, editingCourse]);
|
||||||
|
|
||||||
|
// instructors가 로드된 후 editingCourse의 강사 찾기
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && editingCourse && instructors.length > 0) {
|
||||||
|
const instructor = instructors.find(inst => inst.name === editingCourse.instructorName);
|
||||||
|
if (instructor) {
|
||||||
|
setInstructorId(instructor.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, editingCourse, instructors]);
|
||||||
|
|
||||||
|
// 외부 클릭 시 드롭다운 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDropdownOpen) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isDropdownOpen]);
|
||||||
|
|
||||||
|
// 모달 클릭 시 이벤트 전파 방지
|
||||||
|
const handleModalClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장 버튼 클릭 핸들러
|
||||||
|
const handleSave = async () => {
|
||||||
|
const nextErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!courseName.trim()) {
|
||||||
|
nextErrors.courseName = "교육 과정명을 입력해 주세요.";
|
||||||
|
}
|
||||||
|
if (!instructorId || !selectedInstructor) {
|
||||||
|
nextErrors.instructor = "강사를 선택해 주세요.";
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(nextErrors);
|
||||||
|
|
||||||
|
if (Object.keys(nextErrors).length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSaving) return; // 이미 저장 중이면 중복 호출 방지
|
||||||
|
|
||||||
|
// selectedInstructor가 없으면 종료
|
||||||
|
if (!selectedInstructor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.submit;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let imageKey: string | null = null;
|
||||||
|
|
||||||
|
// 새 이미지가 선택된 경우 업로드
|
||||||
|
if (selectedImage) {
|
||||||
|
try {
|
||||||
|
const uploadResponse = await apiService.uploadFile(selectedImage);
|
||||||
|
|
||||||
|
// 응답에서 imageKey 추출
|
||||||
|
// apiService.uploadFile은 ApiResponse<T> 형태로 반환하므로 uploadResponse.data가 실제 응답 데이터
|
||||||
|
if (uploadResponse.data) {
|
||||||
|
// 다양한 가능한 응답 구조 확인
|
||||||
|
imageKey = uploadResponse.data.imageKey
|
||||||
|
|| uploadResponse.data.key
|
||||||
|
|| uploadResponse.data.id
|
||||||
|
|| uploadResponse.data.fileKey
|
||||||
|
|| uploadResponse.data.fileId
|
||||||
|
|| (uploadResponse.data.data && (uploadResponse.data.data.imageKey || uploadResponse.data.data.key))
|
||||||
|
|| null;
|
||||||
|
}
|
||||||
|
} catch (uploadError) {
|
||||||
|
const errorMessage = uploadError instanceof Error ? uploadError.message : '이미지 업로드 중 오류가 발생했습니다.';
|
||||||
|
console.error('이미지 업로드 오류:', errorMessage);
|
||||||
|
setErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
image: '이미지 업로드 중 오류가 발생했습니다. 이미지 없이 계속 진행됩니다.',
|
||||||
|
}));
|
||||||
|
// 이미지 업로드 오류 발생해도 계속 진행 (선택사항)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody: {
|
||||||
|
title: string;
|
||||||
|
instructor: string;
|
||||||
|
imageKey?: string;
|
||||||
|
} = {
|
||||||
|
title: courseName.trim(),
|
||||||
|
instructor: selectedInstructor.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// imageKey 처리: 수정 모드에서는 항상 명시적으로 설정
|
||||||
|
if (editingCourse && editingCourse.id) {
|
||||||
|
// 수정 모드: 이미지가 삭제된 경우 "null" 문자열, 새 이미지가 있으면 값, 기존 이미지 유지 시 기존 값, 없으면 "null"
|
||||||
|
if (isImageDeleted) {
|
||||||
|
// 이미지가 삭제된 경우 무조건 "null" 문자열로 설정
|
||||||
|
requestBody.imageKey = "null";
|
||||||
|
} else if (imageKey) {
|
||||||
|
// 새 이미지가 업로드된 경우
|
||||||
|
requestBody.imageKey = imageKey;
|
||||||
|
} else if (editingCourse.imageKey) {
|
||||||
|
// 기존 이미지를 유지하는 경우
|
||||||
|
requestBody.imageKey = editingCourse.imageKey;
|
||||||
|
} else {
|
||||||
|
// 이미지가 없는 경우 "null" 문자열로 명시적으로 설정
|
||||||
|
requestBody.imageKey = "null";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 등록 모드: 새 이미지가 있으면 값, 없으면 undefined (선택사항)
|
||||||
|
if (imageKey) {
|
||||||
|
requestBody.imageKey = imageKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수정 모드인지 등록 모드인지 확인
|
||||||
|
if (editingCourse && editingCourse.id) {
|
||||||
|
// 수정 모드: PUT /subjects/{id}
|
||||||
|
try {
|
||||||
|
await apiService.updateSubject(editingCourse.id, requestBody);
|
||||||
|
|
||||||
|
// 성공 시 onSave 콜백 호출 및 모달 닫기
|
||||||
|
if (onSave && selectedInstructor) {
|
||||||
|
onSave(courseName.trim(), selectedInstructor.name);
|
||||||
|
}
|
||||||
|
onClose(); // 모달 닫기
|
||||||
|
} catch (updateError) {
|
||||||
|
const errorMessage = updateError instanceof Error ? updateError.message : '과목 수정 중 오류가 발생했습니다.';
|
||||||
|
console.error('과목 수정 실패:', errorMessage);
|
||||||
|
setErrors({ submit: errorMessage });
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 등록 모드: POST /subjects
|
||||||
|
try {
|
||||||
|
const createRequestBody: {
|
||||||
|
title: string;
|
||||||
|
instructor: string;
|
||||||
|
imageKey?: string;
|
||||||
|
} = {
|
||||||
|
title: courseName.trim(),
|
||||||
|
instructor: selectedInstructor.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// imageKey 처리: 등록 모드에서 새 이미지가 있으면 포함
|
||||||
|
if (imageKey) {
|
||||||
|
createRequestBody.imageKey = imageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiService.createSubject(createRequestBody);
|
||||||
|
|
||||||
|
// 성공 시 onSave 콜백 호출 및 모달 닫기
|
||||||
|
if (onSave && selectedInstructor) {
|
||||||
|
onSave(courseName.trim(), selectedInstructor.name);
|
||||||
|
}
|
||||||
|
onClose(); // 모달 닫기
|
||||||
|
} catch (createError) {
|
||||||
|
const errorMessage = createError instanceof Error ? createError.message : '과목 등록 중 오류가 발생했습니다.';
|
||||||
|
console.error('과목 등록 실패:', errorMessage);
|
||||||
|
setErrors({ submit: errorMessage });
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
|
||||||
|
console.error('과목 저장 오류:', errorMessage);
|
||||||
|
setErrors({ submit: errorMessage });
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 버튼 클릭 핸들러
|
||||||
|
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDeleteConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 확인 핸들러
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!editingCourse || !editingCourse.id) {
|
||||||
|
console.error('삭제할 교육과정 정보가 없습니다.');
|
||||||
|
setErrors({ submit: '삭제할 교육과정 정보가 없습니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDeleting) return; // 이미 삭제 중이면 중복 호출 방지
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.submit;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiService.deleteSubject(editingCourse.id);
|
||||||
|
|
||||||
|
// 성공 시 모달 닫기 및 콜백 호출
|
||||||
|
setIsDeleteConfirmOpen(false);
|
||||||
|
if (onDelete) {
|
||||||
|
onDelete();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '교육과정 삭제 중 오류가 발생했습니다.';
|
||||||
|
console.error('교육과정 삭제 실패:', errorMessage);
|
||||||
|
setErrors({ submit: errorMessage });
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 취소 핸들러
|
||||||
|
const handleDeleteCancel = () => {
|
||||||
|
setIsDeleteConfirmOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 유효성 검사
|
||||||
|
const validateImageFile = (file: File): string | null => {
|
||||||
|
const maxSize = 30 * 1024 * 1024; // 30MB
|
||||||
|
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg'];
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
return "PNG 또는 JPG 파일만 업로드 가능합니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return "파일 크기는 30MB 미만이어야 합니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이미지 파일 처리
|
||||||
|
const handleImageFile = (file: File) => {
|
||||||
|
const error = validateImageFile(file);
|
||||||
|
if (error) {
|
||||||
|
setErrors((prev) => ({ ...prev, image: error }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedImage(file);
|
||||||
|
setIsImageDeleted(false); // 새 이미지 선택 시 삭제 상태 해제
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.image;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 기존 previewUrl이 Blob URL인 경우 메모리 해제
|
||||||
|
if (previewUrl && previewUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미리보기 URL 생성
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setPreviewUrl(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 선택 핸들러
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleImageFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 클릭으로 파일 선택
|
||||||
|
const handleImageAreaClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드래그 오버 핸들러
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드래그 리브 핸들러
|
||||||
|
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드롭 핸들러
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleImageFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이미지 삭제 핸들러
|
||||||
|
const handleRemoveImage = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedImage(null);
|
||||||
|
// previewUrl이 Blob URL인 경우 메모리 해제
|
||||||
|
if (previewUrl && previewUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setIsImageDeleted(true); // 이미지 삭제 상태 설정
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.image;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open && !isDeleteConfirmOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 메인 모달 */}
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
aria-hidden={!open}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className="relative z-10 shadow-xl"
|
||||||
|
onClick={handleModalClick}
|
||||||
|
>
|
||||||
|
<div className="bg-white border border-[var(--color-neutral-40)] rounded-[12px] w-full min-w-[480px] max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-[10px] p-6">
|
||||||
|
<h2 className="text-[20px] font-bold leading-normal text-[var(--color-neutral-700)]">
|
||||||
|
{editingCourse ? "교육과정 수정" : "과목 등록"}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-6 h-6 flex items-center justify-center cursor-pointer hover:opacity-80 shrink-0"
|
||||||
|
aria-label="닫기"
|
||||||
|
>
|
||||||
|
<ModalCloseSvg />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Container */}
|
||||||
|
<div className="px-6 py-0">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* 교육 과정명 */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-[15px] font-semibold leading-normal text-[var(--color-text-label)] w-[100px]">
|
||||||
|
교육 과정명<span className="text-[var(--color-error)]">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={courseName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCourseName(e.target.value);
|
||||||
|
if (errors.courseName) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.courseName;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="교육 과정명을 입력해 주세요."
|
||||||
|
className={`h-[40px] px-3 py-2 border rounded-[8px] bg-white text-[16px] font-normal leading-normal text-[var(--color-text-title)] placeholder:text-[var(--color-text-placeholder-alt)] focus:outline-none ${
|
||||||
|
errors.courseName
|
||||||
|
? "border-[var(--color-error)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
|
||||||
|
: "border-[var(--color-neutral-40)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{errors.courseName && (
|
||||||
|
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.courseName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 강사 */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-[15px] font-semibold leading-normal text-[var(--color-text-label)] w-[100px]">
|
||||||
|
강사<span className="text-[var(--color-error)]">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
const willOpen = !isDropdownOpen;
|
||||||
|
setIsDropdownOpen(willOpen);
|
||||||
|
// 드롭다운을 열 때만 API 호출
|
||||||
|
if (willOpen) {
|
||||||
|
await loadInstructors();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`w-full h-[40px] px-3 py-2 border rounded-[8px] bg-white flex items-center justify-between text-left focus:outline-none focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)] cursor-pointer ${
|
||||||
|
errors.instructor
|
||||||
|
? "border-border-error"
|
||||||
|
: "border-[var(--color-neutral-40)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`text-[16px] font-normal leading-normal flex-1 ${
|
||||||
|
selectedInstructor ? "text-[var(--color-text-title)]" : "text-[var(--color-text-label)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedInstructor?.name || "강사를 선택해 주세요."}
|
||||||
|
</span>
|
||||||
|
<DropdownIcon stroke="var(--color-text-meta)" className="shrink-0" />
|
||||||
|
</button>
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-[var(--color-neutral-40)] rounded-[8px] shadow-lg z-20 max-h-[200px] overflow-y-auto">
|
||||||
|
{isLoadingInstructors ? (
|
||||||
|
<div className="px-3 py-2 text-[16px] font-normal leading-normal text-[var(--color-text-label)] text-center">
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
) : instructors.length === 0 ? (
|
||||||
|
<div className="px-3 py-2 text-[16px] font-normal leading-normal text-[var(--color-text-label)] text-center">
|
||||||
|
등록된 강사가 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
instructors.map((instructor, index) => (
|
||||||
|
<button
|
||||||
|
key={instructor.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setInstructorId(instructor.id);
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
if (errors.instructor) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.instructor;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`w-full px-3 py-2 text-left text-[16px] font-normal leading-normal hover:bg-[var(--color-bg-gray-light)] transition-colors cursor-pointer ${
|
||||||
|
instructorId === instructor.id
|
||||||
|
? "bg-[var(--color-bg-primary-light)] text-[var(--color-active-button)] font-semibold"
|
||||||
|
: "text-[var(--color-text-title)]"
|
||||||
|
} ${
|
||||||
|
index === 0 ? "rounded-t-[8px]" : ""
|
||||||
|
} ${
|
||||||
|
index === instructors.length - 1 ? "rounded-b-[8px]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{instructor.name}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.instructor && (
|
||||||
|
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.instructor}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 과목 이미지 */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-[15px] font-semibold leading-normal text-[var(--color-text-label)] whitespace-pre">
|
||||||
|
과목 이미지
|
||||||
|
</label>
|
||||||
|
<span className="text-[13px] font-normal leading-[1.4] text-[var(--color-text-meta)]">
|
||||||
|
30MB 미만의 PNG, JPG
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/jpg"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
aria-label="이미지 파일 선택"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
onClick={handleImageAreaClick}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={`border border-dashed min-h-[192px] rounded-[8px] flex flex-col items-center justify-center gap-3 cursor-pointer transition-colors px-0 relative overflow-hidden ${
|
||||||
|
isDragging
|
||||||
|
? "bg-blue-50 border-blue-300"
|
||||||
|
: previewUrl
|
||||||
|
? "bg-white border-[var(--color-neutral-40)]"
|
||||||
|
: "bg-gray-50 border-[var(--color-neutral-40)] hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{previewUrl ? (
|
||||||
|
<>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="relative flex items-start">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="미리보기"
|
||||||
|
className="h-[160px] w-auto object-contain"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveImage}
|
||||||
|
className="w-[18px] h-[18px] flex items-center justify-center ml-1 z-10 hover:opacity-80 transition-opacity shrink-0"
|
||||||
|
aria-label="이미지 삭제"
|
||||||
|
>
|
||||||
|
<CloseXOSvg width={18} height={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-black/0 hover:bg-black/20 transition-colors flex items-center justify-center my-4">
|
||||||
|
<div className="text-center opacity-0 hover:opacity-100 transition-opacity">
|
||||||
|
<p className="text-[14px] font-normal leading-normal text-white">
|
||||||
|
클릭하여 이미지 변경
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-10 h-10 flex items-center justify-center shrink-0">
|
||||||
|
<svg
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 40 40"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M20 13.3333V26.6667M13.3333 20H26.6667"
|
||||||
|
stroke="var(--color-text-meta)"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-[14px] font-normal leading-normal text-[var(--color-text-meta)] whitespace-pre">
|
||||||
|
(클릭하여 이미지 업로드)
|
||||||
|
<br aria-hidden="true" />
|
||||||
|
미첨부 시 기본 이미지가 노출됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.image && (
|
||||||
|
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.image}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 표시 */}
|
||||||
|
{errors.submit && (
|
||||||
|
<div className="px-6 pb-2">
|
||||||
|
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.submit}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions Container */}
|
||||||
|
<div className="flex flex-col gap-8 h-[96px] items-center p-6">
|
||||||
|
<div className="flex items-center justify-center gap-3 w-full">
|
||||||
|
{editingCourse && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
className="h-[48px] px-4 rounded-[10px] bg-[#fef2f2] text-[16px] font-semibold leading-normal text-[var(--color-error)] w-[136px] hover:bg-[#fae6e6] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-[48px] px-4 rounded-[10px] bg-[var(--color-bg-gray-light)] text-[16px] font-semibold leading-normal text-[var(--color-basic-text)] w-[136px] hover:bg-[var(--color-bg-gray-hover)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="h-[48px] px-4 rounded-[10px] bg-[var(--color-active-button)] text-[16px] font-semibold leading-normal text-white w-[136px] hover:bg-[var(--color-active-button-hover)] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSaving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
{isDeleteConfirmOpen && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={handleDeleteCancel}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end">
|
||||||
|
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
|
||||||
|
<div className="flex gap-[8px] items-start w-full">
|
||||||
|
<p className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">
|
||||||
|
강좌를 삭제하시겠습니까?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-start w-full">
|
||||||
|
<div className="text-[15px] font-normal leading-[1.5] text-[#4c5561]">
|
||||||
|
<p className="mb-0">삭제 버튼을 누르면 강좌 정보가 삭제됩니다.</p>
|
||||||
|
<p>정말 삭제하시겠습니까?</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{errors.submit && (
|
||||||
|
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.submit}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center justify-end shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteCancel}
|
||||||
|
className="bg-[#f1f3f5] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e9ecef] cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-[#4c5561] text-center whitespace-pre">
|
||||||
|
취소
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-[#f64c4c] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e63939] cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-white text-center whitespace-pre">
|
||||||
|
{isDeleting ? '삭제 중...' : '삭제'}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
88
src/app/admin/courses/mockData.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
|
export type Course = {
|
||||||
|
id: string;
|
||||||
|
courseName: string;
|
||||||
|
instructorName: string;
|
||||||
|
createdAt: string; // 생성일 (YYYY-MM-DD)
|
||||||
|
createdBy: string; // 등록자
|
||||||
|
hasLessons: boolean; // 강좌포함여부
|
||||||
|
imageKey?: string; // 이미지 키
|
||||||
|
};
|
||||||
|
|
||||||
|
// 과목 리스트 조회 API
|
||||||
|
export async function getCourses(): Promise<Course[]> {
|
||||||
|
try {
|
||||||
|
// 교육과정과 강좌 리스트를 동시에 가져오기
|
||||||
|
const [subjectsResponse, lecturesResponse] = await Promise.all([
|
||||||
|
apiService.getSubjects(),
|
||||||
|
apiService.getLectures().catch(() => ({ data: [] })), // 강좌 리스트 조회 실패 시 빈 배열 반환
|
||||||
|
]);
|
||||||
|
|
||||||
|
const subjectsData = subjectsResponse.data;
|
||||||
|
const lecturesData = lecturesResponse.data || [];
|
||||||
|
|
||||||
|
// 디버깅: API 응답 구조 확인
|
||||||
|
console.log('🔍 [getCourses] API 원본 응답:', subjectsData);
|
||||||
|
console.log('🔍 [getCourses] 응답 타입:', Array.isArray(subjectsData) ? '배열' : typeof subjectsData);
|
||||||
|
console.log('🔍 [getCourses] 강좌 리스트:', lecturesData);
|
||||||
|
|
||||||
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||||
|
let coursesArray: any[] = [];
|
||||||
|
if (Array.isArray(subjectsData)) {
|
||||||
|
coursesArray = subjectsData;
|
||||||
|
} else if (subjectsData && typeof subjectsData === 'object') {
|
||||||
|
// 더 많은 가능한 필드명 확인
|
||||||
|
coursesArray = subjectsData.items || subjectsData.courses || subjectsData.data || subjectsData.list || subjectsData.subjects || subjectsData.subjectList || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 강좌 리스트가 배열이 아닌 경우 처리
|
||||||
|
let lecturesArray: any[] = [];
|
||||||
|
if (Array.isArray(lecturesData)) {
|
||||||
|
lecturesArray = lecturesData;
|
||||||
|
} else if (lecturesData && typeof lecturesData === 'object') {
|
||||||
|
lecturesArray = lecturesData.items || lecturesData.lectures || lecturesData.data || lecturesData.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔍 [getCourses] 변환 전 배열:', coursesArray);
|
||||||
|
console.log('🔍 [getCourses] 배열 길이:', coursesArray.length);
|
||||||
|
console.log('🔍 [getCourses] 강좌 배열 길이:', lecturesArray.length);
|
||||||
|
|
||||||
|
// 각 교육과정에 대해 강좌가 있는지 확인하기 위한 Set 생성
|
||||||
|
const courseIdsWithLessons = new Set<string>();
|
||||||
|
lecturesArray.forEach((lecture: any) => {
|
||||||
|
const subjectId = String(lecture.subjectId || lecture.subject_id || '');
|
||||||
|
if (subjectId) {
|
||||||
|
courseIdsWithLessons.add(subjectId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🔍 [getCourses] 강좌가 있는 교육과정 ID:', Array.from(courseIdsWithLessons));
|
||||||
|
|
||||||
|
// API 응답 데이터를 Course 형식으로 변환
|
||||||
|
const transformedCourses: Course[] = coursesArray.map((item: any) => {
|
||||||
|
const courseId = String(item.id || item.subjectId || item.subject_id || '');
|
||||||
|
const hasLessons = courseIdsWithLessons.has(courseId);
|
||||||
|
|
||||||
|
const transformed = {
|
||||||
|
id: courseId,
|
||||||
|
courseName: item.courseName || item.name || item.subjectName || item.subject_name || item.title || '',
|
||||||
|
instructorName: item.instructorName || item.instructor || item.instructor_name || item.teacherName || '',
|
||||||
|
createdAt: item.createdAt || item.createdDate || item.created_date || item.createdAt || '',
|
||||||
|
createdBy: item.createdBy || item.creator || item.created_by || item.creatorName || '',
|
||||||
|
hasLessons: hasLessons,
|
||||||
|
imageKey: item.imageKey || item.image_key || item.fileKey || item.file_key || undefined,
|
||||||
|
};
|
||||||
|
console.log('🔍 [getCourses] 변환된 항목:', transformed);
|
||||||
|
return transformed;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🔍 [getCourses] 최종 변환된 배열:', transformedCourses);
|
||||||
|
console.log('🔍 [getCourses] 최종 배열 길이:', transformedCourses.length);
|
||||||
|
|
||||||
|
return transformedCourses;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('과목 리스트 조회 오류:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
348
src/app/admin/courses/page.tsx
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useEffect, useRef } from "react";
|
||||||
|
import AdminSidebar from "@/app/components/AdminSidebar";
|
||||||
|
import CourseRegistrationModal from "./CourseRegistrationModal";
|
||||||
|
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
||||||
|
import { getCourses, type Course } from "./mockData";
|
||||||
|
|
||||||
|
// 날짜를 yyyy-mm-dd 형식으로 변환하는 함수
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch {
|
||||||
|
// 이미 yyyy-mm-dd 형식이거나 파싱 실패 시 원본 반환
|
||||||
|
if (dateString.includes('T')) {
|
||||||
|
return dateString.split('T')[0];
|
||||||
|
}
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminCoursesPage() {
|
||||||
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
const prevModalOpenRef = useRef(false);
|
||||||
|
const shouldRefreshRef = useRef(false);
|
||||||
|
|
||||||
|
// API에서 과목 리스트 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchCourses() {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const data = await getCourses();
|
||||||
|
console.log('📋 [AdminCoursesPage] 받은 데이터:', data);
|
||||||
|
console.log('📋 [AdminCoursesPage] 데이터 개수:', data.length);
|
||||||
|
setCourses(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('과목 리스트 로드 오류:', error);
|
||||||
|
setCourses([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCourses();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalCount = useMemo(() => courses.length, [courses]);
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const sortedCourses = useMemo(() => {
|
||||||
|
return [...courses].sort((a, b) => {
|
||||||
|
// 생성일 내림차순 정렬 (최신 날짜가 먼저)
|
||||||
|
return b.createdAt.localeCompare(a.createdAt);
|
||||||
|
});
|
||||||
|
}, [courses]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(sortedCourses.length / ITEMS_PER_PAGE);
|
||||||
|
const paginatedCourses = useMemo(() => {
|
||||||
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||||
|
return sortedCourses.slice(startIndex, endIndex);
|
||||||
|
}, [sortedCourses, currentPage]);
|
||||||
|
|
||||||
|
const handleSaveCourse = async (courseName: string, instructorName: string) => {
|
||||||
|
shouldRefreshRef.current = true; // 새로고침 플래그 설정
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingCourse(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowClick = (course: Course) => {
|
||||||
|
setEditingCourse(course);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalClose = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingCourse(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegisterClick = () => {
|
||||||
|
setEditingCourse(null);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCourse = async () => {
|
||||||
|
shouldRefreshRef.current = true; // 새로고침 플래그 설정
|
||||||
|
setEditingCourse(null);
|
||||||
|
setShowToast(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowToast(false);
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달이 닫힌 후 리스트 새로고침
|
||||||
|
useEffect(() => {
|
||||||
|
// 모달이 열렸다가 닫힐 때, 그리고 새로고침 플래그가 설정되어 있을 때만 새로고침
|
||||||
|
if (prevModalOpenRef.current && !isModalOpen && shouldRefreshRef.current) {
|
||||||
|
shouldRefreshRef.current = false; // 플래그 리셋
|
||||||
|
|
||||||
|
async function refreshList() {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const data = await getCourses();
|
||||||
|
console.log('📋 [AdminCoursesPage] 새로고침된 데이터:', data);
|
||||||
|
console.log('📋 [AdminCoursesPage] 새로고침된 데이터 개수:', data.length);
|
||||||
|
setCourses(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('과목 리스트 새로고침 오류:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshList();
|
||||||
|
}
|
||||||
|
prevModalOpenRef.current = isModalOpen;
|
||||||
|
}, [isModalOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
{/* 메인 레이아웃 */}
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col px-8">
|
||||||
|
{/* 제목 영역 */}
|
||||||
|
<div className="h-[100px] flex items-center">
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
교육과정 관리
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 영역 (제목과 콘텐츠 사이) */}
|
||||||
|
<div className="pt-2 pb-4 flex items-center justify-between">
|
||||||
|
<p className="text-[15px] font-medium leading-[1.5] text-[#333c47] whitespace-nowrap">
|
||||||
|
총 {totalCount}건
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRegisterClick}
|
||||||
|
className="h-[40px] px-4 rounded-[8px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
등록하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 콘텐츠 영역 */}
|
||||||
|
<div className="flex-1 pt-2 flex flex-col">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47] text-center">
|
||||||
|
로딩 중...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : courses.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47] text-center">
|
||||||
|
등록된 교육과정이 없습니다.
|
||||||
|
<br />
|
||||||
|
과목을 등록해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[8px]">
|
||||||
|
<div className="w-full rounded-[8px] border border-[#dee1e6] overflow-visible">
|
||||||
|
<table className="min-w-full border-collapse">
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: '40%' }} />
|
||||||
|
<col style={{ width: '25%' }} />
|
||||||
|
<col style={{ width: '20%' }} />
|
||||||
|
<col style={{ width: '15%' }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr className="h-12 bg-gray-50 text-left">
|
||||||
|
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]">교육과정명</th>
|
||||||
|
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]">강사명</th>
|
||||||
|
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]">생성일</th>
|
||||||
|
<th className="px-4 text-center text-[14px] font-semibold leading-[1.5] text-[#4c5561]">강좌포함여부</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paginatedCourses.map((course) => (
|
||||||
|
<tr
|
||||||
|
key={course.id}
|
||||||
|
className="h-12 cursor-pointer hover:bg-[#F5F7FF] transition-colors"
|
||||||
|
onClick={() => handleRowClick(course)}
|
||||||
|
>
|
||||||
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||||
|
{course.courseName}
|
||||||
|
</td>
|
||||||
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||||
|
{course.instructorName}
|
||||||
|
</td>
|
||||||
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||||
|
{formatDate(course.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="border-t border-[#dee1e6] px-4 text-left text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||||
|
{course.hasLessons ? (
|
||||||
|
<div className="inline-flex items-center justify-center h-[20px] px-[4px] rounded-[4px] bg-[#ecf0ff]">
|
||||||
|
<span className="text-[13px] font-semibold leading-[1.4] text-[#384fbf] whitespace-nowrap">
|
||||||
|
포함
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex items-center justify-center h-[20px] px-[4px] rounded-[4px] bg-[#f1f3f5]">
|
||||||
|
<span className="text-[13px] font-semibold leading-[1.4] text-[#4c5561] whitespace-nowrap">
|
||||||
|
미포함
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
|
||||||
|
{courses.length > ITEMS_PER_PAGE && (() => {
|
||||||
|
// 페이지 번호를 10단위로 표시
|
||||||
|
const pageGroup = Math.floor((currentPage - 1) / 10);
|
||||||
|
const startPage = pageGroup * 10 + 1;
|
||||||
|
const endPage = Math.min(startPage + 9, totalPages);
|
||||||
|
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-8 pb-[32px] flex items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center gap-[8px]">
|
||||||
|
{/* First (맨 앞으로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
aria-label="맨 앞 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
aria-label="이전 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Numbers */}
|
||||||
|
{visiblePages.map((n) => {
|
||||||
|
const active = n === currentPage;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(n)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={[
|
||||||
|
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||||
|
active ? 'bg-[#ecf0ff]' : 'bg-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
aria-label="다음 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Last (맨 뒤로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
aria-label="맨 뒤 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CourseRegistrationModal
|
||||||
|
open={isModalOpen}
|
||||||
|
onClose={handleModalClose}
|
||||||
|
onSave={handleSaveCourse}
|
||||||
|
onDelete={handleDeleteCourse}
|
||||||
|
editingCourse={editingCourse}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 삭제 완료 토스트 */}
|
||||||
|
{showToast && (
|
||||||
|
<div className="fixed right-[60px] bottom-[60px] z-50">
|
||||||
|
<div className="bg-white border border-[#dee1e6] rounded-[8px] p-4 min-w-[360px] flex gap-[10px] items-center">
|
||||||
|
<div className="relative shrink-0 w-[16.667px] h-[16.667px]">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="8.5" cy="8.5" r="8.5" fill="#384FBF"/>
|
||||||
|
<path d="M5.5 8.5L7.5 10.5L11.5 6.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] text-nowrap">
|
||||||
|
교육과정을 삭제했습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
src/app/admin/id/mockData.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
|
type RoleType = 'learner' | 'instructor' | 'admin';
|
||||||
|
type AccountStatus = 'active' | 'inactive';
|
||||||
|
|
||||||
|
export type UserRow = {
|
||||||
|
id: string;
|
||||||
|
joinDate: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: RoleType;
|
||||||
|
status: AccountStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 더미 데이터 제거됨 - 이제 API에서 데이터를 가져옵니다
|
||||||
|
|
||||||
|
// 강사 목록 가져오기 함수 - API에서 가져오기
|
||||||
|
export async function getInstructors(): Promise<UserRow[]> {
|
||||||
|
try {
|
||||||
|
const token = typeof window !== 'undefined'
|
||||||
|
? (localStorage.getItem('token') || document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('token='))
|
||||||
|
?.split('=')[1])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const response = await apiService.getUsersCompact();
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
console.log('📦 [getInstructors] 원본 API 응답 데이터:', {
|
||||||
|
type: typeof data,
|
||||||
|
isArray: Array.isArray(data),
|
||||||
|
length: Array.isArray(data) ? data.length : 'N/A',
|
||||||
|
data: data,
|
||||||
|
sampleItem: Array.isArray(data) && data.length > 0 ? data[0] : null
|
||||||
|
});
|
||||||
|
|
||||||
|
// API 응답 데이터를 UserRow 형식으로 변환하고 강사만 필터링 (null 값도 표시)
|
||||||
|
const users: UserRow[] = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
.map((user: any, index: number) => {
|
||||||
|
// null 값을 명시적으로 처리 (null이면 "(없음)" 표시)
|
||||||
|
const getValue = (value: any, fallback: string = '(없음)') => {
|
||||||
|
if (value === null || value === undefined) return fallback;
|
||||||
|
if (typeof value === 'string' && value.trim() === '') return fallback;
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const transformed = {
|
||||||
|
id: getValue(user.id || user.userId, `user-${index + 1}`),
|
||||||
|
joinDate: getValue(user.joinDate || user.createdAt || user.join_date, new Date().toISOString().split('T')[0]),
|
||||||
|
name: getValue(user.name || user.userName, '(없음)'),
|
||||||
|
email: getValue(user.email || user.userEmail, '(없음)'),
|
||||||
|
role: (user.role === 'instructor' || user.role === 'INSTRUCTOR' ? 'instructor' :
|
||||||
|
user.role === 'admin' || user.role === 'ADMIN' ? 'admin' : 'learner') as RoleType,
|
||||||
|
status: (user.status === 'inactive' || user.status === 'INACTIVE' ? 'inactive' : 'active') as AccountStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모든 항목의 null 체크
|
||||||
|
console.log(`🔎 [getInstructors] [${index + 1}번째] 사용자 데이터 변환:`, {
|
||||||
|
원본: user,
|
||||||
|
변환됨: transformed,
|
||||||
|
null체크: {
|
||||||
|
id: user.id === null || user.id === undefined,
|
||||||
|
userId: user.userId === null || user.userId === undefined,
|
||||||
|
joinDate: user.joinDate === null || user.joinDate === undefined,
|
||||||
|
createdAt: user.createdAt === null || user.createdAt === undefined,
|
||||||
|
join_date: user.join_date === null || user.join_date === undefined,
|
||||||
|
name: user.name === null || user.name === undefined,
|
||||||
|
userName: user.userName === null || user.userName === undefined,
|
||||||
|
email: user.email === null || user.email === undefined,
|
||||||
|
userEmail: user.userEmail === null || user.userEmail === undefined,
|
||||||
|
role: user.role === null || user.role === undefined,
|
||||||
|
status: user.status === null || user.status === undefined,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return transformed;
|
||||||
|
})
|
||||||
|
.filter((user: UserRow) => user.role === 'admin' && user.status === 'active')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
console.log('✅ [getInstructors] 변환된 강사 데이터:', {
|
||||||
|
총개수: users.length,
|
||||||
|
필터링전개수: Array.isArray(data) ? data.length : 0,
|
||||||
|
데이터: users,
|
||||||
|
빈배열여부: users.length === 0
|
||||||
|
});
|
||||||
|
|
||||||
|
return users;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('강사 목록 가져오기 오류:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
621
src/app/admin/id/page.tsx
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
|
import AdminSidebar from "@/app/components/AdminSidebar";
|
||||||
|
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
||||||
|
import { type UserRow } from "./mockData";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
|
type TabType = 'all' | 'learner' | 'instructor' | 'admin';
|
||||||
|
type RoleType = 'learner' | 'instructor' | 'admin';
|
||||||
|
type AccountStatus = 'active' | 'inactive';
|
||||||
|
|
||||||
|
|
||||||
|
const roleLabels: Record<RoleType, string> = {
|
||||||
|
learner: '학습자',
|
||||||
|
instructor: '강사',
|
||||||
|
admin: '관리자',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<AccountStatus, string> = {
|
||||||
|
active: '활성화',
|
||||||
|
inactive: '비활성화',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminIdPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||||
|
const [users, setUsers] = useState<UserRow[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
||||||
|
const [isActivateModalOpen, setIsActivateModalOpen] = useState(false);
|
||||||
|
const [isDeactivateModalOpen, setIsDeactivateModalOpen] = useState(false);
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||||
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
const [toastMessage, setToastMessage] = useState('');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [deactivateReason, setDeactivateReason] = useState('');
|
||||||
|
const dropdownRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
|
||||||
|
// API에서 사용자 데이터 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchUsers() {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// 외부 API 호출
|
||||||
|
const response = await apiService.getUsersCompact();
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||||
|
let usersArray: any[] = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
usersArray = data;
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
usersArray = data.items || data.users || data.data || data.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답 데이터를 UserRow 형식으로 변환
|
||||||
|
const transformedUsers: UserRow[] = usersArray.length > 0
|
||||||
|
? usersArray.map((user: any) => {
|
||||||
|
// 가입일을 YYYY-MM-DD 형식으로 변환
|
||||||
|
const formatDate = (dateString: string | null | undefined): string => {
|
||||||
|
if (!dateString) return new Date().toISOString().split('T')[0];
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
} catch {
|
||||||
|
return new Date().toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// null 값을 명시적으로 처리
|
||||||
|
const getValue = (value: any, fallback: string = '-') => {
|
||||||
|
if (value === null || value === undefined) return fallback;
|
||||||
|
if (typeof value === 'string' && value.trim() === '') return fallback;
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// status가 "ACTIVE"이면 활성화, 아니면 비활성화
|
||||||
|
const accountStatus: AccountStatus =
|
||||||
|
user.status === 'ACTIVE' || user.status === 'active' ? 'active' : 'inactive';
|
||||||
|
|
||||||
|
// role 데이터 처리 (API에서 role이 없을 수 있음)
|
||||||
|
let userRole: RoleType = 'learner'; // 기본값
|
||||||
|
if (user.role) {
|
||||||
|
const roleLower = String(user.role).toLowerCase();
|
||||||
|
if (roleLower === 'instructor' || roleLower === '강사') {
|
||||||
|
userRole = 'instructor';
|
||||||
|
} else if (roleLower === 'admin' || roleLower === '관리자') {
|
||||||
|
userRole = 'admin';
|
||||||
|
} else {
|
||||||
|
userRole = 'learner';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(user.id || user.userId || Math.random()),
|
||||||
|
joinDate: formatDate(user.createdAt || user.joinDate || user.join_date),
|
||||||
|
name: getValue(user.name || user.userName, '-'),
|
||||||
|
email: getValue(user.email || user.userEmail, '-'),
|
||||||
|
role: userRole,
|
||||||
|
status: accountStatus,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
setUsers(transformedUsers);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '사용자 데이터를 불러오는 중 오류가 발생했습니다.';
|
||||||
|
setError(errorMessage);
|
||||||
|
console.error('사용자 데이터 로드 오류:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchUsers();
|
||||||
|
}, [currentPage]);
|
||||||
|
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
return activeTab === 'all'
|
||||||
|
? users
|
||||||
|
: users.filter(user => user.role === activeTab);
|
||||||
|
}, [activeTab, users]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredUsers.length / ITEMS_PER_PAGE);
|
||||||
|
const paginatedUsers = useMemo(() => {
|
||||||
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||||
|
return filteredUsers.slice(startIndex, endIndex);
|
||||||
|
}, [filteredUsers, currentPage]);
|
||||||
|
|
||||||
|
// 탭 변경 시 첫 페이지로 리셋
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
// 외부 클릭 시 드롭다운 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (openDropdownId) {
|
||||||
|
const dropdownElement = dropdownRefs.current[openDropdownId];
|
||||||
|
if (dropdownElement && !dropdownElement.contains(event.target as Node)) {
|
||||||
|
setOpenDropdownId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [openDropdownId]);
|
||||||
|
|
||||||
|
function openActivateModal(userId: string) {
|
||||||
|
setSelectedUserId(userId);
|
||||||
|
setIsActivateModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleActivateConfirm() {
|
||||||
|
if (!selectedUserId) {
|
||||||
|
setIsActivateModalOpen(false);
|
||||||
|
setSelectedUserId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// selectedUserId로 사용자 찾기
|
||||||
|
const user = users.find(u => u.id === selectedUserId);
|
||||||
|
if (!user || !user.email || user.email === '-') {
|
||||||
|
setToastMessage('사용자 정보를 찾을 수 없습니다.');
|
||||||
|
setShowToast(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowToast(false);
|
||||||
|
}, 3000);
|
||||||
|
setIsActivateModalOpen(false);
|
||||||
|
setSelectedUserId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiService.unsuspendUser(selectedUserId);
|
||||||
|
|
||||||
|
// API 호출 성공 시 로컬 상태 업데이트
|
||||||
|
setUsers(prevUsers =>
|
||||||
|
prevUsers.map(u =>
|
||||||
|
u.id === selectedUserId
|
||||||
|
? { ...u, status: 'active' }
|
||||||
|
: u
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setToastMessage('계정을 활성화했습니다.');
|
||||||
|
setShowToast(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowToast(false);
|
||||||
|
}, 3000);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '계정 활성화 중 오류가 발생했습니다.';
|
||||||
|
setToastMessage(errorMessage);
|
||||||
|
setShowToast(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowToast(false);
|
||||||
|
}, 3000);
|
||||||
|
console.error('계정 활성화 오류:', err);
|
||||||
|
} finally {
|
||||||
|
setIsActivateModalOpen(false);
|
||||||
|
setSelectedUserId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActivateCancel() {
|
||||||
|
setIsActivateModalOpen(false);
|
||||||
|
setSelectedUserId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeactivateModal(userId: string) {
|
||||||
|
setSelectedUserId(userId);
|
||||||
|
setIsDeactivateModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeactivateConfirm() {
|
||||||
|
if (!selectedUserId) {
|
||||||
|
setIsDeactivateModalOpen(false);
|
||||||
|
setSelectedUserId(null);
|
||||||
|
setDeactivateReason('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiService.suspendUser(selectedUserId);
|
||||||
|
|
||||||
|
// API 호출 성공 시 로컬 상태 업데이트
|
||||||
|
setUsers(prevUsers =>
|
||||||
|
prevUsers.map(user =>
|
||||||
|
user.id === selectedUserId
|
||||||
|
? { ...user, status: 'inactive' }
|
||||||
|
: user
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setToastMessage('계정을 비활성화했습니다.');
|
||||||
|
setShowToast(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowToast(false);
|
||||||
|
}, 3000);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '계정 비활성화 중 오류가 발생했습니다.';
|
||||||
|
setToastMessage(errorMessage);
|
||||||
|
setShowToast(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowToast(false);
|
||||||
|
}, 3000);
|
||||||
|
console.error('계정 비활성화 오류:', err);
|
||||||
|
} finally {
|
||||||
|
setIsDeactivateModalOpen(false);
|
||||||
|
setSelectedUserId(null);
|
||||||
|
setDeactivateReason('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeactivateCancel() {
|
||||||
|
setIsDeactivateModalOpen(false);
|
||||||
|
setSelectedUserId(null);
|
||||||
|
setDeactivateReason('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAccountStatus(userId: string) {
|
||||||
|
const user = users.find(u => u.id === userId);
|
||||||
|
if (user && user.status === 'inactive') {
|
||||||
|
openActivateModal(userId);
|
||||||
|
} else if (user && user.status === 'active') {
|
||||||
|
openDeactivateModal(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDropdown(userId: string) {
|
||||||
|
setOpenDropdownId(openDropdownId === userId ? null : userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeUserRole(userId: string, newRole: RoleType) {
|
||||||
|
setUsers(prevUsers =>
|
||||||
|
prevUsers.map(user =>
|
||||||
|
user.id === userId
|
||||||
|
? { ...user, role: newRole }
|
||||||
|
: user
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setOpenDropdownId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
{/* 메인 레이아웃 */}
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col px-8">
|
||||||
|
{/* 제목 영역 */}
|
||||||
|
<div className="h-[100px] flex items-center">
|
||||||
|
<h1 className="text-[24px] font-bold leading-normal text-text-title">
|
||||||
|
권한 설정
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
{/* 탭 네비게이션 */}
|
||||||
|
{/* <div>
|
||||||
|
<div className="flex items-center gap-8 border-b border-input-border">
|
||||||
|
{[
|
||||||
|
{ id: 'all' as TabType, label: '전체' },
|
||||||
|
{ id: 'learner' as TabType, label: '학습자' },
|
||||||
|
{ id: 'instructor' as TabType, label: '강사' },
|
||||||
|
{ id: 'admin' as TabType, label: '관리자' },
|
||||||
|
].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={[
|
||||||
|
"pb-4 px-1 text-[16px] font-medium leading-normal transition-colors relative cursor-pointer",
|
||||||
|
activeTab === tab.id
|
||||||
|
? "text-active-button font-semibold"
|
||||||
|
: "text-text-label",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{activeTab === tab.id && (
|
||||||
|
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-active-button" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* 콘텐츠 영역 */}
|
||||||
|
<div className="flex-1 pt-8 flex flex-col">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="rounded-lg border border-input-border bg-white min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium leading-normal text-neutral-700">
|
||||||
|
데이터를 불러오는 중...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="rounded-lg border border-input-border bg-white min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium leading-normal text-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : filteredUsers.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-input-border bg-white min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium leading-normal text-neutral-700">
|
||||||
|
현재 관리할 수 있는 회원 계정이 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="rounded-[8px]">
|
||||||
|
<div className="w-full rounded-[8px] border border-input-border overflow-visible">
|
||||||
|
<table className="min-w-full border-collapse">
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: 200 }} />
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
<col style={{ width: 150 }} />
|
||||||
|
<col style={{ width: 150 }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr className="h-12 bg-gray-50 text-left">
|
||||||
|
<th className="border-r border-input-border px-4 text-[14px] font-semibold leading-normal text-basic-text">가입일</th>
|
||||||
|
<th className="border-r border-input-border px-4 text-[14px] font-semibold leading-normal text-basic-text">성명</th>
|
||||||
|
<th className="border-r border-input-border px-4 text-[14px] font-semibold leading-normal text-basic-text">아이디(이메일)</th>
|
||||||
|
<th className="border-r border-input-border px-4 text-[14px] font-semibold leading-normal text-basic-text">계정상태</th>
|
||||||
|
<th className="px-4 text-center text-[14px] font-semibold leading-normal text-basic-text">계정관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paginatedUsers.map((user) => (
|
||||||
|
<tr
|
||||||
|
key={user.id}
|
||||||
|
className="h-12"
|
||||||
|
>
|
||||||
|
<td className="border-t border-r border-input-border px-4 text-[13px] leading-normal text-text-body whitespace-nowrap">
|
||||||
|
{user.joinDate}
|
||||||
|
</td>
|
||||||
|
<td className="border-t border-r border-input-border px-4 text-[13px] leading-normal text-text-body whitespace-nowrap">
|
||||||
|
{user.name}
|
||||||
|
</td>
|
||||||
|
<td className="border-t border-r border-input-border px-4 text-[13px] leading-normal text-text-body whitespace-nowrap">
|
||||||
|
{user.email}
|
||||||
|
</td>
|
||||||
|
<td className="border-t border-r border-input-border px-4 text-[13px] leading-normal text-text-body whitespace-nowrap">
|
||||||
|
{user.status === 'active' ? (
|
||||||
|
<div className="inline-flex items-center justify-center h-[20px] px-[4px] rounded-[4px] bg-bg-primary-light">
|
||||||
|
<span className="text-[13px] font-semibold leading-[1.4] text-primary whitespace-nowrap">
|
||||||
|
{statusLabels[user.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex items-center justify-center h-[20px] px-[4px] rounded-[4px] bg-bg-gray-light">
|
||||||
|
<span className="text-[13px] font-semibold leading-[1.4] text-basic-text whitespace-nowrap">
|
||||||
|
{statusLabels[user.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="border-t border-input-border px-4 text-center text-[13px] leading-normal text-text-body whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleAccountStatus(user.id)}
|
||||||
|
className="text-[12px] text-blue-500 underline underline-offset-[3px] cursor-pointer whitespace-nowrap hover:opacity-80"
|
||||||
|
>
|
||||||
|
{user.status === 'active' ? '비활성화 처리' : '활성화 처리'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
|
||||||
|
{filteredUsers.length > ITEMS_PER_PAGE && (() => {
|
||||||
|
// 페이지 번호를 10단위로 표시
|
||||||
|
const pageGroup = Math.floor((currentPage - 1) / 10);
|
||||||
|
const startPage = pageGroup * 10 + 1;
|
||||||
|
const endPage = Math.min(startPage + 9, totalPages);
|
||||||
|
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-8 pb-[32px] flex items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center gap-[8px]">
|
||||||
|
{/* First (맨 앞으로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
aria-label="맨 앞 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
aria-label="이전 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Numbers */}
|
||||||
|
{visiblePages.map((n) => {
|
||||||
|
const active = n === currentPage;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(n)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={[
|
||||||
|
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||||
|
active ? 'bg-bg-primary-light' : 'bg-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="text-[16px] leading-[1.4] text-neutral-700">{n}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
aria-label="다음 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Last (맨 뒤로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
aria-label="맨 뒤 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 활성화 확인 모달 */}
|
||||||
|
{isActivateModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={handleActivateCancel}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 bg-white rounded-[8px] p-6 shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-8 items-end justify-end min-w-[500px]">
|
||||||
|
<div className="flex flex-col gap-4 items-start justify-center w-full">
|
||||||
|
<div className="flex gap-2 items-start w-full">
|
||||||
|
<h2 className="text-[18px] font-semibold leading-normal text-neutral-700">
|
||||||
|
계정을 활성화하시겠습니까?
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleActivateCancel}
|
||||||
|
className="h-[40px] px-2 rounded-[8px] bg-bg-gray-light text-[16px] font-semibold leading-normal text-basic-text w-[80px] cursor-pointer hover:bg-bg-gray-hover transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleActivateConfirm}
|
||||||
|
className="h-[40px] px-4 rounded-[8px] bg-active-button text-[16px] font-semibold leading-normal text-white cursor-pointer hover:bg-active-button-hover transition-colors"
|
||||||
|
>
|
||||||
|
활성화하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 비활성화 확인 모달 */}
|
||||||
|
{isDeactivateModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={handleDeactivateCancel}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end min-w-[500px]">
|
||||||
|
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
|
||||||
|
<h2 className="text-[18px] font-semibold leading-normal text-[#333c47]">
|
||||||
|
계정을 비활성화 하시겠습니까?
|
||||||
|
</h2>
|
||||||
|
<p className="text-[15px] font-normal leading-normal text-basic-text">
|
||||||
|
학습자가 강좌를 수강 중일 경우 강좌 수강이 어렵습니다.
|
||||||
|
<br />
|
||||||
|
계정을 비활성화 처리하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<div className="w-full">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={deactivateReason}
|
||||||
|
onChange={(e) => setDeactivateReason(e.target.value)}
|
||||||
|
placeholder="비활성화 사유를 입력해주세요"
|
||||||
|
className="w-full h-[40px] px-[12px] rounded-[8px] border border-input-border text-[14px] leading-normal text-text-body placeholder:text-text-placeholder focus:outline-none focus:ring-2 focus:ring-active-button focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeactivateCancel}
|
||||||
|
className="h-[40px] px-2 rounded-[8px] bg-bg-gray-light text-[16px] font-semibold leading-normal text-basic-text w-[80px] cursor-pointer hover:bg-bg-gray-hover transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeactivateConfirm}
|
||||||
|
className="h-[40px] px-4 rounded-[8px] bg-red-50 text-[16px] font-semibold leading-normal text-error cursor-pointer hover:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
비활성화하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 활성화 완료 토스트 */}
|
||||||
|
{showToast && (
|
||||||
|
<div className="fixed right-[60px] bottom-[60px] z-50">
|
||||||
|
<div className="bg-white border border-input-border rounded-[8px] p-4 min-w-[360px] flex gap-[10px] items-center">
|
||||||
|
<div className="relative shrink-0 w-[16.667px] h-[16.667px]">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="8.5" cy="8.5" r="8.5" fill="var(--color-primary)"/>
|
||||||
|
<path d="M5.5 8.5L7.5 10.5L11.5 6.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] font-medium leading-normal text-text-body text-nowrap">
|
||||||
|
{toastMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
1500
src/app/admin/lessons/[id]/edit/page.tsx
Normal file
1168
src/app/admin/lessons/[id]/page.tsx
Normal file
1851
src/app/admin/lessons/page.tsx
Normal file
145
src/app/admin/logs/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import AdminSidebar from "@/app/components/AdminSidebar";
|
||||||
|
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
||||||
|
|
||||||
|
export default function AdminLogsPage() {
|
||||||
|
// TODO: 나중에 실제 데이터로 교체
|
||||||
|
const items: any[] = [];
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const totalPages = Math.ceil(items.length / ITEMS_PER_PAGE);
|
||||||
|
const paginatedItems = useMemo(() => {
|
||||||
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||||
|
return items.slice(startIndex, endIndex);
|
||||||
|
}, [items, currentPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
{/* 메인 레이아웃 */}
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col px-8">
|
||||||
|
{/* 제목 영역 */}
|
||||||
|
<div className="h-[100px] flex items-center">
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
로그/접속 기록
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 콘텐츠 영역 */}
|
||||||
|
<div className="flex-1 pt-8 flex flex-col">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
|
||||||
|
현재 관리할 수 있는 항목이 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* TODO: 테이블 또는 리스트를 여기에 추가 */}
|
||||||
|
|
||||||
|
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
|
||||||
|
{items.length > ITEMS_PER_PAGE && (() => {
|
||||||
|
// 페이지 번호를 10단위로 표시
|
||||||
|
const pageGroup = Math.floor((currentPage - 1) / 10);
|
||||||
|
const startPage = pageGroup * 10 + 1;
|
||||||
|
const endPage = Math.min(startPage + 9, totalPages);
|
||||||
|
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-8 pb-[32px] flex items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center gap-[8px]">
|
||||||
|
{/* First (맨 앞으로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
aria-label="맨 앞 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
aria-label="이전 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Numbers */}
|
||||||
|
{visiblePages.map((n) => {
|
||||||
|
const active = n === currentPage;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(n)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={[
|
||||||
|
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||||
|
active ? 'bg-[#ecf0ff]' : 'bg-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
aria-label="다음 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Last (맨 뒤로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
aria-label="맨 뒤 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
49
src/app/admin/notices/NoChangesModal.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type NoChangesModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 변경사항이 없을 때 표시되는 모달
|
||||||
|
*/
|
||||||
|
export default function NoChangesModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: NoChangesModalProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end min-w-[320px]">
|
||||||
|
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
|
||||||
|
<div className="flex gap-[8px] items-start w-full">
|
||||||
|
<p className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">
|
||||||
|
변경된 내용이 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center justify-end shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="bg-[#1f2b91] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#1a2478] cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-white text-center whitespace-pre">
|
||||||
|
확인
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
65
src/app/admin/notices/NoticeCancelModal.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type NoticeCancelModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공지사항 작성 취소 확인 모달
|
||||||
|
*/
|
||||||
|
export default function NoticeCancelModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: NoticeCancelModalProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end w-[400px]">
|
||||||
|
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
|
||||||
|
<div className="flex gap-[8px] items-start w-full">
|
||||||
|
<p className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">
|
||||||
|
작성 중인 내용이 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-start w-full">
|
||||||
|
<div className="text-[15px] font-normal leading-[1.5] text-[#4c5561]">
|
||||||
|
<p className="mb-0">정말 취소하시겠습니까?</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center justify-end shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="bg-[#f1f3f5] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e9ecef] cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-[#4c5561] text-center whitespace-pre">
|
||||||
|
취소
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="bg-[#1f2b91] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#1a2478] cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-white text-center whitespace-pre">
|
||||||
|
확인
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
70
src/app/admin/notices/NoticeDeleteModal.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type NoticeDeleteModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
isDeleting?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공지사항 삭제 확인 모달
|
||||||
|
*/
|
||||||
|
export default function NoticeDeleteModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
isDeleting = false,
|
||||||
|
}: NoticeDeleteModalProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end w-[400px]">
|
||||||
|
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
|
||||||
|
<div className="flex gap-[8px] items-start w-full">
|
||||||
|
<p className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">
|
||||||
|
공지사항을 삭제하시겠습니까?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-start w-full">
|
||||||
|
<div className="text-[15px] font-normal leading-[1.5] text-[#4c5561]">
|
||||||
|
<p className="mb-0">삭제 버튼을 누르면 공지사항이 삭제됩니다.</p>
|
||||||
|
<p>정말 삭제하시겠습니까?</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center justify-end shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-[#f1f3f5] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e9ecef] cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-[#4c5561] text-center whitespace-pre">
|
||||||
|
취소
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-[#f64c4c] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e63939] cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-white text-center whitespace-pre">
|
||||||
|
{isDeleting ? '삭제 중...' : '삭제'}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
54
src/app/admin/notices/NoticeValidationModal.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type NoticeValidationModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공지사항 작성 시 제목 또는 내용이 비어있을 때 표시되는 검증 모달
|
||||||
|
*/
|
||||||
|
export default function NoticeValidationModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: NoticeValidationModalProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="닫기"
|
||||||
|
className="absolute inset-0 bg-black/40 cursor-default"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="notice-validation-title"
|
||||||
|
className="relative bg-white box-border flex flex-col items-stretch justify-end gap-[32px] p-6 rounded-[8px] w-[320px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)]"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4 items-center w-full">
|
||||||
|
<p
|
||||||
|
className="text-[15px] font-normal leading-[1.5] text-[#333c47] w-full"
|
||||||
|
id="notice-validation-title"
|
||||||
|
>
|
||||||
|
내용 또는 제목을 입력해 주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center justify-end w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-[37px] min-w-[82px] px-2 rounded-[8px] bg-[#1f2b91] text-white text-[16px] font-semibold leading-[1.5] cursor-pointer whitespace-nowrap"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
483
src/app/admin/notices/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useRef, ChangeEvent, useEffect } from "react";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
import AdminSidebar from "@/app/components/AdminSidebar";
|
||||||
|
import BackArrowSvg from "@/app/svgs/backarrow";
|
||||||
|
import CloseXOSvg from "@/app/svgs/closexo";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
import NoticeValidationModal from "@/app/admin/notices/NoticeValidationModal";
|
||||||
|
import NoticeCancelModal from "@/app/admin/notices/NoticeCancelModal";
|
||||||
|
import NoChangesModal from "@/app/admin/notices/NoChangesModal";
|
||||||
|
|
||||||
|
type Attachment = {
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
url?: string;
|
||||||
|
fileKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminNoticeEditPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [attachedFile, setAttachedFile] = useState<File | null>(null);
|
||||||
|
const [fileKey, setFileKey] = useState<string | null>(null);
|
||||||
|
const [existingAttachment, setExistingAttachment] = useState<Attachment | null>(null);
|
||||||
|
const [originalTitle, setOriginalTitle] = useState<string>('');
|
||||||
|
const [originalContent, setOriginalContent] = useState<string>('');
|
||||||
|
const [originalFileKey, setOriginalFileKey] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
|
const [isValidationModalOpen, setIsValidationModalOpen] = useState(false);
|
||||||
|
const [isCancelModalOpen, setIsCancelModalOpen] = useState(false);
|
||||||
|
const [isNoChangesModalOpen, setIsNoChangesModalOpen] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const characterCount = useMemo(() => content.length, [content]);
|
||||||
|
|
||||||
|
// 공지사항 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchNotice() {
|
||||||
|
if (!params?.id) {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingData(true);
|
||||||
|
const response = await apiService.getNotice(params.id);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// 제목 설정
|
||||||
|
const loadedTitle = data.title || '';
|
||||||
|
setTitle(loadedTitle);
|
||||||
|
setOriginalTitle(loadedTitle);
|
||||||
|
|
||||||
|
// 내용 설정 (배열이면 join, 문자열이면 그대로)
|
||||||
|
let loadedContent = '';
|
||||||
|
if (data.content) {
|
||||||
|
if (Array.isArray(data.content)) {
|
||||||
|
loadedContent = data.content.join('\n');
|
||||||
|
} else if (typeof data.content === 'string') {
|
||||||
|
loadedContent = data.content;
|
||||||
|
} else {
|
||||||
|
loadedContent = String(data.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setContent(loadedContent);
|
||||||
|
setOriginalContent(loadedContent);
|
||||||
|
|
||||||
|
// 기존 첨부파일 정보 설정
|
||||||
|
if (data.attachments && Array.isArray(data.attachments) && data.attachments.length > 0) {
|
||||||
|
const att = data.attachments[0];
|
||||||
|
setExistingAttachment({
|
||||||
|
name: att.name || att.fileName || att.filename || '첨부파일',
|
||||||
|
size: att.size || att.fileSize || '',
|
||||||
|
url: att.url || att.downloadUrl,
|
||||||
|
fileKey: att.fileKey || att.key || att.fileId,
|
||||||
|
});
|
||||||
|
// 기존 파일이 있으면 fileKey도 설정
|
||||||
|
const loadedFileKey = att.fileKey || att.key || att.fileId;
|
||||||
|
if (loadedFileKey) {
|
||||||
|
setFileKey(loadedFileKey);
|
||||||
|
setOriginalFileKey(loadedFileKey);
|
||||||
|
}
|
||||||
|
} else if (data.attachment) {
|
||||||
|
// 단일 첨부파일인 경우
|
||||||
|
setExistingAttachment({
|
||||||
|
name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일',
|
||||||
|
size: data.attachment.size || data.attachment.fileSize || '',
|
||||||
|
url: data.attachment.url || data.attachment.downloadUrl,
|
||||||
|
fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId,
|
||||||
|
});
|
||||||
|
const loadedFileKey = data.attachment.fileKey || data.attachment.key || data.attachment.fileId;
|
||||||
|
if (loadedFileKey) {
|
||||||
|
setFileKey(loadedFileKey);
|
||||||
|
setOriginalFileKey(loadedFileKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('공지사항 조회 오류:', error);
|
||||||
|
alert('공지사항을 불러오는 중 오류가 발생했습니다.');
|
||||||
|
router.push('/admin/notices');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchNotice();
|
||||||
|
}, [params?.id, router]);
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
router.push(`/admin/notices/${params.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileAttach = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (file.size > 30 * 1024 * 1024) {
|
||||||
|
alert('30MB 미만의 파일만 첨부할 수 있습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
// 단일 파일 업로드
|
||||||
|
const uploadResponse = await apiService.uploadFile(file);
|
||||||
|
|
||||||
|
// 응답에서 fileKey 추출
|
||||||
|
let extractedFileKey: string | null = null;
|
||||||
|
if (uploadResponse.data?.fileKey) {
|
||||||
|
extractedFileKey = uploadResponse.data.fileKey;
|
||||||
|
} else if (uploadResponse.data?.key) {
|
||||||
|
extractedFileKey = uploadResponse.data.key;
|
||||||
|
} else if (uploadResponse.data?.id) {
|
||||||
|
extractedFileKey = uploadResponse.data.id;
|
||||||
|
} else if (uploadResponse.data?.imageKey) {
|
||||||
|
extractedFileKey = uploadResponse.data.imageKey;
|
||||||
|
} else if (uploadResponse.data?.fileId) {
|
||||||
|
extractedFileKey = uploadResponse.data.fileId;
|
||||||
|
} else if (uploadResponse.data?.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey)) {
|
||||||
|
extractedFileKey = uploadResponse.data.data.key || uploadResponse.data.data.fileKey;
|
||||||
|
} else if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results) && uploadResponse.data.results.length > 0) {
|
||||||
|
const result = uploadResponse.data.results[0];
|
||||||
|
if (result.ok && result.fileKey) {
|
||||||
|
extractedFileKey = result.fileKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractedFileKey) {
|
||||||
|
setFileKey(extractedFileKey);
|
||||||
|
setAttachedFile(file);
|
||||||
|
// 새 파일을 업로드하면 기존 파일 정보 제거
|
||||||
|
setExistingAttachment(null);
|
||||||
|
} else {
|
||||||
|
throw new Error('파일 키를 받아오지 못했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 업로드 실패:', error);
|
||||||
|
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
setAttachedFile(null);
|
||||||
|
setFileKey(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
// 파일 입력 초기화
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileRemove = () => {
|
||||||
|
setAttachedFile(null);
|
||||||
|
setExistingAttachment(null);
|
||||||
|
setFileKey(null);
|
||||||
|
// 파일 입력 초기화
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!title.trim() || !content.trim()) {
|
||||||
|
setIsValidationModalOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!params?.id) {
|
||||||
|
alert('공지사항 ID를 찾을 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// 변경된 필드만 포함하는 request body 생성
|
||||||
|
const noticeData: any = {};
|
||||||
|
|
||||||
|
// 제목이 변경되었는지 확인
|
||||||
|
const trimmedTitle = title.trim();
|
||||||
|
if (trimmedTitle !== originalTitle) {
|
||||||
|
noticeData.title = trimmedTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 내용이 변경되었는지 확인
|
||||||
|
const trimmedContent = content.trim();
|
||||||
|
if (trimmedContent !== originalContent) {
|
||||||
|
noticeData.content = trimmedContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 변경사항 확인
|
||||||
|
const currentFileKey = fileKey;
|
||||||
|
const hasFileChanged = currentFileKey !== originalFileKey;
|
||||||
|
|
||||||
|
// 파일이 삭제된 경우 (기존에 파일이 있었는데 지금 없음)
|
||||||
|
if (originalFileKey && !currentFileKey) {
|
||||||
|
noticeData.attachments = [];
|
||||||
|
}
|
||||||
|
// 파일이 변경되었거나 새로 추가된 경우
|
||||||
|
else if (hasFileChanged && currentFileKey) {
|
||||||
|
if (attachedFile) {
|
||||||
|
// 새로 업로드한 파일
|
||||||
|
noticeData.attachments = [
|
||||||
|
{
|
||||||
|
fileKey: currentFileKey,
|
||||||
|
filename: attachedFile.name,
|
||||||
|
mimeType: attachedFile.type || 'application/octet-stream',
|
||||||
|
size: attachedFile.size,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else if (existingAttachment && existingAttachment.fileKey) {
|
||||||
|
// 기존 파일 유지
|
||||||
|
noticeData.attachments = [
|
||||||
|
{
|
||||||
|
fileKey: existingAttachment.fileKey,
|
||||||
|
filename: existingAttachment.name,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변경사항이 없으면 알림 후 리턴
|
||||||
|
if (Object.keys(noticeData).length === 0) {
|
||||||
|
setIsNoChangesModalOpen(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiService.updateNotice(params.id, noticeData);
|
||||||
|
|
||||||
|
// 성공 시 공지사항 리스트로 이동 (토스트는 리스트 페이지에서 표시)
|
||||||
|
router.push('/admin/notices?updated=true');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('공지사항 수정 실패:', error);
|
||||||
|
alert('공지사항 수정에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (title.trim() || content.trim() || attachedFile || fileKey) {
|
||||||
|
setIsCancelModalOpen(true);
|
||||||
|
} else {
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelConfirm = () => {
|
||||||
|
setIsCancelModalOpen(false);
|
||||||
|
handleBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingData) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="h-[100px] flex items-center justify-center px-[32px]">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NoticeValidationModal
|
||||||
|
open={isValidationModalOpen}
|
||||||
|
onClose={() => setIsValidationModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<NoticeCancelModal
|
||||||
|
open={isCancelModalOpen}
|
||||||
|
onClose={() => setIsCancelModalOpen(false)}
|
||||||
|
onConfirm={handleCancelConfirm}
|
||||||
|
/>
|
||||||
|
<NoChangesModal
|
||||||
|
open={isNoChangesModalOpen}
|
||||||
|
onClose={() => setIsNoChangesModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
{/* 메인 레이아웃 */}
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col px-8">
|
||||||
|
{/* 작성 모드 헤더 */}
|
||||||
|
<div className="h-[100px] flex items-center">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="flex items-center justify-center w-8 h-8 cursor-pointer"
|
||||||
|
aria-label="뒤로가기"
|
||||||
|
>
|
||||||
|
<BackArrowSvg width={32} height={32} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
공지사항 수정
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작성 폼 */}
|
||||||
|
<div className="flex-1 flex flex-col gap-10 pb-20 pt-8 w-full">
|
||||||
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
{/* 제목 입력 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
|
||||||
|
제목
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="제목을 입력해 주세요."
|
||||||
|
className="w-full h-[40px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 내용 입력 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
|
||||||
|
내용
|
||||||
|
</label>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newContent = e.target.value;
|
||||||
|
if (newContent.length <= 1000) {
|
||||||
|
setContent(newContent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="내용을 입력해 주세요. (최대 1,000자 이내)"
|
||||||
|
className="w-full h-[320px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] resize-none focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-3 right-3">
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#6c7682] text-right">
|
||||||
|
{characterCount}/1000
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 첨부 파일 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<div className="flex items-center justify-between h-8 w-full">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] whitespace-nowrap">
|
||||||
|
첨부 파일{' '}
|
||||||
|
<span className="font-normal">
|
||||||
|
{(attachedFile || existingAttachment) ? 1 : 0}/1
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#8c95a1] whitespace-nowrap">
|
||||||
|
30MB 미만 파일
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFileAttach}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-[32px] w-[62px] px-[4px] py-[3px] rounded-[6px] border border-[#8c95a1] bg-white flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-colors shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span className="text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap">
|
||||||
|
{isLoading ? '업로드 중...' : '첨부'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
accept="*/*"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-16 w-full rounded-[8px] border border-[#dee1e6] bg-gray-50 flex items-center justify-between px-4">
|
||||||
|
{attachedFile ? (
|
||||||
|
<>
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027] flex-1 min-w-0 truncate">
|
||||||
|
{attachedFile.name}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFileRemove}
|
||||||
|
className="flex items-center justify-center w-6 h-6 cursor-pointer hover:opacity-70 transition-opacity shrink-0 ml-2"
|
||||||
|
aria-label="파일 삭제"
|
||||||
|
>
|
||||||
|
<CloseXOSvg />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : existingAttachment ? (
|
||||||
|
<>
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027] flex-1 min-w-0 truncate">
|
||||||
|
{existingAttachment.name}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFileRemove}
|
||||||
|
className="flex items-center justify-center w-6 h-6 cursor-pointer hover:opacity-70 transition-opacity shrink-0 ml-2"
|
||||||
|
aria-label="파일 삭제"
|
||||||
|
>
|
||||||
|
<CloseXOSvg />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center w-full">
|
||||||
|
파일을 첨부해주세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="flex gap-3 items-center justify-end shrink-0 w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="h-12 px-8 rounded-[10px] bg-[#f1f3f5] text-[16px] font-semibold leading-[1.5] text-[#4c5561] whitespace-nowrap hover:bg-[#e5e8eb] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-12 px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? '저장 중...' : '저장하기'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
392
src/app/admin/notices/[id]/page.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import AdminSidebar from '@/app/components/AdminSidebar';
|
||||||
|
import BackCircleSvg from '@/app/svgs/backcirclesvg';
|
||||||
|
import DownloadIcon from '@/app/svgs/downloadicon';
|
||||||
|
import PaperClipSvg from '@/app/svgs/paperclipsvg';
|
||||||
|
import apiService from '@/app/lib/apiService';
|
||||||
|
import type { Notice } from '@/app/admin/notices/mockData';
|
||||||
|
import NoticeDeleteModal from '@/app/admin/notices/NoticeDeleteModal';
|
||||||
|
|
||||||
|
type Attachment = {
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
url?: string;
|
||||||
|
fileKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminNoticeDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [notice, setNotice] = useState<Notice | null>(null);
|
||||||
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchNotice() {
|
||||||
|
if (!params?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await apiService.getNotice(params.id);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// API 응답 데이터를 Notice 형식으로 변환
|
||||||
|
const transformedNotice: Notice = {
|
||||||
|
id: data.id || data.noticeId || Number(params.id),
|
||||||
|
title: data.title || '',
|
||||||
|
date: data.date || data.createdAt || data.createdDate || new Date().toISOString().split('T')[0],
|
||||||
|
views: data.views || data.viewCount || 0,
|
||||||
|
writer: data.writer || data.author || data.createdBy || '관리자',
|
||||||
|
content: data.content
|
||||||
|
? (Array.isArray(data.content)
|
||||||
|
? data.content
|
||||||
|
: typeof data.content === 'string'
|
||||||
|
? data.content.split('\n').filter((line: string) => line.trim())
|
||||||
|
: [String(data.content)])
|
||||||
|
: [],
|
||||||
|
hasAttachment: data.hasAttachment || data.attachment || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 첨부파일 정보 처리
|
||||||
|
if (data.attachments && Array.isArray(data.attachments)) {
|
||||||
|
setAttachments(data.attachments.map((att: any) => ({
|
||||||
|
name: att.name || att.fileName || att.filename || '',
|
||||||
|
size: att.size || att.fileSize || '',
|
||||||
|
url: att.url || att.downloadUrl,
|
||||||
|
fileKey: att.fileKey || att.key || att.fileId,
|
||||||
|
})));
|
||||||
|
} else if (transformedNotice.hasAttachment && data.attachment) {
|
||||||
|
// 단일 첨부파일인 경우
|
||||||
|
setAttachments([{
|
||||||
|
name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일',
|
||||||
|
size: data.attachment.size || data.attachment.fileSize || '',
|
||||||
|
url: data.attachment.url || data.attachment.downloadUrl,
|
||||||
|
fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transformedNotice.title) {
|
||||||
|
throw new Error('공지사항을 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotice(transformedNotice);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('공지사항 조회 오류:', err);
|
||||||
|
setError('공지사항을 불러오는 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchNotice();
|
||||||
|
}, [params?.id]);
|
||||||
|
|
||||||
|
// 토스트 자동 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
if (showToast) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowToast(false);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
const handleDownload = async (fileKey?: string, url?: string, fileName?: string) => {
|
||||||
|
if (url) {
|
||||||
|
// URL이 있으면 직접 다운로드
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
} else if (fileKey) {
|
||||||
|
// fileKey가 있으면 API를 통해 다운로드
|
||||||
|
try {
|
||||||
|
const fileUrl = await apiService.getFile(fileKey);
|
||||||
|
if (fileUrl) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = fileUrl;
|
||||||
|
link.download = fileName || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('파일 다운로드 실패:', err);
|
||||||
|
alert('파일 다운로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="h-[100px] flex items-center justify-center px-[32px]">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !notice || !notice.content || notice.content.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
|
||||||
|
<Link
|
||||||
|
href="/admin/notices"
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</Link>
|
||||||
|
<h1 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">
|
||||||
|
공지사항 상세
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">
|
||||||
|
{error || '공지사항을 찾을 수 없습니다.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* 상단 타이틀 */}
|
||||||
|
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
|
||||||
|
<Link
|
||||||
|
href="/admin/notices"
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline shrink-0"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</Link>
|
||||||
|
<h1 className="m-0 text-[24px] font-bold leading-[1.5] text-[#1B2027]">
|
||||||
|
공지사항 상세
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 */}
|
||||||
|
<section className="flex flex-col gap-[40px] px-[32px] py-[24px]">
|
||||||
|
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="p-[32px]">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-col gap-[8px]">
|
||||||
|
<h2 className="m-0 text-[20px] font-bold leading-[1.5] text-[#333C47]">
|
||||||
|
{notice.title}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-[16px] text-[13px] font-medium leading-[1.4]">
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
<span className="text-[#8C95A1]">작성자</span>
|
||||||
|
<span className="text-[#333C47]">{notice.writer}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-[16px] w-0 flex items-center justify-center">
|
||||||
|
<div className="h-0 w-[16px] border-t border-[#DEE1E6]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
<span className="text-[#8C95A1]">게시일</span>
|
||||||
|
<span className="text-[#333C47]">
|
||||||
|
{notice.date.includes('T')
|
||||||
|
? new Date(notice.date).toISOString().split('T')[0]
|
||||||
|
: notice.date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-[16px] w-0 flex items-center justify-center">
|
||||||
|
<div className="h-0 w-[16px] border-t border-[#DEE1E6]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
<span className="text-[#8C95A1]">조회수</span>
|
||||||
|
<span className="text-[#333C47]">{notice.views.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="h-px bg-[#DEE1E6] w-full" />
|
||||||
|
|
||||||
|
{/* 본문 및 첨부파일 */}
|
||||||
|
<div className="flex flex-col gap-[40px] p-[32px]">
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="text-[15px] font-normal leading-[1.5] text-[#333C47]">
|
||||||
|
{notice.content.map((p, idx) => (
|
||||||
|
<p key={idx} className="mb-0 leading-[1.5]">
|
||||||
|
{p}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 첨부파일 섹션 */}
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-[24px] w-full">
|
||||||
|
<div className="flex flex-col gap-[8px] w-full">
|
||||||
|
<div className="flex items-center gap-[12px] h-[32px]">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<p className="text-[15px] font-semibold leading-[1.5] text-[#6C7682] m-0">
|
||||||
|
첨부 파일
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-3">
|
||||||
|
{attachments.map((attachment, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-white border border-[#DEE1E6] rounded-[6px] h-[64px] flex items-center gap-[12px] px-[17px] py-1 w-full"
|
||||||
|
>
|
||||||
|
<div className="size-[24px] shrink-0">
|
||||||
|
<PaperClipSvg width={24} height={24} className="text-[#333C47]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center gap-[8px] min-w-0">
|
||||||
|
<p className="text-[15px] font-normal leading-[1.5] text-[#1B2027] truncate m-0">
|
||||||
|
{attachment.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#8C95A1] shrink-0 m-0">
|
||||||
|
{attachment.size}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDownload(attachment.fileKey, attachment.url, attachment.name)}
|
||||||
|
className="bg-white border border-[#8C95A1] rounded-[6px] h-[32px] flex items-center justify-center gap-[4px] px-[16px] py-[3px] shrink-0 hover:bg-[#F9FAFB] cursor-pointer"
|
||||||
|
>
|
||||||
|
<DownloadIcon width={16} height={16} className="text-[#4C5561]" />
|
||||||
|
<span className="text-[13px] font-medium leading-[1.4] text-[#4C5561]">
|
||||||
|
다운로드
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
<div className="flex items-center justify-end gap-[12px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsDeleteModalOpen(true)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-[#FEF2F2] h-[48px] rounded-[10px] px-[8px] shrink-0 min-w-[80px] flex items-center justify-center hover:bg-[#FEE2E2] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span className="text-[16px] font-semibold leading-[1.5] text-[#F64C4C] text-center">
|
||||||
|
삭제
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/admin/notices/${params.id}/edit`)}
|
||||||
|
className="bg-[#F1F3F5] h-[48px] rounded-[10px] px-[16px] shrink-0 min-w-[90px] flex items-center justify-center hover:bg-[#E9ECEF] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-[16px] font-semibold leading-[1.5] text-[#4C5561] text-center">
|
||||||
|
수정하기
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
<NoticeDeleteModal
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
onClose={() => setIsDeleteModalOpen(false)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!params?.id) {
|
||||||
|
alert('공지사항 ID를 찾을 수 없습니다.');
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsDeleting(true);
|
||||||
|
await apiService.deleteNotice(params.id);
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setShowToast(true);
|
||||||
|
// 토스트 표시 후 목록 페이지로 이동
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/admin/notices');
|
||||||
|
}, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('공지사항 삭제 오류:', err);
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '공지사항 삭제에 실패했습니다.';
|
||||||
|
alert(errorMessage);
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 삭제 완료 토스트 */}
|
||||||
|
{showToast && (
|
||||||
|
<div className="fixed right-[60px] bottom-[60px] z-50">
|
||||||
|
<div className="bg-white border border-[#dee1e6] rounded-[8px] p-4 min-w-[360px] flex gap-[10px] items-center">
|
||||||
|
<div className="relative shrink-0 w-[16.667px] h-[16.667px]">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="8.5" cy="8.5" r="8.5" fill="#384FBF"/>
|
||||||
|
<path d="M5.5 8.5L7.5 10.5L11.5 6.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] text-nowrap">
|
||||||
|
공지사항이 삭제되었습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
37
src/app/admin/notices/mockData.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export type Notice = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
date: string; // 게시일
|
||||||
|
views: number; // 조회수
|
||||||
|
writer: string; // 작성자
|
||||||
|
content?: string[]; // 본문 내용 (상세 페이지용)
|
||||||
|
hasAttachment?: boolean; // 첨부파일 여부
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: 나중에 DB에서 가져오도록 변경
|
||||||
|
export const MOCK_NOTICES: Notice[] = [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '공지사항 제목이 노출돼요',
|
||||||
|
date: '2025-09-10',
|
||||||
|
views: 1230,
|
||||||
|
writer: '문지호',
|
||||||
|
content: [
|
||||||
|
'사이트 이용 관련 주요 변경 사항을 안내드립니다.',
|
||||||
|
'변경되는 내용은 공지일자로부터 즉시 적용됩니다.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '📢 방사선학 온라인 강의 수강 안내 및 필수 공지',
|
||||||
|
date: '2025-06-28',
|
||||||
|
views: 594,
|
||||||
|
writer: '문지호',
|
||||||
|
hasAttachment: true,
|
||||||
|
content: [
|
||||||
|
'온라인 강의 수강 방법과 필수 확인 사항을 안내드립니다.',
|
||||||
|
'수강 기간 및 출석, 과제 제출 관련 정책을 반드시 확인해 주세요.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
620
src/app/admin/notices/page.tsx
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useRef, ChangeEvent, useEffect } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import AdminSidebar from "@/app/components/AdminSidebar";
|
||||||
|
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
||||||
|
import BackArrowSvg from "@/app/svgs/backarrow";
|
||||||
|
import { type Notice } from "@/app/admin/notices/mockData";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
import NoticeValidationModal from "@/app/admin/notices/NoticeValidationModal";
|
||||||
|
import NoticeCancelModal from "@/app/admin/notices/NoticeCancelModal";
|
||||||
|
|
||||||
|
export default function AdminNoticesPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [notices, setNotices] = useState<Notice[]>([]);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [isWritingMode, setIsWritingMode] = useState(false);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [attachedFile, setAttachedFile] = useState<File | null>(null);
|
||||||
|
const [fileKey, setFileKey] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isValidationModalOpen, setIsValidationModalOpen] = useState(false);
|
||||||
|
const [isCancelModalOpen, setIsCancelModalOpen] = useState(false);
|
||||||
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 날짜를 yyyy-mm-dd 형식으로 포맷팅
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
// 이미 yyyy-mm-dd 형식인 경우 그대로 반환
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API에서 공지사항 목록 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchNotices() {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await apiService.getNotices();
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||||
|
let noticesArray: any[] = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
noticesArray = data;
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
noticesArray = data.items || data.notices || data.data || data.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답 데이터를 Notice 형식으로 변환
|
||||||
|
const transformedNotices: Notice[] = noticesArray.map((notice: any) => ({
|
||||||
|
id: notice.id || notice.noticeId || 0,
|
||||||
|
title: notice.title || '',
|
||||||
|
date: notice.date || notice.createdAt || notice.createdDate || new Date().toISOString().split('T')[0],
|
||||||
|
views: notice.views || notice.viewCount || 0,
|
||||||
|
writer: notice.writer || notice.author || notice.createdBy || '관리자',
|
||||||
|
content: notice.content ? (Array.isArray(notice.content) ? notice.content : [notice.content]) : undefined,
|
||||||
|
hasAttachment: notice.hasAttachment || notice.attachment || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setNotices(transformedNotices);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('공지사항 목록 조회 오류:', error);
|
||||||
|
// 에러 발생 시 빈 배열로 설정
|
||||||
|
setNotices([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchNotices();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 수정 완료 쿼리 파라미터 확인 및 토스트 표시
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('updated') === 'true') {
|
||||||
|
setShowToast(true);
|
||||||
|
// URL에서 쿼리 파라미터 제거
|
||||||
|
router.replace('/admin/notices');
|
||||||
|
// 토스트 자동 닫기
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowToast(false);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [searchParams, router]);
|
||||||
|
|
||||||
|
const totalCount = useMemo(() => notices.length, [notices]);
|
||||||
|
|
||||||
|
const characterCount = useMemo(() => content.length, [content]);
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setIsWritingMode(false);
|
||||||
|
setTitle('');
|
||||||
|
setContent('');
|
||||||
|
setAttachedFile(null);
|
||||||
|
setFileKey(null);
|
||||||
|
// 파일 입력 초기화
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileAttach = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (file.size > 30 * 1024 * 1024) {
|
||||||
|
alert('30MB 미만의 파일만 첨부할 수 있습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
// 단일 파일 업로드
|
||||||
|
const uploadResponse = await apiService.uploadFile(file);
|
||||||
|
|
||||||
|
// 응답에서 fileKey 추출
|
||||||
|
let extractedFileKey: string | null = null;
|
||||||
|
if (uploadResponse.data?.fileKey) {
|
||||||
|
extractedFileKey = uploadResponse.data.fileKey;
|
||||||
|
} else if (uploadResponse.data?.key) {
|
||||||
|
extractedFileKey = uploadResponse.data.key;
|
||||||
|
} else if (uploadResponse.data?.id) {
|
||||||
|
extractedFileKey = uploadResponse.data.id;
|
||||||
|
} else if (uploadResponse.data?.imageKey) {
|
||||||
|
extractedFileKey = uploadResponse.data.imageKey;
|
||||||
|
} else if (uploadResponse.data?.fileId) {
|
||||||
|
extractedFileKey = uploadResponse.data.fileId;
|
||||||
|
} else if (uploadResponse.data?.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey)) {
|
||||||
|
extractedFileKey = uploadResponse.data.data.key || uploadResponse.data.data.fileKey;
|
||||||
|
} else if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results) && uploadResponse.data.results.length > 0) {
|
||||||
|
const result = uploadResponse.data.results[0];
|
||||||
|
if (result.ok && result.fileKey) {
|
||||||
|
extractedFileKey = result.fileKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractedFileKey) {
|
||||||
|
setFileKey(extractedFileKey);
|
||||||
|
setAttachedFile(file);
|
||||||
|
} else {
|
||||||
|
throw new Error('파일 키를 받아오지 못했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 업로드 실패:', error);
|
||||||
|
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
setAttachedFile(null);
|
||||||
|
setFileKey(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
// 파일 입력 초기화
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!title.trim() || !content.trim()) {
|
||||||
|
setIsValidationModalOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// 공지사항 생성 API 호출
|
||||||
|
const noticeData: any = {
|
||||||
|
title: title.trim(),
|
||||||
|
content: content.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// fileKey와 파일 정보가 있으면 attachments 배열로 포함
|
||||||
|
if (fileKey && attachedFile) {
|
||||||
|
noticeData.attachments = [
|
||||||
|
{
|
||||||
|
fileKey: fileKey,
|
||||||
|
filename: attachedFile.name,
|
||||||
|
mimeType: attachedFile.type || 'application/octet-stream',
|
||||||
|
size: attachedFile.size,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiService.createNotice(noticeData);
|
||||||
|
|
||||||
|
// API 응답 후 목록 새로고침
|
||||||
|
const fetchResponse = await apiService.getNotices();
|
||||||
|
const data = fetchResponse.data;
|
||||||
|
|
||||||
|
// API 응답이 배열이 아닌 경우 처리
|
||||||
|
let noticesArray: any[] = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
noticesArray = data;
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
noticesArray = data.items || data.notices || data.data || data.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답 데이터를 Notice 형식으로 변환
|
||||||
|
const transformedNotices: Notice[] = noticesArray.map((notice: any) => ({
|
||||||
|
id: notice.id || notice.noticeId || 0,
|
||||||
|
title: notice.title || '',
|
||||||
|
date: notice.date || notice.createdAt || notice.createdDate || new Date().toISOString().split('T')[0],
|
||||||
|
views: notice.views || notice.viewCount || 0,
|
||||||
|
writer: notice.writer || notice.author || notice.createdBy || '관리자',
|
||||||
|
content: notice.content ? (Array.isArray(notice.content) ? notice.content : [notice.content]) : undefined,
|
||||||
|
hasAttachment: notice.hasAttachment || notice.attachment || !!notice.fileKey || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setNotices(transformedNotices);
|
||||||
|
handleBack();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('공지사항 저장 실패:', error);
|
||||||
|
alert('공지사항 저장에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (title.trim() || content.trim() || attachedFile || fileKey) {
|
||||||
|
setIsCancelModalOpen(true);
|
||||||
|
} else {
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelConfirm = () => {
|
||||||
|
setIsCancelModalOpen(false);
|
||||||
|
handleBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const sortedNotices = useMemo(() => {
|
||||||
|
return [...notices].sort((a, b) => {
|
||||||
|
// 생성일 내림차순 정렬 (최신 날짜가 먼저)
|
||||||
|
return b.date.localeCompare(a.date);
|
||||||
|
});
|
||||||
|
}, [notices]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(sortedNotices.length / ITEMS_PER_PAGE);
|
||||||
|
const paginatedNotices = useMemo(() => {
|
||||||
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||||
|
return sortedNotices.slice(startIndex, endIndex);
|
||||||
|
}, [sortedNotices, currentPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NoticeValidationModal
|
||||||
|
open={isValidationModalOpen}
|
||||||
|
onClose={() => setIsValidationModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<NoticeCancelModal
|
||||||
|
open={isCancelModalOpen}
|
||||||
|
onClose={() => setIsCancelModalOpen(false)}
|
||||||
|
onConfirm={handleCancelConfirm}
|
||||||
|
/>
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
{/* 메인 레이아웃 */}
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col px-8">
|
||||||
|
{isWritingMode ? (
|
||||||
|
<>
|
||||||
|
{/* 작성 모드 헤더 */}
|
||||||
|
<div className="h-[100px] flex items-center">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="flex items-center justify-center w-8 h-8 cursor-pointer"
|
||||||
|
aria-label="뒤로가기"
|
||||||
|
>
|
||||||
|
<BackArrowSvg width={32} height={32} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
공지사항 작성
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작성 폼 */}
|
||||||
|
<div className="flex-1 flex flex-col gap-10 pb-20 pt-8 w-full">
|
||||||
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
{/* 제목 입력 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
|
||||||
|
제목
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="제목을 입력해 주세요."
|
||||||
|
className="w-full h-[40px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 내용 입력 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
|
||||||
|
내용
|
||||||
|
</label>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newContent = e.target.value;
|
||||||
|
if (newContent.length <= 1000) {
|
||||||
|
setContent(newContent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="내용을 입력해 주세요. (최대 1,000자 이내)"
|
||||||
|
className="w-full h-[320px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] resize-none focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-3 right-3">
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#6c7682] text-right">
|
||||||
|
{characterCount}/1000
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 첨부 파일 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<div className="flex items-center justify-between h-8 w-full">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] whitespace-nowrap">
|
||||||
|
첨부 파일{' '}
|
||||||
|
<span className="font-normal">
|
||||||
|
({attachedFile ? 1 : 0}/1)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#8c95a1] whitespace-nowrap">
|
||||||
|
30MB 미만 파일
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFileAttach}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-[32px] w-[62px] px-[4px] py-[3px] rounded-[6px] border border-[#8c95a1] bg-white flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-colors shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span className="text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap">
|
||||||
|
{isLoading ? '업로드 중...' : '첨부'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
accept="*/*"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-16 w-full rounded-[8px] border border-[#dee1e6] bg-gray-50 flex items-center px-4">
|
||||||
|
{attachedFile ? (
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027]">
|
||||||
|
{attachedFile.name}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center w-full">
|
||||||
|
파일을 첨부해주세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="flex gap-3 items-center justify-end shrink-0 w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="h-12 px-8 rounded-[10px] bg-[#f1f3f5] text-[16px] font-semibold leading-[1.5] text-[#4c5561] whitespace-nowrap hover:bg-[#e5e8eb] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-12 px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? '저장 중...' : '저장하기'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 제목 영역 */}
|
||||||
|
<div className="h-[100px] flex items-center">
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
공지사항
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 영역 (제목과 콘텐츠 사이) */}
|
||||||
|
<div className="pt-2 pb-4 flex items-center justify-between">
|
||||||
|
<p className="text-[15px] font-medium leading-[1.5] text-[#333c47] whitespace-nowrap">
|
||||||
|
총 {totalCount}건
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsWritingMode(true)}
|
||||||
|
className="h-[40px] px-4 rounded-[8px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
작성하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 콘텐츠 영역 */}
|
||||||
|
<div className="flex-1 pt-2 flex flex-col">
|
||||||
|
{notices.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47] text-center">
|
||||||
|
등록된 공지사항이 없습니다.
|
||||||
|
<br />
|
||||||
|
공지사항을 등록해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="rounded-[8px]">
|
||||||
|
<div className="w-full rounded-[8px] border border-[#dee1e6] overflow-visible">
|
||||||
|
<table className="min-w-full border-collapse">
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: 80 }} />
|
||||||
|
<col />
|
||||||
|
<col style={{ width: 140 }} />
|
||||||
|
<col style={{ width: 120 }} />
|
||||||
|
<col style={{ width: 120 }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr className="h-12 bg-gray-50 text-left">
|
||||||
|
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]">번호</th>
|
||||||
|
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]">제목</th>
|
||||||
|
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]">게시일</th>
|
||||||
|
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]">조회수</th>
|
||||||
|
<th className="px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]">작성자</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paginatedNotices.map((notice, index) => {
|
||||||
|
// 번호는 전체 목록에서의 순서 (정렬된 목록 기준)
|
||||||
|
const noticeNumber = sortedNotices.length - (currentPage - 1) * ITEMS_PER_PAGE - index;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={notice.id}
|
||||||
|
onClick={() => router.push(`/admin/notices/${notice.id}`)}
|
||||||
|
className="h-12 hover:bg-[#F5F7FF] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap text-center">
|
||||||
|
{noticeNumber}
|
||||||
|
</td>
|
||||||
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027]">
|
||||||
|
{notice.title}
|
||||||
|
</td>
|
||||||
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||||
|
{formatDate(notice.date)}
|
||||||
|
</td>
|
||||||
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||||
|
{notice.views.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="border-t border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||||
|
{notice.writer}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
|
||||||
|
{notices.length > ITEMS_PER_PAGE && (() => {
|
||||||
|
// 페이지 번호를 10단위로 표시
|
||||||
|
const pageGroup = Math.floor((currentPage - 1) / 10);
|
||||||
|
const startPage = pageGroup * 10 + 1;
|
||||||
|
const endPage = Math.min(startPage + 9, totalPages);
|
||||||
|
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-8 pb-[32px] flex items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center gap-[8px]">
|
||||||
|
{/* First (맨 앞으로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
aria-label="맨 앞 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
aria-label="이전 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Numbers */}
|
||||||
|
{visiblePages.map((n) => {
|
||||||
|
const active = n === currentPage;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(n)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={[
|
||||||
|
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||||
|
active ? 'bg-[#ecf0ff]' : 'bg-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
aria-label="다음 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Last (맨 뒤로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
aria-label="맨 뒤 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수정 완료 토스트 */}
|
||||||
|
{showToast && (
|
||||||
|
<div className="fixed right-[60px] bottom-[60px] z-50">
|
||||||
|
<div className="bg-white border border-[#dee1e6] rounded-[8px] p-4 min-w-[360px] flex gap-[10px] items-center">
|
||||||
|
<div className="relative shrink-0 w-[16.667px] h-[16.667px]">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="8.5" cy="8.5" r="8.5" fill="#384FBF"/>
|
||||||
|
<path d="M5.5 8.5L7.5 10.5L11.5 6.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] text-nowrap">
|
||||||
|
수정이 완료되었습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
6
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
redirect('/admin/id');
|
||||||
|
}
|
||||||
|
|
||||||
145
src/app/admin/questions/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import AdminSidebar from "@/app/components/AdminSidebar";
|
||||||
|
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
||||||
|
|
||||||
|
export default function AdminQuestionsPage() {
|
||||||
|
// TODO: 나중에 실제 데이터로 교체
|
||||||
|
const items: any[] = [];
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const totalPages = Math.ceil(items.length / ITEMS_PER_PAGE);
|
||||||
|
const paginatedItems = useMemo(() => {
|
||||||
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||||
|
return items.slice(startIndex, endIndex);
|
||||||
|
}, [items, currentPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
{/* 메인 레이아웃 */}
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col px-8">
|
||||||
|
{/* 제목 영역 */}
|
||||||
|
<div className="h-[100px] flex items-center">
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
문제 은행
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 콘텐츠 영역 */}
|
||||||
|
<div className="flex-1 pt-8 flex flex-col">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
|
||||||
|
현재 관리할 수 있는 항목이 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* TODO: 테이블 또는 리스트를 여기에 추가 */}
|
||||||
|
|
||||||
|
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
|
||||||
|
{items.length > ITEMS_PER_PAGE && (() => {
|
||||||
|
// 페이지 번호를 10단위로 표시
|
||||||
|
const pageGroup = Math.floor((currentPage - 1) / 10);
|
||||||
|
const startPage = pageGroup * 10 + 1;
|
||||||
|
const endPage = Math.min(startPage + 9, totalPages);
|
||||||
|
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-8 pb-[32px] flex items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center gap-[8px]">
|
||||||
|
{/* First (맨 앞으로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
aria-label="맨 앞 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
aria-label="이전 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Numbers */}
|
||||||
|
{visiblePages.map((n) => {
|
||||||
|
const active = n === currentPage;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(n)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={[
|
||||||
|
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||||
|
active ? 'bg-[#ecf0ff]' : 'bg-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
aria-label="다음 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Last (맨 뒤로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
aria-label="맨 뒤 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
483
src/app/admin/resources/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useRef, ChangeEvent, useEffect } from "react";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
import AdminSidebar from "@/app/components/AdminSidebar";
|
||||||
|
import BackArrowSvg from "@/app/svgs/backarrow";
|
||||||
|
import CloseXOSvg from "@/app/svgs/closexo";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
import NoticeValidationModal from "@/app/admin/notices/NoticeValidationModal";
|
||||||
|
import NoticeCancelModal from "@/app/admin/notices/NoticeCancelModal";
|
||||||
|
import NoChangesModal from "@/app/admin/notices/NoChangesModal";
|
||||||
|
|
||||||
|
type Attachment = {
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
url?: string;
|
||||||
|
fileKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminResourceEditPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [attachedFile, setAttachedFile] = useState<File | null>(null);
|
||||||
|
const [fileKey, setFileKey] = useState<string | null>(null);
|
||||||
|
const [existingAttachment, setExistingAttachment] = useState<Attachment | null>(null);
|
||||||
|
const [originalTitle, setOriginalTitle] = useState<string>('');
|
||||||
|
const [originalContent, setOriginalContent] = useState<string>('');
|
||||||
|
const [originalFileKey, setOriginalFileKey] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
|
const [isValidationModalOpen, setIsValidationModalOpen] = useState(false);
|
||||||
|
const [isCancelModalOpen, setIsCancelModalOpen] = useState(false);
|
||||||
|
const [isNoChangesModalOpen, setIsNoChangesModalOpen] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const characterCount = useMemo(() => content.length, [content]);
|
||||||
|
|
||||||
|
// 학습 자료 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchResource() {
|
||||||
|
if (!params?.id) {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingData(true);
|
||||||
|
const response = await apiService.getLibraryItem(params.id);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// 제목 설정
|
||||||
|
const loadedTitle = data.title || '';
|
||||||
|
setTitle(loadedTitle);
|
||||||
|
setOriginalTitle(loadedTitle);
|
||||||
|
|
||||||
|
// 내용 설정 (배열이면 join, 문자열이면 그대로)
|
||||||
|
let loadedContent = '';
|
||||||
|
if (data.content) {
|
||||||
|
if (Array.isArray(data.content)) {
|
||||||
|
loadedContent = data.content.join('\n');
|
||||||
|
} else if (typeof data.content === 'string') {
|
||||||
|
loadedContent = data.content;
|
||||||
|
} else {
|
||||||
|
loadedContent = String(data.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setContent(loadedContent);
|
||||||
|
setOriginalContent(loadedContent);
|
||||||
|
|
||||||
|
// 기존 첨부파일 정보 설정
|
||||||
|
if (data.attachments && Array.isArray(data.attachments) && data.attachments.length > 0) {
|
||||||
|
const att = data.attachments[0];
|
||||||
|
setExistingAttachment({
|
||||||
|
name: att.name || att.fileName || att.filename || '첨부파일',
|
||||||
|
size: att.size || att.fileSize || '',
|
||||||
|
url: att.url || att.downloadUrl,
|
||||||
|
fileKey: att.fileKey || att.key || att.fileId,
|
||||||
|
});
|
||||||
|
// 기존 파일이 있으면 fileKey도 설정
|
||||||
|
const loadedFileKey = att.fileKey || att.key || att.fileId;
|
||||||
|
if (loadedFileKey) {
|
||||||
|
setFileKey(loadedFileKey);
|
||||||
|
setOriginalFileKey(loadedFileKey);
|
||||||
|
}
|
||||||
|
} else if (data.attachment) {
|
||||||
|
// 단일 첨부파일인 경우
|
||||||
|
setExistingAttachment({
|
||||||
|
name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일',
|
||||||
|
size: data.attachment.size || data.attachment.fileSize || '',
|
||||||
|
url: data.attachment.url || data.attachment.downloadUrl,
|
||||||
|
fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId,
|
||||||
|
});
|
||||||
|
const loadedFileKey = data.attachment.fileKey || data.attachment.key || data.attachment.fileId;
|
||||||
|
if (loadedFileKey) {
|
||||||
|
setFileKey(loadedFileKey);
|
||||||
|
setOriginalFileKey(loadedFileKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('학습 자료 조회 오류:', error);
|
||||||
|
alert('학습 자료를 불러오는 중 오류가 발생했습니다.');
|
||||||
|
router.push('/admin/resources');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchResource();
|
||||||
|
}, [params?.id, router]);
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
router.push(`/admin/resources/${params.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileAttach = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (file.size > 30 * 1024 * 1024) {
|
||||||
|
alert('30MB 미만의 파일만 첨부할 수 있습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
// 단일 파일 업로드
|
||||||
|
const uploadResponse = await apiService.uploadFile(file);
|
||||||
|
|
||||||
|
// 응답에서 fileKey 추출
|
||||||
|
let extractedFileKey: string | null = null;
|
||||||
|
if (uploadResponse.data?.fileKey) {
|
||||||
|
extractedFileKey = uploadResponse.data.fileKey;
|
||||||
|
} else if (uploadResponse.data?.key) {
|
||||||
|
extractedFileKey = uploadResponse.data.key;
|
||||||
|
} else if (uploadResponse.data?.id) {
|
||||||
|
extractedFileKey = uploadResponse.data.id;
|
||||||
|
} else if (uploadResponse.data?.imageKey) {
|
||||||
|
extractedFileKey = uploadResponse.data.imageKey;
|
||||||
|
} else if (uploadResponse.data?.fileId) {
|
||||||
|
extractedFileKey = uploadResponse.data.fileId;
|
||||||
|
} else if (uploadResponse.data?.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey)) {
|
||||||
|
extractedFileKey = uploadResponse.data.data.key || uploadResponse.data.data.fileKey;
|
||||||
|
} else if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results) && uploadResponse.data.results.length > 0) {
|
||||||
|
const result = uploadResponse.data.results[0];
|
||||||
|
if (result.ok && result.fileKey) {
|
||||||
|
extractedFileKey = result.fileKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractedFileKey) {
|
||||||
|
setFileKey(extractedFileKey);
|
||||||
|
setAttachedFile(file);
|
||||||
|
// 새 파일을 업로드하면 기존 파일 정보 제거
|
||||||
|
setExistingAttachment(null);
|
||||||
|
} else {
|
||||||
|
throw new Error('파일 키를 받아오지 못했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 업로드 실패:', error);
|
||||||
|
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
setAttachedFile(null);
|
||||||
|
setFileKey(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
// 파일 입력 초기화
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileRemove = () => {
|
||||||
|
setAttachedFile(null);
|
||||||
|
setExistingAttachment(null);
|
||||||
|
setFileKey(null);
|
||||||
|
// 파일 입력 초기화
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!title.trim() || !content.trim()) {
|
||||||
|
setIsValidationModalOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!params?.id) {
|
||||||
|
alert('학습 자료 ID를 찾을 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// 변경된 필드만 포함하는 request body 생성
|
||||||
|
const resourceData: any = {};
|
||||||
|
|
||||||
|
// 제목이 변경되었는지 확인
|
||||||
|
const trimmedTitle = title.trim();
|
||||||
|
if (trimmedTitle !== originalTitle) {
|
||||||
|
resourceData.title = trimmedTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 내용이 변경되었는지 확인
|
||||||
|
const trimmedContent = content.trim();
|
||||||
|
if (trimmedContent !== originalContent) {
|
||||||
|
resourceData.content = trimmedContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 변경사항 확인
|
||||||
|
const currentFileKey = fileKey;
|
||||||
|
const hasFileChanged = currentFileKey !== originalFileKey;
|
||||||
|
|
||||||
|
// 파일이 삭제된 경우 (기존에 파일이 있었는데 지금 없음)
|
||||||
|
if (originalFileKey && !currentFileKey) {
|
||||||
|
resourceData.attachments = [];
|
||||||
|
}
|
||||||
|
// 파일이 변경되었거나 새로 추가된 경우
|
||||||
|
else if (hasFileChanged && currentFileKey) {
|
||||||
|
if (attachedFile) {
|
||||||
|
// 새로 업로드한 파일
|
||||||
|
resourceData.attachments = [
|
||||||
|
{
|
||||||
|
fileKey: currentFileKey,
|
||||||
|
filename: attachedFile.name,
|
||||||
|
mimeType: attachedFile.type || 'application/octet-stream',
|
||||||
|
size: attachedFile.size,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else if (existingAttachment && existingAttachment.fileKey) {
|
||||||
|
// 기존 파일 유지
|
||||||
|
resourceData.attachments = [
|
||||||
|
{
|
||||||
|
fileKey: existingAttachment.fileKey,
|
||||||
|
filename: existingAttachment.name,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변경사항이 없으면 알림 후 리턴
|
||||||
|
if (Object.keys(resourceData).length === 0) {
|
||||||
|
setIsNoChangesModalOpen(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiService.updateLibraryItem(params.id, resourceData);
|
||||||
|
|
||||||
|
// 성공 시 학습 자료 리스트로 이동 (토스트는 리스트 페이지에서 표시)
|
||||||
|
router.push('/admin/resources?updated=true');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('학습 자료 수정 실패:', error);
|
||||||
|
alert('학습 자료 수정에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (title.trim() || content.trim() || attachedFile || fileKey) {
|
||||||
|
setIsCancelModalOpen(true);
|
||||||
|
} else {
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelConfirm = () => {
|
||||||
|
setIsCancelModalOpen(false);
|
||||||
|
handleBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingData) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="h-[100px] flex items-center justify-center px-[32px]">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NoticeValidationModal
|
||||||
|
open={isValidationModalOpen}
|
||||||
|
onClose={() => setIsValidationModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<NoticeCancelModal
|
||||||
|
open={isCancelModalOpen}
|
||||||
|
onClose={() => setIsCancelModalOpen(false)}
|
||||||
|
onConfirm={handleCancelConfirm}
|
||||||
|
/>
|
||||||
|
<NoChangesModal
|
||||||
|
open={isNoChangesModalOpen}
|
||||||
|
onClose={() => setIsNoChangesModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
{/* 메인 레이아웃 */}
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col px-8">
|
||||||
|
{/* 작성 모드 헤더 */}
|
||||||
|
<div className="h-[100px] flex items-center">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="flex items-center justify-center w-8 h-8 cursor-pointer"
|
||||||
|
aria-label="뒤로가기"
|
||||||
|
>
|
||||||
|
<BackArrowSvg width={32} height={32} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
학습 자료 수정
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작성 폼 */}
|
||||||
|
<div className="flex-1 flex flex-col gap-10 pb-20 pt-8 w-full">
|
||||||
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
{/* 제목 입력 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
|
||||||
|
제목
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="제목을 입력해 주세요."
|
||||||
|
className="w-full h-[40px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 내용 입력 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
|
||||||
|
내용
|
||||||
|
</label>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newContent = e.target.value;
|
||||||
|
if (newContent.length <= 1000) {
|
||||||
|
setContent(newContent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="내용을 입력해 주세요. (최대 1,000자 이내)"
|
||||||
|
className="w-full h-[320px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] resize-none focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-3 right-3">
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#6c7682] text-right">
|
||||||
|
{characterCount}/1000
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 첨부 파일 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<div className="flex items-center justify-between h-8 w-full">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] whitespace-nowrap">
|
||||||
|
첨부 파일{' '}
|
||||||
|
<span className="font-normal">
|
||||||
|
{(attachedFile || existingAttachment) ? 1 : 0}/1
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#8c95a1] whitespace-nowrap">
|
||||||
|
30MB 미만 파일
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFileAttach}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-[32px] w-[62px] px-[4px] py-[3px] rounded-[6px] border border-[#8c95a1] bg-white flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-colors shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span className="text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap">
|
||||||
|
{isLoading ? '업로드 중...' : '첨부'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
accept="*/*"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-16 w-full rounded-[8px] border border-[#dee1e6] bg-gray-50 flex items-center justify-between px-4">
|
||||||
|
{attachedFile ? (
|
||||||
|
<>
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027] flex-1 min-w-0 truncate">
|
||||||
|
{attachedFile.name}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFileRemove}
|
||||||
|
className="flex items-center justify-center w-6 h-6 cursor-pointer hover:opacity-70 transition-opacity shrink-0 ml-2"
|
||||||
|
aria-label="파일 삭제"
|
||||||
|
>
|
||||||
|
<CloseXOSvg />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : existingAttachment ? (
|
||||||
|
<>
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027] flex-1 min-w-0 truncate">
|
||||||
|
{existingAttachment.name}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFileRemove}
|
||||||
|
className="flex items-center justify-center w-6 h-6 cursor-pointer hover:opacity-70 transition-opacity shrink-0 ml-2"
|
||||||
|
aria-label="파일 삭제"
|
||||||
|
>
|
||||||
|
<CloseXOSvg />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center w-full">
|
||||||
|
파일을 첨부해주세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="flex gap-3 items-center justify-end shrink-0 w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="h-12 px-8 rounded-[10px] bg-[#f1f3f5] text-[16px] font-semibold leading-[1.5] text-[#4c5561] whitespace-nowrap hover:bg-[#e5e8eb] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-12 px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? '저장 중...' : '저장하기'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
390
src/app/admin/resources/[id]/page.tsx
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import AdminSidebar from '@/app/components/AdminSidebar';
|
||||||
|
import BackCircleSvg from '@/app/svgs/backcirclesvg';
|
||||||
|
import DownloadIcon from '@/app/svgs/downloadicon';
|
||||||
|
import PaperClipSvg from '@/app/svgs/paperclipsvg';
|
||||||
|
import apiService from '@/app/lib/apiService';
|
||||||
|
import type { Resource } from '@/app/admin/resources/mockData';
|
||||||
|
import NoticeDeleteModal from '@/app/admin/notices/NoticeDeleteModal';
|
||||||
|
|
||||||
|
type Attachment = {
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
url?: string;
|
||||||
|
fileKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminResourceDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [resource, setResource] = useState<Resource | null>(null);
|
||||||
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchResource() {
|
||||||
|
if (!params?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await apiService.getLibraryItem(params.id);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// API 응답 데이터를 Resource 형식으로 변환
|
||||||
|
const transformedResource: Resource = {
|
||||||
|
id: data.id || data.resourceId || Number(params.id),
|
||||||
|
title: data.title || '',
|
||||||
|
date: data.date || data.createdAt || data.createdDate || new Date().toISOString().split('T')[0],
|
||||||
|
views: data.views || data.viewCount || 0,
|
||||||
|
writer: data.writer || data.author || data.createdBy || '관리자',
|
||||||
|
content: data.content
|
||||||
|
? (Array.isArray(data.content)
|
||||||
|
? data.content
|
||||||
|
: typeof data.content === 'string'
|
||||||
|
? data.content.split('\n').filter((line: string) => line.trim())
|
||||||
|
: [String(data.content)])
|
||||||
|
: [],
|
||||||
|
hasAttachment: data.hasAttachment || data.attachment || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 첨부파일 정보 처리
|
||||||
|
if (data.attachments && Array.isArray(data.attachments)) {
|
||||||
|
setAttachments(data.attachments.map((att: any) => ({
|
||||||
|
name: att.name || att.fileName || att.filename || '',
|
||||||
|
size: att.size || att.fileSize || '',
|
||||||
|
url: att.url || att.downloadUrl,
|
||||||
|
fileKey: att.fileKey || att.key || att.fileId,
|
||||||
|
})));
|
||||||
|
} else if (transformedResource.hasAttachment && data.attachment) {
|
||||||
|
// 단일 첨부파일인 경우
|
||||||
|
setAttachments([{
|
||||||
|
name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일',
|
||||||
|
size: data.attachment.size || data.attachment.fileSize || '',
|
||||||
|
url: data.attachment.url || data.attachment.downloadUrl,
|
||||||
|
fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transformedResource.title) {
|
||||||
|
throw new Error('학습 자료를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setResource(transformedResource);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('학습 자료 조회 오류:', err);
|
||||||
|
setError('학습 자료를 불러오는 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchResource();
|
||||||
|
}, [params?.id]);
|
||||||
|
|
||||||
|
// 토스트 자동 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
if (showToast) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowToast(false);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
const handleDownload = async (fileKey?: string, url?: string, fileName?: string) => {
|
||||||
|
if (url) {
|
||||||
|
// URL이 있으면 직접 다운로드
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
} else if (fileKey) {
|
||||||
|
// fileKey가 있으면 API를 통해 다운로드
|
||||||
|
try {
|
||||||
|
const fileUrl = await apiService.getFile(fileKey);
|
||||||
|
if (fileUrl) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = fileUrl;
|
||||||
|
link.download = fileName || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('파일 다운로드 실패:', err);
|
||||||
|
alert('파일 다운로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="h-[100px] flex items-center justify-center px-[32px]">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !resource || !resource.content || resource.content.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
|
||||||
|
<Link
|
||||||
|
href="/admin/resources"
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</Link>
|
||||||
|
<h1 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">
|
||||||
|
학습 자료 상세
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">
|
||||||
|
{error || '학습 자료를 찾을 수 없습니다.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* 상단 타이틀 */}
|
||||||
|
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
|
||||||
|
<Link
|
||||||
|
href="/admin/resources"
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline shrink-0"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</Link>
|
||||||
|
<h1 className="m-0 text-[24px] font-bold leading-[1.5] text-[#1B2027]">
|
||||||
|
학습 자료 상세
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 */}
|
||||||
|
<section className="flex flex-col gap-[40px] px-[32px] py-[24px]">
|
||||||
|
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="p-[32px]">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-col gap-[8px]">
|
||||||
|
<h2 className="m-0 text-[20px] font-bold leading-[1.5] text-[#333C47]">
|
||||||
|
{resource.title}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-[16px] text-[13px] font-medium leading-[1.4]">
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
<span className="text-[#8C95A1]">작성자</span>
|
||||||
|
<span className="text-[#333C47]">{resource.writer}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-[16px] w-0 flex items-center justify-center">
|
||||||
|
<div className="h-0 w-[16px] border-t border-[#DEE1E6]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
<span className="text-[#8C95A1]">게시일</span>
|
||||||
|
<span className="text-[#333C47]">
|
||||||
|
{resource.date.includes('T')
|
||||||
|
? new Date(resource.date).toISOString().split('T')[0]
|
||||||
|
: resource.date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-[16px] w-0 flex items-center justify-center">
|
||||||
|
<div className="h-0 w-[16px] border-t border-[#DEE1E6]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
<span className="text-[#8C95A1]">조회수</span>
|
||||||
|
<span className="text-[#333C47]">{resource.views.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="h-px bg-[#DEE1E6] w-full" />
|
||||||
|
|
||||||
|
{/* 본문 및 첨부파일 */}
|
||||||
|
<div className="flex flex-col gap-[40px] p-[32px]">
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="text-[15px] font-normal leading-[1.5] text-[#333C47]">
|
||||||
|
{resource.content.map((p, idx) => (
|
||||||
|
<p key={idx} className="mb-0 leading-[1.5]">
|
||||||
|
{p}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 첨부파일 섹션 */}
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-[24px] w-full">
|
||||||
|
<div className="flex flex-col gap-[8px] w-full">
|
||||||
|
<div className="flex items-center gap-[12px] h-[32px]">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<p className="text-[15px] font-semibold leading-[1.5] text-[#6C7682] m-0">
|
||||||
|
첨부 파일
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{attachments.map((attachment, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-white border border-[#DEE1E6] rounded-[6px] h-[64px] flex items-center gap-[12px] px-[17px] py-1 w-full"
|
||||||
|
>
|
||||||
|
<div className="size-[24px] shrink-0">
|
||||||
|
<PaperClipSvg width={24} height={24} className="text-[#333C47]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center gap-[8px] min-w-0">
|
||||||
|
<p className="text-[15px] font-normal leading-[1.5] text-[#1B2027] truncate m-0">
|
||||||
|
{attachment.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#8C95A1] shrink-0 m-0">
|
||||||
|
{attachment.size}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDownload(attachment.fileKey, attachment.url, attachment.name)}
|
||||||
|
className="bg-white border border-[#8C95A1] rounded-[6px] h-[32px] flex items-center justify-center gap-[4px] px-[16px] py-[3px] shrink-0 hover:bg-[#F9FAFB] cursor-pointer"
|
||||||
|
>
|
||||||
|
<DownloadIcon width={16} height={16} className="text-[#4C5561]" />
|
||||||
|
<span className="text-[13px] font-medium leading-[1.4] text-[#4C5561]">
|
||||||
|
다운로드
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
<div className="flex items-center justify-end gap-[12px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsDeleteModalOpen(true)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-[#FEF2F2] h-[48px] rounded-[10px] px-[8px] shrink-0 min-w-[80px] flex items-center justify-center hover:bg-[#FEE2E2] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span className="text-[16px] font-semibold leading-[1.5] text-[#F64C4C] text-center">
|
||||||
|
삭제
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/admin/resources/${params.id}/edit`)}
|
||||||
|
className="bg-[#F1F3F5] h-[48px] rounded-[10px] px-[16px] shrink-0 min-w-[90px] flex items-center justify-center hover:bg-[#E9ECEF] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-[16px] font-semibold leading-[1.5] text-[#4C5561] text-center">
|
||||||
|
수정하기
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
<NoticeDeleteModal
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
onClose={() => setIsDeleteModalOpen(false)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!params?.id) {
|
||||||
|
alert('학습 자료 ID를 찾을 수 없습니다.');
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsDeleting(true);
|
||||||
|
await apiService.deleteLibraryItem(params.id);
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setShowToast(true);
|
||||||
|
// 토스트 표시 후 목록 페이지로 이동
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/admin/resources');
|
||||||
|
}, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('학습 자료 삭제 오류:', err);
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '학습 자료 삭제에 실패했습니다.';
|
||||||
|
alert(errorMessage);
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 삭제 완료 토스트 */}
|
||||||
|
{showToast && (
|
||||||
|
<div className="fixed right-[60px] bottom-[60px] z-50">
|
||||||
|
<div className="bg-white border border-[#dee1e6] rounded-[8px] p-4 min-w-[360px] flex gap-[10px] items-center">
|
||||||
|
<div className="relative shrink-0 w-[16.667px] h-[16.667px]">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="8.5" cy="8.5" r="8.5" fill="#384FBF"/>
|
||||||
|
<path d="M5.5 8.5L7.5 10.5L11.5 6.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] text-nowrap">
|
||||||
|
학습 자료가 삭제되었습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
37
src/app/admin/resources/mockData.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export type Resource = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
date: string; // 게시일
|
||||||
|
views: number; // 조회수
|
||||||
|
writer: string; // 작성자
|
||||||
|
content?: string[]; // 본문 내용 (상세 페이지용)
|
||||||
|
hasAttachment?: boolean; // 첨부파일 여부
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: 나중에 DB에서 가져오도록 변경
|
||||||
|
export const MOCK_RESOURCES: Resource[] = [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '학습 자료 제목이 노출돼요',
|
||||||
|
date: '2025-09-10',
|
||||||
|
views: 1230,
|
||||||
|
writer: '문지호',
|
||||||
|
content: [
|
||||||
|
'학습 자료 관련 주요 내용을 안내드립니다.',
|
||||||
|
'학습 자료는 수강 기간 동안 언제든지 다운로드 가능합니다.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '📚 방사선학 학습 자료 모음',
|
||||||
|
date: '2025-06-28',
|
||||||
|
views: 594,
|
||||||
|
writer: '문지호',
|
||||||
|
hasAttachment: true,
|
||||||
|
content: [
|
||||||
|
'방사선학 강의에 필요한 학습 자료를 첨부합니다.',
|
||||||
|
'학습 자료는 강의 수강 시 참고하시기 바랍니다.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
602
src/app/admin/resources/page.tsx
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useRef, ChangeEvent, useEffect } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import AdminSidebar from "@/app/components/AdminSidebar";
|
||||||
|
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
||||||
|
import BackArrowSvg from "@/app/svgs/backarrow";
|
||||||
|
import { type Resource } from "@/app/admin/resources/mockData";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
|
export default function AdminResourcesPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [isWritingMode, setIsWritingMode] = useState(false);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [attachedFile, setAttachedFile] = useState<File | null>(null);
|
||||||
|
const [fileKey, setFileKey] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 날짜를 yyyy-mm-dd 형식으로 포맷팅
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
// 이미 yyyy-mm-dd 형식인 경우 그대로 반환
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API에서 학습 자료 목록 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchResources() {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await apiService.getLibrary();
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||||
|
let resourcesArray: any[] = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
resourcesArray = data;
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
resourcesArray = data.items || data.resources || data.data || data.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답 데이터를 Resource 형식으로 변환
|
||||||
|
const transformedResources: Resource[] = resourcesArray.map((resource: any) => ({
|
||||||
|
id: resource.id || resource.resourceId || 0,
|
||||||
|
title: resource.title || '',
|
||||||
|
date: resource.date || resource.createdAt || resource.createdDate || new Date().toISOString().split('T')[0],
|
||||||
|
views: resource.views || resource.viewCount || 0,
|
||||||
|
writer: resource.writer || resource.author || resource.createdBy || '관리자',
|
||||||
|
content: resource.content ? (Array.isArray(resource.content) ? resource.content : [resource.content]) : undefined,
|
||||||
|
hasAttachment: resource.hasAttachment || resource.attachment || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setResources(transformedResources);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('학습 자료 목록 조회 오류:', error);
|
||||||
|
// 에러 발생 시 빈 배열로 설정
|
||||||
|
setResources([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchResources();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 수정 완료 쿼리 파라미터 확인 및 토스트 표시
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('updated') === 'true') {
|
||||||
|
setShowToast(true);
|
||||||
|
// URL에서 쿼리 파라미터 제거
|
||||||
|
router.replace('/admin/resources');
|
||||||
|
// 토스트 자동 닫기
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowToast(false);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [searchParams, router]);
|
||||||
|
|
||||||
|
const totalCount = useMemo(() => resources.length, [resources]);
|
||||||
|
|
||||||
|
const characterCount = useMemo(() => content.length, [content]);
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setIsWritingMode(false);
|
||||||
|
setTitle('');
|
||||||
|
setContent('');
|
||||||
|
setAttachedFile(null);
|
||||||
|
setFileKey(null);
|
||||||
|
// 파일 입력 초기화
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileAttach = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (file.size > 30 * 1024 * 1024) {
|
||||||
|
alert('30MB 미만의 파일만 첨부할 수 있습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
// 단일 파일 업로드
|
||||||
|
const uploadResponse = await apiService.uploadFile(file);
|
||||||
|
|
||||||
|
// 응답에서 fileKey 추출
|
||||||
|
let extractedFileKey: string | null = null;
|
||||||
|
if (uploadResponse.data?.fileKey) {
|
||||||
|
extractedFileKey = uploadResponse.data.fileKey;
|
||||||
|
} else if (uploadResponse.data?.key) {
|
||||||
|
extractedFileKey = uploadResponse.data.key;
|
||||||
|
} else if (uploadResponse.data?.id) {
|
||||||
|
extractedFileKey = uploadResponse.data.id;
|
||||||
|
} else if (uploadResponse.data?.imageKey) {
|
||||||
|
extractedFileKey = uploadResponse.data.imageKey;
|
||||||
|
} else if (uploadResponse.data?.fileId) {
|
||||||
|
extractedFileKey = uploadResponse.data.fileId;
|
||||||
|
} else if (uploadResponse.data?.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey)) {
|
||||||
|
extractedFileKey = uploadResponse.data.data.key || uploadResponse.data.data.fileKey;
|
||||||
|
} else if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results) && uploadResponse.data.results.length > 0) {
|
||||||
|
const result = uploadResponse.data.results[0];
|
||||||
|
if (result.ok && result.fileKey) {
|
||||||
|
extractedFileKey = result.fileKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractedFileKey) {
|
||||||
|
setFileKey(extractedFileKey);
|
||||||
|
setAttachedFile(file);
|
||||||
|
} else {
|
||||||
|
throw new Error('파일 키를 받아오지 못했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 업로드 실패:', error);
|
||||||
|
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
setAttachedFile(null);
|
||||||
|
setFileKey(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
// 파일 입력 초기화
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!title.trim() || !content.trim()) {
|
||||||
|
alert('제목과 내용을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// 학습 자료 생성 API 호출
|
||||||
|
const resourceData: any = {
|
||||||
|
title: title.trim(),
|
||||||
|
content: content.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// fileKey와 파일 정보가 있으면 attachments 배열로 포함
|
||||||
|
if (fileKey && attachedFile) {
|
||||||
|
resourceData.attachments = [
|
||||||
|
{
|
||||||
|
fileKey: fileKey,
|
||||||
|
filename: attachedFile.name,
|
||||||
|
mimeType: attachedFile.type || 'application/octet-stream',
|
||||||
|
size: attachedFile.size,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiService.createLibraryItem(resourceData);
|
||||||
|
|
||||||
|
// API 응답 후 목록 새로고침
|
||||||
|
const fetchResponse = await apiService.getLibrary();
|
||||||
|
const data = fetchResponse.data;
|
||||||
|
|
||||||
|
// API 응답이 배열이 아닌 경우 처리
|
||||||
|
let resourcesArray: any[] = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
resourcesArray = data;
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
resourcesArray = data.items || data.resources || data.data || data.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답 데이터를 Resource 형식으로 변환
|
||||||
|
const transformedResources: Resource[] = resourcesArray.map((resource: any) => ({
|
||||||
|
id: resource.id || resource.resourceId || 0,
|
||||||
|
title: resource.title || '',
|
||||||
|
date: resource.date || resource.createdAt || resource.createdDate || new Date().toISOString().split('T')[0],
|
||||||
|
views: resource.views || resource.viewCount || 0,
|
||||||
|
writer: resource.writer || resource.author || resource.createdBy || '관리자',
|
||||||
|
content: resource.content ? (Array.isArray(resource.content) ? resource.content : [resource.content]) : undefined,
|
||||||
|
hasAttachment: resource.hasAttachment || resource.attachment || !!resource.fileKey || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setResources(transformedResources);
|
||||||
|
handleBack();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('학습 자료 저장 실패:', error);
|
||||||
|
alert('학습 자료 저장에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (title.trim() || content.trim() || attachedFile || fileKey) {
|
||||||
|
if (confirm('작성 중인 내용이 있습니다. 정말 취소하시겠습니까?')) {
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const sortedResources = useMemo(() => {
|
||||||
|
return [...resources].sort((a, b) => {
|
||||||
|
// 생성일 내림차순 정렬 (최신 날짜가 먼저)
|
||||||
|
return b.date.localeCompare(a.date);
|
||||||
|
});
|
||||||
|
}, [resources]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(sortedResources.length / ITEMS_PER_PAGE);
|
||||||
|
const paginatedResources = useMemo(() => {
|
||||||
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||||
|
return sortedResources.slice(startIndex, endIndex);
|
||||||
|
}, [sortedResources, currentPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
{/* 메인 레이아웃 */}
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col px-8">
|
||||||
|
{isWritingMode ? (
|
||||||
|
<>
|
||||||
|
{/* 작성 모드 헤더 */}
|
||||||
|
<div className="h-[100px] flex items-center">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="flex items-center justify-center w-8 h-8 cursor-pointer"
|
||||||
|
aria-label="뒤로가기"
|
||||||
|
>
|
||||||
|
<BackArrowSvg width={32} height={32} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
학습 자료 작성
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작성 폼 */}
|
||||||
|
<div className="flex-1 flex flex-col gap-10 pb-20 pt-8 w-full">
|
||||||
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
{/* 제목 입력 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
|
||||||
|
제목
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="제목을 입력해 주세요."
|
||||||
|
className="w-full h-[40px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 내용 입력 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
|
||||||
|
내용
|
||||||
|
</label>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newContent = e.target.value;
|
||||||
|
if (newContent.length <= 1000) {
|
||||||
|
setContent(newContent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="내용을 입력해 주세요. (최대 1,000자 이내)"
|
||||||
|
className="w-full h-[320px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] resize-none focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-3 right-3">
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#6c7682] text-right">
|
||||||
|
{characterCount}/1000
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 첨부 파일 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<div className="flex items-center justify-between h-8 w-full">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] whitespace-nowrap">
|
||||||
|
첨부 파일{' '}
|
||||||
|
<span className="font-normal">
|
||||||
|
({attachedFile ? 1 : 0}/1)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#8c95a1] whitespace-nowrap">
|
||||||
|
30MB 미만 파일
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFileAttach}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-[32px] w-[62px] px-[4px] py-[3px] rounded-[6px] border border-[#8c95a1] bg-white flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-colors shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span className="text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap">
|
||||||
|
{isLoading ? '업로드 중...' : '첨부'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
accept="*/*"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-16 w-full rounded-[8px] border border-[#dee1e6] bg-gray-50 flex items-center px-4">
|
||||||
|
{attachedFile ? (
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027]">
|
||||||
|
{attachedFile.name}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center w-full">
|
||||||
|
파일을 첨부해주세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="flex gap-3 items-center justify-end shrink-0 w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="h-12 px-8 rounded-[10px] bg-[#f1f3f5] text-[16px] font-semibold leading-[1.5] text-[#4c5561] whitespace-nowrap hover:bg-[#e5e8eb] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-12 px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? '저장 중...' : '저장하기'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 제목 영역 */}
|
||||||
|
<div className="h-[100px] flex items-center">
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
학습 자료실
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 영역 (제목과 콘텐츠 사이) */}
|
||||||
|
<div className="pt-2 pb-4 flex items-center justify-between">
|
||||||
|
<p className="text-[15px] font-medium leading-[1.5] text-[#333c47] whitespace-nowrap">
|
||||||
|
총 {totalCount}건
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsWritingMode(true)}
|
||||||
|
className="h-[40px] px-4 rounded-[8px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
등록하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 콘텐츠 영역 */}
|
||||||
|
<div className="flex-1 pt-2 flex flex-col">
|
||||||
|
{resources.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47] text-center">
|
||||||
|
등록된 학습 자료가 없습니다.
|
||||||
|
<br />
|
||||||
|
학습 자료를 등록해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="rounded-[8px]">
|
||||||
|
<div className="w-full rounded-[8px] border border-[#dee1e6] overflow-visible">
|
||||||
|
<table className="min-w-full border-collapse">
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: 80 }} />
|
||||||
|
<col />
|
||||||
|
<col style={{ width: 140 }} />
|
||||||
|
<col style={{ width: 120 }} />
|
||||||
|
<col style={{ width: 120 }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr className="h-12 bg-gray-50 text-left">
|
||||||
|
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]">번호</th>
|
||||||
|
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]">제목</th>
|
||||||
|
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]">게시일</th>
|
||||||
|
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]">조회수</th>
|
||||||
|
<th className="px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]">작성자</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paginatedResources.map((resource, index) => {
|
||||||
|
// 번호는 전체 목록에서의 순서 (정렬된 목록 기준)
|
||||||
|
const resourceNumber = sortedResources.length - (currentPage - 1) * ITEMS_PER_PAGE - index;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={resource.id}
|
||||||
|
onClick={() => router.push(`/admin/resources/${resource.id}`)}
|
||||||
|
className="h-12 hover:bg-[#F5F7FF] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap text-center">
|
||||||
|
{resourceNumber}
|
||||||
|
</td>
|
||||||
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027]">
|
||||||
|
{resource.title}
|
||||||
|
</td>
|
||||||
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||||
|
{formatDate(resource.date)}
|
||||||
|
</td>
|
||||||
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||||
|
{resource.views.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="border-t border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||||
|
{resource.writer}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
|
||||||
|
{resources.length > ITEMS_PER_PAGE && (() => {
|
||||||
|
// 페이지 번호를 10단위로 표시
|
||||||
|
const pageGroup = Math.floor((currentPage - 1) / 10);
|
||||||
|
const startPage = pageGroup * 10 + 1;
|
||||||
|
const endPage = Math.min(startPage + 9, totalPages);
|
||||||
|
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-8 pb-[32px] flex items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center gap-[8px]">
|
||||||
|
{/* First (맨 앞으로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
aria-label="맨 앞 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
aria-label="이전 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Numbers */}
|
||||||
|
{visiblePages.map((n) => {
|
||||||
|
const active = n === currentPage;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(n)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={[
|
||||||
|
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||||
|
active ? 'bg-[#ecf0ff]' : 'bg-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
aria-label="다음 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Last (맨 뒤로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
aria-label="맨 뒤 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수정 완료 토스트 */}
|
||||||
|
{showToast && (
|
||||||
|
<div className="fixed right-[60px] bottom-[60px] z-50">
|
||||||
|
<div className="bg-white border border-[#dee1e6] rounded-[8px] p-4 min-w-[360px] flex gap-[10px] items-center">
|
||||||
|
<div className="relative shrink-0 w-[16.667px] h-[16.667px]">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="8.5" cy="8.5" r="8.5" fill="#384FBF"/>
|
||||||
|
<path d="M5.5 8.5L7.5 10.5L11.5 6.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] text-nowrap">
|
||||||
|
수정이 완료되었습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
52
src/app/components/AdminSidebar.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADMIN_SIDEBAR_ITEMS: NavItem[] = [
|
||||||
|
{ label: "권한 설정", href: "/admin/id" },
|
||||||
|
{ label: "교육과정 관리", href: "/admin/courses" },
|
||||||
|
{ label: "강좌 관리", href: "/admin/lessons" },
|
||||||
|
{ label: "문제 은행", href: "/admin/questions" },
|
||||||
|
{ label: "수료증 발급/검증키 관리", href: "/admin/certificates" },
|
||||||
|
{ label: "공지사항", href: "/admin/notices" },
|
||||||
|
{ label: "학습 자료실", href: "/admin/resources" },
|
||||||
|
{ label: "로그/접속 기록", href: "/admin/logs" },
|
||||||
|
{ label: "배너 관리", href: "/admin/banner" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminSidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-[320px] border-r border-[#dee1e6] bg-white flex-shrink-0 h-full">
|
||||||
|
<nav className="p-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{ADMIN_SIDEBAR_ITEMS.map((item) => {
|
||||||
|
const isActive = pathname === item.href || (item.href !== "#" && pathname.startsWith(item.href));
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
href={item.href}
|
||||||
|
className={[
|
||||||
|
"flex h-12 items-center px-3 rounded-lg text-[16px] leading-[1.5] transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-[rgba(236,240,255,0.5)] font-bold text-[#1f2b91]"
|
||||||
|
: "font-medium text-[#333c47] hover:bg-[rgba(0,0,0,0.02)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
231
src/app/components/CsvViewer.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
|
||||||
|
interface CsvViewerProps {
|
||||||
|
onFileSelect?: (file: File) => void;
|
||||||
|
onDataParsed?: (data: string[][]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CsvViewer({ onFileSelect, onDataParsed }: CsvViewerProps) {
|
||||||
|
const [csvData, setCsvData] = useState<string[][]>([]);
|
||||||
|
const [headers, setHeaders] = useState<string[]>([]);
|
||||||
|
const [rows, setRows] = useState<string[][]>([]);
|
||||||
|
const [fileName, setFileName] = useState<string>('');
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// CSV 파싱 함수
|
||||||
|
const parseCsv = (text: string): string[][] => {
|
||||||
|
const lines: string[][] = [];
|
||||||
|
let currentLine: string[] = [];
|
||||||
|
let currentField = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const char = text[i];
|
||||||
|
const nextChar = text[i + 1];
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && nextChar === '"') {
|
||||||
|
// 이스케이프된 따옴표
|
||||||
|
currentField += '"';
|
||||||
|
i++; // 다음 문자 건너뛰기
|
||||||
|
} else {
|
||||||
|
// 따옴표 시작/끝
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
// 필드 구분자
|
||||||
|
currentLine.push(currentField.trim());
|
||||||
|
currentField = '';
|
||||||
|
} else if ((char === '\n' || char === '\r') && !inQuotes) {
|
||||||
|
// 줄바꿈
|
||||||
|
if (char === '\r' && nextChar === '\n') {
|
||||||
|
i++; // \r\n 건너뛰기
|
||||||
|
}
|
||||||
|
if (currentField || currentLine.length > 0) {
|
||||||
|
currentLine.push(currentField.trim());
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = [];
|
||||||
|
currentField = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentField += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마지막 필드 추가
|
||||||
|
if (currentField || currentLine.length > 0) {
|
||||||
|
currentLine.push(currentField.trim());
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// CSV 파일인지 확인
|
||||||
|
if (!file.name.toLowerCase().endsWith('.csv')) {
|
||||||
|
alert('CSV 파일만 업로드할 수 있습니다.');
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFileName(file.name);
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const text = event.target?.result as string;
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = parseCsv(text);
|
||||||
|
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
alert('CSV 파일이 비어있습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첫 번째 줄을 헤더로 사용
|
||||||
|
const csvHeaders = parsed[0];
|
||||||
|
const csvRows = parsed.slice(1);
|
||||||
|
|
||||||
|
setHeaders(csvHeaders);
|
||||||
|
setRows(csvRows);
|
||||||
|
setCsvData(parsed);
|
||||||
|
|
||||||
|
// 콜백 호출
|
||||||
|
if (onFileSelect) {
|
||||||
|
onFileSelect(file);
|
||||||
|
}
|
||||||
|
if (onDataParsed) {
|
||||||
|
onDataParsed(parsed);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('CSV 파싱 오류:', error);
|
||||||
|
alert('CSV 파일을 읽는 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
alert('파일을 읽는 중 오류가 발생했습니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file, 'UTF-8');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setCsvData([]);
|
||||||
|
setHeaders([]);
|
||||||
|
setRows([]);
|
||||||
|
setFileName('');
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* 파일 업로드 영역 */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="h-[40px] px-4 py-2 border border-[#8c95a1] rounded-[8px] bg-white text-[16px] font-semibold leading-[1.5] text-[#4c5561] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer flex items-center justify-center">
|
||||||
|
<span>CSV 파일 선택</span>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv,application/vnd.ms-excel"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{fileName && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">
|
||||||
|
{fileName}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] hover:text-[#4c5561] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 표 영역 */}
|
||||||
|
{csvData.length > 0 && (
|
||||||
|
<div className="w-full border border-[#dee1e6] border-solid relative size-full bg-white">
|
||||||
|
<div className="content-stretch flex flex-col items-start justify-center relative size-full">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="bg-[#f1f8ff] content-stretch flex h-[48px] items-center overflow-clip relative shrink-0 w-full">
|
||||||
|
{headers.map((header, index) => {
|
||||||
|
const isLast = index === headers.length - 1;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`border-[#dee1e6] border-[0px_1px_0px_0px] border-solid box-border content-stretch flex gap-[10px] h-full items-center justify-center px-[8px] py-[12px] relative shrink-0 ${
|
||||||
|
index === 0 ? 'w-[48px]' : index === 1 ? 'basis-0 grow min-h-px min-w-px' : 'w-[140px]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col font-['Pretendard:SemiBold',sans-serif] justify-center leading-[0] not-italic relative shrink-0 text-[#4c5561] text-[14px] text-nowrap">
|
||||||
|
<p className="leading-[1.5] whitespace-pre">{header || `열 ${index + 1}`}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행 */}
|
||||||
|
{rows.map((row, rowIndex) => (
|
||||||
|
<div
|
||||||
|
key={rowIndex}
|
||||||
|
className="border-[#dee1e6] border-[1px_0px_0px] border-solid h-[48px] relative shrink-0 w-full"
|
||||||
|
>
|
||||||
|
<div className="content-stretch flex h-[48px] items-start overflow-clip relative rounded-[inherit] w-full">
|
||||||
|
{headers.map((_, colIndex) => {
|
||||||
|
const isLast = colIndex === headers.length - 1;
|
||||||
|
const cellValue = row[colIndex] || '';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={colIndex}
|
||||||
|
className={`border-[#dee1e6] ${
|
||||||
|
isLast ? '' : 'border-[0px_1px_0px_0px]'
|
||||||
|
} border-solid box-border content-stretch flex flex-col gap-[4px] ${
|
||||||
|
colIndex === 0
|
||||||
|
? 'items-center justify-center w-[48px] px-[8px] py-[12px]'
|
||||||
|
: 'items-center min-h-px min-w-px px-[8px] py-[12px]'
|
||||||
|
} ${
|
||||||
|
colIndex === 1 ? 'basis-0 grow' : ''
|
||||||
|
} relative shrink-0`}
|
||||||
|
>
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#1b2027] text-[15px] text-nowrap whitespace-pre">
|
||||||
|
{cellValue}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 데이터가 없을 때 */}
|
||||||
|
{csvData.length === 0 && (
|
||||||
|
<div className="w-full rounded-[8px] border border-[#dee1e6] bg-white min-h-[200px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
|
||||||
|
CSV 파일을 선택하면 표 형태로 표시됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ export default function Footer() {
|
|||||||
<div className="flex flex-col items-center gap-[7px] w-[72px]">
|
<div className="flex flex-col items-center gap-[7px] w-[72px]">
|
||||||
<MainLogoSvg width={72} height={54} />
|
<MainLogoSvg width={72} height={54} />
|
||||||
<div className="text-[16px] font-extrabold leading-[1.45] tracking-[-0.08px] text-black">
|
<div className="text-[16px] font-extrabold leading-[1.45] tracking-[-0.08px] text-black">
|
||||||
XL LMS
|
XR LMS
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex flex-col justify-end gap-[24px]">
|
<div className="flex-1 flex flex-col justify-end gap-[24px]">
|
||||||
@@ -33,13 +33,13 @@ export default function Footer() {
|
|||||||
<p className="leading-[1.45] text-nowrap">문의: 1234-1234 (평일 09:00 ~ 18:00)</p>
|
<p className="leading-[1.45] text-nowrap">문의: 1234-1234 (평일 09:00 ~ 18:00)</p>
|
||||||
<p className="leading-[1.45] text-nowrap">이메일: qwer1234@go.or.kr</p>
|
<p className="leading-[1.45] text-nowrap">이메일: qwer1234@go.or.kr</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="leading-[1.45] text-nowrap">Copyright ⓒ 2025 XL LMS. All rights reserved</p>
|
<p className="leading-[1.45] text-nowrap">Copyright ⓒ 2025 XR LMS. All rights reserved</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<img
|
<img
|
||||||
src="/imgs/talk.png"
|
src="/imgs/talk.png"
|
||||||
alt="talk"
|
alt="talk"
|
||||||
className="self-end ml-auto mr-[40px] mb-[40px]"
|
className="self-end ml-auto mr-[40px] mb-[0px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
17
src/app/components/FooterVisibility.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import Footer from "./Footer";
|
||||||
|
|
||||||
|
const HIDE_FOOTER_PREFIXES = ["/pages", "/login"];
|
||||||
|
|
||||||
|
export default function FooterVisibility() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const shouldHide = HIDE_FOOTER_PREFIXES.some(
|
||||||
|
(prefix) => pathname === prefix || pathname.startsWith(prefix + "/")
|
||||||
|
);
|
||||||
|
if (shouldHide) return null;
|
||||||
|
return <Footer />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3,11 +3,12 @@
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import NavBar from "../NavBar";
|
import NavBar from "../NavBar";
|
||||||
|
|
||||||
const HIDE_HEADER_PREFIXES = ["/login", "/register", "/reset-password", "/find-id"];
|
const HIDE_HEADER_PREFIXES = ["/login", "/register", "/reset-password", "/find-id", "/pages"];
|
||||||
|
|
||||||
export default function HeaderVisibility() {
|
export default function HeaderVisibility() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const shouldHide = HIDE_HEADER_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
|
const shouldHide =
|
||||||
|
HIDE_HEADER_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
|
||||||
if (shouldHide) return null;
|
if (shouldHide) return null;
|
||||||
return <NavBar />;
|
return <NavBar />;
|
||||||
}
|
}
|
||||||
|
|||||||
388
src/app/course-list/[id]/page.tsx
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import apiService from '../../lib/apiService';
|
||||||
|
import BackCircleSvg from '../../svgs/backcirclesvg';
|
||||||
|
|
||||||
|
const imgPlay = '/imgs/play.svg';
|
||||||
|
const imgMusicAudioPlay = '/imgs/music-audio-play.svg';
|
||||||
|
|
||||||
|
type Lesson = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
duration: string; // "12:46" 형식
|
||||||
|
state: "제출완료" | "제출대기";
|
||||||
|
action: "복습하기" | "이어서 수강하기" | "수강하기";
|
||||||
|
};
|
||||||
|
|
||||||
|
type CourseDetail = {
|
||||||
|
id: string;
|
||||||
|
status: "수강 중" | "수강 예정" | "수강 완료";
|
||||||
|
title: string;
|
||||||
|
goal: string;
|
||||||
|
method: string;
|
||||||
|
summary: string; // VOD · 총 n강 · n시간 n분
|
||||||
|
submitSummary: string; // 학습 제출 n/n
|
||||||
|
thumbnail: string;
|
||||||
|
lessons: Lesson[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CourseDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [course, setCourse] = useState<CourseDetail | null>(null);
|
||||||
|
const [lectures, setLectures] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCourse = async () => {
|
||||||
|
if (!params?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
let data: any = null;
|
||||||
|
const subjectId = String(params.id);
|
||||||
|
|
||||||
|
// getSubjects로 과목 정보 가져오기
|
||||||
|
try {
|
||||||
|
const subjectsResponse = await apiService.getSubjects();
|
||||||
|
let subjectsData: any[] = [];
|
||||||
|
|
||||||
|
if (Array.isArray(subjectsResponse.data)) {
|
||||||
|
subjectsData = subjectsResponse.data;
|
||||||
|
} else if (subjectsResponse.data && typeof subjectsResponse.data === 'object') {
|
||||||
|
subjectsData = subjectsResponse.data.items ||
|
||||||
|
subjectsResponse.data.courses ||
|
||||||
|
subjectsResponse.data.data ||
|
||||||
|
subjectsResponse.data.list ||
|
||||||
|
subjectsResponse.data.subjects ||
|
||||||
|
subjectsResponse.data.subjectList ||
|
||||||
|
[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID로 과목 찾기
|
||||||
|
data = subjectsData.find((s: any) => String(s.id || s.subjectId) === subjectId);
|
||||||
|
} catch (subjectsErr) {
|
||||||
|
console.error('getSubjects 실패:', subjectsErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
throw new Error('강좌를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 썸네일 이미지 가져오기
|
||||||
|
let thumbnail = '/imgs/talk.png';
|
||||||
|
if (data.imageKey) {
|
||||||
|
try {
|
||||||
|
const imageUrl = await apiService.getFile(data.imageKey);
|
||||||
|
if (imageUrl) {
|
||||||
|
thumbnail = imageUrl;
|
||||||
|
}
|
||||||
|
} catch (imgErr) {
|
||||||
|
console.error('이미지 다운로드 실패:', imgErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLectures로 모든 lecture 가져오기
|
||||||
|
let allLectures: any[] = [];
|
||||||
|
try {
|
||||||
|
const lecturesResponse = await apiService.getLectures();
|
||||||
|
if (Array.isArray(lecturesResponse.data)) {
|
||||||
|
allLectures = lecturesResponse.data;
|
||||||
|
} else if (lecturesResponse.data && typeof lecturesResponse.data === 'object') {
|
||||||
|
allLectures = lecturesResponse.data.items ||
|
||||||
|
lecturesResponse.data.lectures ||
|
||||||
|
lecturesResponse.data.data ||
|
||||||
|
lecturesResponse.data.list ||
|
||||||
|
[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// subjectId로 필터링
|
||||||
|
const filteredLectures = allLectures.filter((lecture: any) =>
|
||||||
|
String(lecture.subjectId || lecture.subject_id) === subjectId
|
||||||
|
);
|
||||||
|
|
||||||
|
setLectures(filteredLectures);
|
||||||
|
|
||||||
|
// API 응답 데이터를 CourseDetail 타입으로 변환
|
||||||
|
const courseDetail: CourseDetail = {
|
||||||
|
id: String(data.id || params.id),
|
||||||
|
status: data.status || "수강 예정",
|
||||||
|
title: data.title || data.lectureName || data.subjectName || '',
|
||||||
|
goal: data.objective || data.goal || '',
|
||||||
|
method: data.method || '',
|
||||||
|
summary: data.summary || `VOD · 총 ${filteredLectures.length || 0}강`,
|
||||||
|
submitSummary: data.submitSummary || '',
|
||||||
|
thumbnail: thumbnail,
|
||||||
|
lessons: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
setCourse(courseDetail);
|
||||||
|
} catch (lecturesErr) {
|
||||||
|
console.error('getLectures 실패:', lecturesErr);
|
||||||
|
|
||||||
|
// API 응답 데이터를 CourseDetail 타입으로 변환 (lecture 없이)
|
||||||
|
const courseDetail: CourseDetail = {
|
||||||
|
id: String(data.id || params.id),
|
||||||
|
status: data.status || "수강 예정",
|
||||||
|
title: data.title || data.lectureName || data.subjectName || '',
|
||||||
|
goal: data.objective || data.goal || '',
|
||||||
|
method: data.method || '',
|
||||||
|
summary: data.summary || `VOD · 총 0강`,
|
||||||
|
submitSummary: data.submitSummary || '',
|
||||||
|
thumbnail: thumbnail,
|
||||||
|
lessons: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
setCourse(courseDetail);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('강좌 조회 실패:', err);
|
||||||
|
setError(err instanceof Error ? err.message : '강좌를 불러오는데 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCourse();
|
||||||
|
}, [params?.id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<main className="flex w-full flex-col items-center">
|
||||||
|
<div className="flex h-[100px] w-full max-w-[1440px] items-center gap-[12px] px-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 상세보기</h1>
|
||||||
|
</div>
|
||||||
|
<section className="w-full max-w-[1440px] px-8 pb-20">
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<p className="text-[16px] text-[#8c95a1]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !course) {
|
||||||
|
return (
|
||||||
|
<main className="flex w-full flex-col items-center">
|
||||||
|
<div className="flex h-[100px] w-full max-w-[1440px] items-center gap-[12px] px-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 상세보기</h1>
|
||||||
|
</div>
|
||||||
|
<section className="w-full max-w-[1440px] px-8 pb-20">
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
<p className="text-[16px] text-red-500 mb-4">{error || '강좌를 찾을 수 없습니다.'}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/course-list')}
|
||||||
|
className="px-4 py-2 rounded-[6px] bg-primary text-white text-[14px] font-medium"
|
||||||
|
>
|
||||||
|
강좌 목록으로 돌아가기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex w-full flex-col items-center">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex h-[100px] w-full max-w-[1440px] items-center gap-[12px] px-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 상세보기</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<section className="w-full max-w-[1440px] px-8 pb-[80px] pt-[24px]">
|
||||||
|
{/* 상단 정보 카드 */}
|
||||||
|
<div className="bg-[#f8f9fa] box-border flex gap-[24px] items-start p-[24px] rounded-[8px] w-full">
|
||||||
|
{/* 이미지 컨테이너 */}
|
||||||
|
<div className="overflow-clip relative rounded-[4px] shrink-0 w-[220.5px] h-[159px]">
|
||||||
|
<Image
|
||||||
|
src={course.thumbnail}
|
||||||
|
alt={course.title}
|
||||||
|
fill
|
||||||
|
sizes="220.5px"
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 교육과정 정보 */}
|
||||||
|
<div className="basis-0 flex flex-col gap-[12px] grow items-start min-h-px min-w-px relative shrink-0">
|
||||||
|
{/* 제목 영역 */}
|
||||||
|
<div className="flex gap-[8px] h-[27px] items-center w-full">
|
||||||
|
<div className="bg-[#e5f5ec] box-border flex h-[20px] items-center justify-center px-[4px] py-0 rounded-[4px] shrink-0">
|
||||||
|
<p className="font-['Pretendard:SemiBold',sans-serif] text-[#0c9d61] text-[13px] leading-[1.4]">{course.status}</p>
|
||||||
|
</div>
|
||||||
|
<h2 className="font-['Pretendard:SemiBold',sans-serif] text-[#333c47] text-[18px] leading-[1.5]">{course.title}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 학습 목표 및 방법 */}
|
||||||
|
<div className="flex flex-col gap-[4px] items-start w-full">
|
||||||
|
<p className="font-['Pretendard:Regular',sans-serif] text-[#333c47] text-[15px] leading-[1.5] mb-0">
|
||||||
|
<span className="font-['Pretendard:Medium',sans-serif]">학습 목표:</span>
|
||||||
|
<span>{` ${course.goal}`}</span>
|
||||||
|
</p>
|
||||||
|
<p className="font-['Pretendard:Regular',sans-serif] text-[#333c47] text-[15px] leading-[1.5]">
|
||||||
|
<span className="font-['Pretendard:Medium',sans-serif]">학습 방법:</span>
|
||||||
|
<span>{` ${course.method}`}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 통계 정보 */}
|
||||||
|
<div className="flex gap-[4px] items-center w-full">
|
||||||
|
<div className="flex gap-[20px] items-center">
|
||||||
|
{/* VOD 정보 */}
|
||||||
|
<div className="flex gap-[4px] items-center">
|
||||||
|
<div className="relative shrink-0 size-[16px]">
|
||||||
|
<img src={imgPlay} alt="" className="block max-w-none size-full" />
|
||||||
|
</div>
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4]">{course.summary}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 학습 제출 정보 */}
|
||||||
|
{course.submitSummary && (
|
||||||
|
<div className="flex gap-[4px] items-center">
|
||||||
|
<div className="relative shrink-0 size-[16px]">
|
||||||
|
<img src={imgMusicAudioPlay} alt="" className="block max-w-none size-full" />
|
||||||
|
</div>
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4]">{course.submitSummary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lecture 리스트 */}
|
||||||
|
<div className="flex flex-col gap-[8px] items-start mt-[24px] w-full">
|
||||||
|
{lectures.length > 0 ? (
|
||||||
|
lectures.map((lecture: any, index: number) => {
|
||||||
|
const isSubmitted = false; // TODO: 진행률 API에서 가져와야 함
|
||||||
|
const action = isSubmitted ? "복습하기" : (index === 0 ? "수강하기" : "이어서 수강하기");
|
||||||
|
const submitBtnBorder = isSubmitted
|
||||||
|
? "border-transparent"
|
||||||
|
: (action === "이어서 수강하기" || action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]");
|
||||||
|
const submitBtnText = isSubmitted ? "text-[#384fbf]" : (action === "이어서 수강하기" || action === "수강하기" ? "text-[#b1b8c0]" : "text-[#4c5561]");
|
||||||
|
const rightBtnStyle =
|
||||||
|
action === "이어서 수강하기" || action === "수강하기"
|
||||||
|
? "bg-[#ecf0ff] text-[#384fbf]"
|
||||||
|
: "bg-[#f1f3f5] text-[#4c5561]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={lecture.id || lecture.lectureId || index} className="bg-white border border-[#dee1e6] border-solid relative rounded-[8px] w-full">
|
||||||
|
<div className="box-border flex gap-[16px] items-center overflow-clip px-[24px] py-[16px] rounded-[inherit] w-full">
|
||||||
|
<div className="basis-0 flex grow h-[46px] items-center justify-between min-h-px min-w-px relative shrink-0">
|
||||||
|
{/* Lecture 정보 */}
|
||||||
|
<div className="basis-0 grow min-h-px min-w-px relative shrink-0">
|
||||||
|
<div className="flex flex-col gap-[4px] items-start w-full">
|
||||||
|
<p className="font-['Pretendard:SemiBold',sans-serif] text-[#333c47] text-[16px] leading-[1.5]">
|
||||||
|
{index + 1}. {lecture.title || lecture.lectureName || ''}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-[12px] items-center w-full">
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4] w-[40px]">
|
||||||
|
{lecture.duration || '00:00'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
{/* 학습 제출 버튼 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
"bg-white box-border flex flex-col h-[32px] items-center justify-center px-[16px] py-[3px] rounded-[6px] shrink-0",
|
||||||
|
"border border-solid",
|
||||||
|
submitBtnBorder,
|
||||||
|
submitBtnText,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{isSubmitted ? (
|
||||||
|
<div className="flex gap-[4px] h-[18px] items-center">
|
||||||
|
<div className="relative shrink-0 size-[12px]">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10 3L4.5 8.5L2 6" stroke="#384fbf" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] text-[#384fbf] text-[13px] leading-[1.4]">학습 제출 완료</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className={[
|
||||||
|
"font-['Pretendard:Medium',sans-serif] text-[14px] leading-[1.5] text-center",
|
||||||
|
submitBtnText,
|
||||||
|
].join(" ")}>학습 제출 하기</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 수강/복습 버튼 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const lectureId = lecture.id || lecture.lectureId;
|
||||||
|
if (lectureId) {
|
||||||
|
router.push(`/menu/courses/lessons/${lectureId}/start`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
"box-border flex flex-col h-[32px] items-center justify-center px-[16px] py-[3px] rounded-[6px] shrink-0 transition-colors",
|
||||||
|
rightBtnStyle,
|
||||||
|
action === "이어서 수강하기" || action === "수강하기"
|
||||||
|
? "hover:bg-[#d0d9ff]"
|
||||||
|
: "hover:bg-[#e5e8eb]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<p className={[
|
||||||
|
"font-['Pretendard:Medium',sans-serif] text-[14px] leading-[1.5] text-center",
|
||||||
|
action === "이어서 수강하기" || action === "수강하기" ? "text-[#384fbf]" : "text-[#4c5561]",
|
||||||
|
].join(" ")}>{action}</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-8 w-full">
|
||||||
|
<p className="text-[14px] text-[#8c95a1]">등록된 강의가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,62 +1,289 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import ChevronDownSvg from '../svgs/chevrondownsvg';
|
||||||
|
import apiService from '../lib/apiService';
|
||||||
|
|
||||||
|
// 피그마 선택 컴포넌트의 구조/스타일(타이포/여백/색상)을 반영한 리스트 UI
|
||||||
|
// - 프로젝트는 Tailwind v4(@import "tailwindcss")를 사용하므로 클래스 그대로 적용
|
||||||
|
// - 헤더/푸터는 레이아웃에서 처리되므로 본 페이지에는 포함하지 않음
|
||||||
|
// - 카드 구성: 섬네일(rounded 8px) + 상태 태그 + 제목 + 메타(재생 아이콘 + 텍스트)
|
||||||
|
|
||||||
|
const imgPlay = '/imgs/play.svg'; // public/imgs/play.svg
|
||||||
|
|
||||||
|
interface Subject {
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
imageKey?: string;
|
||||||
|
instructor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Course {
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
image: string;
|
||||||
|
inProgress?: boolean;
|
||||||
|
meta?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorfulTag({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-bg-success-light box-border flex h-[20px] items-center justify-center px-[4px] rounded-[4px]">
|
||||||
|
<p className="font-['Pretendard:SemiBold',sans-serif] text-success text-[13px] leading-[1.4]">{text}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function CourseListPage() {
|
export default function CourseListPage() {
|
||||||
const courses = [
|
const ITEMS_PER_PAGE = 20;
|
||||||
{
|
const [page, setPage] = useState(1);
|
||||||
id: "p1",
|
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||||
title: "원자로 운전 및 계통",
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
description:
|
const [loading, setLoading] = useState(true);
|
||||||
"원자로 운전 원리와 주요 계통의 구조 및 기능을 이해하고, 실제 운전 상황을 가상 환경에서 체험합니다.",
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
},
|
const router = useRouter();
|
||||||
{
|
|
||||||
id: "p2",
|
// 과목 리스트 가져오기
|
||||||
title: "확률론",
|
useEffect(() => {
|
||||||
description:
|
let isMounted = true;
|
||||||
"확률과 통계의 핵심 개념을 직관적으로 이해하고 문제 해결력을 기릅니다.",
|
|
||||||
},
|
async function fetchSubjects() {
|
||||||
{
|
try {
|
||||||
id: "p3",
|
setLoading(true);
|
||||||
title: "부서간 협업",
|
const response = await apiService.getSubjects();
|
||||||
description:
|
|
||||||
"협업 절차, 기록 기준, 리스크 관리까지 실제 사례 기반으로 배우는 협업 가이드.",
|
if (response.status !== 200 || !response.data) {
|
||||||
},
|
return;
|
||||||
{
|
}
|
||||||
id: "p4",
|
|
||||||
title: "방사선의 이해",
|
// 응답 데이터 구조 확인 및 배열 추출
|
||||||
description:
|
let subjectsData: any[] = [];
|
||||||
"방사선 안전 기준과 보호 장비 선택법을 배우는 실무형 과정.",
|
let total = 0;
|
||||||
},
|
|
||||||
];
|
if (Array.isArray(response.data)) {
|
||||||
|
subjectsData = response.data;
|
||||||
|
total = response.data.length;
|
||||||
|
} else if (response.data && typeof response.data === 'object') {
|
||||||
|
// 다양한 응답 구조 처리
|
||||||
|
subjectsData = response.data.items ||
|
||||||
|
response.data.courses ||
|
||||||
|
response.data.data ||
|
||||||
|
response.data.list ||
|
||||||
|
response.data.subjects ||
|
||||||
|
response.data.subjectList ||
|
||||||
|
[];
|
||||||
|
|
||||||
|
// 전체 개수는 total, totalCount, count 등의 필드에서 가져오거나 배열 길이 사용
|
||||||
|
total = response.data.total !== undefined ? response.data.total :
|
||||||
|
response.data.totalCount !== undefined ? response.data.totalCount :
|
||||||
|
response.data.count !== undefined ? response.data.count :
|
||||||
|
subjectsData.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
setTotalCount(total);
|
||||||
|
setSubjects(subjectsData);
|
||||||
|
|
||||||
|
// 각 과목의 이미지 다운로드
|
||||||
|
const coursesWithImages = await Promise.all(
|
||||||
|
subjectsData.map(async (subject: Subject) => {
|
||||||
|
let imageUrl = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800';
|
||||||
|
|
||||||
|
if (subject.imageKey) {
|
||||||
|
try {
|
||||||
|
const fileUrl = await apiService.getFile(subject.imageKey);
|
||||||
|
if (fileUrl) {
|
||||||
|
imageUrl = fileUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`이미지 다운로드 실패 (과목 ID: ${subject.id}):`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: subject.id,
|
||||||
|
title: subject.title || '',
|
||||||
|
image: imageUrl,
|
||||||
|
inProgress: false, // TODO: 수강 중 상태는 진행률 API에서 가져와야 함
|
||||||
|
meta: subject.instructor ? `강사: ${subject.instructor}` : 'VOD • 온라인',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setCourses(coursesWithImages);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('과목 리스트 조회 오류:', error);
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSubjects();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||||
|
const pagedCourses = useMemo(
|
||||||
|
() => courses.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE),
|
||||||
|
[courses, page]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 페이지네이션: 10개씩 표시
|
||||||
|
const pageGroup = Math.floor((page - 1) / 10);
|
||||||
|
const startPage = pageGroup * 10 + 1;
|
||||||
|
const endPage = Math.min(startPage + 9, totalPages);
|
||||||
|
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex w-full flex-col">
|
<main className="flex w-full flex-col items-center">
|
||||||
<div className="flex h-[100px] items-center px-8">
|
{/* 상단 타이틀 영역 */}
|
||||||
<h1 className="text-[24px] font-bold leading-[1.5] text-white">교육 과정 목록</h1>
|
<div className="flex h-[100px] w-full max-w-[1440px] items-center px-8">
|
||||||
|
<h1 className="text-[24px] font-bold leading-normal text-text-title">교육 과정 목록</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="px-8 pb-16">
|
{/* 콘텐츠 래퍼: Figma 기준 1440 컨테이너, 내부 1376 그리드 폭 */}
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<section className="w-full max-w-[1440px] px-8 pt-8 pb-20">
|
||||||
{courses.map((c) => (
|
{/* 상단 카운트/정렬 영역 */}
|
||||||
<article
|
<div className="flex items-center justify-between">
|
||||||
key={c.id}
|
<p className="text-[15px] font-medium leading-normal text-neutral-700">
|
||||||
className="rounded-xl border border-[#ecf0ff] bg-white p-4 shadow-[0_2px_8px_rgba(0,0,0,0.02)]"
|
총 <span className="text-primary">{totalCount}</span>건
|
||||||
>
|
</p>
|
||||||
<h2 className="truncate text-[18px] font-bold leading-[1.5] text-[#1b2027]">{c.title}</h2>
|
<div className="h-[40px] w-[114px]" />
|
||||||
<p className="mt-1 line-clamp-3 text-[14px] leading-[1.5] text-[#4c5561]">{c.description}</p>
|
</div>
|
||||||
<div className="mt-3">
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="mt-4 flex items-center justify-center h-[400px]">
|
||||||
|
<p className="text-[16px] font-medium text-[#6C7682]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 카드 그리드(고정 5열, gap 32px) */}
|
||||||
|
<div className="mt-4 flex flex-col items-center">
|
||||||
|
<div className="w-[1376px] grid grid-cols-5 gap-[32px]">
|
||||||
|
{pagedCourses.map((c) => (
|
||||||
|
<article
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => router.push(`/course-list/${c.id}`)}
|
||||||
|
className="flex h-[260px] w-[249.6px] flex-col gap-[16px] rounded-[8px] bg-white cursor-pointer"
|
||||||
|
>
|
||||||
|
{/* 섬네일 */}
|
||||||
|
<div className="relative h-[166.4px] w-full overflow-clip rounded-[8px] flex items-center justify-center bg-[#F1F3F5] hover:shadow-lg transition-shadow">
|
||||||
|
<img
|
||||||
|
src={c.image}
|
||||||
|
alt={c.title}
|
||||||
|
className="h-full w-auto object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
const t = e.currentTarget as HTMLImageElement;
|
||||||
|
if (!t.src.includes('picsum.photos')) t.src = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 텍스트 블록 */}
|
||||||
|
<div className="flex w-full flex-col gap-[4px]">
|
||||||
|
<div className="flex flex-col gap-[4px]">
|
||||||
|
{c.inProgress && <ColorfulTag text="수강 중" />}
|
||||||
|
<h2 className="text-[18px] font-semibold leading-normal text-neutral-700 truncate" title={c.title}>
|
||||||
|
{c.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[4px]">
|
||||||
|
<img src={imgPlay} alt="" className="size-[16px]" />
|
||||||
|
<p className="text-[13px] font-medium leading-[1.4] text-text-meta">
|
||||||
|
{c.meta || 'VOD • 온라인'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 - admin 페이지와 동일한 메커니즘 (10개씩 표시) */}
|
||||||
|
{totalCount > ITEMS_PER_PAGE && (
|
||||||
|
<div className="mt-8 flex items-center justify-center gap-[8px]">
|
||||||
|
{/* First (맨 앞으로) */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="h-9 rounded-md border border-[#dee1e6] px-3 text-[14px] font-medium leading-[1.5] text-[#4c5561] hover:bg-[#f9fafb]"
|
onClick={() => setPage(1)}
|
||||||
|
aria-label="맨 앞 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={page === 1}
|
||||||
>
|
>
|
||||||
자세히 보기
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
aria-label="이전 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Numbers */}
|
||||||
|
{visiblePages.map((n) => {
|
||||||
|
const active = n === page;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage(n)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={[
|
||||||
|
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||||
|
active ? 'bg-bg-primary-light' : 'bg-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="text-[16px] leading-[1.4] text-neutral-700">{n}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
aria-label="다음 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={page === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Last (맨 뒤로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage(totalPages)}
|
||||||
|
aria-label="맨 뒤 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={page === totalPages}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
)}
|
||||||
))}
|
</>
|
||||||
</div>
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,20 +49,20 @@ export default function IdFindDone({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-[60px]">
|
<div className="mt-[60px]">
|
||||||
<div className="bg-[#f9fafb] border border-neutral-40 rounded-[16px] py-[24px]">
|
<div className="bg-[#f9fafb] border border-neutral-40 rounded-[16px] py-[24px] min-h-[108px] flex items-center justify-center">
|
||||||
{userId && (
|
{userId && (
|
||||||
<p className="text-[18px] font-semibold text-neutral-700">
|
<p className="text-[18px] font-semibold text-neutral-700 text-center">
|
||||||
{userId}
|
{userId}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{joinedAtText && (
|
{/* {joinedAtText && (
|
||||||
<p className="text-[16px] text-[#6c7682] mt-2">
|
<p className="text-[16px] text-[#6c7682] mt-2">
|
||||||
({joinedAtText})
|
({joinedAtText})
|
||||||
</p>
|
</p>
|
||||||
)}
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-[24px] flex gap-3">
|
<div className="mt-[60px] flex gap-3">
|
||||||
<Link
|
<Link
|
||||||
href={secondaryHref}
|
href={secondaryHref}
|
||||||
className="h-[56px] flex-1 rounded-[12px] bg-[#f1f3f5] text-[18px] font-semibold text-basic-text flex items-center justify-center"
|
className="h-[56px] flex-1 rounded-[12px] bg-[#f1f3f5] text-[18px] font-semibold text-basic-text flex items-center justify-center"
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import IdFindDone from "./IdFindDone";
|
|||||||
import IdFindFailed from "./IdFindFailed";
|
import IdFindFailed from "./IdFindFailed";
|
||||||
import FindIdOption from "./FindIdOption";
|
import FindIdOption from "./FindIdOption";
|
||||||
import LoginInputSvg from "@/app/svgs/inputformx";
|
import LoginInputSvg from "@/app/svgs/inputformx";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
export default function FindIdPage() {
|
export default function FindIdPage() {
|
||||||
const [isDoneOpen, setIsDoneOpen] = useState(false);
|
const [isDoneOpen, setIsDoneOpen] = useState(false);
|
||||||
const [isFailedOpen, setIsFailedOpen] = useState(false);
|
const [isFailedOpen, setIsFailedOpen] = useState(false);
|
||||||
const [foundUserId, setFoundUserId] = useState<string | undefined>(undefined);
|
const [foundUserId, setFoundUserId] = useState<string | undefined>(undefined);
|
||||||
|
const [joinedAt, setJoinedAt] = useState<string | undefined>(undefined);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [phone, setPhone] = useState("");
|
const [phone, setPhone] = useState("");
|
||||||
const [focused, setFocused] = useState<Record<string, boolean>>({});
|
const [focused, setFocused] = useState<Record<string, boolean>>({});
|
||||||
@@ -19,6 +21,22 @@ export default function FindIdPage() {
|
|||||||
const isPhoneValid = useMemo(() => /^\d{9,11}$/.test(phone), [phone]);
|
const isPhoneValid = useMemo(() => /^\d{9,11}$/.test(phone), [phone]);
|
||||||
const canSubmit = useMemo(() => isNameValid && isPhoneValid, [isNameValid, isPhoneValid]);
|
const canSubmit = useMemo(() => isNameValid && isPhoneValid, [isNameValid, isPhoneValid]);
|
||||||
|
|
||||||
|
function formatPhoneNumber(value: string): string {
|
||||||
|
const numbers = value.replace(/[^0-9]/g, "");
|
||||||
|
if (numbers.length <= 3) return numbers;
|
||||||
|
if (numbers.length <= 7) return `${numbers.slice(0, 3)}-${numbers.slice(3)}`;
|
||||||
|
if (numbers.length <= 11) return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7)}`;
|
||||||
|
return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// function formatJoinedAt(dateString: string | Date): string {
|
||||||
|
// const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
|
||||||
|
// const year = date.getFullYear();
|
||||||
|
// const month = date.getMonth() + 1;
|
||||||
|
// const day = date.getDate();
|
||||||
|
// return `${year}년 ${month}월 ${day}일`;
|
||||||
|
// }
|
||||||
|
|
||||||
function validateAll() {
|
function validateAll() {
|
||||||
const next: Record<string, string> = {};
|
const next: Record<string, string> = {};
|
||||||
if (!isNameValid) next.name = "이름을 입력해 주세요.";
|
if (!isNameValid) next.name = "이름을 입력해 주세요.";
|
||||||
@@ -27,12 +45,26 @@ export default function FindIdPage() {
|
|||||||
return Object.keys(next).length === 0;
|
return Object.keys(next).length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!validateAll()) return;
|
if (!validateAll()) return;
|
||||||
const mockUserId = `${name.trim()}@example.com`;
|
|
||||||
setFoundUserId(mockUserId);
|
try {
|
||||||
setIsDoneOpen(true);
|
const response = await apiService.findUserId(name, phone);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// body의 emails 배열에서 첫 번째 이메일 가져오기
|
||||||
|
if (data.emails && Array.isArray(data.emails) && data.emails.length > 0) {
|
||||||
|
setFoundUserId(data.emails[0]);
|
||||||
|
} else if (data.email) {
|
||||||
|
setFoundUserId(data.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDoneOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('아이디 찾기 오류:', error);
|
||||||
|
setIsFailedOpen(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -40,6 +72,7 @@ export default function FindIdPage() {
|
|||||||
<IdFindDone
|
<IdFindDone
|
||||||
on={isDoneOpen}
|
on={isDoneOpen}
|
||||||
userId={foundUserId}
|
userId={foundUserId}
|
||||||
|
// joinedAtText={joinedAt ? `가입일: ${joinedAt}` : undefined}
|
||||||
onClose={() => setIsDoneOpen(false)}
|
onClose={() => setIsDoneOpen(false)}
|
||||||
/>
|
/>
|
||||||
<IdFindFailed
|
<IdFindFailed
|
||||||
@@ -47,90 +80,117 @@ export default function FindIdPage() {
|
|||||||
onClose={() => setIsFailedOpen(false)}
|
onClose={() => setIsFailedOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="rounded-xl bg-white max-w-[560px] px-[40px] w-full relative">
|
<div className="flex-1 flex items-center justify-center w-full py-6">
|
||||||
|
<div className="rounded-xl bg-white max-w-[560px] px-[40px] w-full relative">
|
||||||
|
|
||||||
<div className="my-15 flex flex-col items-center">
|
<div className="my-15 flex flex-col items-center">
|
||||||
<div className="text-[24px] font-extrabold leading-[150%] text-neutral-700">
|
<div className="text-[24px] font-extrabold leading-[150%] text-neutral-700">
|
||||||
아이디 찾기
|
아이디 찾기
|
||||||
|
</div>
|
||||||
|
<p className="text-[18px] leading-[150%] text-[#6c7682] mt-[8px] text-center">
|
||||||
|
가입 시 등록한 회원정보를 입력해 주세요.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[18px] leading-[150%] text-[#6c7682] mt-[8px] text-center">
|
|
||||||
가입 시 등록한 회원정보를 입력해 주세요.
|
<form onSubmit={handleSubmit}>
|
||||||
</p>
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="name" className="text-[15px] font-semibold text-[#6c7682]">
|
||||||
|
이름
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
if (errors.name) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.name;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => setFocused((p) => ({ ...p, name: true }))}
|
||||||
|
onBlur={() => setFocused((p) => ({ ...p, name: false }))}
|
||||||
|
placeholder="이름을 입력해 주세요."
|
||||||
|
className={`h-[40px] px-[12px] py-[7px] w-full rounded-[8px] mt-3 border focus:outline-none text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px] ${errors.name
|
||||||
|
? "border-error focus:border-error"
|
||||||
|
: "border-neutral-40 focus:border-neutral-700"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{name.trim().length > 0 && focused.name && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setName("");
|
||||||
|
}}
|
||||||
|
aria-label="입력 지우기"
|
||||||
|
className="absolute right-3 top-[32px] -translate-y-1/2 cursor-pointer flex items-center justify-center">
|
||||||
|
<LoginInputSvg />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.name && <p className="text-error text-[13px] leading-tight">{errors.name}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 mt-6">
|
||||||
|
<label htmlFor="phone" className="text-[15px] font-semibold text-[#6c7682]">
|
||||||
|
휴대폰 번호
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
type="tel"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={formatPhoneNumber(phone)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const numbersOnly = e.target.value.replace(/[^0-9]/g, "");
|
||||||
|
setPhone(numbersOnly);
|
||||||
|
if (errors.phone) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.phone;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => setFocused((p) => ({ ...p, phone: true }))}
|
||||||
|
onBlur={() => setFocused((p) => ({ ...p, phone: false }))}
|
||||||
|
placeholder="-없이 입력해 주세요."
|
||||||
|
className={`h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border mt-3 focus:outline-none text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px] ${errors.phone
|
||||||
|
? "border-error focus:border-error"
|
||||||
|
: "border-neutral-40 focus:border-neutral-700"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{phone.length > 0 && focused.phone && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPhone("");
|
||||||
|
}}
|
||||||
|
aria-label="입력 지우기"
|
||||||
|
className="absolute right-3 top-[32px] -translate-y-1/2 cursor-pointer flex items-center justify-center">
|
||||||
|
<LoginInputSvg />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.phone && <p className="text-error text-[13px] leading-tight">{errors.phone}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`h-[56px] w-full rounded-lg text-[16px] font-semibold text-white transition-opacity cursor-pointer mb-3 hover:bg-[#1F2B91] mt-[60px] ${canSubmit ? "bg-active-button" : "bg-inactive-button"}`}>
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
|
||||||
|
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="name" className="text-[15px] font-semibold text-[#6c7682]">
|
|
||||||
이름
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
onFocus={() => setFocused((p) => ({ ...p, name: true }))}
|
|
||||||
onBlur={() => setFocused((p) => ({ ...p, name: false }))}
|
|
||||||
placeholder="이름을 입력해 주세요."
|
|
||||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
|
||||||
/>
|
|
||||||
{name.trim().length > 0 && focused.name && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setName("");
|
|
||||||
}}
|
|
||||||
aria-label="입력 지우기"
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer">
|
|
||||||
<LoginInputSvg />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{errors.name && <p className="text-error text-[13px] leading-tight">{errors.name}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="phone" className="text-[15px] font-semibold text-[#6c7682]">
|
|
||||||
휴대폰 번호
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
id="phone"
|
|
||||||
name="phone"
|
|
||||||
type="tel"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={phone}
|
|
||||||
onChange={(e) => setPhone(e.target.value.replace(/[^0-9]/g, ""))}
|
|
||||||
onFocus={() => setFocused((p) => ({ ...p, phone: true }))}
|
|
||||||
onBlur={() => setFocused((p) => ({ ...p, phone: false }))}
|
|
||||||
placeholder="-없이 입력해 주세요."
|
|
||||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
|
||||||
/>
|
|
||||||
{phone.trim().length > 0 && focused.phone && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setPhone("");
|
|
||||||
}}
|
|
||||||
aria-label="입력 지우기"
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer">
|
|
||||||
<LoginInputSvg />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{errors.phone && <p className="text-error text-[13px] leading-tight">{errors.phone}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className={`h-[40px] w-full rounded-[12px] text-[18px] font-semibold text-white ${canSubmit ? "bg-active-button" : "bg-inactive-button"} cursor-pointer`}>
|
|
||||||
다음
|
|
||||||
</button>
|
|
||||||
|
|
||||||
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FindIdOption
|
<FindIdOption
|
||||||
|
|||||||
@@ -11,40 +11,122 @@
|
|||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
|
|
||||||
|
/* ===== 텍스트 색상 ===== */
|
||||||
/*login color start*/
|
--color-text-title: #1b2027;
|
||||||
--color-input-placeholder-text: #b1b8c0;
|
--color-text-body: #1b2027;
|
||||||
|
--color-text-label: #6c7682;
|
||||||
--color-neutral-40: #dee1e6;
|
--color-text-meta: #8c95a1;
|
||||||
--color-input-border: #dee1e6;
|
--color-text-placeholder: #9ca3af;
|
||||||
|
--color-text-placeholder-alt: #b1b8c0;
|
||||||
--color-basic-text: #4c5561;
|
--color-basic-text: #4c5561;
|
||||||
|
|
||||||
/* color natural 700*/
|
|
||||||
--color-neutral-700: #333c47;
|
--color-neutral-700: #333c47;
|
||||||
--color-logo-text: #333c47;
|
--color-logo-text: #333c47;
|
||||||
--color-input-border-select: #333c47;
|
|
||||||
--color-input-alert-text: #333c47;
|
--color-input-alert-text: #333c47;
|
||||||
|
|
||||||
--color-error: #f64c4c;
|
/* ===== 배경 색상 ===== */
|
||||||
|
--color-bg-gray-light: #f1f3f5;
|
||||||
|
--color-bg-gray-hover: #e5e7eb;
|
||||||
|
--color-bg-success-light: #e5f5ec;
|
||||||
|
--color-bg-primary-light: #ecf0ff;
|
||||||
|
--color-bg-header: #060958;
|
||||||
|
|
||||||
--color-inactive-checkbox: #c2c9cf;
|
/* ===== 테두리 색상 ===== */
|
||||||
|
--color-neutral-40: #dee1e6;
|
||||||
|
--color-input-border: #dee1e6;
|
||||||
|
--color-input-border-select: #333c47;
|
||||||
|
|
||||||
|
/* ===== 버튼/액션 색상 ===== */
|
||||||
--color-inactive-button: #8598e8;
|
--color-inactive-button: #8598e8;
|
||||||
--color-active-button: #1f2b91;
|
--color-active-button: #1f2b91;
|
||||||
/*login color end*/
|
--color-active-button-hover: #1a2478;
|
||||||
|
--color-primary: #384fbf;
|
||||||
|
--color-primary-alt: #384FBF;
|
||||||
|
|
||||||
|
/* ===== 상태 색상 ===== */
|
||||||
}
|
--color-error: #f64c4c;
|
||||||
|
--color-success: #0c9d61;
|
||||||
@media (prefers-color-scheme: dark) {
|
--color-inactive-checkbox: #c2c9cf;
|
||||||
:root {
|
--color-input-placeholder-text: #b1b8c0;
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 전역 스크롤바 스타일 - 배경(트랙)만 투명하게, 썸은 보이게 */
|
||||||
|
html {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 드롭다운 스크롤바 스타일 - 배경 없애기 */
|
||||||
|
.dropdown-scroll {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-scroll::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background-color: transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-scroll:hover::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSV 표 스크롤바 스타일 - 배경만 투명, thumb는 보이게 */
|
||||||
|
.csv-table-scroll {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-table-scroll::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-table-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-table-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-table-scroll::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|||||||
731
src/app/instructor/courses/page.tsx
Normal file
@@ -0,0 +1,731 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import ChevronDownSvg from '../../svgs/chevrondownsvg';
|
||||||
|
import apiService from '../../lib/apiService';
|
||||||
|
|
||||||
|
// 드롭다운 아이콘 컴포넌트
|
||||||
|
function ArrowDownIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 6L8 10L12 6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색 아이콘 컴포넌트
|
||||||
|
function SearchIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9 17C13.4183 17 17 13.4183 17 9C17 4.58172 13.4183 1 9 1C4.58172 1 1 4.58172 1 9C1 13.4183 4.58172 17 9 17Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M19 19L14.65 14.65"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 태그 컴포넌트
|
||||||
|
function StatusTag({ text, type = 'default', color = 'primary' }: { text: string; type?: 'default' | 'emphasis'; color?: 'primary' | 'gray' }) {
|
||||||
|
if (type === 'default' && color === 'primary') {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center justify-center h-[20px] px-[4px] rounded-[4px] bg-[#ecf0ff]">
|
||||||
|
<span className="text-[13px] font-semibold leading-[1.4] text-[#384fbf] whitespace-nowrap">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type === 'default' && color === 'gray') {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center justify-center h-[20px] px-[4px] rounded-[4px] bg-[#f1f3f5]">
|
||||||
|
<span className="text-[13px] font-semibold leading-[1.4] text-[#4c5561] whitespace-nowrap">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center justify-center h-[20px] px-[4px] rounded-[4px] bg-[#ecf0ff]">
|
||||||
|
<span className="text-[13px] font-semibold leading-[1.4] text-[#384fbf] whitespace-nowrap">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LearnerProgress = {
|
||||||
|
id: string;
|
||||||
|
courseName: string;
|
||||||
|
lessonName: string;
|
||||||
|
learnerName: string;
|
||||||
|
enrollmentDate: string;
|
||||||
|
lastStudyDate: string;
|
||||||
|
progressRate: number;
|
||||||
|
hasSubmitted: boolean;
|
||||||
|
score: number | null;
|
||||||
|
isCompleted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InstructorCoursesPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [userRole, setUserRole] = useState<string>('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
// 필터 상태
|
||||||
|
const [selectedCourse, setSelectedCourse] = useState<string>('all');
|
||||||
|
const [selectedSubmissionStatus, setSelectedSubmissionStatus] = useState<string>('all');
|
||||||
|
const [selectedCompletionStatus, setSelectedCompletionStatus] = useState<string>('all');
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
|
|
||||||
|
// 드롭다운 열림 상태
|
||||||
|
const [isCourseDropdownOpen, setIsCourseDropdownOpen] = useState(false);
|
||||||
|
const [isSubmissionDropdownOpen, setIsSubmissionDropdownOpen] = useState(false);
|
||||||
|
const [isCompletionDropdownOpen, setIsCompletionDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
// 데이터
|
||||||
|
const [courses, setCourses] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [learnerProgress, setLearnerProgress] = useState<LearnerProgress[]>([]);
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
|
||||||
|
// 사용자 정보 및 권한 확인
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function fetchUserInfo() {
|
||||||
|
try {
|
||||||
|
const localStorageToken = localStorage.getItem('token');
|
||||||
|
const cookieToken = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('token='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
const token = localStorageToken || cookieToken;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiService.getCurrentUser();
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
if (isMounted) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
const role = data.role || data.userRole || '';
|
||||||
|
setUserRole(role);
|
||||||
|
|
||||||
|
// admin이 아니면 접근 불가
|
||||||
|
if (role !== 'ADMIN' && role !== 'admin') {
|
||||||
|
router.push('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('사용자 정보 조회 오류:', error);
|
||||||
|
if (isMounted) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchUserInfo();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
// 교육 과정 목록 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchCourses() {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token') || document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('token='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
const response = await apiService.getSubjects();
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
const coursesArray = Array.isArray(data) ? data : (data.items || data.courses || data.data || []);
|
||||||
|
setCourses(coursesArray.map((item: any) => ({
|
||||||
|
id: String(item.id || item.subjectId || ''),
|
||||||
|
name: item.courseName || item.name || item.subjectName || '',
|
||||||
|
})));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('교육 과정 목록 조회 오류:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCourses();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 학습자 진행 상황 데이터 (더미 데이터 - 실제 API로 교체 필요)
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchLearnerProgress() {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
// TODO: 실제 API 호출로 교체
|
||||||
|
// 현재는 더미 데이터 사용
|
||||||
|
const dummyData: LearnerProgress[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
courseName: '방사선 관리',
|
||||||
|
lessonName: '{강좌명}',
|
||||||
|
learnerName: '김하늘',
|
||||||
|
enrollmentDate: '2025-09-10',
|
||||||
|
lastStudyDate: '2025-09-10',
|
||||||
|
progressRate: 100,
|
||||||
|
hasSubmitted: true,
|
||||||
|
score: 100,
|
||||||
|
isCompleted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
courseName: '방사선 관리',
|
||||||
|
lessonName: '{강좌명}',
|
||||||
|
learnerName: '김하늘',
|
||||||
|
enrollmentDate: '2025-09-10',
|
||||||
|
lastStudyDate: '2025-09-10',
|
||||||
|
progressRate: 100,
|
||||||
|
hasSubmitted: true,
|
||||||
|
score: 100,
|
||||||
|
isCompleted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
courseName: '방사선 관리',
|
||||||
|
lessonName: '{강좌명}',
|
||||||
|
learnerName: '김하늘',
|
||||||
|
enrollmentDate: '2025-09-10',
|
||||||
|
lastStudyDate: '2025-09-10',
|
||||||
|
progressRate: 100,
|
||||||
|
hasSubmitted: true,
|
||||||
|
score: 100,
|
||||||
|
isCompleted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
courseName: '원자로 운전 및 계통',
|
||||||
|
lessonName: '6. 원자로 시동, 운전 및 정지 절차',
|
||||||
|
learnerName: '김하늘',
|
||||||
|
enrollmentDate: '2025-09-10',
|
||||||
|
lastStudyDate: '2025-09-10',
|
||||||
|
progressRate: 100,
|
||||||
|
hasSubmitted: true,
|
||||||
|
score: 60,
|
||||||
|
isCompleted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
courseName: '방사선 관리',
|
||||||
|
lessonName: '{강좌명}',
|
||||||
|
learnerName: '김하늘',
|
||||||
|
enrollmentDate: '2025-09-10',
|
||||||
|
lastStudyDate: '2025-09-10',
|
||||||
|
progressRate: 100,
|
||||||
|
hasSubmitted: true,
|
||||||
|
score: 30,
|
||||||
|
isCompleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
courseName: '방사선 관리',
|
||||||
|
lessonName: '{강좌명}',
|
||||||
|
learnerName: '김하늘',
|
||||||
|
enrollmentDate: '2025-09-10',
|
||||||
|
lastStudyDate: '2025-09-10',
|
||||||
|
progressRate: 100,
|
||||||
|
hasSubmitted: true,
|
||||||
|
score: 30,
|
||||||
|
isCompleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
courseName: '방사선 관리',
|
||||||
|
lessonName: '{강좌명}',
|
||||||
|
learnerName: '김하늘',
|
||||||
|
enrollmentDate: '2025-09-10',
|
||||||
|
lastStudyDate: '2025-09-10',
|
||||||
|
progressRate: 100,
|
||||||
|
hasSubmitted: false,
|
||||||
|
score: null,
|
||||||
|
isCompleted: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setLearnerProgress(dummyData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('학습자 진행 상황 조회 오류:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchLearnerProgress();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 필터링된 데이터
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
return learnerProgress.filter((item) => {
|
||||||
|
// 교육 과정 필터
|
||||||
|
if (selectedCourse !== 'all' && item.courseName !== selectedCourse) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문제 제출 여부 필터
|
||||||
|
if (selectedSubmissionStatus === 'submitted' && !item.hasSubmitted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (selectedSubmissionStatus === 'not-submitted' && item.hasSubmitted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수료 여부 필터
|
||||||
|
if (selectedCompletionStatus === 'completed' && !item.isCompleted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (selectedCompletionStatus === 'not-completed' && item.isCompleted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색어 필터
|
||||||
|
if (searchQuery && !item.learnerName.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [learnerProgress, selectedCourse, selectedSubmissionStatus, selectedCompletionStatus, searchQuery]);
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE);
|
||||||
|
const paginatedData = useMemo(() => {
|
||||||
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||||
|
return filteredData.slice(startIndex, endIndex);
|
||||||
|
}, [filteredData, currentPage]);
|
||||||
|
|
||||||
|
// 드롭다운 외부 클릭 감지
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest('.dropdown-container')) {
|
||||||
|
setIsCourseDropdownOpen(false);
|
||||||
|
setIsSubmissionDropdownOpen(false);
|
||||||
|
setIsCompletionDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white min-h-screen flex flex-col">
|
||||||
|
<div className="flex-1 max-w-[1440px] w-full mx-auto px-0">
|
||||||
|
<div className="flex flex-col h-[100px] items-start justify-center px-[32px] ">
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
강좌 현황
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-[16px] px-[32px] py-[32px]">
|
||||||
|
{/* 필터 및 검색 영역 */}
|
||||||
|
<div className="flex items-end justify-between gap-[16px]">
|
||||||
|
<div className="flex gap-[8px] items-end">
|
||||||
|
{/* 교육 과정 드롭다운 */}
|
||||||
|
<div className="flex flex-col gap-[4px] relative dropdown-container">
|
||||||
|
<label className="text-[14px] font-semibold leading-[1.5] text-[#6c7682]">
|
||||||
|
교육 과정
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsCourseDropdownOpen(!isCourseDropdownOpen);
|
||||||
|
setIsSubmissionDropdownOpen(false);
|
||||||
|
setIsCompletionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="bg-white border border-[#dee1e6] rounded-[8px] h-[40px] px-[12px] py-[6px] flex items-center justify-between gap-[8px] w-[400px]"
|
||||||
|
>
|
||||||
|
<span className="text-[15px] font-normal leading-[1.5] text-[#6c7682] flex-1 text-left">
|
||||||
|
{selectedCourse === 'all' ? '선택 안함' : courses.find(c => c.id === selectedCourse)?.name || '선택 안함'}
|
||||||
|
</span>
|
||||||
|
<ArrowDownIcon className="size-[16px] text-[#6c7682] shrink-0" />
|
||||||
|
</button>
|
||||||
|
{isCourseDropdownOpen && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 bg-white border border-[#dee1e6] rounded-[8px] shadow-lg z-50 w-[400px] max-h-[200px] overflow-y-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCourse('all');
|
||||||
|
setIsCourseDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-[12px] py-[8px] text-left text-[15px] font-normal leading-[1.5] text-[#1b2027] hover:bg-[#f1f3f5]"
|
||||||
|
>
|
||||||
|
선택 안함
|
||||||
|
</button>
|
||||||
|
{courses.map((course) => (
|
||||||
|
<button
|
||||||
|
key={course.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCourse(course.name);
|
||||||
|
setIsCourseDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-[12px] py-[8px] text-left text-[15px] font-normal leading-[1.5] text-[#1b2027] hover:bg-[#f1f3f5]"
|
||||||
|
>
|
||||||
|
{course.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 문제 제출 여부 드롭다운 */}
|
||||||
|
<div className="flex flex-col gap-[4px] relative dropdown-container">
|
||||||
|
<label className="text-[14px] font-semibold leading-[1.5] text-[#6c7682]">
|
||||||
|
문제 제출 여부
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsSubmissionDropdownOpen(!isSubmissionDropdownOpen);
|
||||||
|
setIsCourseDropdownOpen(false);
|
||||||
|
setIsCompletionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="bg-white border border-[#dee1e6] rounded-[8px] h-[40px] px-[12px] py-[6px] flex items-center justify-between gap-[8px] w-[190px]"
|
||||||
|
>
|
||||||
|
<span className="text-[15px] font-normal leading-[1.5] text-[#6c7682] flex-1 text-left">
|
||||||
|
{selectedSubmissionStatus === 'all' ? '모든 상태' : selectedSubmissionStatus === 'submitted' ? '제출' : '미제출'}
|
||||||
|
</span>
|
||||||
|
<ArrowDownIcon className="size-[16px] text-[#6c7682] shrink-0" />
|
||||||
|
</button>
|
||||||
|
{isSubmissionDropdownOpen && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 bg-white border border-[#dee1e6] rounded-[8px] shadow-lg z-50 w-[190px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSubmissionStatus('all');
|
||||||
|
setIsSubmissionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-[12px] py-[8px] text-left text-[15px] font-normal leading-[1.5] text-[#1b2027] hover:bg-[#f1f3f5]"
|
||||||
|
>
|
||||||
|
모든 상태
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSubmissionStatus('submitted');
|
||||||
|
setIsSubmissionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-[12px] py-[8px] text-left text-[15px] font-normal leading-[1.5] text-[#1b2027] hover:bg-[#f1f3f5]"
|
||||||
|
>
|
||||||
|
제출
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSubmissionStatus('not-submitted');
|
||||||
|
setIsSubmissionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-[12px] py-[8px] text-left text-[15px] font-normal leading-[1.5] text-[#1b2027] hover:bg-[#f1f3f5]"
|
||||||
|
>
|
||||||
|
미제출
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수료 여부 드롭다운 */}
|
||||||
|
<div className="flex flex-col gap-[4px] relative dropdown-container">
|
||||||
|
<label className="text-[14px] font-semibold leading-[1.5] text-[#6c7682]">
|
||||||
|
수료 여부
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsCompletionDropdownOpen(!isCompletionDropdownOpen);
|
||||||
|
setIsCourseDropdownOpen(false);
|
||||||
|
setIsSubmissionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="bg-white border border-[#dee1e6] rounded-[8px] h-[40px] px-[12px] py-[6px] flex items-center justify-between gap-[8px] w-[190px]"
|
||||||
|
>
|
||||||
|
<span className="text-[15px] font-normal leading-[1.5] text-[#6c7682] flex-1 text-left">
|
||||||
|
{selectedCompletionStatus === 'all' ? '모든 상태' : selectedCompletionStatus === 'completed' ? '완료' : '미완료'}
|
||||||
|
</span>
|
||||||
|
<ArrowDownIcon className="size-[16px] text-[#6c7682] shrink-0" />
|
||||||
|
</button>
|
||||||
|
{isCompletionDropdownOpen && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 bg-white border border-[#dee1e6] rounded-[8px] shadow-lg z-50 w-[190px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCompletionStatus('all');
|
||||||
|
setIsCompletionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-[12px] py-[8px] text-left text-[15px] font-normal leading-[1.5] text-[#1b2027] hover:bg-[#f1f3f5]"
|
||||||
|
>
|
||||||
|
모든 상태
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCompletionStatus('completed');
|
||||||
|
setIsCompletionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-[12px] py-[8px] text-left text-[15px] font-normal leading-[1.5] text-[#1b2027] hover:bg-[#f1f3f5]"
|
||||||
|
>
|
||||||
|
완료
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCompletionStatus('not-completed');
|
||||||
|
setIsCompletionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-[12px] py-[8px] text-left text-[15px] font-normal leading-[1.5] text-[#1b2027] hover:bg-[#f1f3f5]"
|
||||||
|
>
|
||||||
|
미완료
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색바 */}
|
||||||
|
<div className="bg-white border border-[#dee1e6] rounded-[8px] h-[40px] px-[12px] py-[8px] flex items-center gap-[10px] w-[240px]">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="학습자명으로 검색"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="flex-1 text-[16px] font-normal leading-[1.5] text-[#b1b8c0] outline-none placeholder:text-[#b1b8c0]"
|
||||||
|
/>
|
||||||
|
<SearchIcon className="size-[20px] text-[#b1b8c0] shrink-0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 영역 */}
|
||||||
|
<div className="border border-[#dee1e6] rounded-[8px] overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredData.length === 0 ? (
|
||||||
|
<div className="min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">데이터가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 테이블 헤더 */}
|
||||||
|
<div className="bg-gray-50 h-[48px] flex items-center border-b border-[#dee1e6]">
|
||||||
|
<div className="flex-1 border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">교육 과정명</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">강좌명</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[140px] border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">학습자명</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[140px] border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">가입일</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[140px] border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">마지막 수강일</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[76px] border-r border-[#dee1e6] px-[16px] py-[12px] text-center">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">진도율</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[112px] border-r border-[#dee1e6] px-[16px] py-[12px] text-center">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">문제 제출 여부</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[84px] border-r border-[#dee1e6] px-[16px] py-[12px] text-center">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">평가 점수</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[84px] px-[16px] py-[12px] text-center">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">수료 여부</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 바디 */}
|
||||||
|
<div className="bg-white">
|
||||||
|
{paginatedData.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
// 상세 페이지로 이동 (API 연동 시 item.id 사용)
|
||||||
|
router.push(`/instructor/courses/${item.id}`);
|
||||||
|
}}
|
||||||
|
className="h-[48px] w-full flex items-center border-b border-[#dee1e6] last:border-b-0 cursor-pointer hover:bg-[#F5F7FF] transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="flex-1 border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">{item.courseName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">{item.lessonName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[140px] border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">{item.learnerName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[140px] border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">{item.enrollmentDate}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[140px] border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">{item.lastStudyDate}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[76px] border-r border-[#dee1e6] px-[16px] py-[12px] text-center">
|
||||||
|
<span className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">{item.progressRate}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[112px] border-r border-[#dee1e6] px-[16px] py-[12px] text-center">
|
||||||
|
{item.hasSubmitted ? (
|
||||||
|
<StatusTag text="제출" type="default" color="primary" />
|
||||||
|
) : (
|
||||||
|
<StatusTag text="미제출" type="default" color="gray" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-[84px] border-r border-[#dee1e6] px-[16px] py-[12px] text-center">
|
||||||
|
<span className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">
|
||||||
|
{item.score !== null ? `${item.score}점` : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[84px] px-[16px] py-[12px] text-center">
|
||||||
|
{item.isCompleted ? (
|
||||||
|
<StatusTag text="완료" type="default" color="primary" />
|
||||||
|
) : (
|
||||||
|
<StatusTag text="미완료" type="default" color="gray" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{filteredData.length > ITEMS_PER_PAGE && (
|
||||||
|
<div className="flex items-center justify-center gap-[8px] pt-[32px]">
|
||||||
|
{/* First */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="flex items-center justify-center rounded-full size-[32px] p-[8.615px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="flex items-center justify-center rounded-full size-[32px] p-[8.615px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page Numbers */}
|
||||||
|
{Array.from({ length: Math.min(10, totalPages) }, (_, i) => {
|
||||||
|
const pageNum = i + 1;
|
||||||
|
const isActive = pageNum === currentPage;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pageNum}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(pageNum)}
|
||||||
|
className={`flex items-center justify-center rounded-full size-[32px] text-[16px] leading-[1.4] text-[#333c47] cursor-pointer ${
|
||||||
|
isActive ? 'bg-[#ecf0ff]' : 'bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="flex items-center justify-center rounded-full size-[32px] p-[8.615px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Last */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="flex items-center justify-center rounded-full size-[32px] p-[8.615px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
344
src/app/instructor/page.tsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import MainLogoSvg from '../svgs/mainlogosvg';
|
||||||
|
import ChevronDownSvg from '../svgs/chevrondownsvg';
|
||||||
|
import apiService from '../lib/apiService';
|
||||||
|
|
||||||
|
// 아이콘 컴포넌트들
|
||||||
|
function BookIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 19.5C4 18.837 4.263 18.201 4.732 17.732C5.201 17.263 5.837 17 6.5 17H20"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M6.5 2H20V22H6.5C5.837 22 5.201 21.737 4.732 21.268C4.263 20.799 4 20.163 4 19.5V4.5C4 3.837 4.263 3.201 4.732 2.732C5.201 2.263 5.837 2 6.5 2Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8L14 2Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14 2V8H20"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M16 13H8"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M16 17H8"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10 9H9H8"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckCircleIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.7088 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M22 4L12 14.01L9 11.01"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8 8C9.65685 8 11 6.65685 11 5C11 3.34315 9.65685 2 8 2C6.34315 2 5 3.34315 5 5C5 6.65685 6.34315 8 8 8Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M2 13.3333C2 11.0862 3.75333 9.33333 6 9.33333H10C12.2467 9.33333 14 11.0862 14 13.3333V14H2V13.3333Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronRightIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 12L10 8L6 4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Activity = {
|
||||||
|
id: string;
|
||||||
|
userName: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InstructorPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [userName, setUserName] = useState<string>('');
|
||||||
|
const [userRole, setUserRole] = useState<string>('');
|
||||||
|
const [totalCourses, setTotalCourses] = useState<number>(5);
|
||||||
|
const [submissionStatus, setSubmissionStatus] = useState<{ current: number; total: number }>({ current: 10, total: 50 });
|
||||||
|
const [completionStatus, setCompletionStatus] = useState<{ current: number; total: number }>({ current: 14, total: 50 });
|
||||||
|
const [activities, setActivities] = useState<Activity[]>([
|
||||||
|
{ id: '1', userName: '김하늘', message: '{강좌명} 문제를 제출했습니다.', timestamp: '2025-12-12 14:44' },
|
||||||
|
{ id: '2', userName: '김하늘', message: '{강좌명} 문제를 제출했습니다.', timestamp: '2025-12-12 14:44' },
|
||||||
|
{ id: '3', userName: '김하늘', message: '모든 강좌를 수강했습니다.', timestamp: '2025-12-12 14:44' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 사용자 정보 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function fetchUserInfo() {
|
||||||
|
try {
|
||||||
|
const localStorageToken = localStorage.getItem('token');
|
||||||
|
const cookieToken = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('token='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
const token = localStorageToken || cookieToken;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiService.getCurrentUser();
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
if (isMounted) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
const role = data.role || data.userRole || '';
|
||||||
|
setUserRole(role);
|
||||||
|
|
||||||
|
// admin이 아니면 접근 불가
|
||||||
|
if (role !== 'ADMIN' && role !== 'admin') {
|
||||||
|
router.push('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.name) {
|
||||||
|
setUserName(data.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('사용자 정보 조회 오류:', error);
|
||||||
|
if (isMounted) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchUserInfo();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white min-h-screen flex flex-col">
|
||||||
|
<div className="flex-1 max-w-[1440px] w-full mx-auto px-0">
|
||||||
|
<div className="flex flex-col gap-[40px] w-full">
|
||||||
|
{/* 강좌별 상세 내역 섹션 */}
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
|
<div className="flex h-[100px] items-center justify-between px-[32px]">
|
||||||
|
<h2 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
강좌별 상세 내역
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
href="/instructor/courses"
|
||||||
|
className="flex items-center gap-[2px] text-[14px] font-medium text-[#6c7682]"
|
||||||
|
>
|
||||||
|
<span>전체보기</span>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-[16px] pb-[32px] pt-0 px-[32px]">
|
||||||
|
<div className="flex gap-[16px] h-[120px]">
|
||||||
|
{/* 총 강좌 수 카드 */}
|
||||||
|
<div className="flex-1 bg-white border border-[#dee1e6] rounded-[16px] flex gap-[16px] items-center justify-center p-[24px]">
|
||||||
|
<div className="bg-[#ecf0ff] rounded-full size-[48px] flex items-center justify-center shrink-0">
|
||||||
|
<BookIcon className="size-[24px] text-[#060958]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col items-start">
|
||||||
|
<p className="text-[14px] font-normal text-[#333c47] leading-[1.5]">
|
||||||
|
총 강좌 수
|
||||||
|
</p>
|
||||||
|
<p className="text-[20px] font-bold text-[#333c47] leading-[1.5]">
|
||||||
|
{totalCourses}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 학습자 문제 제출 현황 카드 */}
|
||||||
|
<div className="flex-1 bg-white border border-[#dee1e6] rounded-[16px] flex gap-[16px] items-center justify-center p-[24px]">
|
||||||
|
<div className="bg-[#ecf0ff] rounded-full size-[48px] flex items-center justify-center shrink-0">
|
||||||
|
<DocumentIcon className="size-[24px] text-[#060958]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col items-start">
|
||||||
|
<p className="text-[14px] font-normal text-[#333c47] leading-[1.5]">
|
||||||
|
학습자 문제 제출 현황
|
||||||
|
</p>
|
||||||
|
<p className="text-[20px] font-bold text-[#333c47] leading-[1.5]">
|
||||||
|
{submissionStatus.current} / {submissionStatus.total}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 학습자 수료 현황 카드 */}
|
||||||
|
<div className="flex-1 bg-white border border-[#dee1e6] rounded-[16px] flex gap-[16px] items-center justify-center p-[24px]">
|
||||||
|
<div className="bg-[#ecf0ff] rounded-full size-[48px] flex items-center justify-center shrink-0">
|
||||||
|
<CheckCircleIcon className="size-[24px] text-[#060958]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col items-start">
|
||||||
|
<p className="text-[14px] font-normal text-[#333c47] leading-[1.5]">
|
||||||
|
학습자 수료 현황
|
||||||
|
</p>
|
||||||
|
<p className="text-[20px] font-bold text-[#333c47] leading-[1.5]">
|
||||||
|
{completionStatus.current} / {completionStatus.total}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최근 학습자 활동 섹션 */}
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
|
<div className="flex gap-[10px] h-[100px] items-center px-[32px]">
|
||||||
|
<h2 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
최근 학습자 활동
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-[16px] pb-[80px] pt-0 px-[32px]">
|
||||||
|
<div className="flex flex-col gap-[8px] min-h-[256px]">
|
||||||
|
{activities.map((activity) => (
|
||||||
|
<div
|
||||||
|
key={activity.id}
|
||||||
|
className="bg-white border border-[#dee1e6] rounded-[8px] flex gap-[12px] items-center p-[17px]"
|
||||||
|
>
|
||||||
|
<div className="bg-[#f1f3f5] rounded-full size-[32px] flex items-center justify-center shrink-0">
|
||||||
|
<UserIcon className="size-[16px] text-[#333c47]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col items-start">
|
||||||
|
<div className="flex flex-col text-[15px] leading-[1.5] text-[#1b2027]">
|
||||||
|
<span className="font-semibold">{activity.userName}</span>
|
||||||
|
<span className="font-normal">{activity.message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[13px] font-normal text-[#6c7682] leading-[1.4] shrink-0">
|
||||||
|
{activity.timestamp}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { pretendard } from "./fonts";
|
import { pretendard } from "./fonts";
|
||||||
import HeaderVisibility from "./components/HeaderVisibility";
|
import HeaderVisibility from "./components/HeaderVisibility";
|
||||||
import Footer from "./components/Footer";
|
import FooterVisibility from "./components/FooterVisibility";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "XRLMS",
|
title: "XRLMS",
|
||||||
@@ -19,7 +19,7 @@ export default function RootLayout({
|
|||||||
<main className="flex-1 min-h-0">
|
<main className="flex-1 min-h-0">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<FooterVisibility />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
744
src/app/lib/apiService.ts
Normal file
@@ -0,0 +1,744 @@
|
|||||||
|
/**
|
||||||
|
* 중앙화된 API 서비스
|
||||||
|
* 모든 외부 API 요청을 이 모듈에서 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ApiResponse<T = any> {
|
||||||
|
data: T;
|
||||||
|
status: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestConfig {
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: any;
|
||||||
|
timeout?: number;
|
||||||
|
params?: Record<string, string | number | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiService {
|
||||||
|
private baseURL: string;
|
||||||
|
private defaultTimeout: number = 30000; // 30초
|
||||||
|
|
||||||
|
constructor(baseURL: string) {
|
||||||
|
this.baseURL = baseURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토큰을 가져오는 헬퍼 함수
|
||||||
|
*/
|
||||||
|
private getToken(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
// localStorage에서 토큰 우선 확인
|
||||||
|
const localToken = localStorage.getItem('token');
|
||||||
|
if (localToken) return localToken;
|
||||||
|
|
||||||
|
// 쿠키에서 토큰 확인
|
||||||
|
const cookieToken = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('token='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
return cookieToken || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토큰 삭제 및 로그인 페이지로 리다이렉트
|
||||||
|
*/
|
||||||
|
private handleTokenError() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
// 토큰 삭제
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
|
||||||
|
// 현재 경로 가져오기
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
|
||||||
|
// 로그인 페이지가 아닐 때만 리다이렉트
|
||||||
|
if (currentPath !== '/login') {
|
||||||
|
const loginUrl = new URL('/login', window.location.origin);
|
||||||
|
loginUrl.searchParams.set('redirect', currentPath);
|
||||||
|
window.location.href = loginUrl.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 헤더 생성
|
||||||
|
*/
|
||||||
|
private getDefaultHeaders(isFormData: boolean = false): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
|
// FormData인 경우 Content-Type을 설정하지 않음 (브라우저가 자동으로 설정)
|
||||||
|
if (!isFormData) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = this.getToken();
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타임아웃이 있는 fetch
|
||||||
|
*/
|
||||||
|
private async fetchWithTimeout(url: string, options: RequestInit, timeout: number): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw new Error('요청 시간이 초과되었습니다.');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공통 API 요청 함수
|
||||||
|
*/
|
||||||
|
private async request<T = any>(
|
||||||
|
endpoint: string,
|
||||||
|
config: RequestConfig = {}
|
||||||
|
): Promise<ApiResponse<T>> {
|
||||||
|
const {
|
||||||
|
method = 'GET',
|
||||||
|
headers = {},
|
||||||
|
body,
|
||||||
|
timeout = this.defaultTimeout,
|
||||||
|
params
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
let url = endpoint.startsWith('http') ? endpoint : `${this.baseURL}${endpoint}`;
|
||||||
|
|
||||||
|
// 쿼리 파라미터 추가
|
||||||
|
if (params) {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
queryParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const queryString = queryParams.toString();
|
||||||
|
if (queryString) {
|
||||||
|
url += (url.includes('?') ? '&' : '?') + queryString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormData 여부 확인
|
||||||
|
const isFormData = body instanceof FormData;
|
||||||
|
|
||||||
|
const requestOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
...this.getDefaultHeaders(isFormData),
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body && method !== 'GET') {
|
||||||
|
// FormData인 경우 그대로 사용, 아닌 경우 JSON으로 변환
|
||||||
|
requestOptions.body = isFormData ? body : JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetchWithTimeout(url, requestOptions, timeout);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// 토큰 오류 (401, 403) 발생 시 처리
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
this.handleTokenError();
|
||||||
|
throw new Error('인증이 만료되었습니다. 다시 로그인해주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.message) {
|
||||||
|
errorMessage = errorData.message;
|
||||||
|
} else if (errorData.error) {
|
||||||
|
errorMessage = errorData.error;
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
// JSON 파싱 실패 시 기본 에러 메시지 사용
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
status: response.status,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error('알 수 없는 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 인증 관련 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인
|
||||||
|
*/
|
||||||
|
async login(email: string, password: string) {
|
||||||
|
return this.request('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, password },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 정보 조회
|
||||||
|
*/
|
||||||
|
async getCurrentUser() {
|
||||||
|
return this.request('/auth/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입
|
||||||
|
*/
|
||||||
|
async register(userData: {
|
||||||
|
email: string;
|
||||||
|
emailCode: string;
|
||||||
|
password: string;
|
||||||
|
passwordConfirm: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
gender: 'MALE' | 'FEMALE';
|
||||||
|
birthDate: string;
|
||||||
|
}) {
|
||||||
|
return this.request('/auth/signup', {
|
||||||
|
method: 'POST',
|
||||||
|
body: userData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일 인증번호 전송
|
||||||
|
*/
|
||||||
|
async sendEmailVerification(email: string) {
|
||||||
|
return this.request('/auth/verify-email/send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일 인증번호 확인
|
||||||
|
*/
|
||||||
|
async verifyEmailCode(email: string, emailCode: string) {
|
||||||
|
return this.request('/auth/verify-email/confirm', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, emailCode },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 아이디 찾기
|
||||||
|
*/
|
||||||
|
async findUserId(name: string, phone: string) {
|
||||||
|
return this.request('/auth/find-id', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name, phone },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 재설정 이메일 전송
|
||||||
|
*/
|
||||||
|
async sendPasswordReset(email: string) {
|
||||||
|
return this.request('/auth/password/forgot', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 재설정 코드 확인
|
||||||
|
*/
|
||||||
|
async verifyPasswordResetCode(email: string, code: string) {
|
||||||
|
return this.request('/auth/password/confirm', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, emailCode: code },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 재설정
|
||||||
|
*/
|
||||||
|
async resetPassword(email: string, emailCode: string, newPassword: string, newPasswordConfirm: string) {
|
||||||
|
return this.request('/auth/password/reset', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, emailCode, newPassword, newPasswordConfirm },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계정 삭제
|
||||||
|
*/
|
||||||
|
async deleteAccount() {
|
||||||
|
return this.request('/auth/delete/me', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 관리자 관련 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 과목 리스트 조회
|
||||||
|
*/
|
||||||
|
async getSubjects() {
|
||||||
|
return this.request('/subjects');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 과목 생성
|
||||||
|
*/
|
||||||
|
async createSubject(subjectData: {
|
||||||
|
title: string;
|
||||||
|
instructor: string;
|
||||||
|
imageKey?: string;
|
||||||
|
}) {
|
||||||
|
return this.request('/subjects', {
|
||||||
|
method: 'POST',
|
||||||
|
body: subjectData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 과목 수정
|
||||||
|
*/
|
||||||
|
async updateSubject(subjectId: string, subjectData: {
|
||||||
|
title: string;
|
||||||
|
instructor: string;
|
||||||
|
imageKey?: string | null;
|
||||||
|
}) {
|
||||||
|
return this.request(`/subjects/${subjectId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: subjectData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 과목 삭제
|
||||||
|
*/
|
||||||
|
async deleteSubject(subjectId: string) {
|
||||||
|
return this.request(`/subjects/${subjectId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 리스트 조회
|
||||||
|
*/
|
||||||
|
async getUsers() {
|
||||||
|
return this.request('/users');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 생성
|
||||||
|
*/
|
||||||
|
async createUser(userData: any) {
|
||||||
|
return this.request('/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: userData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 수정
|
||||||
|
*/
|
||||||
|
async updateUser(userId: string, userData: any) {
|
||||||
|
return this.request(`/users/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: userData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 삭제
|
||||||
|
*/
|
||||||
|
async deleteUser(userId: string) {
|
||||||
|
return this.request(`/users/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 리스트 조회 (컴팩트) - 관리자 전용
|
||||||
|
*/
|
||||||
|
async getUsersCompact(params?: { type?: string; limit?: number }) {
|
||||||
|
return this.request('/admin/users/compact', {
|
||||||
|
params: params ? {
|
||||||
|
...(params.type && { type: params.type }),
|
||||||
|
...(params.limit && { limit: params.limit }),
|
||||||
|
} : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 회원 정지 (ID 기준) - 관리자 전용
|
||||||
|
*/
|
||||||
|
async suspendUser(userId: string | number) {
|
||||||
|
return this.request(`/admin/users/${userId}/suspend`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 회원 정지 해제 (ID 기준) - 관리자 전용
|
||||||
|
*/
|
||||||
|
async unsuspendUser(userId: string | number) {
|
||||||
|
return this.request(`/admin/users/${userId}/unsuspend`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 대량 정지 (ID 배열) - 관리자 전용
|
||||||
|
*/
|
||||||
|
async suspendUsers(userIds: (string | number)[]) {
|
||||||
|
return this.request('/admin/users/suspend', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { userIds },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 기타 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공지사항 목록 조회 (페이징)
|
||||||
|
*/
|
||||||
|
async getNotices() {
|
||||||
|
return this.request('/notices');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공지 단건 조회
|
||||||
|
*/
|
||||||
|
async getNotice(id: string | number) {
|
||||||
|
return this.request(`/notices/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공지 등록 (ADMIN)
|
||||||
|
*/
|
||||||
|
async createNotice(noticeData: any) {
|
||||||
|
return this.request('/notices', {
|
||||||
|
method: 'POST',
|
||||||
|
body: noticeData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공지 수정 (ADMIN)
|
||||||
|
*/
|
||||||
|
async updateNotice(id: string | number, noticeData: any) {
|
||||||
|
return this.request(`/notices/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: noticeData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공지 삭제 (ADMIN)
|
||||||
|
*/
|
||||||
|
async deleteNotice(id: string | number) {
|
||||||
|
return this.request(`/notices/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 강좌 조회
|
||||||
|
*/
|
||||||
|
async getLessons() {
|
||||||
|
return this.request('/lessons');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 강좌 리스트 조회
|
||||||
|
*/
|
||||||
|
async getLectures() {
|
||||||
|
return this.request('/lectures', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 강좌 조회
|
||||||
|
*/
|
||||||
|
async getLecture(id: string | number) {
|
||||||
|
return this.request(`/lectures/${id}`, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 강좌(lecture) 생성
|
||||||
|
*/
|
||||||
|
async createLecture(lectureData: {
|
||||||
|
subjectId: number;
|
||||||
|
title: string;
|
||||||
|
objective: string;
|
||||||
|
videoUrl?: string | string[];
|
||||||
|
webglUrl?: string | string[];
|
||||||
|
csvKey?: string;
|
||||||
|
}) {
|
||||||
|
return this.request('/lectures', {
|
||||||
|
method: 'POST',
|
||||||
|
body: lectureData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 강좌(lecture) 수정
|
||||||
|
*/
|
||||||
|
async updateLecture(lectureId: string | number, lectureData: {
|
||||||
|
subjectId?: number;
|
||||||
|
title?: string;
|
||||||
|
objective?: string;
|
||||||
|
videoUrl?: string | string[];
|
||||||
|
webglUrl?: string | string[];
|
||||||
|
csvKey?: string;
|
||||||
|
csvUrl?: string;
|
||||||
|
}) {
|
||||||
|
return this.request(`/lectures/${lectureId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: lectureData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 강좌(lecture) 삭제
|
||||||
|
*/
|
||||||
|
async deleteLecture(lectureId: string | number) {
|
||||||
|
return this.request(`/lectures/${lectureId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리소스 조회
|
||||||
|
*/
|
||||||
|
async getResources() {
|
||||||
|
return this.request('/resources');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 학습 자료실 (Library) 관련 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 학습 자료실 목록 조회 (로그인 필요, 페이징)
|
||||||
|
*/
|
||||||
|
async getLibrary() {
|
||||||
|
return this.request('/library');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 학습 자료 단건 조회
|
||||||
|
*/
|
||||||
|
async getLibraryItem(id: string | number) {
|
||||||
|
return this.request(`/library/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 학습 자료 등록 (ADMIN)
|
||||||
|
*/
|
||||||
|
async createLibraryItem(libraryData: any) {
|
||||||
|
return this.request('/library', {
|
||||||
|
method: 'POST',
|
||||||
|
body: libraryData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 학습 자료 수정 (ADMIN)
|
||||||
|
*/
|
||||||
|
async updateLibraryItem(id: string | number, libraryData: any) {
|
||||||
|
return this.request(`/library/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: libraryData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 학습 자료 삭제 (ADMIN)
|
||||||
|
*/
|
||||||
|
async deleteLibraryItem(id: string | number) {
|
||||||
|
return this.request(`/library/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 진행률 (Progress) 관련 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 강의에서 "내" 진행률 조회
|
||||||
|
*/
|
||||||
|
async getLectureProgress(lectureId: string | number) {
|
||||||
|
return this.request(`/progress/lectures/${lectureId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 강의 진행(Heartbeat) 업서트
|
||||||
|
*/
|
||||||
|
async updateLectureProgress(progressData: any) {
|
||||||
|
return this.request('/progress/lectures/progress', {
|
||||||
|
method: 'POST',
|
||||||
|
body: progressData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 과목 진행률 요약(내 기준)
|
||||||
|
*/
|
||||||
|
async getSubjectProgressSummary(subjectId: string | number) {
|
||||||
|
return this.request(`/progress/subjects/${subjectId}/summary`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 평가 (Evaluation) 관련 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 강의 평가 제출 (60점 이상만 저장)
|
||||||
|
*/
|
||||||
|
async submitEvaluation(evaluationData: any) {
|
||||||
|
return this.request('/evaluations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: evaluationData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 강의에서 "내" 마지막 평가 결과 조회
|
||||||
|
*/
|
||||||
|
async getMyEvaluation(lectureId: string | number) {
|
||||||
|
return this.request(`/evaluations/lectures/${lectureId}/me`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 강의에서 특정 수강생의 마지막 평가 결과 조회 (ADMIN/INSTRUCTOR)
|
||||||
|
*/
|
||||||
|
async getUserEvaluation(lectureId: string | number, userId: string | number) {
|
||||||
|
return this.request(`/evaluations/lectures/${lectureId}/users/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 관리자 강의 현황 관련 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 강좌별 학습/문제 제출/수료 현황 리스트 (관리자/강사용)
|
||||||
|
*/
|
||||||
|
async getLecturesStatus() {
|
||||||
|
return this.request('/admin/lectures/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 강좌 + 특정 학습자의 수강/문제 풀이 상세 (관리자/강사용)
|
||||||
|
*/
|
||||||
|
async getLectureStudentDetail(lectureId: string | number, userId: string | number) {
|
||||||
|
return this.request(`/admin/lectures/${lectureId}/students/${userId}/detail`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 수료증 및 결과 관련 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 과목 수료증용 정보 조회 (학생 본인)
|
||||||
|
*/
|
||||||
|
async getCertificate(subjectId: string | number) {
|
||||||
|
return this.request(`/certificates/subjects/${subjectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 학생 기준 학습 결과(수료 과목 목록)
|
||||||
|
*/
|
||||||
|
async getMyResults() {
|
||||||
|
return this.request('/results/my-subjects');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 파일 업로드 관련 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 파일 업로드
|
||||||
|
* @param file 업로드할 파일 (File 객체 또는 Blob)
|
||||||
|
*/
|
||||||
|
async uploadFile(file: File | Blob) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
return this.request('/uploads-api/file', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다중 파일 업로드
|
||||||
|
* @param files 업로드할 파일 배열 (File[] 또는 Blob[])
|
||||||
|
*/
|
||||||
|
async uploadFiles(files: (File | Blob)[]) {
|
||||||
|
const formData = new FormData();
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
formData.append('files', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.request('/uploads-api/files', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 다운로드
|
||||||
|
* @param fileKey 파일 키
|
||||||
|
* @returns 파일 URL (Blob URL), 파일이 없으면 null 반환
|
||||||
|
*/
|
||||||
|
async getFile(fileKey: string): Promise<string | null> {
|
||||||
|
const url = `${this.baseURL}/api/files/${fileKey}`;
|
||||||
|
const token = this.getToken();
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 404 에러는 이미지가 없는 것으로 간주하고 null 반환
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`파일을 가져오는데 실패했습니다. (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미지 파일이므로 Blob으로 변환하여 URL 생성
|
||||||
|
const blob = await response.blob();
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 API 서비스 인스턴스 생성
|
||||||
|
const apiService = new ApiService(
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL || 'https://hrdi.coconutmeet.net'
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiService;
|
||||||
|
export type { ApiResponse, RequestConfig };
|
||||||
@@ -5,11 +5,14 @@ import React from "react";
|
|||||||
type LoginErrorModalProps = {
|
type LoginErrorModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
errorMessage?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LoginErrorModal({ open, onClose }: LoginErrorModalProps) {
|
export default function LoginErrorModal({ open, onClose, errorMessage }: LoginErrorModalProps) {
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
|
const isSuspendedAccount = errorMessage?.includes("정지된 계정입니다");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<button
|
<button
|
||||||
@@ -25,9 +28,19 @@ export default function LoginErrorModal({ open, onClose }: LoginErrorModalProps)
|
|||||||
className="relative bg-white box-border flex flex-col items-stretch justify-start p-6 rounded-[8px] min-w-[500px] max-w-[calc(100%-48px)]"
|
className="relative bg-white box-border flex flex-col items-stretch justify-start p-6 rounded-[8px] min-w-[500px] max-w-[calc(100%-48px)]"
|
||||||
>
|
>
|
||||||
<div className="text-[18px] leading-normal font-semibold text-neutral-700 mb-8" id="login-error-title">
|
<div className="text-[18px] leading-normal font-semibold text-neutral-700 mb-8" id="login-error-title">
|
||||||
아이디 또는 비밀번호가 일치하지 않습니다.
|
{isSuspendedAccount ? (
|
||||||
<br />
|
<>
|
||||||
확인 후 다시 시도해 주세요.
|
정지된 계정입니다.
|
||||||
|
<br />
|
||||||
|
확인 후 다시 시도해 주세요.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
아이디 또는 비밀번호가 일치하지 않습니다.
|
||||||
|
<br />
|
||||||
|
확인 후 다시 시도해 주세요.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-[8px]">
|
<div className="flex items-center justify-end gap-[8px]">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,61 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
type LoginOptionProps = {
|
type LoginOptionProps = {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
loginErrorModalEnabled?: boolean;
|
loginErrorModalEnabled?: boolean;
|
||||||
setLoginErrorModalEnabled?: (enabled: boolean) => void;
|
setLoginErrorModalEnabled?: (enabled: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LoginOption({
|
export default function LoginOption({
|
||||||
className,
|
className,
|
||||||
loginErrorModalEnabled,
|
loginErrorModalEnabled,
|
||||||
setLoginErrorModalEnabled,
|
setLoginErrorModalEnabled,
|
||||||
}: LoginOptionProps) {
|
}: LoginOptionProps) {
|
||||||
|
return null;
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className={`fixed bottom-2 right-2 bg-red-400 cursor-pointer rounded-full w-[40px] h-[40px] shadow-xl z-100`}
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
{ isOpen && (
|
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-50">
|
|
||||||
<div className="w-[500px] h-[600px] flex bg-white/80 p-10 border rounded-lg relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="닫기"
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
className="absolute top-3 right-3 inline-flex items-center justify-center rounded-full w-8 h-8 bg-gray-200 hover:bg-gray-300 text-gray-700"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
<ul className="flex flex-col gap-4">
|
|
||||||
<li className="flex items-center justify-between">
|
|
||||||
<p className="mr-4">login error modal</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="login error modal 토글"
|
|
||||||
aria-pressed={!!loginErrorModalEnabled}
|
|
||||||
onClick={() => setLoginErrorModalEnabled?.(!loginErrorModalEnabled)}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${loginErrorModalEnabled ? 'bg-blue-600' : 'bg-gray-300'}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition ${loginErrorModalEnabled ? 'translate-x-5' : 'translate-x-1'}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import MainLogo from "@/app/svgs/mainlogosvg"
|
import MainLogo from "@/app/svgs/mainlogosvg"
|
||||||
import LoginCheckboxActiveSvg from "@/app/svgs/logincheckboxactivesvg";
|
import LoginCheckboxActiveSvg from "@/app/svgs/logincheckboxactivesvg";
|
||||||
import LoginCheckboxInactiveSvg from "@/app/svgs/logincheckboxinactivesvg";
|
import LoginCheckboxInactiveSvg from "@/app/svgs/logincheckboxinactivesvg";
|
||||||
import LoginInputSvg from "@/app/svgs/inputformx";
|
import LoginInputSvg from "@/app/svgs/inputformx";
|
||||||
import LoginErrorModal from "./LoginErrorModal";
|
import LoginErrorModal from "./LoginErrorModal";
|
||||||
import LoginOption from "@/app/login/LoginOption";
|
import LoginOption from "@/app/login/loginoption";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [userId, setUserId] = useState("");
|
const [userId, setUserId] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [rememberId, setRememberId] = useState(false);
|
const [rememberId, setRememberId] = useState(false);
|
||||||
@@ -17,185 +20,313 @@ export default function LoginPage() {
|
|||||||
const [isUserIdFocused, setIsUserIdFocused] = useState(false);
|
const [isUserIdFocused, setIsUserIdFocused] = useState(false);
|
||||||
const [isPasswordFocused, setIsPasswordFocused] = useState(false);
|
const [isPasswordFocused, setIsPasswordFocused] = useState(false);
|
||||||
const [isLoginErrorOpen, setIsLoginErrorOpen] = useState(false);
|
const [isLoginErrorOpen, setIsLoginErrorOpen] = useState(false);
|
||||||
const [idError, setIdError] = useState("");
|
const [loginErrorMessage, setLoginErrorMessage] = useState("");
|
||||||
const [passwordError, setPasswordError] = useState("");
|
const [idError, setIdError] = useState("");
|
||||||
|
const [passwordError, setPasswordError] = useState("");
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
// 컴포넌트 마운트 시 저장된 아이디 불러오기 및 자동 로그인 확인
|
||||||
|
useEffect(() => {
|
||||||
|
const savedId = localStorage.getItem('savedUserId');
|
||||||
|
if (savedId) {
|
||||||
|
setUserId(savedId);
|
||||||
|
setRememberId(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 로그인 확인: localStorage에 토큰이 있고 쿠키에도 토큰이 있으면 자동 로그인
|
||||||
|
const savedToken = localStorage.getItem('token');
|
||||||
|
const cookieToken = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('token='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
if (savedToken && cookieToken && savedToken === cookieToken) {
|
||||||
|
// 토큰이 유효한지 확인
|
||||||
|
apiService.getCurrentUser()
|
||||||
|
.then(response => {
|
||||||
|
const userData = response.data;
|
||||||
|
// 계정 상태 확인
|
||||||
|
const userStatus = userData.status || userData.userStatus;
|
||||||
|
if (userStatus === 'INACTIVE' || userStatus === 'inactive') {
|
||||||
|
// 비활성화된 계정인 경우 로그아웃 처리
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
document.cookie = 'token=; path=/; max-age=0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 권한 확인
|
||||||
|
const userRole = userData.role || userData.userRole;
|
||||||
|
if (userRole === 'ADMIN' || userRole === 'admin') {
|
||||||
|
// admin 권한이면 /admin/id로 리다이렉트
|
||||||
|
router.push('/admin/id');
|
||||||
|
} else {
|
||||||
|
// 그 외의 경우 기존 로직대로 리다이렉트
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
const redirectPath = searchParams.get('redirect') || '/';
|
||||||
|
router.push(redirectPath);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// 에러 발생 시 토큰 삭제
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
document.cookie = 'token=; path=/; max-age=0';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
// 아이디 기억하기 상태나 아이디가 변경될 때마다 저장 처리
|
||||||
|
useEffect(() => {
|
||||||
|
if (rememberId && userId.trim()) {
|
||||||
|
localStorage.setItem('savedUserId', userId);
|
||||||
|
} else if (!rememberId) {
|
||||||
|
localStorage.removeItem('savedUserId');
|
||||||
|
}
|
||||||
|
}, [rememberId, userId]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// 실제 로그인 API 연동 전까지는 실패 모달을 노출합니다.
|
|
||||||
// API 연동 시 결과에 따라 성공/실패 분기에서 setIsLoginErrorOpen(true) 호출로 교체하세요.
|
// 에러 초기화
|
||||||
// if (userId.trim().length > 0 && password.trim().length > 0) {
|
setIdError("");
|
||||||
// setIsLoginErrorOpen(true);
|
setPasswordError("");
|
||||||
// }
|
|
||||||
|
// 입력 검증
|
||||||
|
let hasError = false;
|
||||||
|
if (userId.trim().length === 0) {
|
||||||
|
setIdError("아이디를 입력해 주세요.");
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
if (password.trim().length === 0) {
|
||||||
|
setPasswordError("비밀번호를 입력해 주세요.");
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiService.login(userId, password);
|
||||||
|
const data = response.data;
|
||||||
|
console.log("로그인 성공:", data);
|
||||||
|
|
||||||
|
// 로그인 성공 시 토큰 저장 (다양한 필드명 지원)
|
||||||
|
const token = data.token || data.accessToken || data.access_token;
|
||||||
|
if (token) {
|
||||||
|
if (autoLogin) {
|
||||||
|
// 자동 로그인이 체크되어 있으면 localStorage와 쿠키에 장기 저장 (30일)
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
document.cookie = `token=${token}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`;
|
||||||
|
console.log("자동 로그인 토큰 저장 완료 (30일 유지)");
|
||||||
|
} else {
|
||||||
|
// 자동 로그인이 체크되어 있지 않으면 쿠키에만 세션 쿠키로 저장 (브라우저 종료 시 삭제)
|
||||||
|
// localStorage에는 저장하지 않음
|
||||||
|
document.cookie = `token=${token}; path=/; SameSite=Lax`;
|
||||||
|
console.log("세션 토큰 저장 완료 (브라우저 종료 시 삭제)");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("토큰이 응답에 없습니다. 응답 데이터:", data);
|
||||||
|
// 토큰이 없어도 로그인은 성공했으므로 진행
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 정보 가져오기 (권한 확인을 위해)
|
||||||
|
try {
|
||||||
|
const userResponse = await apiService.getCurrentUser();
|
||||||
|
const userData = userResponse.data;
|
||||||
|
const userRole = userData.role || userData.userRole;
|
||||||
|
|
||||||
|
// admin 권한이면 /admin/id로 리다이렉트
|
||||||
|
if (userRole === 'ADMIN' || userRole === 'admin') {
|
||||||
|
router.push('/admin/id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("사용자 정보 조회 오류:", error);
|
||||||
|
// 사용자 정보 조회 실패 시에도 기존 로직대로 진행
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리다이렉트 경로 확인
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
const redirectPath = searchParams.get('redirect') || '/';
|
||||||
|
|
||||||
|
// 메인 페이지로 이동
|
||||||
|
router.push(redirectPath);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "네트워크 오류가 발생했습니다.";
|
||||||
|
console.error("로그인 오류:", errorMessage);
|
||||||
|
setLoginErrorMessage(errorMessage);
|
||||||
|
setIsLoginErrorOpen(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-full flex flex-col items-center justify-between">
|
<>
|
||||||
|
|
||||||
<LoginErrorModal
|
<LoginErrorModal
|
||||||
open={isLoginErrorOpen}
|
open={isLoginErrorOpen}
|
||||||
onClose={() => setIsLoginErrorOpen(false)}
|
onClose={() => {
|
||||||
|
setIsLoginErrorOpen(false);
|
||||||
|
setLoginErrorMessage("");
|
||||||
|
}}
|
||||||
|
errorMessage={loginErrorMessage}
|
||||||
/>
|
/>
|
||||||
<LoginOption
|
<LoginOption
|
||||||
onClick={() => setIsLoginErrorOpen(true)}
|
onClick={() => setIsLoginErrorOpen(true)}
|
||||||
loginErrorModalEnabled={isLoginErrorOpen}
|
loginErrorModalEnabled={isLoginErrorOpen}
|
||||||
setLoginErrorModalEnabled={setIsLoginErrorOpen}
|
setLoginErrorModalEnabled={setIsLoginErrorOpen}
|
||||||
/>
|
/>
|
||||||
|
<div className="h-screen w-full flex flex-col overflow-hidden">
|
||||||
|
{/* 메인 컨텐츠 영역 - flex-1로 남은 공간 차지 */}
|
||||||
|
<div className="flex-1 flex items-center justify-center min-h-0">
|
||||||
|
<div className="rounded-xl bg-white max-w-[560px] px-[40px] w-full my-auto">
|
||||||
|
{/* 로고 영역 */}
|
||||||
|
<div className="my-15 flex flex-col items-center">
|
||||||
|
<div className="mb-[7px]">
|
||||||
|
<MainLogo />
|
||||||
|
</div>
|
||||||
|
<div className="text-[28.8px] font-extrabold leading-[145%] text-neutral-700" >
|
||||||
|
XR LMS
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl bg-white max-w-[560px] px-[40px] w-full">
|
{/* 폼 */}
|
||||||
{/* 로고 영역 */}
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="my-15 flex flex-col items-center">
|
<div className="space-y-4">
|
||||||
<div className="mb-[7px]">
|
{/* 아이디 */}
|
||||||
<MainLogo/>
|
<div className="relative">
|
||||||
</div>
|
<label htmlFor="userId" className="sr-only">
|
||||||
<div className="text-[28.8px] font-extrabold leading-[145%] text-neutral-700" >
|
아이디
|
||||||
XR LMS
|
</label>
|
||||||
|
<input
|
||||||
|
id="userId"
|
||||||
|
name="userId"
|
||||||
|
value={userId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUserId(e.target.value);
|
||||||
|
if (idError) setIdError("");
|
||||||
|
}}
|
||||||
|
onFocus={() => setIsUserIdFocused(true)}
|
||||||
|
onBlur={() => setIsUserIdFocused(false)}
|
||||||
|
placeholder="아이디(이메일)"
|
||||||
|
className={`h-[56px] px-[12px] py-[7px] w-full rounded-[8px] border focus:outline-none focus:ring-0 focus:ring-offset-0 focus:shadow-none focus:appearance-none text-[18px] text-neutral-700 font-normal leading-[150%] placeholder:text-input-placeholder-text pr-[40px] ${idError ? 'border-error' : 'border-neutral-40 focus:border-neutral-700'}`}
|
||||||
|
/>
|
||||||
|
{userId.trim().length > 0 && isUserIdFocused && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setUserId("");
|
||||||
|
}}
|
||||||
|
aria-label="입력 지우기"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<LoginInputSvg />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{idError && <p className="text-error text-[13px] leading-tight mt-[10px]">{idError}</p>}
|
||||||
|
{/* 비밀번호 */}
|
||||||
|
<div className="relative">
|
||||||
|
<label htmlFor="password" className="sr-only">
|
||||||
|
비밀번호
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPassword(e.target.value);
|
||||||
|
if (passwordError) setPasswordError("");
|
||||||
|
}}
|
||||||
|
onFocus={() => setIsPasswordFocused(true)}
|
||||||
|
onBlur={() => setIsPasswordFocused(false)}
|
||||||
|
placeholder="비밀번호 입력"
|
||||||
|
className={`h-[56px] px-[12px] py-[7px] rounded-[8px] w-full border focus:outline-none focus:ring-0 focus:ring-offset-0 focus:shadow-none focus:appearance-none text-[18px] text-neutral-700 font-normal leading-[150%] placeholder:text-input-placeholder-text pr-[40px] ${passwordError ? 'border-error' : 'border-neutral-40 focus:border-neutral-700'}`}
|
||||||
|
/>
|
||||||
|
{password.trim().length > 0 && isPasswordFocused && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPassword("");
|
||||||
|
}}
|
||||||
|
aria-label="입력 지우기"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<LoginInputSvg />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{passwordError && <p className="text-error text-[13px] leading-tight mt-[4px]">{passwordError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 체크박스들 */}
|
||||||
|
<div className="flex items-center justify-start gap-6 mb-15">
|
||||||
|
<label className="flex cursor-pointer select-none items-center gap-2 text-[15px] font-normal text-basic-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rememberId}
|
||||||
|
onChange={(e) => setRememberId(e.target.checked)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
{rememberId ? (
|
||||||
|
<LoginCheckboxActiveSvg />
|
||||||
|
) : (
|
||||||
|
<LoginCheckboxInactiveSvg />
|
||||||
|
)}
|
||||||
|
아이디 기억하기
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer select-none items-center gap-2 text-[15px] font-normal text-basic-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoLogin}
|
||||||
|
onChange={(e) => setAutoLogin(e.target.checked)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
{autoLogin ? (
|
||||||
|
<LoginCheckboxActiveSvg />
|
||||||
|
) : (
|
||||||
|
<LoginCheckboxInactiveSvg />
|
||||||
|
)}
|
||||||
|
자동 로그인
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 로그인 버튼 */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`h-[56px] w-full rounded-lg text-[16px] font-semibold text-white transition-opacity cursor-pointer mb-3 ${userId.trim().length > 0 && password.trim().length > 0 ? "bg-active-button hover:bg-[#1F2B91]" : "bg-inactive-button"}`}
|
||||||
|
>
|
||||||
|
로그인
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 하단 링크들 */}
|
||||||
|
<div className="flex items-center justify-between text-[15px] leading-[150%] h-[36px]">
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="underline-offset-2 text-basic-text font-bold"
|
||||||
|
>
|
||||||
|
회원가입
|
||||||
|
</Link>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 text-basic-text"
|
||||||
|
>
|
||||||
|
<Link href="/find-id" className="underline-offset-2">
|
||||||
|
아이디 찾기
|
||||||
|
</Link>
|
||||||
|
<span className="h-3 w-px bg-input-border" />
|
||||||
|
<Link href="/reset-password" className="underline-offset-2">
|
||||||
|
비밀번호 재설정
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Copyright 영역 - 하단 고정 */}
|
||||||
{/* 폼 */}
|
<p className="text-center py-[40px] text-[15px] text-basic-text flex-shrink-0">
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
Copyright ⓒ 2025 XL LMS. All rights reserved
|
||||||
<div className="space-y-4">
|
</p>
|
||||||
{/* 아이디 */}
|
|
||||||
<div className="relative">
|
|
||||||
<label htmlFor="userId" className="sr-only">
|
|
||||||
아이디
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="userId"
|
|
||||||
name="userId"
|
|
||||||
value={userId}
|
|
||||||
onChange={(e) => setUserId(e.target.value)}
|
|
||||||
onFocus={() => setIsUserIdFocused(true)}
|
|
||||||
onBlur={() => setIsUserIdFocused(false)}
|
|
||||||
placeholder="아이디 (이메일)"
|
|
||||||
className="
|
|
||||||
h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40
|
|
||||||
focus:outline-none focus:ring-0 focus:ring-offset-0 focus:shadow-none
|
|
||||||
focus:appearance-none focus:border-neutral-700
|
|
||||||
text-[18px] text-neutral-700 font-normal leading-[150%] placeholder:text-input-placeholder-text
|
|
||||||
pr-[40px]
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
{userId.trim().length > 0 && isUserIdFocused && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setUserId("");
|
|
||||||
}}
|
|
||||||
aria-label="입력 지우기"
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer"
|
|
||||||
>
|
|
||||||
<LoginInputSvg />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* 비밀번호 */}
|
|
||||||
<div className="relative">
|
|
||||||
<label htmlFor="password" className="sr-only">
|
|
||||||
비밀번호
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
onFocus={() => setIsPasswordFocused(true)}
|
|
||||||
onBlur={() => setIsPasswordFocused(false)}
|
|
||||||
placeholder="비밀번호"
|
|
||||||
className="
|
|
||||||
h-[40px] px-[12px] py-[7px] rounded-[8px] w-full border border-neutral-40
|
|
||||||
focus:outline-none focus:ring-0 focus:ring-offset-0 focus:shadow-none
|
|
||||||
focus:appearance-none focus:border-neutral-700
|
|
||||||
text-[18px] text-neutral-700 font-normal leading-[150%] placeholder:text-input-placeholder-text
|
|
||||||
pr-[40px]
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
{password.trim().length > 0 && isPasswordFocused && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setPassword("");
|
|
||||||
}}
|
|
||||||
aria-label="입력 지우기"
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer"
|
|
||||||
>
|
|
||||||
<LoginInputSvg />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 체크박스들 */}
|
|
||||||
<div className="flex items-center justify-start gap-6 mb-15">
|
|
||||||
<label className="flex cursor-pointer select-none items-center gap-2 text-[15px] font-normal text-basic-text">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={rememberId}
|
|
||||||
onChange={(e) => setRememberId(e.target.checked)}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
{rememberId ? (
|
|
||||||
<LoginCheckboxActiveSvg />
|
|
||||||
) : (
|
|
||||||
<LoginCheckboxInactiveSvg />
|
|
||||||
)}
|
|
||||||
아이디 기억하기
|
|
||||||
</label>
|
|
||||||
<label className="flex cursor-pointer select-none items-center gap-2 text-[15px] font-normal text-basic-text">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={autoLogin}
|
|
||||||
onChange={(e) => setAutoLogin(e.target.checked)}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
{autoLogin ? (
|
|
||||||
<LoginCheckboxActiveSvg />
|
|
||||||
) : (
|
|
||||||
<LoginCheckboxInactiveSvg />
|
|
||||||
)}
|
|
||||||
자동 로그인
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 로그인 버튼 */}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className={`h-[40px] w-full rounded-lg text-[16px] font-semibold text-white transition-opacity cursor-pointer mb-3 ${userId.trim().length > 0 && password.trim().length > 0 ? "bg-active-button" : "bg-inactive-button"}`}
|
|
||||||
>
|
|
||||||
로그인
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 하단 링크들 */}
|
|
||||||
<div className="flex items-center justify-between text-[15px] leading-[150%] h-[36px]">
|
|
||||||
<Link
|
|
||||||
href="/register"
|
|
||||||
className="underline-offset-2 text-basic-text font-bold"
|
|
||||||
>
|
|
||||||
회원가입
|
|
||||||
</Link>
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-3 text-basic-text"
|
|
||||||
>
|
|
||||||
<Link href="/find-id" className="underline-offset-2">
|
|
||||||
아이디 찾기
|
|
||||||
</Link>
|
|
||||||
<span className="h-3 w-px bg-input-border" />
|
|
||||||
<Link href="/reset-password" className="underline-offset-2">
|
|
||||||
비밀번호 재설정
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<div></div>
|
</>
|
||||||
<p className="text-center py-[40px] text-[15px] text-basic-text">
|
|
||||||
Copyright ⓒ 2025 XL LMS. All rights reserved
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import ModalCloseSvg from "../svgs/closexsvg";
|
import ModalCloseSvg from "../svgs/closexsvg";
|
||||||
|
import apiService from "../lib/apiService";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -9,6 +12,31 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function AccountDeleteModal({ open, onClose, onConfirm }: Props) {
|
export default function AccountDeleteModal({ open, onClose, onConfirm }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await apiService.deleteAccount();
|
||||||
|
|
||||||
|
// 성공 시 토큰 제거 및 로그인 페이지로 이동
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
onClose();
|
||||||
|
router.push('/login');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
|
||||||
|
console.error('회원 탈퇴 오류:', errorMessage);
|
||||||
|
alert(errorMessage);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -20,7 +48,7 @@ export default function AccountDeleteModal({ open, onClose, onConfirm }: Props)
|
|||||||
<div className="w-[528px] rounded-[12px] border border-[#dee1e6] bg-white shadow-[0_10px_30px_rgba(0,0,0,0.06)]">
|
<div className="w-[528px] rounded-[12px] border border-[#dee1e6] bg-white shadow-[0_10px_30px_rgba(0,0,0,0.06)]">
|
||||||
{/* header */}
|
{/* header */}
|
||||||
<div className="flex items-center justify-between p-6">
|
<div className="flex items-center justify-between p-6">
|
||||||
<h2 className="text-[20px] font-bold leading-[1.5] text-[#333c47]">회원 탈퇴</h2>
|
<h2 className="text-[20px] font-bold leading-normal text-[#333c47]">회원 탈퇴</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="닫기"
|
aria-label="닫기"
|
||||||
@@ -34,10 +62,10 @@ export default function AccountDeleteModal({ open, onClose, onConfirm }: Props)
|
|||||||
{/* body */}
|
{/* body */}
|
||||||
<div className="px-6">
|
<div className="px-6">
|
||||||
<div className="rounded-[16px] border border-[#dee1e6] bg-gray-50 p-6">
|
<div className="rounded-[16px] border border-[#dee1e6] bg-gray-50 p-6">
|
||||||
<p className="mb-3 text-[15px] font-bold leading-[1.5] text-[#4c5561]">
|
<p className="mb-3 text-[15px] font-bold leading-normal text-basic-text">
|
||||||
회원 탈퇴 시 유의사항을 확인해주세요.
|
회원 탈퇴 시 유의사항을 확인해주세요.
|
||||||
</p>
|
</p>
|
||||||
<div className="text-[15px] leading-[1.5] text-[#4c5561]">
|
<div className="text-[15px] leading-normal text-basic-text">
|
||||||
<p className="mb-0">- 탈퇴 후에도 재가입은 가능합니다.</p>
|
<p className="mb-0">- 탈퇴 후에도 재가입은 가능합니다.</p>
|
||||||
<p className="mb-0">- 수강 및 학습 이력이 모두 삭제되며, 복구가 불가능합니다.</p>
|
<p className="mb-0">- 수강 및 학습 이력이 모두 삭제되며, 복구가 불가능합니다.</p>
|
||||||
<p>- 수강 서비스 이용 권한이 즉시 종료됩니다.</p>
|
<p>- 수강 서비스 이용 권한이 즉시 종료됩니다.</p>
|
||||||
@@ -50,16 +78,17 @@ export default function AccountDeleteModal({ open, onClose, onConfirm }: Props)
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="h-12 w-[136px] rounded-[10px] bg-[#f1f3f5] px-4 text-[16px] font-semibold leading-[1.5] text-[#4c5561] cursor-pointer"
|
className="h-12 w-[136px] rounded-[10px] bg-[#f1f3f5] px-4 text-[16px] font-semibold leading-normal text-basic-text cursor-pointer"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onConfirm}
|
onClick={handleConfirm}
|
||||||
className="h-12 w-[136px] rounded-[10px] bg-red-50 px-4 text-[16px] font-semibold leading-[1.5] text-[#f64c4c] cursor-pointer"
|
disabled={isLoading}
|
||||||
|
className="h-12 w-[136px] rounded-[10px] bg-red-50 px-4 text-[16px] font-semibold leading-normal text-error cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
회원 탈퇴
|
{isLoading ? '처리 중...' : '회원 탈퇴'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import ModalCloseSvg from "../svgs/closexsvg";
|
import ModalCloseSvg from "../svgs/closexsvg";
|
||||||
|
import apiService from "../lib/apiService";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -9,10 +11,12 @@ type Props = {
|
|||||||
onSubmit?: (payload: { email: string; code?: string; newPassword: string }) => void;
|
onSubmit?: (payload: { email: string; code?: string; newPassword: string }) => void;
|
||||||
showVerification?: boolean;
|
showVerification?: boolean;
|
||||||
devVerificationState?: 'initial' | 'sent' | 'verified' | 'failed';
|
devVerificationState?: 'initial' | 'sent' | 'verified' | 'failed';
|
||||||
|
initialEmail?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ChangePasswordModal({ open, onClose, onSubmit, showVerification = false, devVerificationState }: Props) {
|
export default function ChangePasswordModal({ open, onClose, onSubmit, showVerification = false, devVerificationState, initialEmail }: Props) {
|
||||||
const [email, setEmail] = useState("xrlms2025@gmail.com");
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState(initialEmail || "");
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
@@ -21,8 +25,18 @@ export default function ChangePasswordModal({ open, onClose, onSubmit, showVerif
|
|||||||
const [isCodeSent, setIsCodeSent] = useState<boolean>(showVerification);
|
const [isCodeSent, setIsCodeSent] = useState<boolean>(showVerification);
|
||||||
const canConfirm = code.trim().length > 0;
|
const canConfirm = code.trim().length > 0;
|
||||||
const [isVerified, setIsVerified] = useState(false);
|
const [isVerified, setIsVerified] = useState(false);
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const [isVerifying, setIsVerifying] = useState(false);
|
||||||
|
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||||
const hasError = !!error;
|
const hasError = !!error;
|
||||||
|
|
||||||
|
// initialEmail이 변경되면 email state 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialEmail) {
|
||||||
|
setEmail(initialEmail);
|
||||||
|
}
|
||||||
|
}, [initialEmail]);
|
||||||
|
|
||||||
// 외부에서 전달된 개발모드 상태(devVerificationState)에 따라 UI 동기화
|
// 외부에서 전달된 개발모드 상태(devVerificationState)에 따라 UI 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!devVerificationState) return;
|
if (!devVerificationState) return;
|
||||||
@@ -60,9 +74,18 @@ export default function ChangePasswordModal({ open, onClose, onSubmit, showVerif
|
|||||||
}
|
}
|
||||||
}, [devVerificationState]);
|
}, [devVerificationState]);
|
||||||
|
|
||||||
if (!open) return null;
|
const handleLoginClick = () => {
|
||||||
|
// 토큰 삭제 (로그아웃)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
document.cookie = 'token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
||||||
|
}
|
||||||
|
// 로그인 페이지로 이동
|
||||||
|
router.push('/login');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (requireCode) {
|
if (requireCode) {
|
||||||
if (!code) {
|
if (!code) {
|
||||||
@@ -78,25 +101,102 @@ export default function ChangePasswordModal({ open, onClose, onSubmit, showVerif
|
|||||||
setError("새 비밀번호가 일치하지 않습니다.");
|
setError("새 비밀번호가 일치하지 않습니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSubmit?.({ email, code: requireCode ? code : undefined, newPassword });
|
if (!email.trim()) {
|
||||||
|
setError("이메일을 입력해 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requireCode && !code.trim()) {
|
||||||
|
setError("인증번호를 입력해 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiService.resetPassword(email, code, newPassword, confirmPassword);
|
||||||
|
onSubmit?.({ email, code: requireCode ? code : undefined, newPassword });
|
||||||
|
setShowSuccessModal(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "비밀번호 변경에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
// 모든 상태 초기화
|
||||||
|
setEmail(initialEmail || "");
|
||||||
|
setCode("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
setError(null);
|
||||||
|
setRequireCode(showVerification);
|
||||||
|
setIsCodeSent(showVerification);
|
||||||
|
setIsVerified(false);
|
||||||
|
setIsSending(false);
|
||||||
|
setIsVerifying(false);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 완료 팝업은 open prop과 관계없이 표시
|
||||||
|
if (showSuccessModal) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[2px]"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div className="w-[480px] rounded-[12px] border border-[#dee1e6] bg-white">
|
||||||
|
{/* header */}
|
||||||
|
<div className="flex items-center justify-end h-[80px] p-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="닫기"
|
||||||
|
onClick={handleLoginClick}
|
||||||
|
className="inline-flex size-6 items-center justify-center text-neutral-700 hover:opacity-80 cursor-pointer"
|
||||||
|
>
|
||||||
|
<ModalCloseSvg />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* body */}
|
||||||
|
<div className="flex flex-col gap-4 items-center px-6 pb-0 pt-[60px]">
|
||||||
|
<h2 className="text-[24px] font-extrabold leading-[1.45] text-[#333c47] text-center">
|
||||||
|
비밀번호 변경이 완료됐습니다.
|
||||||
|
</h2>
|
||||||
|
<p className="text-[18px] font-normal leading-[1.5] text-[#6c7682] text-center mb-[148px]">
|
||||||
|
새로운 비밀번호로 다시 로그인 해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* footer */}
|
||||||
|
<div className="flex gap-8 items-end justify-center p-6 h-[72px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLoginClick}
|
||||||
|
className="h-12 w-[284px] rounded-[10px] bg-[#1f2b91] px-2 text-[16px] font-semibold leading-[1.5] text-white cursor-pointer"
|
||||||
|
>
|
||||||
|
로그인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[2px]"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[2px]"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
>
|
>
|
||||||
<div className="w-[480px] rounded-[12px] border border-[#dee1e6] bg-white shadow-[0_10px_30px_rgba(0,0,0,0.06)]">
|
<div className="w-[480px] rounded-[12px] border border-input-border bg-white shadow-[0_10px_30px_rgba(0,0,0,0.06)]">
|
||||||
{/* header */}
|
{/* header */}
|
||||||
<div className="flex items-center justify-between p-6">
|
<div className="flex items-center justify-between p-6">
|
||||||
<h2 className="text-[20px] font-bold leading-[1.5] text-[#333c47]">비밀번호 변경</h2>
|
<h2 className="text-[20px] font-bold leading-[1.5] text-neutral-700">비밀번호 변경</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="닫기"
|
aria-label="닫기"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="inline-flex size-6 items-center justify-center text-[#333c47] hover:opacity-80 cursor-pointer"
|
className="inline-flex size-6 items-center justify-center text-neutral-700 hover:opacity-80 cursor-pointer"
|
||||||
>
|
>
|
||||||
<ModalCloseSvg />
|
<ModalCloseSvg />
|
||||||
</button>
|
</button>
|
||||||
@@ -105,86 +205,131 @@ export default function ChangePasswordModal({ open, onClose, onSubmit, showVerif
|
|||||||
{/* body */}
|
{/* body */}
|
||||||
<div className="flex flex-col gap-[10px] px-6">
|
<div className="flex flex-col gap-[10px] px-6">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="w-[100px] text-[15px] font-semibold leading-[1.5] text-[#6c7682]">아이디 (이메일)</label>
|
<label className="w-[100px] text-[15px] font-semibold leading-[1.5] text-text-label">아이디 (이메일)</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className={[
|
className={[
|
||||||
"h-10 flex-1 rounded-[8px] border border-[#dee1e6] px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none",
|
"h-10 flex-1 rounded-[8px] border border-input-border px-3 text-[16px] leading-[1.5] text-neutral-700 placeholder:text-text-placeholder-alt outline-none",
|
||||||
hasError ? "bg-white" : isCodeSent ? "bg-neutral-50" : "bg-white",
|
hasError ? "bg-white" : isCodeSent ? "bg-neutral-50" : "bg-white",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
placeholder="이메일"
|
placeholder="이메일"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
setRequireCode(true);
|
if (!email.trim()) {
|
||||||
setIsCodeSent(true);
|
setError("이메일을 입력해 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSending(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiService.sendPasswordReset(email);
|
||||||
|
setRequireCode(true);
|
||||||
|
setIsCodeSent(true);
|
||||||
|
// 재전송 시 기존 인증 상태 초기화
|
||||||
|
setCode("");
|
||||||
|
setIsVerified(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "인증번호 전송에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="h-10 w-[136px] rounded-[8px] bg-[#f1f3f5] px-3 text-[16px] font-semibold leading-[1.5] text-[#333c47] cursor-pointer"
|
disabled={isSending}
|
||||||
|
className={[
|
||||||
|
"h-10 w-[136px] rounded-[8px] bg-bg-gray-light px-3 text-[16px] font-semibold leading-[1.5] text-neutral-700",
|
||||||
|
isSending ? "cursor-not-allowed opacity-50" : "cursor-pointer"
|
||||||
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{isCodeSent ? "인증번호 재전송" : "인증번호 전송"}
|
{isSending ? "전송 중..." : (isCodeSent ? "인증번호 재전송" : "인증번호 전송")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{requireCode ? (
|
{requireCode ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="w-[100px] text-[15px] font-semibold leading-[1.5] text-[#6c7682]">인증번호</div>
|
<div className="w-[100px] text-[15px] font-semibold leading-[1.5] text-text-label">인증번호</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
onChange={(e) => setCode(e.target.value)}
|
||||||
className="h-10 flex-1 rounded-[8px] border border-[#dee1e6] bg-white px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none"
|
className="h-10 flex-1 rounded-[8px] border border-input-border bg-white px-3 text-[16px] leading-normal text-neutral-700 placeholder:text-text-placeholder-alt outline-none"
|
||||||
placeholder="인증번호를 입력해 주세요."
|
placeholder="인증번호를 입력해 주세요."
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canConfirm}
|
disabled={!canConfirm || isVerifying}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!email.trim()) {
|
||||||
|
setError("이메일을 입력해 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!code.trim()) {
|
||||||
|
setError("인증번호를 입력해 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsVerifying(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiService.verifyPasswordResetCode(email, code);
|
||||||
|
setIsVerified(true);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "올바르지 않은 인증번호입니다. 인증번호를 확인해주세요.");
|
||||||
|
setIsVerified(false);
|
||||||
|
} finally {
|
||||||
|
setIsVerifying(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={[
|
className={[
|
||||||
"h-10 w-[136px] rounded-[8px] px-3 text-[16px] font-semibold leading-[1.5] cursor-pointer disabled:cursor-default",
|
"h-10 w-[136px] rounded-[8px] px-3 text-[16px] font-semibold leading-[1.5]",
|
||||||
canConfirm ? "bg-[#f1f3f5] text-[#4c5561]" : "bg-gray-50 text-[#b1b8c0]",
|
canConfirm && !isVerifying ? "bg-bg-gray-light text-basic-text cursor-pointer" : "bg-gray-50 text-text-placeholder-alt cursor-default",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
인증번호 확인
|
{isVerifying ? "확인 중..." : "인증번호 확인"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{isCodeSent && !hasError && !isVerified ? (
|
{isCodeSent && !hasError && !isVerified ? (
|
||||||
<div className="px-1">
|
<div className="px-1">
|
||||||
<p className="text-[13px] font-semibold leading-[1.4] text-[#384fbf]">
|
<p className="text-[13px] font-semibold leading-[1.4] text-primary">
|
||||||
인증 확인을 위해 작성한 이메일로 인증번호를 발송했습니다.
|
인증 확인을 위해 작성한 이메일로 인증번호를 발송했습니다.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[13px] font-semibold leading-[1.4] text-[#384fbf]">
|
<p className="text-[13px] font-semibold leading-[1.4] text-primary">
|
||||||
이메일을 확인해 주세요.
|
이메일을 확인해 주세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="px-1 text-[13px] font-semibold leading-[1.4] text-[#f64c4c]">
|
<p className="px-1 text-[13px] font-semibold leading-[1.4] text-error">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682]">새 비밀번호</label>
|
<label className="text-[15px] font-semibold leading-[1.5] text-text-label">새 비밀번호</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
disabled={!isVerified}
|
disabled={!isVerified}
|
||||||
className={[
|
className={[
|
||||||
"h-10 rounded-[8px] border border-[#dee1e6] px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none",
|
"h-10 rounded-[8px] border border-input-border px-3 text-[16px] leading-[1.5] text-neutral-700 placeholder:text-text-placeholder-alt outline-none",
|
||||||
isVerified ? "bg-white" : "bg-neutral-50",
|
isVerified ? "bg-white" : "bg-neutral-50",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
placeholder="새 비밀번호"
|
placeholder="새 비밀번호"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682]">
|
<label className="text-[15px] font-semibold leading-[1.5] text-text-label">
|
||||||
새 비밀번호 확인
|
새 비밀번호 확인
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -193,7 +338,7 @@ export default function ChangePasswordModal({ open, onClose, onSubmit, showVerif
|
|||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
disabled={!isVerified}
|
disabled={!isVerified}
|
||||||
className={[
|
className={[
|
||||||
"h-10 rounded-[8px] border border-[#dee1e6] px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none",
|
"h-10 rounded-[8px] border border-input-border px-3 text-[16px] leading-[1.5] text-neutral-700 placeholder:text-text-placeholder-alt outline-none",
|
||||||
isVerified ? "bg-white" : "bg-neutral-50",
|
isVerified ? "bg-white" : "bg-neutral-50",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
placeholder="새 비밀번호 확인"
|
placeholder="새 비밀번호 확인"
|
||||||
@@ -205,15 +350,15 @@ export default function ChangePasswordModal({ open, onClose, onSubmit, showVerif
|
|||||||
<div className="flex items-center justify-center gap-3 p-6">
|
<div className="flex items-center justify-center gap-3 p-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={handleCancel}
|
||||||
className="h-12 w-[136px] rounded-[10px] bg-[#f1f3f5] px-4 text-[16px] font-semibold leading-[1.5] text-[#4c5561] cursor-pointer"
|
className="h-12 w-[136px] rounded-[10px] bg-bg-gray-light px-4 text-[16px] font-semibold leading-[1.5] text-basic-text cursor-pointer"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="h-12 w-[136px] rounded-[10px] bg-[#8598e8] px-4 text-[16px] font-semibold leading-[1.5] text-white cursor-pointer"
|
className="h-12 w-[136px] rounded-[10px] bg-inactive-button px-4 text-[16px] font-semibold leading-[1.5] text-white cursor-pointer"
|
||||||
>
|
>
|
||||||
비밀번호 변경
|
비밀번호 변경
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,18 +1,97 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import ChangePasswordModal from "../ChangePasswordModal";
|
import ChangePasswordModal from "../ChangePasswordModal";
|
||||||
import PasswordChangeDoneModal from "../PasswordChangeDoneModal";
|
import PasswordChangeDoneModal from "../PasswordChangeDoneModal";
|
||||||
import AccountDeleteModal from "../AccountDeleteModal";
|
import AccountDeleteModal from "../AccountDeleteModal";
|
||||||
import MenuAccountOption from "@/app/menu/account/MenuAccountOption";
|
import MenuAccountOption from "@/app/menu/account/MenuAccountOption";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
type VerificationState = 'initial' | 'sent' | 'verified' | 'failed' | 'changed';
|
type VerificationState = 'initial' | 'sent' | 'verified' | 'failed' | 'changed';
|
||||||
|
|
||||||
|
type UserInfo = {
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function AccountPage() {
|
export default function AccountPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [verificationState, setVerificationState] = useState<VerificationState>('initial');
|
const [verificationState, setVerificationState] = useState<VerificationState>('initial');
|
||||||
const [doneOpen, setDoneOpen] = useState(false);
|
const [doneOpen, setDoneOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [userInfo, setUserInfo] = useState<UserInfo>({});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// 페이지 로드 시 사용자 정보 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function fetchUserInfo() {
|
||||||
|
try {
|
||||||
|
// localStorage와 쿠키 모두에서 토큰 확인
|
||||||
|
const localStorageToken = localStorage.getItem('token');
|
||||||
|
const cookieToken = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('token='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
const token = localStorageToken || cookieToken;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
if (isMounted) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// localStorage에 토큰이 없고 쿠키에만 있으면 localStorage에도 저장 (동기화)
|
||||||
|
if (!localStorageToken && cookieToken) {
|
||||||
|
localStorage.setItem('token', cookieToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiService.getCurrentUser();
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
// 토큰이 만료되었거나 유효하지 않은 경우
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
if (isMounted) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
const errorMessage = response.message || `사용자 정보 조회 실패 (${response.status})`;
|
||||||
|
console.error('사용자 정보 조회 실패:', errorMessage);
|
||||||
|
if (isMounted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
if (isMounted) {
|
||||||
|
setUserInfo(data);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
|
||||||
|
console.error('사용자 정보 조회 오류:', errorMessage);
|
||||||
|
if (isMounted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchUserInfo();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 개발 옵션에서 'changed'로 전환하면 완료 모달 표시
|
// 개발 옵션에서 'changed'로 전환하면 완료 모달 표시
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -22,30 +101,32 @@ export default function AccountPage() {
|
|||||||
return (
|
return (
|
||||||
<main className="flex w-full flex-col">
|
<main className="flex w-full flex-col">
|
||||||
<div className="flex h-[100px] items-center px-8">
|
<div className="flex h-[100px] items-center px-8">
|
||||||
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">내 정보 수정</h1>
|
<h1 className="text-[24px] font-bold leading-normal text-[#1b2027]">내 정보 수정</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-8 pb-20">
|
<div className="px-8 pb-20">
|
||||||
<div className="rounded-lg border border-[#dee1e6] bg-white p-8">
|
<div className="rounded-lg border border-input-border bg-white p-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="w-[100px] text-[15px] font-semibold leading-[1.5] text-[#6c7682]">
|
<label className="w-[100px] text-[15px] font-semibold leading-[1.5] text-text-label">
|
||||||
아이디 (이메일)
|
아이디 (이메일)
|
||||||
</label>
|
</label>
|
||||||
<div className="h-10 rounded-lg border border-[#dee1e6] bg-neutral-50 px-3 py-2">
|
<div className="h-10 rounded-lg border border-input-border bg-neutral-50 px-3 py-2">
|
||||||
<span className="text-[16px] leading-[1.5] text-[#333c47]">skyblue@edu.com</span>
|
<span className="text-[16px] leading-normal text-neutral-700">
|
||||||
|
{isLoading ? '로딩 중...' : (userInfo.email || '이메일 정보 없음')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex flex-col gap-2">
|
<div className="mt-6 flex flex-col gap-2">
|
||||||
<label className="w-[100px] text-[15px] font-semibold leading-[1.5] text-[#6c7682]">
|
<label className="w-[100px] text-[15px] font-semibold leading-[1.5] text-text-label">
|
||||||
비밀번호 변경
|
비밀번호 변경
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-10 flex-1 rounded-lg border border-[#dee1e6] bg-neutral-50 px-3 py-2">
|
<div className="h-10 flex-1 rounded-lg border border-input-border bg-neutral-50 px-3 py-2">
|
||||||
<span className="text-[16px] leading-[1.5] text-[#333c47]">●●●●●●●●●●</span>
|
<span className="text-[16px] leading-normal text-neutral-700">●●●●●●●●●●</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
className="h-10 rounded-lg bg-[#f1f3f5] px-4 text-[16px] font-semibold leading-[1.5] text-[#4c5561]"
|
className="h-10 rounded-lg bg-bg-gray-light px-4 text-[16px] font-semibold leading-[1.5] text-basic-text"
|
||||||
>
|
>
|
||||||
비밀번호 변경
|
비밀번호 변경
|
||||||
</button>
|
</button>
|
||||||
@@ -69,7 +150,10 @@ export default function AccountPage() {
|
|||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
// TODO: integrate API
|
// TODO: integrate API
|
||||||
}}
|
}}
|
||||||
devVerificationState={verificationState}
|
devVerificationState={
|
||||||
|
verificationState === 'changed' ? 'verified' : verificationState
|
||||||
|
}
|
||||||
|
initialEmail={userInfo.email}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MenuAccountOption
|
<MenuAccountOption
|
||||||
@@ -87,9 +171,19 @@ export default function AccountPage() {
|
|||||||
<AccountDeleteModal
|
<AccountDeleteModal
|
||||||
open={deleteOpen}
|
open={deleteOpen}
|
||||||
onClose={() => setDeleteOpen(false)}
|
onClose={() => setDeleteOpen(false)}
|
||||||
onConfirm={() => {
|
onConfirm={async () => {
|
||||||
// TODO: 탈퇴 API 연동
|
try {
|
||||||
setDeleteOpen(false);
|
await apiService.deleteAccount();
|
||||||
|
|
||||||
|
// 성공 시 토큰 제거 및 로그인 페이지로 이동
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
setDeleteOpen(false);
|
||||||
|
router.push('/login');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
|
||||||
|
console.error('회원 탈퇴 오류:', errorMessage);
|
||||||
|
alert(errorMessage);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import ChevronDownSvg from "../../svgs/chevrondownsvg";
|
import ChevronDownSvg from "../../svgs/chevrondownsvg";
|
||||||
|
|
||||||
@@ -25,9 +26,9 @@ type Course = {
|
|||||||
function ProgressBar({ value }: { value: number }) {
|
function ProgressBar({ value }: { value: number }) {
|
||||||
const pct = Math.max(0, Math.min(100, value));
|
const pct = Math.max(0, Math.min(100, value));
|
||||||
return (
|
return (
|
||||||
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-[#ecf0ff]">
|
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-bg-primary-light">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-[#384fbf] transition-[width] duration-300 ease-out"
|
className="h-full rounded-full bg-primary transition-[width] duration-300 ease-out"
|
||||||
style={{ width: `${pct}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,14 +37,15 @@ function ProgressBar({ value }: { value: number }) {
|
|||||||
|
|
||||||
export default function CourseCard({ course, defaultOpen = false }: { course: Course; defaultOpen?: boolean }) {
|
export default function CourseCard({ course, defaultOpen = false }: { course: Course; defaultOpen?: boolean }) {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const totalMinutes = course.lessons.reduce((sum, l) => sum + (l.durationMin || 0), 0);
|
const totalMinutes = course.lessons.reduce((sum, l) => sum + (l.durationMin || 0), 0);
|
||||||
const totalHours = Math.floor(totalMinutes / 60);
|
const totalHours = Math.floor(totalMinutes / 60);
|
||||||
const restMinutes = totalMinutes % 60;
|
const restMinutes = totalMinutes % 60;
|
||||||
const firstIncomplete = course.lessons.find((l) => !l.isCompleted)?.id;
|
const firstIncomplete = course.lessons.find((l) => !l.isCompleted)?.id;
|
||||||
const cardClassName = [
|
const cardClassName = [
|
||||||
"rounded-xl border bg-white shadow-[0_2px_8px_rgba(0,0,0,0.02)]",
|
"rounded-xl bg-white shadow-[0_2px_8px_rgba(0,0,0,0.02)]",
|
||||||
open ? "border-[#384fbf]" : "border-[#ecf0ff]",
|
open ? "border-[3px] border-primary" : "border border-bg-primary-light",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
|
||||||
const formatDuration = (m: number) => {
|
const formatDuration = (m: number) => {
|
||||||
@@ -54,33 +56,33 @@ export default function CourseCard({ course, defaultOpen = false }: { course: Co
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={cardClassName}>
|
<article className={cardClassName}>
|
||||||
<header className="flex items-center gap-4 p-4">
|
<header className="flex items-center gap-6 px-8 py-6">
|
||||||
<div className="relative h-[76px] w-[120px] overflow-hidden rounded-md bg-[#f1f3f5]">
|
<div className="relative h-[120px] w-[180px] overflow-hidden rounded-[8px] bg-bg-gray-light">
|
||||||
<Image
|
<Image
|
||||||
src={`https://picsum.photos/seed/${encodeURIComponent(course.id)}/240/152`}
|
src={`https://picsum.photos/seed/${encodeURIComponent(course.id)}/240/152`}
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
sizes="120px"
|
sizes="180px"
|
||||||
unoptimized
|
unoptimized
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="rounded bg-[#e5f5ec] px-2 py-0.5 text-[12px] font-semibold leading-[1.4] text-[#0c9d61]">
|
<span className="flex h-[20px] items-center justify-center rounded-[4px] bg-bg-success-light px-[4px] text-[13px] font-semibold leading-[1.4] text-success">
|
||||||
{course.status}
|
{course.status}
|
||||||
</span>
|
</span>
|
||||||
<h2 className="truncate text-[18px] font-bold leading-[1.5] text-[#1b2027]">
|
<h2 className="truncate text-[18px] font-semibold leading-normal text-neutral-700">
|
||||||
{course.title}
|
{course.title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 line-clamp-2 text-[14px] leading-[1.5] text-[#4c5561]">{course.description}</p>
|
<p className="mt-1 line-clamp-2 text-[14px] leading-normal text-basic-text">{course.description}</p>
|
||||||
<div className="mt-2 flex items-center justify-between gap-3">
|
<div className="mt-2 flex items-center justify-between gap-3">
|
||||||
<p className="text-[13px] leading-[1.4] text-[#8c95a1]">
|
<p className="text-[13px] leading-[1.4] text-text-meta">
|
||||||
VOD · 총 {course.lessons.length}강 · {totalHours}시간 {restMinutes}분
|
VOD · 총 {course.lessons.length}강 · {totalHours}시간 {restMinutes}분
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[13px] font-semibold leading-[1.4] text-[#384fbf]">
|
<p className="text-[13px] font-semibold leading-[1.4] text-primary">
|
||||||
진행도 {course.progressPct}%
|
전체 진도율: {course.progressPct}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
@@ -92,7 +94,7 @@ export default function CourseCard({ course, defaultOpen = false }: { course: Co
|
|||||||
type="button"
|
type="button"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="flex h-8 w-8 items-center justify-center text-[#6c7682] cursor-pointer"
|
className="flex h-8 w-8 items-center justify-center text-text-label cursor-pointer"
|
||||||
aria-label={open ? "접기" : "펼치기"}
|
aria-label={open ? "접기" : "펼치기"}
|
||||||
>
|
>
|
||||||
<ChevronDownSvg
|
<ChevronDownSvg
|
||||||
@@ -105,22 +107,17 @@ export default function CourseCard({ course, defaultOpen = false }: { course: Co
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{open ? (
|
{open ? (
|
||||||
<div className="px-4 pb-4">
|
<div className="px-6 pb-6">
|
||||||
<ul className="flex flex-col gap-2">
|
<ul className="flex flex-col gap-2">
|
||||||
{course.lessons.map((lesson, idx) => (
|
{course.lessons.map((lesson, idx) => (
|
||||||
<li key={lesson.id} className="rounded-lg border border-[#ecf0ff] bg-white">
|
<li key={lesson.id} className="rounded-lg border border-input-border bg-white">
|
||||||
<div className="flex items-center justify-between gap-4 px-4 py-3">
|
<div className="flex items-center justify-between gap-4 px-[24px] py-[16px]">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-3">
|
<p className="truncate text-[16px] font-semibold leading-normal text-neutral-700">
|
||||||
<span className="w-[20px] text-[13px] font-semibold leading-[1.4] text-[#8c95a1]">
|
{`${idx + 1}. ${lesson.title}`}
|
||||||
{idx + 1}.
|
</p>
|
||||||
</span>
|
|
||||||
<p className="truncate text-[14px] font-medium leading-[1.5] text-[#333c47]">
|
|
||||||
{lesson.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex items-center gap-3">
|
<div className="mt-2 flex items-center gap-3">
|
||||||
<span className="text-[13px] leading-[1.4] text-[#8c95a1]">
|
<span className="text-[13px] leading-[1.4] text-text-meta">
|
||||||
{formatDuration(lesson.durationMin)}
|
{formatDuration(lesson.durationMin)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,29 +126,59 @@ export default function CourseCard({ course, defaultOpen = false }: { course: Co
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={[
|
className={[
|
||||||
"rounded-md px-3 py-2 text-[14px] font-medium leading-[1.5]",
|
"inline-flex h-[32px] w-[140px] items-center justify-center gap-[6px] rounded-[6px] px-4 text-center whitespace-nowrap cursor-pointer",
|
||||||
lesson.isCompleted
|
lesson.isCompleted
|
||||||
? "text-[#384fbf] border border-transparent bg-white"
|
? "bg-white text-[13px] font-medium leading-[1.4] text-primary"
|
||||||
: "text-[#4c5561] border border-[#8c95a1] bg-white",
|
: "bg-white text-[14px] font-medium leading-normal text-basic-text border border-text-meta",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{lesson.isCompleted ? "학습 제출 완료" : "학습 제출 하기"}
|
{lesson.isCompleted ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="10"
|
||||||
|
height="7"
|
||||||
|
viewBox="0 0 10 7"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8.75 0.75L3.25 6.25L0.75 3.75"
|
||||||
|
stroke="var(--color-primary)"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>학습 제출 완료</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"학습 제출 하기"
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{lesson.isCompleted ? (
|
{lesson.isCompleted ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-md border border-[#dee1e6] px-3 py-2 text-[14px] font-medium leading-[1.5] text-[#4c5561] hover:bg-[#f9fafb]"
|
onClick={() => router.push(`/menu/courses/lessons/${lesson.id}/review`)}
|
||||||
|
className="inline-flex h-[32px] w-[140px] items-center justify-center rounded-[6px] bg-bg-gray-light px-4 text-center text-[14px] font-medium leading-normal text-basic-text whitespace-nowrap cursor-pointer"
|
||||||
>
|
>
|
||||||
복습하기
|
복습하기
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(
|
||||||
|
lesson.id === firstIncomplete
|
||||||
|
? `/menu/courses/lessons/${lesson.id}/continue`
|
||||||
|
: `/menu/courses/lessons/${lesson.id}/start`,
|
||||||
|
)
|
||||||
|
}
|
||||||
className={[
|
className={[
|
||||||
"rounded-md px-3 py-2 text-[14px] font-medium leading-[1.5]",
|
"inline-flex h-[32px] w-[140px] items-center justify-center rounded-[6px] px-4 text-center text-[14px] font-medium leading-normal whitespace-nowrap cursor-pointer",
|
||||||
lesson.id === firstIncomplete
|
lesson.id === firstIncomplete
|
||||||
? "bg-[#ecf0ff] text-[#384fbf]"
|
? "bg-bg-primary-light text-primary"
|
||||||
: "border border-[#dee1e6] text-[#4c5561] hover:bg-[#f9fafb]",
|
: "border border-input-border text-basic-text",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{lesson.id === firstIncomplete ? "이어서 수강하기" : "수강하기"}
|
{lesson.id === firstIncomplete ? "이어서 수강하기" : "수강하기"}
|
||||||
|
|||||||
168
src/app/menu/courses/lessons/FigmaSelectedLessonPage.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
const imgImage2 = "/imgs/image-2.png";
|
||||||
|
const imgLine58 = "/imgs/line-58.svg";
|
||||||
|
const img = "/imgs/asset-base.svg";
|
||||||
|
const imgArrowsDiagramsArrow = "/imgs/arrows-diagrams-arrow.svg";
|
||||||
|
const imgIcon = "/imgs/icon.svg";
|
||||||
|
const imgGroup = "/imgs/group.svg";
|
||||||
|
const imgEllipse2 = "/imgs/ellipse-2.svg";
|
||||||
|
const imgMusicAudioPlay = "/imgs/music-audio-play.svg";
|
||||||
|
const imgIcon1 = "/imgs/icon-1.svg";
|
||||||
|
const imgIcon2 = "/imgs/icon-2.svg";
|
||||||
|
const imgIcon3 = "/imgs/icon-3.svg";
|
||||||
|
const imgIcon4 = "/imgs/icon-4.svg";
|
||||||
|
|
||||||
|
export default function FigmaSelectedLessonPage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white content-stretch flex flex-col items-center relative size-full">
|
||||||
|
<div className="content-stretch flex flex-col items-start max-w-[1440px] relative shrink-0 w-[1440px]">
|
||||||
|
<div className="box-border content-stretch flex gap-[10px] h-[100px] items-center px-[32px] py-0 relative shrink-0 w-full">
|
||||||
|
<div className="basis-0 content-stretch flex gap-[12px] grow items-center min-h-px min-w-px relative shrink-0">
|
||||||
|
<div className="relative shrink-0 size-[32px]">
|
||||||
|
<img alt="" className="block max-w-none size-full" src={imgArrowsDiagramsArrow} />
|
||||||
|
</div>
|
||||||
|
<div className="basis-0 content-stretch flex flex-col grow items-start justify-center leading-[1.5] min-h-px min-w-px not-italic relative shrink-0">
|
||||||
|
<p className="font-['Pretendard:SemiBold',sans-serif] relative shrink-0 text-[#6c7682] text-[16px] w-full">
|
||||||
|
원자로 운전 및 계통
|
||||||
|
</p>
|
||||||
|
<p className="font-['Pretendard:Bold',sans-serif] relative shrink-0 text-[#1b2027] text-[24px] w-full">
|
||||||
|
6. 원자로 시동, 운전 및 정지 절차
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="content-stretch flex gap-[20px] h-[81px] items-center justify-center relative shrink-0">
|
||||||
|
<div className="relative shrink-0 w-[52px]">
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex flex-col gap-[4px] items-center relative w-[52px]">
|
||||||
|
<div className="bg-[#384fbf] relative rounded-[2.23696e+07px] shrink-0 size-[32px]">
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex items-center justify-center relative size-[32px]">
|
||||||
|
<p className="font-['Pretendard:Bold',sans-serif] leading-[18px] not-italic relative shrink-0 text-[14px] text-nowrap text-white whitespace-pre">
|
||||||
|
1
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#384fbf] text-[14px] text-nowrap whitespace-pre">
|
||||||
|
강좌 수강
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative shrink-0 size-[24px]">
|
||||||
|
<img alt="" className="block max-w-none size-full" src={imgIcon} />
|
||||||
|
</div>
|
||||||
|
<div className="relative shrink-0 w-[52px]">
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex flex-col gap-[4px] items-center relative w-[52px]">
|
||||||
|
<div className="bg-[#dee1e6] relative rounded-[2.23696e+07px] shrink-0 size-[32px]">
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex items-center justify-center relative size-[32px]">
|
||||||
|
<p className="font-['Pretendard:Bold',sans-serif] leading-[18px] not-italic relative shrink-0 text-[#6c7682] text-[14px] text-nowrap whitespace-pre">
|
||||||
|
2
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#6c7682] text-[14px] text-nowrap whitespace-pre">
|
||||||
|
XR 실습
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative shrink-0 size-[24px]">
|
||||||
|
<img alt="" className="block max-w-none size-full" src={imgIcon} />
|
||||||
|
</div>
|
||||||
|
<div className="relative shrink-0 w-[52px]">
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex flex-col gap-[4px] items-center relative w-[52px]">
|
||||||
|
<div className="bg-[#dee1e6] relative rounded-[2.23696e+07px] shrink-0 size-[32px]">
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex items-center justify-center relative size-[32px]">
|
||||||
|
<p className="font-['Pretendard:Bold',sans-serif] leading-[18px] not-italic relative shrink-0 text-[#6c7682] text-[14px] text-nowrap whitespace-pre">
|
||||||
|
3
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#6c7682] text-[14px] text-nowrap whitespace-pre">
|
||||||
|
문제 풀기
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="box-border content-stretch flex flex-col gap-[24px] items-center overflow-clip pb-[80px] pt-[24px] px-8 relative rounded-[8px] shrink-0 w-full">
|
||||||
|
<div className="aspect-[1920/1080] bg-black overflow-clip relative rounded-[8px] shrink-0 w-full">
|
||||||
|
<div className="absolute left-1/2 size-[120px] top-1/2 translate-x-[-50%] translate-y-[-50%]">
|
||||||
|
<div className="absolute contents inset-0">
|
||||||
|
<img alt="" className="block max-w-none size-full" src={imgGroup} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bg-gradient-to-t bottom-0 box-border content-stretch flex flex-col from-[rgba(0,0,0,0.8)] gap-[20px] items-center justify-center left-[-0.5px] px-[16px] py-[24px] to-[rgba(0,0,0,0)] w-[1376px]">
|
||||||
|
<div className="bg-[#333c47] h-[4px] relative rounded-[3.35544e+07px] shrink-0 w-full">
|
||||||
|
<div className="absolute left-0 size-[12px] top-1/2 translate-y-[-50%]">
|
||||||
|
<img alt="" className="block max-w-none size-full" src={imgEllipse2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="content-stretch flex h-[32px] items-center justify-between relative shrink-0 w-full">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex gap-[8px] items-center relative">
|
||||||
|
<div className="content-stretch flex gap-[16px] items-center relative shrink-0">
|
||||||
|
<div className="relative shrink-0 size-[32px]">
|
||||||
|
<img alt="" className="block max-w-none size-full" src={imgMusicAudioPlay} />
|
||||||
|
</div>
|
||||||
|
<div className="content-stretch flex gap-[8px] h-[32px] items-center relative shrink-0 w-[120px]">
|
||||||
|
<div className="relative rounded-[4px] shrink-0 size-[32px]">
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex items-center justify-center relative size-[32px]">
|
||||||
|
<div className="relative shrink-0 size-[18px]">
|
||||||
|
<img alt="" className="block max-w-none size-full" src={imgIcon1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="basis-0 grow h-[4px] min-h-px min-w-px relative rounded-[3.35544e+07px] shrink-0">
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[4px] w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] leading-[19.5px] not-italic relative shrink-0 text-[13px] text-nowrap text-white whitespace-pre">
|
||||||
|
0:00 / 12:26
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex gap-[12px] items-center relative">
|
||||||
|
<div className="bg-[#333c47] box-border content-stretch flex gap-[4px] h-[32px] items-center justify-center px-[16px] py-[3px] relative rounded-[6px] shrink-0 w-[112px]">
|
||||||
|
<div className="relative shrink-0 size-[16px]">
|
||||||
|
<img alt="" className="block max-w-none size-full" src={imgIcon2} />
|
||||||
|
</div>
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[14px] text-center text-nowrap text-white whitespace-pre">
|
||||||
|
이전 강의
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#333c47] box-border content-stretch flex gap-[4px] h-[32px] items-center justify-center px-[16px] py-[3px] relative rounded-[6px] shrink-0 w-[112px]">
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[14px] text-center text-nowrap text-white whitespace-pre">
|
||||||
|
다음 강의
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-center relative shrink-0">
|
||||||
|
<div className="flex-none rotate-[180deg] scale-y-[-100%]">
|
||||||
|
<div className="relative size-[16px]">
|
||||||
|
<img alt="" className="block max-w-none size-full" src={imgIcon3} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="content-stretch flex items-center justify-center relative rounded-[4px] shrink-0 size-[32px]">
|
||||||
|
<div className="relative shrink-0 size-[18px]">
|
||||||
|
<img alt="" className="block max-w-none size-full" src={imgIcon4} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#fff9ee] border border-[#ffdd82] border-solid box-border content-stretch flex flex-col h-[55px] items-start pb-px pt-[17px] px-[17px] relative rounded-[8px] shrink-0 w-full">
|
||||||
|
<ul className="[white-space-collapse:collapse] block font-['Pretendard:Medium',sans-serif] leading-[0] not-italic relative shrink-0 text-[#333c47] text-[14px] text-nowrap">
|
||||||
|
<li className="ms-[21px]">
|
||||||
|
<span className="leading-[1.5]">강좌 수강 완료 후 문제를 풀어야 하니 집중해서 강좌 수강해 주세요.</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
'use client';
|
||||||
|
import FigmaSelectedLessonPage from '../../FigmaSelectedLessonPage';
|
||||||
|
|
||||||
|
export default function ContinueLessonPage() {
|
||||||
|
return <FigmaSelectedLessonPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
7
src/app/menu/courses/lessons/[lessonId]/review/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function ReviewLessonPage({ params }: { params: { lessonId: string } }) {
|
||||||
|
redirect(`/${params.lessonId}/review`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
8
src/app/menu/courses/lessons/[lessonId]/start/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
'use client';
|
||||||
|
import FigmaSelectedLessonPage from '../../FigmaSelectedLessonPage';
|
||||||
|
|
||||||
|
export default function StartLessonPage() {
|
||||||
|
return <FigmaSelectedLessonPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ import MenuSidebar from "./MenuSidebar";
|
|||||||
export default function MenuLayout({ children }: { children: ReactNode }) {
|
export default function MenuLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-[1440px] min-h-full">
|
<div className="mx-auto flex w-full max-w-[1440px] min-h-full">
|
||||||
<aside className="w-[320px] border-r border-[#dee1e6] px-4 py-6">
|
<aside className="hidden w-[320px] border-r border-[#dee1e6] px-4 py-6">
|
||||||
<MenuSidebar />
|
<MenuSidebar />
|
||||||
</aside>
|
</aside>
|
||||||
<section className="flex-1">{children}</section>
|
<section className="flex-1">{children}</section>
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ type Props = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const imgImage1 = "http://localhost:3845/assets/89fda8e949171025b1232bae70fc9d442e4e70c8.png";
|
const imgImage1 = "/imgs/image-1.png";
|
||||||
const imgContainer = "http://localhost:3845/assets/d04df6bb7fe1bd29946d04be9442029bca1503b0.png";
|
const imgContainer = "/imgs/certificate-container.png";
|
||||||
const img = "http://localhost:3845/assets/7adf9a5e43b6c9e5f9bee6adfee64e85eabac44a.svg";
|
const img = "/imgs/certificate-asset.svg";
|
||||||
const img1 = "http://localhost:3845/assets/9e3b52939dbaa99088659a82db437772ef1ad40e.svg";
|
const img1 = "/imgs/certificate-asset-1.svg";
|
||||||
|
|
||||||
export default function FigmaCertificateContent({ onClose }: Props) {
|
export default function FigmaCertificateContent({ onClose }: Props) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ type Props = {
|
|||||||
scoreText?: string;
|
scoreText?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const img = "http://localhost:3845/assets/7adf9a5e43b6c9e5f9bee6adfee64e85eabac44a.svg";
|
const img = "/imgs/feedback-asset.svg";
|
||||||
const img1 = "http://localhost:3845/assets/498f1d9877c6da3dadf581f98114a7f15bfc6769.svg";
|
const img1 = "/imgs/feedback-asset-1.svg";
|
||||||
|
|
||||||
export default function FigmaFeedbackContent({
|
export default function FigmaFeedbackContent({
|
||||||
onClose,
|
onClose,
|
||||||
|
|||||||
@@ -1,50 +1,169 @@
|
|||||||
import Link from 'next/link';
|
'use client';
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import BackCircleSvg from '../../svgs/backcirclesvg';
|
|
||||||
|
|
||||||
type NoticeItem = {
|
import { useState, useEffect } from 'react';
|
||||||
id: number;
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
title: string;
|
import BackCircleSvg from '../../svgs/backcirclesvg';
|
||||||
date: string;
|
import DownloadIcon from '../../svgs/downloadicon';
|
||||||
views: number;
|
import apiService from '../../lib/apiService';
|
||||||
writer: string;
|
import type { Notice } from '../../admin/notices/mockData';
|
||||||
content: string[];
|
|
||||||
|
type Attachment = {
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
url?: string;
|
||||||
|
fileKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DATA: NoticeItem[] = [
|
export default function NoticeDetailPage() {
|
||||||
{
|
const params = useParams();
|
||||||
id: 2,
|
const router = useRouter();
|
||||||
title: '공지사항 제목이 노출돼요',
|
const [notice, setNotice] = useState<Notice | null>(null);
|
||||||
date: '2025-09-10',
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
views: 1230,
|
const [loading, setLoading] = useState(true);
|
||||||
writer: '문지호',
|
const [error, setError] = useState<string | null>(null);
|
||||||
content: [
|
|
||||||
'사이트 이용 관련 주요 변경 사항을 안내드립니다.',
|
|
||||||
'변경되는 내용은 공지일자로부터 즉시 적용됩니다.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: '📢 방사선학 온라인 강의 수강 안내 및 필수 공지',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 594,
|
|
||||||
writer: '문지호',
|
|
||||||
content: [
|
|
||||||
'온라인 강의 수강 방법과 필수 확인 사항을 안내드립니다.',
|
|
||||||
'수강 기간 및 출석, 과제 제출 관련 정책을 반드시 확인해 주세요.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default async function NoticeDetailPage({
|
// 날짜를 yyyy-mm-dd 형식으로 포맷팅
|
||||||
params,
|
const formatDate = (dateString: string): string => {
|
||||||
}: {
|
if (!dateString) return '';
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}) {
|
try {
|
||||||
const { id } = await params;
|
const date = new Date(dateString);
|
||||||
const numericId = Number(id);
|
if (isNaN(date.getTime())) {
|
||||||
const item = DATA.find((r) => r.id === numericId);
|
// 이미 yyyy-mm-dd 형식인 경우 그대로 반환
|
||||||
if (!item) return notFound();
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchNotice() {
|
||||||
|
if (!params?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const noticeId = Array.isArray(params.id) ? params.id[0] : params.id;
|
||||||
|
const response = await apiService.getNotice(noticeId);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// API 응답 데이터를 Notice 형식으로 변환
|
||||||
|
const transformedNotice: Notice = {
|
||||||
|
id: data.id || data.noticeId || Number(params.id),
|
||||||
|
title: data.title || '',
|
||||||
|
date: formatDate(data.date || data.createdAt || data.createdDate || new Date().toISOString().split('T')[0]),
|
||||||
|
views: data.views || data.viewCount || 0,
|
||||||
|
writer: data.writer || data.author || data.createdBy || '관리자',
|
||||||
|
content: data.content
|
||||||
|
? (Array.isArray(data.content)
|
||||||
|
? data.content
|
||||||
|
: typeof data.content === 'string'
|
||||||
|
? data.content.split('\n').filter((line: string) => line.trim())
|
||||||
|
: [String(data.content)])
|
||||||
|
: [],
|
||||||
|
hasAttachment: data.hasAttachment || data.attachment || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 첨부파일 정보 처리
|
||||||
|
if (data.attachments && Array.isArray(data.attachments)) {
|
||||||
|
setAttachments(data.attachments.map((att: any) => ({
|
||||||
|
name: att.name || att.fileName || att.filename || '',
|
||||||
|
size: att.size || att.fileSize || '',
|
||||||
|
url: att.url || att.downloadUrl,
|
||||||
|
fileKey: att.fileKey || att.key || att.fileId,
|
||||||
|
})));
|
||||||
|
} else if (transformedNotice.hasAttachment && data.attachment) {
|
||||||
|
// 단일 첨부파일인 경우
|
||||||
|
setAttachments([{
|
||||||
|
name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일',
|
||||||
|
size: data.attachment.size || data.attachment.fileSize || '',
|
||||||
|
url: data.attachment.url || data.attachment.downloadUrl,
|
||||||
|
fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transformedNotice.title) {
|
||||||
|
setError('공지사항을 찾을 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotice(transformedNotice);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('공지사항 조회 오류:', err);
|
||||||
|
setError('공지사항을 불러오는 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchNotice();
|
||||||
|
}, [params?.id]);
|
||||||
|
|
||||||
|
const handleDownload = async (fileKey?: string, url?: string, fileName?: string) => {
|
||||||
|
if (url) {
|
||||||
|
// URL이 있으면 직접 다운로드
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
} else if (fileKey) {
|
||||||
|
// fileKey가 있으면 API를 통해 다운로드
|
||||||
|
try {
|
||||||
|
const fileUrl = await apiService.getFile(fileKey);
|
||||||
|
if (fileUrl) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = fileUrl;
|
||||||
|
link.download = fileName || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 다운로드 오류:', error);
|
||||||
|
alert('파일 다운로드 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-full max-w-[1440px]">
|
||||||
|
<div className="h-[100px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium text-[#6C7682]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !notice) {
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-full max-w-[1440px]">
|
||||||
|
<div className="h-[100px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium text-[#6C7682]">{error || '공지사항을 찾을 수 없습니다.'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-white">
|
<div className="w-full bg-white">
|
||||||
@@ -52,13 +171,14 @@ export default async function NoticeDetailPage({
|
|||||||
<div className="w-full max-w-[1440px]">
|
<div className="w-full max-w-[1440px]">
|
||||||
{/* 상단 타이틀 */}
|
{/* 상단 타이틀 */}
|
||||||
<div className="h-[100px] flex items-center gap-3 px-8">
|
<div className="h-[100px] flex items-center gap-3 px-8">
|
||||||
<Link
|
<button
|
||||||
href="/notices"
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
aria-label="뒤로 가기"
|
aria-label="뒤로 가기"
|
||||||
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline"
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
|
||||||
>
|
>
|
||||||
<BackCircleSvg width={32} height={32} />
|
<BackCircleSvg width={32} height={32} />
|
||||||
</Link>
|
</button>
|
||||||
<h1 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">
|
<h1 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">
|
||||||
공지사항 상세
|
공지사항 상세
|
||||||
</h1>
|
</h1>
|
||||||
@@ -70,17 +190,17 @@ export default async function NoticeDetailPage({
|
|||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<h2 className="m-0 text-[20px] font-bold leading-normal text-[#333C47]">
|
<h2 className="m-0 text-[20px] font-bold leading-normal text-[#333C47]">
|
||||||
{item.title}
|
{notice.title}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-2 flex items-center gap-4 text-[13px] leading-[1.4]">
|
<div className="mt-2 flex items-center gap-4 text-[13px] leading-[1.4]">
|
||||||
<span className="text-[#8C95A1]">작성자</span>
|
<span className="text-[#8C95A1]">작성자</span>
|
||||||
<span className="text-[#333C47]">{item.writer}</span>
|
<span className="text-[#333C47]">{notice.writer}</span>
|
||||||
<span className="w-px h-4 bg-[#DEE1E6]" />
|
<span className="w-px h-4 bg-[#DEE1E6]" />
|
||||||
<span className="text-[#8C95A1]">게시일</span>
|
<span className="text-[#8C95A1]">게시일</span>
|
||||||
<span className="text-[#333C47]">{item.date}</span>
|
<span className="text-[#333C47]">{notice.date}</span>
|
||||||
<span className="w-px h-4 bg-[#DEE1E6]" />
|
<span className="w-px h-4 bg-[#DEE1E6]" />
|
||||||
<span className="text-[#8C95A1]">조회수</span>
|
<span className="text-[#8C95A1]">조회수</span>
|
||||||
<span className="text-[#333C47]">{item.views.toLocaleString()}</span>
|
<span className="text-[#333C47]">{notice.views.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -88,14 +208,79 @@ export default async function NoticeDetailPage({
|
|||||||
<div className="h-px bg-[#DEE1E6] w-full" />
|
<div className="h-px bg-[#DEE1E6] w-full" />
|
||||||
|
|
||||||
{/* 본문 */}
|
{/* 본문 */}
|
||||||
<div className="p-8">
|
<div className="p-8 flex flex-col gap-10">
|
||||||
<div className="text-[15px] leading-normal text-[#333C47] space-y-2">
|
<div className="text-[15px] leading-normal text-[#333C47]">
|
||||||
{item.content.map((p, idx) => (
|
{notice.content && notice.content.length > 0 ? (
|
||||||
<p key={idx} className="m-0">
|
notice.content.map((p, idx) => (
|
||||||
{p}
|
<p key={idx} className="m-0 mb-2 last:mb-0">
|
||||||
</p>
|
{p}
|
||||||
))}
|
</p>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="m-0">내용이 없습니다.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 첨부파일 섹션 */}
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="text-[15px] font-semibold leading-[1.5] text-[#6C7682]">
|
||||||
|
첨부 파일
|
||||||
|
</div>
|
||||||
|
<div className="pt-3">
|
||||||
|
{attachments.map((attachment, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-white border border-[#DEE1E6] rounded-[6px] h-[64px] flex items-center gap-3 px-[17px]"
|
||||||
|
>
|
||||||
|
<div className="size-6 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="text-[#8C95A1]"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8L14 2Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14 2V8H20"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||||
|
<p className="text-[15px] font-normal leading-[1.5] text-[#1B2027] truncate">
|
||||||
|
{attachment.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#8C95A1] whitespace-nowrap">
|
||||||
|
{attachment.size}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDownload(attachment.fileKey, attachment.url, attachment.name)}
|
||||||
|
className="bg-white border border-[#8C95A1] rounded-[6px] h-8 px-4 flex items-center justify-center gap-1 hover:bg-[#F9FAFB] transition-colors"
|
||||||
|
>
|
||||||
|
<DownloadIcon width={16} height={16} className="text-[#4C5561]" />
|
||||||
|
<span className="text-[13px] font-medium leading-[1.4] text-[#4C5561]">
|
||||||
|
다운로드
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -104,6 +289,3 @@ export default async function NoticeDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,115 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import PaperClipSvg from '../svgs/paperclipsvg';
|
import PaperClipSvg from '../svgs/paperclipsvg';
|
||||||
|
import ChevronDownSvg from '../svgs/chevrondownsvg';
|
||||||
type NoticeRow = {
|
import apiService from '../lib/apiService';
|
||||||
id: number;
|
import type { Notice } from '../admin/notices/mockData';
|
||||||
title: string;
|
|
||||||
date: string;
|
|
||||||
views: number;
|
|
||||||
writer: string;
|
|
||||||
hasAttachment?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const rows: NoticeRow[] = [
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: '공지사항 제목이 노출돼요',
|
|
||||||
date: '2025-09-10',
|
|
||||||
views: 1230,
|
|
||||||
writer: '문지호',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: '📢 방사선학 온라인 강의 수강 안내 및 필수 공지',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 594,
|
|
||||||
writer: '문지호',
|
|
||||||
hasAttachment: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function NoticesPage() {
|
export default function NoticesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [notices, setNotices] = useState<Notice[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
// 공지사항 리스트 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function fetchNotices() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await apiService.getNotices();
|
||||||
|
|
||||||
|
if (response.status !== 200 || !response.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||||
|
let noticesArray: any[] = [];
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
if (Array.isArray(response.data)) {
|
||||||
|
noticesArray = response.data;
|
||||||
|
total = response.data.length;
|
||||||
|
} else if (response.data && typeof response.data === 'object') {
|
||||||
|
noticesArray = response.data.items || response.data.notices || response.data.data || response.data.list || [];
|
||||||
|
total = response.data.total !== undefined ? response.data.total :
|
||||||
|
response.data.totalCount !== undefined ? response.data.totalCount :
|
||||||
|
response.data.count !== undefined ? response.data.count :
|
||||||
|
noticesArray.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜를 yyyy-mm-dd 형식으로 포맷팅
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
// 이미 yyyy-mm-dd 형식인 경우 그대로 반환
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API 응답 데이터를 Notice 형식으로 변환
|
||||||
|
const transformedNotices: Notice[] = noticesArray.map((notice: any) => ({
|
||||||
|
id: notice.id || notice.noticeId || 0,
|
||||||
|
title: notice.title || '',
|
||||||
|
date: formatDate(notice.date || notice.createdAt || notice.createdDate || new Date().toISOString().split('T')[0]),
|
||||||
|
views: notice.views || notice.viewCount || 0,
|
||||||
|
writer: notice.writer || notice.author || notice.createdBy || '관리자',
|
||||||
|
content: notice.content ? (Array.isArray(notice.content) ? notice.content : [notice.content]) : undefined,
|
||||||
|
hasAttachment: notice.hasAttachment || notice.attachment || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
// 날짜 내림차순 정렬 (최신 날짜가 먼저)
|
||||||
|
const sortedNotices = [...transformedNotices].sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
setNotices(sortedNotices);
|
||||||
|
setTotalCount(total || sortedNotices.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('공지사항 리스트 조회 오류:', error);
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchNotices();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||||
|
const pagedNotices = useMemo(
|
||||||
|
() => notices.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE),
|
||||||
|
[notices, currentPage]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 페이지네이션: 10개씩 표시
|
||||||
|
const pageGroup = Math.floor((currentPage - 1) / 10);
|
||||||
|
const startPage = pageGroup * 10 + 1;
|
||||||
|
const endPage = Math.min(startPage + 9, totalPages);
|
||||||
|
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-white">
|
<div className="w-full bg-white">
|
||||||
@@ -49,70 +127,162 @@ export default function NoticesPage() {
|
|||||||
{/* 총 건수 */}
|
{/* 총 건수 */}
|
||||||
<div className="h-10 flex items-center">
|
<div className="h-10 flex items-center">
|
||||||
<p className="m-0 text-[15px] font-medium leading-[1.5] text-[#333C47]">
|
<p className="m-0 text-[15px] font-medium leading-[1.5] text-[#333C47]">
|
||||||
총 <span className="text-[#384FBF]">{rows.length}</span>건
|
총 <span className="text-[#384FBF]">{totalCount}</span>건
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 표 */}
|
{loading ? (
|
||||||
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
<div className="flex items-center justify-center h-[240px]">
|
||||||
{/* 헤더 */}
|
<p className="text-[16px] font-medium text-[#6C7682]">로딩 중...</p>
|
||||||
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
|
|
||||||
<div className="flex items-center justify-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
|
||||||
번호
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
|
||||||
제목
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
|
||||||
게시일
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
|
||||||
조회수
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center px-4">작성자</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
{/* 바디 */}
|
<>
|
||||||
<div>
|
{/* 표 */}
|
||||||
{rows.map((r) => (
|
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
||||||
<div
|
{/* 헤더 */}
|
||||||
key={r.id}
|
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
|
||||||
role="button"
|
<div className="flex items-center justify-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
tabIndex={0}
|
번호
|
||||||
onClick={() => router.push(`/notices/${r.id}`)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
router.push(`/notices/${r.id}`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={[
|
|
||||||
'grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">
|
|
||||||
{r.id}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
제목
|
||||||
title={r.title}
|
|
||||||
>
|
|
||||||
{r.title}
|
|
||||||
{r.hasAttachment && (
|
|
||||||
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
||||||
{r.date}
|
게시일
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
||||||
{r.views.toLocaleString()}
|
조회수
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center px-4">{r.writer}</div>
|
<div className="flex items-center px-4">작성자</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
{/* 바디 */}
|
||||||
</div>
|
<div>
|
||||||
|
{pagedNotices.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-[240px]">
|
||||||
|
<p className="text-[16px] font-medium text-[#6C7682]">공지사항이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
pagedNotices.map((notice, index) => {
|
||||||
|
// 번호는 전체 목록에서의 순서 (최신이 1번)
|
||||||
|
const noticeNumber = totalCount - ((currentPage - 1) * ITEMS_PER_PAGE + index);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={notice.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => router.push(`/notices/${notice.id}`)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
router.push(`/notices/${notice.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">
|
||||||
|
{noticeNumber}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
|
title={notice.title}
|
||||||
|
>
|
||||||
|
{notice.title}
|
||||||
|
{notice.hasAttachment && (
|
||||||
|
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
||||||
|
{notice.date}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
||||||
|
{notice.views.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4">{notice.writer}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
|
||||||
|
{totalCount > ITEMS_PER_PAGE && (
|
||||||
|
<div className="pt-8 pb-[32px] flex items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center gap-[8px]">
|
||||||
|
{/* First (맨 앞으로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
aria-label="맨 앞 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
aria-label="이전 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Numbers */}
|
||||||
|
{visiblePages.map((n) => {
|
||||||
|
const active = n === currentPage;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(n)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={[
|
||||||
|
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||||
|
active ? 'bg-bg-primary-light' : 'bg-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="text-[16px] leading-[1.4] text-neutral-700">{n}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
aria-label="다음 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Last (맨 뒤로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
aria-label="맨 뒤 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
506
src/app/page.tsx
@@ -2,92 +2,37 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
import MainLogoSvg from './svgs/mainlogosvg';
|
import MainLogoSvg from './svgs/mainlogosvg';
|
||||||
import ChevronDownSvg from './svgs/chevrondownsvg';
|
import apiService from './lib/apiService';
|
||||||
|
import type { Notice } from './admin/notices/mockData';
|
||||||
|
|
||||||
|
interface Subject {
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
imageKey?: string;
|
||||||
|
instructor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CourseCard {
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
meta: string;
|
||||||
|
image: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const router = useRouter();
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [isNameActive, setIsNameActive] = useState(false);
|
const [userName, setUserName] = useState<string>('');
|
||||||
|
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||||
// 코스, 공지사항 더미 데이터
|
const [courseCards, setCourseCards] = useState<CourseCard[]>([]);
|
||||||
const courseCards = useMemo(
|
const [loadingSubjects, setLoadingSubjects] = useState(true);
|
||||||
() =>
|
const [totalSubjectsCount, setTotalSubjectsCount] = useState(0);
|
||||||
[
|
const [notices, setNotices] = useState<Notice[]>([]);
|
||||||
{
|
const [loadingNotices, setLoadingNotices] = useState(true);
|
||||||
id: 'c1',
|
|
||||||
title: '원자력 운영 기초',
|
|
||||||
meta: 'VOD • 초급 • 4시간 20분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c1/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c2',
|
|
||||||
title: '반도체',
|
|
||||||
meta: 'VOD • 중급 • 3시간 10분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c2/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c3',
|
|
||||||
title: '방사선 안전',
|
|
||||||
meta: 'VOD • 중급 • 4시간 20분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c3/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c4',
|
|
||||||
title: '방사선 폐기물',
|
|
||||||
meta: 'VOD • 중급 • 4시간 20분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c4/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c5',
|
|
||||||
title: '원자력 운전 개론',
|
|
||||||
meta: 'VOD • 초급 • 3시간 00분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c5/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c6',
|
|
||||||
title: '안전 표지와 표준',
|
|
||||||
meta: 'VOD • 초급 • 2시간 40분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c6/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c7',
|
|
||||||
title: '발전소 운영',
|
|
||||||
meta: 'VOD • 중급 • 4시간 20분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c7/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c8',
|
|
||||||
title: '방사선 안전 실습',
|
|
||||||
meta: 'VOD • 중급 • 3시간 30분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c8/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c9',
|
|
||||||
title: '실험실 안전',
|
|
||||||
meta: 'VOD • 초급 • 2시간 10분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c9/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c10',
|
|
||||||
title: '기초 장비 운용',
|
|
||||||
meta: 'VOD • 초급 • 2시간 50분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c10/1200/800',
|
|
||||||
},
|
|
||||||
] as Array<{ id: string; title: string; meta: string; image: string }>,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const noticeRows = useMemo(
|
|
||||||
() =>
|
|
||||||
[
|
|
||||||
{ id: 5, title: '(공지)시스템 개선이 완료되었...', date: '2025-09-10', views: 1320, writer: '운영팀' },
|
|
||||||
{ id: 4, title: '(공지)서버 점검 안내(9/10 새벽)', date: '2025-09-10', views: 1210, writer: '운영팀' },
|
|
||||||
{ id: 3, title: '(공지)서비스 개선 안내', date: '2025-09-10', views: 1230, writer: '운영팀' },
|
|
||||||
{ id: 2, title: '(공지)시장점검 공지', date: '2025-09-10', views: 1320, writer: '관리자' },
|
|
||||||
{ id: 1, title: '뉴: 봉사시간 안내 및 한눈에 보는 현황 정리', date: '2025-08-28', views: 594, writer: '운영팀' },
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// NOTE: 실제 이미지 자산 연결 시 해당 src를 교체하세요.
|
// NOTE: 실제 이미지 자산 연결 시 해당 src를 교체하세요.
|
||||||
const slides = useMemo(
|
const slides = useMemo(
|
||||||
@@ -114,6 +59,227 @@ export default function Home() {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 사용자 정보 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function fetchUserInfo() {
|
||||||
|
try {
|
||||||
|
const localStorageToken = localStorage.getItem('token');
|
||||||
|
const cookieToken = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('token='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
const token = localStorageToken || cookieToken;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiService.getCurrentUser();
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
// 사용자 권한 확인
|
||||||
|
const userRole = data.role || data.userRole;
|
||||||
|
if (userRole === 'ADMIN' || userRole === 'admin') {
|
||||||
|
// admin 권한이면 /admin/id로 리다이렉트
|
||||||
|
router.push('/admin/id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.name) {
|
||||||
|
setUserName(data.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('사용자 정보 조회 오류:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchUserInfo();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 과목 리스트 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function fetchSubjects() {
|
||||||
|
try {
|
||||||
|
setLoadingSubjects(true);
|
||||||
|
const response = await apiService.getSubjects();
|
||||||
|
|
||||||
|
if (response.status !== 200 || !response.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 응답 데이터 구조 확인 및 배열 추출
|
||||||
|
let subjectsData: any[] = [];
|
||||||
|
let totalCount = 0;
|
||||||
|
|
||||||
|
if (Array.isArray(response.data)) {
|
||||||
|
subjectsData = response.data;
|
||||||
|
totalCount = response.data.length;
|
||||||
|
} else if (response.data && typeof response.data === 'object') {
|
||||||
|
// 다양한 응답 구조 처리
|
||||||
|
subjectsData = response.data.items ||
|
||||||
|
response.data.courses ||
|
||||||
|
response.data.data ||
|
||||||
|
response.data.list ||
|
||||||
|
response.data.subjects ||
|
||||||
|
response.data.subjectList ||
|
||||||
|
[];
|
||||||
|
|
||||||
|
// 전체 개수는 total, totalCount, count 등의 필드에서 가져오거나 배열 길이 사용
|
||||||
|
totalCount = response.data.total !== undefined ? response.data.total :
|
||||||
|
response.data.totalCount !== undefined ? response.data.totalCount :
|
||||||
|
response.data.count !== undefined ? response.data.count :
|
||||||
|
subjectsData.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('과목 리스트 응답:', response.data);
|
||||||
|
console.log('추출된 과목 데이터:', subjectsData);
|
||||||
|
console.log('전체 과목 개수:', totalCount);
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
// 전체 과목 개수 저장
|
||||||
|
setTotalSubjectsCount(totalCount);
|
||||||
|
|
||||||
|
// 상위 10개만 가져오기
|
||||||
|
const top10Subjects = subjectsData.slice(0, 10);
|
||||||
|
setSubjects(top10Subjects);
|
||||||
|
|
||||||
|
// 각 과목의 이미지 다운로드
|
||||||
|
const courseCardsWithImages = await Promise.all(
|
||||||
|
top10Subjects.map(async (subject: Subject) => {
|
||||||
|
let imageUrl = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800';
|
||||||
|
|
||||||
|
if (subject.imageKey) {
|
||||||
|
try {
|
||||||
|
const fileUrl = await apiService.getFile(subject.imageKey);
|
||||||
|
if (fileUrl) {
|
||||||
|
imageUrl = fileUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`이미지 다운로드 실패 (과목 ID: ${subject.id}):`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: subject.id,
|
||||||
|
title: subject.title || '',
|
||||||
|
meta: subject.instructor ? `강사: ${subject.instructor}` : 'VOD • 온라인',
|
||||||
|
image: imageUrl,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setCourseCards(courseCardsWithImages);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('과목 리스트 조회 오류:', error);
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoadingSubjects(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSubjects();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 공지사항 리스트 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function fetchNotices() {
|
||||||
|
try {
|
||||||
|
setLoadingNotices(true);
|
||||||
|
const response = await apiService.getNotices();
|
||||||
|
|
||||||
|
if (response.status !== 200 || !response.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||||
|
let noticesArray: any[] = [];
|
||||||
|
if (Array.isArray(response.data)) {
|
||||||
|
noticesArray = response.data;
|
||||||
|
} else if (response.data && typeof response.data === 'object') {
|
||||||
|
noticesArray = response.data.items || response.data.notices || response.data.data || response.data.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜를 yyyy-mm-dd 형식으로 포맷팅
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
// 이미 yyyy-mm-dd 형식인 경우 그대로 반환
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API 응답 데이터를 Notice 형식으로 변환
|
||||||
|
const transformedNotices: Notice[] = noticesArray.map((notice: any) => ({
|
||||||
|
id: notice.id || notice.noticeId || 0,
|
||||||
|
title: notice.title || '',
|
||||||
|
date: formatDate(notice.date || notice.createdAt || notice.createdDate || new Date().toISOString().split('T')[0]),
|
||||||
|
views: notice.views || notice.viewCount || 0,
|
||||||
|
writer: notice.writer || notice.author || notice.createdBy || '관리자',
|
||||||
|
content: notice.content ? (Array.isArray(notice.content) ? notice.content : [notice.content]) : undefined,
|
||||||
|
hasAttachment: notice.hasAttachment || notice.attachment || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
// 날짜 내림차순 정렬 (최신 날짜가 먼저)
|
||||||
|
const sortedNotices = [...transformedNotices].sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
setNotices(sortedNotices);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('공지사항 리스트 조회 오류:', error);
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoadingNotices(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchNotices();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const containerEl = containerRef.current;
|
const containerEl = containerRef.current;
|
||||||
if (!containerEl) return;
|
if (!containerEl) return;
|
||||||
@@ -141,10 +307,6 @@ export default function Home() {
|
|||||||
const handlePrev = () => scrollToIndex(currentIndex - 1);
|
const handlePrev = () => scrollToIndex(currentIndex - 1);
|
||||||
const handleNext = () => scrollToIndex(currentIndex + 1);
|
const handleNext = () => scrollToIndex(currentIndex + 1);
|
||||||
|
|
||||||
const handleNameClick = () => {
|
|
||||||
setIsNameActive((prev) => !prev);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full min-h-screen flex flex-col bg-white">
|
<div className="w-full min-h-screen flex flex-col bg-white">
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
@@ -223,19 +385,9 @@ export default function Home() {
|
|||||||
<div className="px-8 py-8">
|
<div className="px-8 py-8">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<span className="text-[18px] font-bold leading-normal text-[#333C47]">
|
||||||
type="button"
|
{userName ? `${userName}님` : '사용자님'}
|
||||||
onClick={handleNameClick}
|
</span>
|
||||||
aria-expanded={isNameActive}
|
|
||||||
className="m-0 p-0 bg-transparent border-0 text-[18px] font-bold leading-normal text-[#333C47] cursor-pointer inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
김하늘님
|
|
||||||
<ChevronDownSvg
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className={'transition-transform duration-200 ' + (isNameActive ? 'rotate-180' : 'rotate-0')}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<span className="text-[18px] font-bold leading-normal text-[#333C47]">환영합니다.</span>
|
<span className="text-[18px] font-bold leading-normal text-[#333C47]">환영합니다.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,11 +434,11 @@ export default function Home() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">교육 과정</h2>
|
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">교육 과정</h2>
|
||||||
<div className="text-[15px] font-medium leading-normal text-[#333C47]">
|
<div className="text-[15px] font-medium leading-normal text-[#333C47]">
|
||||||
총 <span className="text-[#384FBF]">28</span>건
|
총 <span className="text-[#384FBF]">{totalSubjectsCount}</span>건
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<Link
|
||||||
href="#"
|
href="/course-list"
|
||||||
className="flex items-center gap-0.5 text-[14px] font-medium leading-normal text-[#6C7682] no-underline"
|
className="flex items-center gap-0.5 text-[14px] font-medium leading-normal text-[#6C7682] no-underline"
|
||||||
>
|
>
|
||||||
전체보기
|
전체보기
|
||||||
@@ -300,55 +452,60 @@ export default function Home() {
|
|||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-5 gap-8">
|
{loadingSubjects ? (
|
||||||
{courseCards.map((c) => (
|
<div className="flex items-center justify-center h-[260px]">
|
||||||
<article key={c.id} className="flex flex-col gap-4 h-[260px]">
|
<p className="text-[16px] font-medium text-[#6C7682]">로딩 중...</p>
|
||||||
<div className="h-[166.4px] overflow-hidden rounded-[8px]">
|
</div>
|
||||||
<img
|
) : (
|
||||||
alt={c.title}
|
<div className="grid grid-cols-5 gap-8">
|
||||||
src={c.image}
|
{courseCards.map((c) => (
|
||||||
className="w-full h-full object-cover block"
|
<article
|
||||||
onError={(e) => {
|
key={c.id}
|
||||||
const t = e.currentTarget as HTMLImageElement;
|
onClick={() => router.push(`/course-list/${c.id}`)}
|
||||||
if (!t.src.includes('picsum.photos')) t.src = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800';
|
className="flex flex-col gap-4 h-[260px] cursor-pointer"
|
||||||
}}
|
>
|
||||||
/>
|
<div className="h-[166.4px] overflow-hidden rounded-[8px] flex items-center justify-center bg-[#F1F3F5] hover:shadow-lg transition-shadow">
|
||||||
</div>
|
<img
|
||||||
<div className="flex flex-col gap-1">
|
alt={c.title}
|
||||||
{c.id === 'c1' && (
|
src={c.image}
|
||||||
<span className="inline-flex h-[20px] items-center justify-center px-1 bg-[#E5F5EC] rounded-[4px] text-[13px] font-semibold leading-[1.4] text-[#0C9D61]">
|
className="h-full w-auto object-contain block"
|
||||||
수강 중
|
onError={(e) => {
|
||||||
</span>
|
const t = e.currentTarget as HTMLImageElement;
|
||||||
)}
|
if (!t.src.includes('picsum.photos')) t.src = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800';
|
||||||
<h5 className="m-0 text-[#333C47] font-semibold text-[18px] leading-normal truncate" title={c.title}>
|
}}
|
||||||
{c.title}
|
/>
|
||||||
</h5>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden className="text-[#8C95A1]">
|
|
||||||
<path d="M8 5v14l11-7z" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
<p className="m-0 text-[#8C95A1] text-[13px] font-medium leading-[1.4]">{c.meta}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex flex-col gap-1">
|
||||||
</article>
|
<h5 className="m-0 text-[#333C47] font-semibold text-[18px] leading-normal truncate" title={c.title}>
|
||||||
))}
|
{c.title}
|
||||||
</div>
|
</h5>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden className="text-[#8C95A1]">
|
||||||
|
<path d="M8 5v14l11-7z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
<p className="m-0 text-[#8C95A1] text-[13px] font-medium leading-[1.4]">{c.meta}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 공지사항 */}
|
{/* 공지사항 */}
|
||||||
<section className="mt-9">
|
<section className="mt-9 pb-20">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">공지사항</h2>
|
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">공지사항</h2>
|
||||||
<div className="text-[15px] font-medium leading-normal text-[#333C47]">
|
<div className="text-[15px] font-medium leading-normal text-[#333C47]">
|
||||||
총 <span className="text-[#384FBF]">{noticeRows.length}</span>건
|
총 <span className="text-[#384FBF]">{notices.length}</span>건
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<Link
|
||||||
href="#"
|
href="/notices"
|
||||||
className="flex items-center gap-0.5 text-[14px] font-medium leading-normal text-[#6C7682] no-underline"
|
className="flex items-center gap-0.5 text-[14px] font-medium leading-normal text-[#6C7682] no-underline"
|
||||||
>
|
>
|
||||||
전체보기
|
전체보기
|
||||||
@@ -362,38 +519,57 @@ export default function Home() {
|
|||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
{loadingNotices ? (
|
||||||
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
|
<div className="flex items-center justify-center h-[240px]">
|
||||||
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">번호</div>
|
<p className="text-[16px] font-medium text-[#6C7682]">로딩 중...</p>
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">제목</div>
|
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">게시일</div>
|
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">조회수</div>
|
|
||||||
<div className="flex items-center px-4">작성자</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
||||||
|
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
|
||||||
|
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">번호</div>
|
||||||
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">제목</div>
|
||||||
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">게시일</div>
|
||||||
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">조회수</div>
|
||||||
|
<div className="flex items-center px-4">작성자</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{noticeRows.map((r) => (
|
{notices.map((notice, index) => {
|
||||||
<div
|
// 번호는 정렬된 목록에서의 순서 (최신이 1번)
|
||||||
key={r.id}
|
const noticeNumber = notices.length - index;
|
||||||
className="grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6]"
|
return (
|
||||||
>
|
<div
|
||||||
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">{r.id}</div>
|
key={notice.id}
|
||||||
<div
|
role="button"
|
||||||
className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
tabIndex={0}
|
||||||
title={r.title}
|
onClick={() => router.push(`/notices/${notice.id}`)}
|
||||||
>
|
onKeyDown={(e) => {
|
||||||
{r.title}
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
</div>
|
e.preventDefault();
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{r.date}</div>
|
router.push(`/notices/${notice.id}`);
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{r.views.toLocaleString()}</div>
|
}
|
||||||
<div className="flex items-center px-4">{r.writer}</div>
|
}}
|
||||||
</div>
|
className="grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer"
|
||||||
))}
|
>
|
||||||
|
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">{noticeNumber}</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
|
title={notice.title}
|
||||||
|
>
|
||||||
|
{notice.title}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{notice.date}</div>
|
||||||
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{notice.views.toLocaleString()}</div>
|
||||||
|
<div className="flex items-center px-4">{notice.writer}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
66
src/app/pages/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type RouteItem = { label: string; href: string };
|
||||||
|
|
||||||
|
const STATIC_ROUTES: RouteItem[] = [
|
||||||
|
{ label: "홈", href: "/" },
|
||||||
|
{ label: "교육 과정 목록", href: "/course-list" },
|
||||||
|
{ label: "학습 자료실 목록", href: "/resources" },
|
||||||
|
{ label: "공지사항 목록", href: "/notices" },
|
||||||
|
{ label: "내 강좌실 - 강좌 목록", href: "/menu/courses" },
|
||||||
|
{ label: "내 강좌실 - 학습 결과", href: "/menu/results" },
|
||||||
|
{ label: "내 정보 수정", href: "/menu/account" },
|
||||||
|
{ label: "로그인", href: "/login" },
|
||||||
|
{ label: "회원가입", href: "/register" },
|
||||||
|
{ label: "아이디 찾기", href: "/find-id" },
|
||||||
|
{ label: "비밀번호 재설정", href: "/reset-password" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 예시가 필요한 동적 라우트
|
||||||
|
const DYNAMIC_EXAMPLES: RouteItem[] = [
|
||||||
|
{ label: "공지사항 상세(예시)", href: "/notices/1" },
|
||||||
|
{ label: "자료실 상세(예시)", href: "/resources/1" },
|
||||||
|
{ label: "강좌 상세(예시)", href: "/menu/courses/abc123" },
|
||||||
|
// { label: "레슨 시작(예시)", href: "/menu/courses/lessons/c1l1/start" },
|
||||||
|
// { label: "레슨 이어서(예시)", href: "/menu/courses/lessons/c1l1/continue" },
|
||||||
|
{ label: "영상", href: "/c1l1/review" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AllPages() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-[960px] p-8">
|
||||||
|
|
||||||
|
<section className="mb-10">
|
||||||
|
<h2 className="mb-3 text-[18px] font-semibold leading-[1.5] text-[#333c47]">
|
||||||
|
기본/정적 페이지
|
||||||
|
</h2>
|
||||||
|
<ul className="list-disc pl-6">
|
||||||
|
{STATIC_ROUTES.map((r) => (
|
||||||
|
<li key={r.href} className="mb-1">
|
||||||
|
<Link href={r.href} className="text-[#1f2b91] underline">
|
||||||
|
{r.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-[18px] font-semibold leading-[1.5] text-[#333c47]">
|
||||||
|
동적 페이지 (예시 링크)
|
||||||
|
</h2>
|
||||||
|
<ul className="list-disc pl-6">
|
||||||
|
{DYNAMIC_EXAMPLES.map((r) => (
|
||||||
|
<li key={r.href} className="mb-1">
|
||||||
|
<Link href={r.href} className="text-[#1f2b91] underline">
|
||||||
|
{r.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import LoginCheckboxActiveSvg from "@/app/svgs/logincheckboxactivesvg";
|
import LoginCheckboxActiveSvg from "@/app/svgs/logincheckboxactivesvg";
|
||||||
import LoginCheckboxInactiveSvg from "@/app/svgs/logincheckboxinactivesvg";
|
import LoginCheckboxInactiveSvg from "@/app/svgs/logincheckboxinactivesvg";
|
||||||
import LoginInputSvg from "@/app/svgs/inputformx";
|
import LoginInputSvg from "@/app/svgs/inputformx";
|
||||||
|
import CalendarSvg from "@/app/svgs/callendar";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
type Gender = "male" | "female" | "";
|
type Gender = "MALE" | "FEMALE" | "";
|
||||||
|
|
||||||
type RegisterFormProps = {
|
type RegisterFormProps = {
|
||||||
onOpenDone: () => void;
|
onOpenDone: () => void;
|
||||||
@@ -21,6 +23,7 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||||
const [gender, setGender] = useState<Gender>("");
|
const [gender, setGender] = useState<Gender>("");
|
||||||
const [birthdate, setBirthdate] = useState("");
|
const [birthdate, setBirthdate] = useState("");
|
||||||
|
const [birthdateInput, setBirthdateInput] = useState("");
|
||||||
|
|
||||||
// 이메일 인증 관련
|
// 이메일 인증 관련
|
||||||
const [emailCodeSent, setEmailCodeSent] = useState(false);
|
const [emailCodeSent, setEmailCodeSent] = useState(false);
|
||||||
@@ -41,9 +44,57 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
const [focused, setFocused] = useState<Record<string, boolean>>({});
|
const [focused, setFocused] = useState<Record<string, boolean>>({});
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 휴대폰 번호 포맷팅 함수
|
||||||
|
function formatPhoneNumber(phoneNumber: string): string {
|
||||||
|
const numbers = phoneNumber.replace(/[^0-9]/g, "");
|
||||||
|
if (numbers.length <= 3) return numbers;
|
||||||
|
if (numbers.length <= 7) return `${numbers.slice(0, 3)}-${numbers.slice(3)}`;
|
||||||
|
if (numbers.length <= 11) return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7)}`;
|
||||||
|
return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 생년월일 YYYYMMDD를 YYYY-MM-DD로 변환하는 함수
|
||||||
|
function parseBirthdate(input: string): string {
|
||||||
|
// 숫자만 추출
|
||||||
|
const numbers = input.replace(/[^0-9]/g, "");
|
||||||
|
|
||||||
|
// YYYYMMDD 형식인지 확인 (8자리)
|
||||||
|
if (numbers.length === 8) {
|
||||||
|
const year = numbers.slice(0, 4);
|
||||||
|
const month = numbers.slice(4, 6);
|
||||||
|
const day = numbers.slice(6, 8);
|
||||||
|
|
||||||
|
// 유효한 날짜인지 검증
|
||||||
|
const yearNum = parseInt(year, 10);
|
||||||
|
const monthNum = parseInt(month, 10);
|
||||||
|
const dayNum = parseInt(day, 10);
|
||||||
|
|
||||||
|
if (yearNum >= 1900 && yearNum <= 2100 && monthNum >= 1 && monthNum <= 12 && dayNum >= 1 && dayNum <= 31) {
|
||||||
|
// 날짜 유효성 검사
|
||||||
|
const date = new Date(yearNum, monthNum - 1, dayNum);
|
||||||
|
if (date.getFullYear() === yearNum && date.getMonth() === monthNum - 1 && date.getDate() === dayNum) {
|
||||||
|
return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// YYYY-MM-DD 형식이면 그대로 반환
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(input)) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
const isEmailValid = useMemo(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email), [email]);
|
const isEmailValid = useMemo(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email), [email]);
|
||||||
const isPhoneValid = useMemo(() => /^\d{9,11}$/.test(phone), [phone]);
|
const isPhoneValid = useMemo(() => /^\d{9,11}$/.test(phone), [phone]);
|
||||||
const isPasswordValid = useMemo(() => password.length >= 8 && password.length <= 16, [password]);
|
const isPasswordValid = useMemo(() => {
|
||||||
|
if (password.length < 8 || password.length > 16) return false;
|
||||||
|
const hasEnglish = /[a-zA-Z]/.test(password);
|
||||||
|
const hasNumber = /[0-9]/.test(password);
|
||||||
|
const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
|
||||||
|
return hasEnglish && hasNumber && hasSpecialChar;
|
||||||
|
}, [password]);
|
||||||
const isPasswordConfirmValid = useMemo(() => passwordConfirm === password && passwordConfirm.length > 0, [password, passwordConfirm]);
|
const isPasswordConfirmValid = useMemo(() => passwordConfirm === password && passwordConfirm.length > 0, [password, passwordConfirm]);
|
||||||
|
|
||||||
const canSubmit = useMemo(() => {
|
const canSubmit = useMemo(() => {
|
||||||
@@ -64,7 +115,18 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
if (name.trim().length === 0) nextErrors.name = "이름을 입력해 주세요.";
|
if (name.trim().length === 0) nextErrors.name = "이름을 입력해 주세요.";
|
||||||
if (!isPhoneValid) nextErrors.phone = "휴대폰 번호를 숫자만 9~11자리로 입력해 주세요.";
|
if (!isPhoneValid) nextErrors.phone = "휴대폰 번호를 숫자만 9~11자리로 입력해 주세요.";
|
||||||
if (!isEmailValid) nextErrors.email = "올바른 이메일 형식을 입력해 주세요.";
|
if (!isEmailValid) nextErrors.email = "올바른 이메일 형식을 입력해 주세요.";
|
||||||
if (!isPasswordValid) nextErrors.password = "비밀번호는 8~16자여야 합니다.";
|
if (!isPasswordValid) {
|
||||||
|
if (password.length < 8 || password.length > 16) {
|
||||||
|
nextErrors.password = "비밀번호는 8~16자여야 합니다.";
|
||||||
|
} else {
|
||||||
|
const hasEnglish = /[a-zA-Z]/.test(password);
|
||||||
|
const hasNumber = /[0-9]/.test(password);
|
||||||
|
const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
|
||||||
|
if (!hasEnglish || !hasNumber || !hasSpecialChar) {
|
||||||
|
nextErrors.password = "8~16자의 영문/숫자/특수문자를 조합해서 입력해 주세요.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!isPasswordConfirmValid) nextErrors.passwordConfirm = "비밀번호가 일치하지 않습니다.";
|
if (!isPasswordConfirmValid) nextErrors.passwordConfirm = "비밀번호가 일치하지 않습니다.";
|
||||||
if (gender === "") nextErrors.gender = "성별을 선택해 주세요.";
|
if (gender === "") nextErrors.gender = "성별을 선택해 주세요.";
|
||||||
if (birthdate.trim().length === 0) nextErrors.birthdate = "생년월일을 선택해 주세요.";
|
if (birthdate.trim().length === 0) nextErrors.birthdate = "생년월일을 선택해 주세요.";
|
||||||
@@ -73,10 +135,95 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
return Object.keys(nextErrors).length === 0;
|
return Object.keys(nextErrors).length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
// 입력 필드가 유효해지면 해당 필드의 에러를 자동으로 지움
|
||||||
|
useEffect(() => {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (name.trim().length > 0 && prev.name) delete next.name;
|
||||||
|
if (isPhoneValid && prev.phone) delete next.phone;
|
||||||
|
if (isEmailValid && prev.email) delete next.email;
|
||||||
|
if (isPasswordValid && prev.password) delete next.password;
|
||||||
|
if (isPasswordConfirmValid && prev.passwordConfirm) delete next.passwordConfirm;
|
||||||
|
if (gender !== "" && prev.gender) delete next.gender;
|
||||||
|
if (birthdate.trim().length > 0 && prev.birthdate) delete next.birthdate;
|
||||||
|
if (allAgree && prev.agreements) delete next.agreements;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [name, isPhoneValid, isEmailValid, isPasswordValid, isPasswordConfirmValid, gender, birthdate, allAgree]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
console.log("handleSubmit");
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!validateAll()) return;
|
if (!validateAll()) return;
|
||||||
onOpenDone();
|
await RegisterUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyEmailCode() {
|
||||||
|
try {
|
||||||
|
await apiService.verifyEmailCode(email, emailCode);
|
||||||
|
// 인증 성공 시 상태 업데이트
|
||||||
|
setEmailCodeVerified(true);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("이메일 인증번호 검증 오류:", error);
|
||||||
|
onOpenCodeError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function sendEmailCode() {
|
||||||
|
|
||||||
|
if (!isEmailValid) return;
|
||||||
|
try {
|
||||||
|
await apiService.sendEmailVerification(email);
|
||||||
|
// 성공 시에만 상태 업데이트
|
||||||
|
setEmailCodeSent(true);
|
||||||
|
setEmailCode("");
|
||||||
|
setEmailCodeVerified(false);
|
||||||
|
// 성공 시 이메일 에러 제거
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (next.email) delete next.email;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("이메일 인증번호 전송 오류:", error);
|
||||||
|
if (error instanceof Error && error.message.includes("409")) {
|
||||||
|
setErrors((prev) => ({ ...prev, email: "이메일이 중복되었습니다." }));
|
||||||
|
} else {
|
||||||
|
alert("인증번호 전송실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function RegisterUser() {
|
||||||
|
if (!emailCodeVerified) {
|
||||||
|
onOpenCodeError("이메일 인증을 완료해주세요.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (gender === "") {
|
||||||
|
onOpenCodeError("성별을 선택해주세요.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiService.register({
|
||||||
|
email,
|
||||||
|
emailCode,
|
||||||
|
password,
|
||||||
|
passwordConfirm,
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
gender: gender as "MALE" | "FEMALE",
|
||||||
|
birthDate: birthdate
|
||||||
|
});
|
||||||
|
onOpenDone();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "네트워크 오류가 발생했습니다.";
|
||||||
|
console.error("회원가입 오류:", errorMessage);
|
||||||
|
onOpenCodeError(errorMessage);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -87,7 +234,6 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
회원가입
|
회원가입
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* 이름 */}
|
{/* 이름 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -101,7 +247,7 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
onFocus={() => setFocused((p) => ({ ...p, name: true }))}
|
onFocus={() => setFocused((p) => ({ ...p, name: true }))}
|
||||||
onBlur={() => setFocused((p) => ({ ...p, name: false }))}
|
onBlur={() => setFocused((p) => ({ ...p, name: false }))}
|
||||||
placeholder="이름을 입력해 주세요."
|
placeholder="이름을 입력해 주세요."
|
||||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
className={`h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border focus:outline-none text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px] ${errors.name ? 'border-error' : 'border-neutral-40 focus:border-neutral-700'}`}
|
||||||
/>
|
/>
|
||||||
{name.trim().length > 0 && focused.name && (
|
{name.trim().length > 0 && focused.name && (
|
||||||
<button
|
<button
|
||||||
@@ -114,7 +260,7 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{errors.name && <p className="text-error text-[13px] leading-tight">{errors.name}</p>}
|
{errors.name && <p className="text-error text-[13px] leading-tight mt-[10px]">{errors.name}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 휴대폰 번호 */}
|
{/* 휴대폰 번호 */}
|
||||||
@@ -126,12 +272,12 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
name="phone"
|
name="phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={phone}
|
value={formatPhoneNumber(phone)}
|
||||||
|
placeholder="-없이 입력해 주세요."
|
||||||
onChange={(e) => setPhone(e.target.value.replace(/[^0-9]/g, ""))}
|
onChange={(e) => setPhone(e.target.value.replace(/[^0-9]/g, ""))}
|
||||||
onFocus={() => setFocused((p) => ({ ...p, phone: true }))}
|
onFocus={() => setFocused((p) => ({ ...p, phone: true }))}
|
||||||
onBlur={() => setFocused((p) => ({ ...p, phone: false }))}
|
onBlur={() => setFocused((p) => ({ ...p, phone: false }))}
|
||||||
placeholder="-없이 입력해 주세요."
|
className={`h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border focus:outline-none text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px] ${errors.phone ? 'border-error' : 'border-neutral-40 focus:border-neutral-700'}`}
|
||||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
|
||||||
/>
|
/>
|
||||||
{phone.trim().length > 0 && focused.phone && (
|
{phone.trim().length > 0 && focused.phone && (
|
||||||
<button
|
<button
|
||||||
@@ -144,7 +290,7 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{errors.phone && <p className="text-error text-[13px] leading-tight">{errors.phone}</p>}
|
{errors.phone && <p className="text-error text-[13px] leading-tight mt-[10px]">{errors.phone}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 아이디(이메일) + 인증번호 전송 */}
|
{/* 아이디(이메일) + 인증번호 전송 */}
|
||||||
@@ -157,13 +303,24 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setEmail(e.target.value);
|
||||||
|
// 이메일 변경 시 중복 오류 제거
|
||||||
|
if (errors.email === "이메일이 중복되었습니다.") {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.email;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
onFocus={() => setFocused((p) => ({ ...p, email: true }))}
|
onFocus={() => setFocused((p) => ({ ...p, email: true }))}
|
||||||
onBlur={() => setFocused((p) => ({ ...p, email: false }))}
|
onBlur={() => setFocused((p) => ({ ...p, email: false }))}
|
||||||
placeholder="이메일을 입력해 주세요."
|
placeholder="이메일을 입력해 주세요."
|
||||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
disabled={emailCodeVerified}
|
||||||
|
className={`h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border focus:outline-none text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px] disabled:bg-gray-100 disabled:cursor-not-allowed ${emailCodeVerified ? '' : (errors.email ? 'border-error' : 'border-neutral-40 focus:border-neutral-700')}`}
|
||||||
/>
|
/>
|
||||||
{email.trim().length > 0 && focused.email && (
|
{email.trim().length > 0 && focused.email && !emailCodeVerified && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={(e) => { e.preventDefault(); setEmail(""); }}
|
onMouseDown={(e) => { e.preventDefault(); setEmail(""); }}
|
||||||
@@ -176,20 +333,14 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!isEmailValid}
|
disabled={!isEmailValid || emailCodeVerified}
|
||||||
className={`h-[40px] px-[12px] rounded-[8px] text-[16px] font-semibold ${isEmailValid ? "bg-inactive-button text-white" : "bg-gray-50 text-input-placeholder-text"}`}
|
className={`h-[40px] w-[130px] px-[12px] rounded-[8px] text-[16px] font-semibold ${isEmailValid && !emailCodeVerified ? "bg-[#F1F3F5] text-neutral-700" : "bg-gray-50 text-input-placeholder-text"}`}
|
||||||
onClick={() => {
|
onClick={sendEmailCode}
|
||||||
if (!isEmailValid) return;
|
|
||||||
alert("인증번호 전송 (가상 동작)");
|
|
||||||
setEmailCodeSent(true);
|
|
||||||
setEmailCode("");
|
|
||||||
setEmailCodeVerified(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
인증번호 전송
|
{emailCodeSent && !emailCodeVerified ? "인증번호 재전송" : "인증번호 전송"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{errors.email && <p className="text-error text-[13px] leading-tight">{errors.email}</p>}
|
{errors.email && <p className="text-error text-[13px] leading-tight mt-[10px]">{errors.email}</p>}
|
||||||
{emailCodeSent && (
|
{emailCodeSent && (
|
||||||
<div className="space-y-2" aria-expanded={emailCodeSent}>
|
<div className="space-y-2" aria-expanded={emailCodeSent}>
|
||||||
<label htmlFor="emailCode" className="sr-only">인증번호</label>
|
<label htmlFor="emailCode" className="sr-only">인증번호</label>
|
||||||
@@ -207,7 +358,8 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
onFocus={() => setFocused((p) => ({ ...p, emailCode: true }))}
|
onFocus={() => setFocused((p) => ({ ...p, emailCode: true }))}
|
||||||
onBlur={() => setFocused((p) => ({ ...p, emailCode: false }))}
|
onBlur={() => setFocused((p) => ({ ...p, emailCode: false }))}
|
||||||
placeholder="인증번호 6자리"
|
placeholder="인증번호 6자리"
|
||||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
disabled={emailCodeVerified}
|
||||||
|
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px] disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
{emailCode.trim().length > 0 && focused.emailCode && !emailCodeVerified && (
|
{emailCode.trim().length > 0 && focused.emailCode && !emailCodeVerified && (
|
||||||
<button
|
<button
|
||||||
@@ -223,19 +375,23 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={emailCodeVerified}
|
disabled={emailCodeVerified}
|
||||||
className={`h-[40px] px-[12px] rounded-[8px] text-[16px] font-semibold ${!emailCodeVerified ? "bg-active-button text-white" : "bg-gray-50 text-input-placeholder-text"}`}
|
className={`h-[40px] w-[130px] px-[12px] rounded-[8px] text-[16px] font-semibold ${!emailCodeVerified ? "bg-[#F1F3F5] text-neutral-700" : "bg-gray-50 text-input-placeholder-text"}`}
|
||||||
onClick={() => {
|
onClick={verifyEmailCode}
|
||||||
// 가상 검증: 6자리면 성공, 아니면 에러 모달
|
|
||||||
if (emailCode.length !== 6) {
|
|
||||||
onOpenCodeError();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setEmailCodeVerified(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{emailCodeVerified ? "인증완료" : "인증하기"}
|
{emailCodeVerified ? "인증완료" : "인증하기"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[13px] leading-[normal] text-[#384fbf]">
|
||||||
|
{emailCodeVerified ? (
|
||||||
|
"인증이 완료됐습니다"
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
인증 확인을 위해 작성한 이메일로 인증번호를 발송했습니다.
|
||||||
|
<br />
|
||||||
|
이메일을 확인해 주세요.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +409,7 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
onFocus={() => setFocused((p) => ({ ...p, password: true }))}
|
onFocus={() => setFocused((p) => ({ ...p, password: true }))}
|
||||||
onBlur={() => setFocused((p) => ({ ...p, password: false }))}
|
onBlur={() => setFocused((p) => ({ ...p, password: false }))}
|
||||||
placeholder="8~16자의 영문/숫자/특수문자를 조합해서 입력해 주세요."
|
placeholder="8~16자의 영문/숫자/특수문자를 조합해서 입력해 주세요."
|
||||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
className={`h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border focus:outline-none text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px] ${errors.password ? 'border-error' : 'border-neutral-40 focus:border-neutral-700'}`}
|
||||||
/>
|
/>
|
||||||
{password.trim().length > 0 && focused.password && (
|
{password.trim().length > 0 && focused.password && (
|
||||||
<button
|
<button
|
||||||
@@ -266,7 +422,7 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{errors.password && <p className="text-error text-[13px] leading-tight">{errors.password}</p>}
|
{errors.password && <p className="text-error text-[13px] leading-tight mt-[10px]">{errors.password}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 비밀번호 확인 */}
|
{/* 비밀번호 확인 */}
|
||||||
@@ -282,7 +438,7 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
onFocus={() => setFocused((p) => ({ ...p, passwordConfirm: true }))}
|
onFocus={() => setFocused((p) => ({ ...p, passwordConfirm: true }))}
|
||||||
onBlur={() => setFocused((p) => ({ ...p, passwordConfirm: false }))}
|
onBlur={() => setFocused((p) => ({ ...p, passwordConfirm: false }))}
|
||||||
placeholder="비밀번호를 다시 입력해 주세요."
|
placeholder="비밀번호를 다시 입력해 주세요."
|
||||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
className={`h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border focus:outline-none text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px] ${errors.passwordConfirm ? 'border-error' : 'border-neutral-40 focus:border-neutral-700'}`}
|
||||||
/>
|
/>
|
||||||
{passwordConfirm.trim().length > 0 && focused.passwordConfirm && (
|
{passwordConfirm.trim().length > 0 && focused.passwordConfirm && (
|
||||||
<button
|
<button
|
||||||
@@ -295,7 +451,7 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{errors.passwordConfirm && <p className="text-error text-[13px] leading-tight">{errors.passwordConfirm}</p>}
|
{errors.passwordConfirm && <p className="text-error text-[13px] leading-tight mt-[10px]">{errors.passwordConfirm}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 성별 */}
|
{/* 성별 */}
|
||||||
@@ -306,13 +462,13 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="gender"
|
name="gender"
|
||||||
value="male"
|
value="MALE"
|
||||||
checked={gender === "male"}
|
checked={gender === "MALE"}
|
||||||
onChange={() => setGender("male")}
|
onChange={() => setGender("MALE")}
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
/>
|
/>
|
||||||
<span className={`inline-block rounded-full size-[18px] border ${gender === "male" ? "border-active-button" : "border-[#8c95a1]"}`}>
|
<span className={`relative inline-flex items-center justify-center rounded-full w-[18px] h-[18px] shrink-0 border box-border ${gender === "MALE" ? "border-active-button" : "border-[#8c95a1]"}`}>
|
||||||
{gender === "male" && <span className="block size-[9px] rounded-full bg-active-button m-[4.5px]" />}
|
{gender === "MALE" && <span className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 block size-[9px] rounded-full bg-active-button" />}
|
||||||
</span>
|
</span>
|
||||||
남성
|
남성
|
||||||
</label>
|
</label>
|
||||||
@@ -320,32 +476,74 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="gender"
|
name="gender"
|
||||||
value="female"
|
value="FEMALE"
|
||||||
checked={gender === "female"}
|
checked={gender === "FEMALE"}
|
||||||
onChange={() => setGender("female")}
|
onChange={() => setGender("FEMALE")}
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
/>
|
/>
|
||||||
<span className={`inline-block rounded-full size-[18px] border ${gender === "female" ? "border-active-button" : "border-[#8c95a1]"}`}>
|
<span className={`relative inline-flex items-center justify-center rounded-full w-[18px] h-[18px] shrink-0 border box-border ${gender === "FEMALE" ? "border-active-button" : "border-[#8c95a1]"}`}>
|
||||||
{gender === "female" && <span className="block size-[9px] rounded-full bg-active-button m-[4.5px]" />}
|
{gender === "FEMALE" && <span className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 block size-[9px] rounded-full bg-active-button" />}
|
||||||
</span>
|
</span>
|
||||||
여성
|
여성
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{errors.gender && <p className="text-error text-[13px] leading-tight">{errors.gender}</p>}
|
{errors.gender && <p className="text-error text-[13px] leading-tight mt-[10px]">{errors.gender}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 생년월일 */}
|
{/* 생년월일 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="birthdate" className="text-[15px] font-semibold text-[#6c7682]">생년월일</label>
|
<label htmlFor="birthdate" className="text-[15px] font-semibold text-[#6c7682]">생년월일</label>
|
||||||
<input
|
<div className="relative">
|
||||||
id="birthdate"
|
<input
|
||||||
name="birthdate"
|
id="birthdate"
|
||||||
type="date"
|
name="birthdate"
|
||||||
value={birthdate}
|
type="text"
|
||||||
onChange={(e) => setBirthdate(e.target.value)}
|
value={birthdateInput || (birthdate ? birthdate.replace(/-/g, ".") : "")}
|
||||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[16px] text-neutral-700"
|
onChange={(e) => {
|
||||||
/>
|
const inputValue = e.target.value;
|
||||||
{errors.birthdate && <p className="text-error text-[13px] leading-tight">{errors.birthdate}</p>}
|
setBirthdateInput(inputValue);
|
||||||
|
if (inputValue === "") {
|
||||||
|
setBirthdate("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
// 포커스를 잃을 때 YYYYMMDD 형식이면 변환
|
||||||
|
const formatted = parseBirthdate(e.target.value);
|
||||||
|
if (formatted) {
|
||||||
|
setBirthdate(formatted);
|
||||||
|
setBirthdateInput("");
|
||||||
|
} else if (e.target.value === "") {
|
||||||
|
setBirthdate("");
|
||||||
|
setBirthdateInput("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="생년월일"
|
||||||
|
className={`h-[40px] px-[12px] w-full rounded-[8px] border focus:outline-none text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px] flex items-center ${errors.birthdate ? 'border-error' : 'border-neutral-40 focus:border-neutral-700'}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const dateInput = document.getElementById("birthdate-date-picker") as HTMLInputElement;
|
||||||
|
dateInput?.showPicker?.();
|
||||||
|
}}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||||
|
aria-label="날짜 선택"
|
||||||
|
>
|
||||||
|
<CalendarSvg width="20" height="20" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
id="birthdate-date-picker"
|
||||||
|
type="date"
|
||||||
|
value={birthdate}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBirthdate(e.target.value);
|
||||||
|
setBirthdateInput("");
|
||||||
|
}}
|
||||||
|
className="absolute right-50 top-1 h-full w-[20px] opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.birthdate && <p className="text-error text-[13px] leading-tight mt-[10px]">{errors.birthdate}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 약관 동의 */}
|
{/* 약관 동의 */}
|
||||||
@@ -399,11 +597,11 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{errors.agreements && <p className="text-error text-[13px] leading-tight">{errors.agreements}</p>}
|
{errors.agreements && <p className="text-error text-[13px] leading-tight mt-[10px]">{errors.agreements}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 액션 버튼 */}
|
{/* 액션 버튼 */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3 pt-[60px]">
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="h-[40px] flex-1 rounded-[12px] bg-[#f1f3f5] text-[18px] font-semibold text-center flex items-center justify-center text-basic-text"
|
className="h-[40px] flex-1 rounded-[12px] bg-[#f1f3f5] text-[18px] font-semibold text-center flex items-center justify-center text-basic-text"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default function RegisterPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="min-h-screen w-full flex flex-col items-center justify-between">
|
<div className="min-h-screen w-full flex flex-col items-center justify-between">
|
||||||
|
|
||||||
<RegisterForm
|
<RegisterForm
|
||||||
onOpenDone={() => setDoneOpen(true)}
|
onOpenDone={() => setDoneOpen(true)}
|
||||||
onOpenCodeError={(msg) => {
|
onOpenCodeError={(msg) => {
|
||||||
@@ -21,6 +22,7 @@ export default function RegisterPage() {
|
|||||||
setCodeErrorOpen(true);
|
setCodeErrorOpen(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RegisterOption
|
<RegisterOption
|
||||||
doneOpen={doneOpen}
|
doneOpen={doneOpen}
|
||||||
setDoneOpen={setDoneOpen}
|
setDoneOpen={setDoneOpen}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import LoginInputSvg from "@/app/svgs/inputformx";
|
import LoginInputSvg from "@/app/svgs/inputformx";
|
||||||
import ResetPasswordDone from "./ResetPasswordDone";
|
import ResetPasswordDone from "./ResetPasswordDone";
|
||||||
import ResetPaswordOption from "./ResetPaswordOption";
|
import ResetPaswordOption from "./ResetPaswordOption";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
export default function ResetPasswordPage() {
|
||||||
const [isDoneOpen, setIsDoneOpen] = useState(false);
|
const [isDoneOpen, setIsDoneOpen] = useState(false);
|
||||||
@@ -14,13 +15,18 @@ export default function ResetPasswordPage() {
|
|||||||
const [focused, setFocused] = useState<Record<string, boolean>>({});
|
const [focused, setFocused] = useState<Record<string, boolean>>({});
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 인증번호 관련 상태
|
||||||
|
const [emailCodeSent, setEmailCodeSent] = useState(false);
|
||||||
|
const [emailCode, setEmailCode] = useState("");
|
||||||
|
const [emailCodeVerified, setEmailCodeVerified] = useState(false);
|
||||||
|
|
||||||
const isEmailValid = useMemo(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email), [email]);
|
const isEmailValid = useMemo(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email), [email]);
|
||||||
const isPasswordValid = useMemo(() => password.length >= 8 && password.length <= 16, [password]);
|
const isPasswordValid = useMemo(() => password.length >= 8 && password.length <= 16, [password]);
|
||||||
const isPasswordConfirmValid = useMemo(() => passwordConfirm === password && passwordConfirm.length > 0, [password, passwordConfirm]);
|
const isPasswordConfirmValid = useMemo(() => passwordConfirm === password && passwordConfirm.length > 0, [password, passwordConfirm]);
|
||||||
|
|
||||||
const canSubmit = useMemo(() => {
|
const canSubmit = useMemo(() => {
|
||||||
return isEmailValid && isPasswordValid && isPasswordConfirmValid;
|
return isEmailValid && isPasswordValid && isPasswordConfirmValid && emailCodeVerified;
|
||||||
}, [isEmailValid, isPasswordValid, isPasswordConfirmValid]);
|
}, [isEmailValid, isPasswordValid, isPasswordConfirmValid, emailCodeVerified]);
|
||||||
|
|
||||||
function validateAll() {
|
function validateAll() {
|
||||||
const nextErrors: Record<string, string> = {};
|
const nextErrors: Record<string, string> = {};
|
||||||
@@ -31,11 +37,63 @@ export default function ResetPasswordPage() {
|
|||||||
return Object.keys(nextErrors).length === 0;
|
return Object.keys(nextErrors).length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSendVerificationCode() {
|
||||||
|
if (!isEmailValid) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiService.sendPasswordReset(email);
|
||||||
|
// 성공 시 상태 업데이트
|
||||||
|
setEmailCodeSent(true);
|
||||||
|
setEmailCode("");
|
||||||
|
setEmailCodeVerified(false);
|
||||||
|
// 성공 시 에러 메시지 제거
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.email;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "네트워크 오류가 발생했습니다.";
|
||||||
|
console.error("인증번호 전송 오류:", errorMessage);
|
||||||
|
setErrors((prev) => ({ ...prev, email: errorMessage }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyEmailCode() {
|
||||||
|
try {
|
||||||
|
await apiService.verifyPasswordResetCode(email, emailCode);
|
||||||
|
// 인증 성공 시 상태 업데이트
|
||||||
|
setEmailCodeVerified(true);
|
||||||
|
// 성공 시 에러 메시지 제거
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.emailCode;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
let errorMessage = error instanceof Error ? error.message : "네트워크 오류가 발생했습니다.";
|
||||||
|
// 특정 에러 메시지를 사용자 친화적인 메시지로 변경
|
||||||
|
if (errorMessage.includes("잘못되었거나 만료된 코드") || errorMessage.includes("잘못되었거나") || errorMessage.includes("만료된")) {
|
||||||
|
errorMessage = "올바르지 않은 인증번호입니다. 인증번호를 확인해주세요.";
|
||||||
|
}
|
||||||
|
console.error("인증번호 확인 오류:", errorMessage);
|
||||||
|
setErrors((prev) => ({ ...prev, emailCode: errorMessage }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!validateAll()) return;
|
if (!validateAll()) return;
|
||||||
// 성공 시 완료 오버레이 표시
|
|
||||||
setIsDoneOpen(true);
|
try {
|
||||||
|
await apiService.resetPassword(email, emailCode, password, passwordConfirm);
|
||||||
|
// 성공 시 완료 오버레이 표시
|
||||||
|
setIsDoneOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "네트워크 오류가 발생했습니다.";
|
||||||
|
console.error("비밀번호 재설정 오류:", errorMessage);
|
||||||
|
setErrors((prev) => ({ ...prev, submit: errorMessage }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,7 +111,7 @@ export default function ResetPasswordPage() {
|
|||||||
<div className="text-[24px] font-extrabold leading-[150%] text-neutral-700">
|
<div className="text-[24px] font-extrabold leading-[150%] text-neutral-700">
|
||||||
비밀번호 재설정
|
비밀번호 재설정
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[18px] font-normal leading-[150%] text-[#6c7682]">
|
<p className="mt-2 text-[18px] font-normal leading-[150%] text-text-label">
|
||||||
비밀번호 재설정을 위해 아래 정보를 입력해 주세요.
|
비밀번호 재설정을 위해 아래 정보를 입력해 주세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,7 +119,7 @@ export default function ResetPasswordPage() {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* 아이디(이메일) + 인증번호 전송 */}
|
{/* 아이디(이메일) + 인증번호 전송 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="email" className="text-[15px] font-semibold text-[#6c7682]">아이디 (이메일)</label>
|
<label htmlFor="email" className="text-[15px] font-semibold text-text-label">아이디 (이메일)</label>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<input
|
<input
|
||||||
@@ -73,9 +131,10 @@ export default function ResetPasswordPage() {
|
|||||||
onFocus={() => setFocused((p) => ({ ...p, email: true }))}
|
onFocus={() => setFocused((p) => ({ ...p, email: true }))}
|
||||||
onBlur={() => setFocused((p) => ({ ...p, email: false }))}
|
onBlur={() => setFocused((p) => ({ ...p, email: false }))}
|
||||||
placeholder="이메일을 입력해 주세요."
|
placeholder="이메일을 입력해 주세요."
|
||||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
disabled={emailCodeVerified}
|
||||||
|
className={`h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border focus:outline-none text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px] disabled:bg-gray-100 disabled:cursor-not-allowed ${emailCodeVerified ? '' : (errors.email ? 'border-error' : 'border-neutral-40 focus:border-neutral-700')}`}
|
||||||
/>
|
/>
|
||||||
{email.trim().length > 0 && focused.email && (
|
{email.trim().length > 0 && focused.email && !emailCodeVerified && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={(e) => { e.preventDefault(); setEmail(""); }}
|
onMouseDown={(e) => { e.preventDefault(); setEmail(""); }}
|
||||||
@@ -88,22 +147,75 @@ export default function ResetPasswordPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!isEmailValid}
|
disabled={!isEmailValid || emailCodeVerified}
|
||||||
className={`h-[40px] px-[12px] rounded-[8px] text-[16px] font-semibold ${isEmailValid ? "bg-inactive-button text-white" : "bg-gray-50 text-input-placeholder-text"}`}
|
className={`h-[40px] w-[130px] px-[12px] rounded-[8px] text-[16px] font-semibold ${isEmailValid && !emailCodeVerified ? "bg-[#F1F3F5] text-neutral-700" : "bg-gray-50 text-input-placeholder-text"}`}
|
||||||
onClick={() => {
|
onClick={handleSendVerificationCode}
|
||||||
if (!isEmailValid) return;
|
|
||||||
alert("인증번호 전송 (가상 동작)");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
인증번호 전송
|
{emailCodeSent && !emailCodeVerified ? "인증번호 재전송" : "인증번호 전송"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{errors.email && <p className="text-error text-[13px] leading-tight">{errors.email}</p>}
|
{errors.email && <p className="text-error text-[13px] leading-tight">{errors.email}</p>}
|
||||||
|
{emailCodeSent && (
|
||||||
|
<div className="space-y-2" aria-expanded={emailCodeSent}>
|
||||||
|
<label htmlFor="emailCode" className="sr-only">인증번호</label>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
id="emailCode"
|
||||||
|
name="emailCode"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={emailCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
const onlyDigits = e.target.value.replace(/[^0-9]/g, "");
|
||||||
|
setEmailCode(onlyDigits.slice(0, 6));
|
||||||
|
}}
|
||||||
|
onFocus={() => setFocused((p) => ({ ...p, emailCode: true }))}
|
||||||
|
onBlur={() => setFocused((p) => ({ ...p, emailCode: false }))}
|
||||||
|
placeholder="인증번호 6자리"
|
||||||
|
disabled={emailCodeVerified}
|
||||||
|
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px] disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
{emailCode.trim().length > 0 && focused.emailCode && !emailCodeVerified && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => { e.preventDefault(); setEmailCode(""); }}
|
||||||
|
aria-label="입력 지우기"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<LoginInputSvg />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={emailCodeVerified || emailCode.trim().length === 0}
|
||||||
|
className={`h-[40px] w-[130px] px-[12px] rounded-[8px] text-[16px] font-semibold ${!emailCodeVerified && emailCode.trim().length > 0 ? "bg-[#F1F3F5] text-neutral-700" : "bg-gray-50 text-input-placeholder-text"}`}
|
||||||
|
onClick={verifyEmailCode}
|
||||||
|
>
|
||||||
|
{emailCodeVerified ? "인증완료" : "인증하기"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!errors.emailCode && (
|
||||||
|
<p className="text-[13px] leading-[normal] text-primary">
|
||||||
|
{emailCodeVerified ? (
|
||||||
|
"인증이 완료됐습니다"
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
인증 확인을 위해 작성한 이메일로 인증번호를 발송했습니다.
|
||||||
|
<br />
|
||||||
|
이메일을 확인해 주세요.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{errors.emailCode && <p className="text-error text-[13px] leading-tight">{errors.emailCode}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 새 비밀번호 */}
|
{/* 새 비밀번호 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="password" className="text-[15px] font-semibold text-[#6c7682]">새 비밀번호</label>
|
<label htmlFor="password" className="text-[15px] font-semibold text-text-label">새 비밀번호</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
@@ -132,7 +244,7 @@ export default function ResetPasswordPage() {
|
|||||||
|
|
||||||
{/* 새 비밀번호 확인 */}
|
{/* 새 비밀번호 확인 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="passwordConfirm" className="text-[15px] font-semibold text-[#6c7682]">새 비밀번호 확인</label>
|
<label htmlFor="passwordConfirm" className="text-[15px] font-semibold text-text-label">새 비밀번호 확인</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
id="passwordConfirm"
|
id="passwordConfirm"
|
||||||
@@ -163,7 +275,7 @@ export default function ResetPasswordPage() {
|
|||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="h-[40px] flex-1 rounded-[12px] bg-[#f1f3f5] text-[18px] font-semibold text-center flex items-center justify-center text-basic-text"
|
className="h-[40px] flex-1 rounded-[12px] bg-bg-gray-light text-[18px] font-semibold text-center flex items-center justify-center text-basic-text"
|
||||||
>
|
>
|
||||||
이전
|
이전
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,92 +1,162 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import PaperClipSvg from '../../svgs/paperclipsvg';
|
import PaperClipSvg from '../../svgs/paperclipsvg';
|
||||||
import BackCircleSvg from '../../svgs/backcirclesvg';
|
import BackCircleSvg from '../../svgs/backcirclesvg';
|
||||||
|
import DownloadIcon from '../../svgs/downloadicon';
|
||||||
|
import apiService from '../../lib/apiService';
|
||||||
|
import type { Resource } from '../../admin/resources/mockData';
|
||||||
|
|
||||||
type ResourceRow = {
|
type Attachment = {
|
||||||
id: number;
|
name: string;
|
||||||
title: string;
|
size: string;
|
||||||
date: string;
|
url?: string;
|
||||||
views: number;
|
fileKey?: string;
|
||||||
writer: string;
|
|
||||||
content: string[];
|
|
||||||
attachments?: Array<{ name: string; size: string; url: string }>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DATA: ResourceRow[] = [
|
export default function ResourceDetailPage() {
|
||||||
{
|
const params = useParams();
|
||||||
id: 6,
|
const router = useRouter();
|
||||||
title: '방사선과 물질의 상호작용 관련 학습 자료',
|
const [resource, setResource] = useState<Resource | null>(null);
|
||||||
date: '2025-06-28',
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
views: 1230,
|
const [loading, setLoading] = useState(true);
|
||||||
writer: '강민재',
|
const [error, setError] = useState<string | null>(null);
|
||||||
content: [
|
|
||||||
'방사선(Radiation)이 물질 속을 지나갈 때 발생하는 다양한 상호작용(Interaction)의 기본적인 원리를 이해합니다.',
|
|
||||||
'상호작용의 원리는 방사선 측정, 방사선 이용(의료, 산업), 방사선 차폐 등 방사선 관련 분야의 기본이 됨을 인식합니다.',
|
|
||||||
'방사선의 종류(광자, 하전입자, 중성자 등) 및 에너지에 따라 물질과의 상호작용 형태가 어떻게 달라지는지 학습합니다.',
|
|
||||||
],
|
|
||||||
attachments: [
|
|
||||||
{
|
|
||||||
name: '[PPT] 방사선-물질 상호작용의 3가지 유형.pptx',
|
|
||||||
size: '796.35 KB',
|
|
||||||
url: '#',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: '감마선과 베타선의 특성 및 차이 분석 자료',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 594,
|
|
||||||
writer: '강민재',
|
|
||||||
content: [
|
|
||||||
'감마선과 베타선의 발생 원리, 물질과의 상호작용 차이를 비교합니다.',
|
|
||||||
'차폐 설계 시 고려해야 할 변수들을 사례와 함께 설명합니다.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: '방사선량 단위(Sv, Gy) 비교 및 계산 예제',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
content: ['방사선량 단위 변환 및 예제 계산을 통해 실무 감각을 익힙니다.'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: '의료 영상 촬영 시 방사선 안전 수칙 가이드',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
content: ['촬영 환경에서의 방사선 안전 수칙을 체크리스트 형태로 정리합니다.'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'X선 발생 원리 및 특성에 대한 이해 자료',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
content: ['X선의 발생 원리와 에너지 스펙트럼의 특성을 개관합니다.'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: '방사선의 기초 개념과 물질과의 상호작용 정리 자료',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
content: ['방사선 기초 개념을 한눈에 정리한 입문용 자료입니다.'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default async function ResourceDetailPage({
|
useEffect(() => {
|
||||||
params,
|
async function fetchResource() {
|
||||||
}: {
|
if (!params?.id) return;
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}) {
|
try {
|
||||||
const { id } = await params;
|
setLoading(true);
|
||||||
const numericId = Number(id);
|
setError(null);
|
||||||
const item = DATA.find((r) => r.id === numericId);
|
|
||||||
if (!item) return notFound();
|
const response = await apiService.getLibraryItem(params.id);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// API 응답 데이터를 Resource 형식으로 변환
|
||||||
|
const transformedResource: Resource = {
|
||||||
|
id: data.id || data.resourceId || Number(params.id),
|
||||||
|
title: data.title || '',
|
||||||
|
date: data.date || data.createdAt || data.createdDate || new Date().toISOString().split('T')[0],
|
||||||
|
views: data.views || data.viewCount || 0,
|
||||||
|
writer: data.writer || data.author || data.createdBy || '관리자',
|
||||||
|
content: data.content
|
||||||
|
? (Array.isArray(data.content)
|
||||||
|
? data.content
|
||||||
|
: typeof data.content === 'string'
|
||||||
|
? data.content.split('\n').filter((line: string) => line.trim())
|
||||||
|
: [String(data.content)])
|
||||||
|
: [],
|
||||||
|
hasAttachment: data.hasAttachment || data.attachment || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 첨부파일 정보 처리
|
||||||
|
if (data.attachments && Array.isArray(data.attachments)) {
|
||||||
|
setAttachments(data.attachments.map((att: any) => ({
|
||||||
|
name: att.name || att.fileName || att.filename || '',
|
||||||
|
size: att.size || att.fileSize || '',
|
||||||
|
url: att.url || att.downloadUrl,
|
||||||
|
fileKey: att.fileKey || att.key || att.fileId,
|
||||||
|
})));
|
||||||
|
} else if (transformedResource.hasAttachment && data.attachment) {
|
||||||
|
// 단일 첨부파일인 경우
|
||||||
|
setAttachments([{
|
||||||
|
name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일',
|
||||||
|
size: data.attachment.size || data.attachment.fileSize || '',
|
||||||
|
url: data.attachment.url || data.attachment.downloadUrl,
|
||||||
|
fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transformedResource.title) {
|
||||||
|
throw new Error('학습 자료를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setResource(transformedResource);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('학습 자료 조회 오류:', err);
|
||||||
|
setError('학습 자료를 불러오는 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchResource();
|
||||||
|
}, [params?.id]);
|
||||||
|
|
||||||
|
const handleDownload = async (fileKey?: string, url?: string, fileName?: string) => {
|
||||||
|
if (url) {
|
||||||
|
// URL이 있으면 직접 다운로드
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
} else if (fileKey) {
|
||||||
|
// fileKey가 있으면 API를 통해 다운로드
|
||||||
|
try {
|
||||||
|
const fileUrl = await apiService.getFile(fileKey);
|
||||||
|
if (fileUrl) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = fileUrl;
|
||||||
|
link.download = fileName || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('파일 다운로드 실패:', err);
|
||||||
|
alert('파일 다운로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-full max-w-[1440px]">
|
||||||
|
<div className="h-[100px] flex items-center justify-center px-8">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !resource) {
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-full max-w-[1440px]">
|
||||||
|
<div className="h-[100px] flex items-center gap-3 px-8">
|
||||||
|
<Link
|
||||||
|
href="/resources"
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</Link>
|
||||||
|
<h1 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">
|
||||||
|
학습 자료 상세
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center px-8 pb-8">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">
|
||||||
|
{error || '학습 자료를 찾을 수 없습니다.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = resource;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-white">
|
<div className="w-full bg-white">
|
||||||
@@ -119,7 +189,11 @@ export default async function ResourceDetailPage({
|
|||||||
<span className="text-[#333C47]">{item.writer}</span>
|
<span className="text-[#333C47]">{item.writer}</span>
|
||||||
<span className="w-px h-4 bg-[#DEE1E6]" />
|
<span className="w-px h-4 bg-[#DEE1E6]" />
|
||||||
<span className="text-[#8C95A1]">게시일</span>
|
<span className="text-[#8C95A1]">게시일</span>
|
||||||
<span className="text-[#333C47]">{item.date}</span>
|
<span className="text-[#333C47]">
|
||||||
|
{item.date.includes('T')
|
||||||
|
? new Date(item.date).toISOString().split('T')[0]
|
||||||
|
: item.date}
|
||||||
|
</span>
|
||||||
<span className="w-px h-4 bg-[#DEE1E6]" />
|
<span className="w-px h-4 bg-[#DEE1E6]" />
|
||||||
<span className="text-[#8C95A1]">조회수</span>
|
<span className="text-[#8C95A1]">조회수</span>
|
||||||
<span className="text-[#333C47]">{item.views.toLocaleString()}</span>
|
<span className="text-[#333C47]">{item.views.toLocaleString()}</span>
|
||||||
@@ -132,21 +206,25 @@ export default async function ResourceDetailPage({
|
|||||||
{/* 본문 */}
|
{/* 본문 */}
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="text-[15px] leading-normal text-[#333C47] space-y-2">
|
<div className="text-[15px] leading-normal text-[#333C47] space-y-2">
|
||||||
{item.content.map((p, idx) => (
|
{item.content && item.content.length > 0 ? (
|
||||||
<p key={idx} className="m-0">
|
item.content.map((p, idx) => (
|
||||||
{p}
|
<p key={idx} className="m-0">
|
||||||
</p>
|
{p}
|
||||||
))}
|
</p>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="m-0 text-[#8C95A1]">내용이 없습니다.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 첨부 파일 */}
|
{/* 첨부 파일 */}
|
||||||
{item.attachments?.length ? (
|
{attachments.length > 0 && (
|
||||||
<div className="p-8 pt-0">
|
<div className="p-8 pt-0">
|
||||||
<div className="mb-2 text-[15px] font-semibold text-[#6C7682]">
|
<div className="mb-2 text-[15px] font-semibold text-[#6C7682]">
|
||||||
첨부 파일
|
첨부 파일
|
||||||
</div>
|
</div>
|
||||||
{item.attachments.map((f, idx) => (
|
{attachments.map((f, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="bg-white border border-[#DEE1E6] h-[64px] rounded-[6px] flex items-center gap-3 px-[17px]"
|
className="bg-white border border-[#DEE1E6] h-[64px] rounded-[6px] flex items-center gap-3 px-[17px]"
|
||||||
@@ -158,31 +236,24 @@ export default async function ResourceDetailPage({
|
|||||||
<span className="text-[15px] text-[#1B2027] truncate">
|
<span className="text-[15px] text-[#1B2027] truncate">
|
||||||
{f.name}
|
{f.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[13px] text-[#8C95A1] whitespace-nowrap">
|
{f.size && (
|
||||||
{f.size}
|
<span className="text-[13px] text-[#8C95A1] whitespace-nowrap">
|
||||||
</span>
|
{f.size}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<button
|
||||||
href={f.url}
|
type="button"
|
||||||
className="h-8 px-4 rounded-[6px] border border-[#8C95A1] text-[13px] text-[#4C5561] inline-flex items-center gap-1 hover:bg-[#F9FAFB] no-underline"
|
onClick={() => handleDownload(f.fileKey, f.url, f.name)}
|
||||||
download
|
className="h-8 px-4 rounded-[6px] border border-[#8C95A1] text-[13px] text-[#4C5561] inline-flex items-center gap-1 hover:bg-[#F9FAFB] cursor-pointer"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden>
|
<DownloadIcon width={16} height={16} className="text-[#4C5561]" />
|
||||||
<path
|
|
||||||
d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
다운로드
|
다운로드
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,65 +1,97 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import PaperClipSvg from '../svgs/paperclipsvg';
|
import PaperClipSvg from '../svgs/paperclipsvg';
|
||||||
|
import ChevronDownSvg from '../svgs/chevrondownsvg';
|
||||||
type ResourceRow = {
|
import apiService from '../lib/apiService';
|
||||||
id: number;
|
import type { Resource } from '../admin/resources/mockData';
|
||||||
title: string;
|
|
||||||
date: string;
|
|
||||||
views: number;
|
|
||||||
writer: string;
|
|
||||||
hasAttachment?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const rows: ResourceRow[] = [
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
title: '방사선과 물질의 상호작용 관련 학습 자료',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
hasAttachment: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: '감마선과 베타선의 특성 및 차이 분석 자료',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 594,
|
|
||||||
writer: '강민재',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: '방사선량 단위(Sv, Gy) 비교 및 계산 예제',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: '의료 영상 촬영 시 방사선 안전 수칙 가이드',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'X선 발생 원리 및 특성에 대한 이해 자료',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: '방사선의 기초 개념과 물질과의 상호작용 정리 자료',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ResourcesPage() {
|
export default function ResourcesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 날짜를 yyyy-mm-dd 형식으로 포맷팅
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
// 이미 yyyy-mm-dd 형식인 경우 그대로 반환
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API에서 학습 자료 목록 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchResources() {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await apiService.getLibrary();
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||||
|
let resourcesArray: any[] = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
resourcesArray = data;
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
resourcesArray = data.items || data.resources || data.data || data.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답 데이터를 Resource 형식으로 변환
|
||||||
|
const transformedResources: Resource[] = resourcesArray.map((resource: any) => ({
|
||||||
|
id: resource.id || resource.resourceId || 0,
|
||||||
|
title: resource.title || '',
|
||||||
|
date: resource.date || resource.createdAt || resource.createdDate || new Date().toISOString().split('T')[0],
|
||||||
|
views: resource.views || resource.viewCount || 0,
|
||||||
|
writer: resource.writer || resource.author || resource.createdBy || '관리자',
|
||||||
|
content: resource.content ? (Array.isArray(resource.content) ? resource.content : [resource.content]) : undefined,
|
||||||
|
hasAttachment: resource.hasAttachment || resource.attachment || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setResources(transformedResources);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('학습 자료 목록 조회 오류:', error);
|
||||||
|
// 에러 발생 시 빈 배열로 설정
|
||||||
|
setResources([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchResources();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalCount = useMemo(() => resources.length, [resources]);
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const sortedResources = useMemo(() => {
|
||||||
|
return [...resources].sort((a, b) => {
|
||||||
|
// 생성일 내림차순 정렬 (최신 날짜가 먼저)
|
||||||
|
return b.date.localeCompare(a.date);
|
||||||
|
});
|
||||||
|
}, [resources]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(sortedResources.length / ITEMS_PER_PAGE);
|
||||||
|
const paginatedResources = useMemo(() => {
|
||||||
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||||
|
return sortedResources.slice(startIndex, endIndex);
|
||||||
|
}, [sortedResources, currentPage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-white">
|
<div className="w-full bg-white">
|
||||||
@@ -77,70 +109,173 @@ export default function ResourcesPage() {
|
|||||||
{/* 총 건수 */}
|
{/* 총 건수 */}
|
||||||
<div className="h-10 flex items-center">
|
<div className="h-10 flex items-center">
|
||||||
<p className="m-0 text-[15px] font-medium leading-[1.5] text-[#333C47]">
|
<p className="m-0 text-[15px] font-medium leading-[1.5] text-[#333C47]">
|
||||||
총 <span className="text-[#384FBF]">{rows.length}</span>건
|
총 <span className="text-[#384FBF]">{totalCount}</span>건
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 표 */}
|
{isLoading ? (
|
||||||
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
<div className="rounded-[8px] border border-[#DEE1E6] bg-white min-h-[400px] flex items-center justify-center">
|
||||||
{/* 헤더 */}
|
<p className="text-[16px] font-medium leading-[1.5] text-[#333C47]">
|
||||||
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
|
로딩 중...
|
||||||
<div className="flex items-center justify-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
</p>
|
||||||
번호
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
|
||||||
제목
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
|
||||||
게시일
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
|
||||||
조회수
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center px-4 whitespace-nowrap">등록자</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : resources.length === 0 ? (
|
||||||
{/* 바디 */}
|
<div className="rounded-[8px] border border-[#DEE1E6] bg-white min-h-[400px] flex items-center justify-center">
|
||||||
<div>
|
<p className="text-[16px] font-medium leading-[1.5] text-[#333C47] text-center">
|
||||||
{rows.map((r) => (
|
등록된 학습 자료가 없습니다.
|
||||||
<div
|
</p>
|
||||||
key={r.id}
|
</div>
|
||||||
role="button"
|
) : (
|
||||||
tabIndex={0}
|
<>
|
||||||
onClick={() => router.push(`/resources/${r.id}`)}
|
{/* 표 */}
|
||||||
onKeyDown={(e) => {
|
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
{/* 헤더 */}
|
||||||
e.preventDefault();
|
<div className="grid grid-cols-[80px_1fr_140px_120px_120px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
|
||||||
router.push(`/resources/${r.id}`);
|
<div className="flex items-center justify-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
}
|
번호
|
||||||
}}
|
|
||||||
className={[
|
|
||||||
'grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">
|
|
||||||
{r.id}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
제목
|
||||||
title={r.title}
|
|
||||||
>
|
|
||||||
{r.title}
|
|
||||||
{r.hasAttachment && (
|
|
||||||
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
{r.date}
|
게시일
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
{r.views.toLocaleString()}
|
조회수
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center px-4">{r.writer}</div>
|
<div className="flex items-center px-4 whitespace-nowrap">작성자</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
{/* 바디 */}
|
||||||
</div>
|
<div>
|
||||||
|
{paginatedResources.map((resource, index) => {
|
||||||
|
// 번호는 전체 목록에서의 순서 (정렬된 목록 기준)
|
||||||
|
const resourceNumber = sortedResources.length - (currentPage - 1) * ITEMS_PER_PAGE - index;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={resource.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => router.push(`/resources/${resource.id}`)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
router.push(`/resources/${resource.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
'grid grid-cols-[80px_1fr_140px_120px_120px] h-[48px] text-[13px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
|
{resourceNumber}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
|
title={resource.title}
|
||||||
|
>
|
||||||
|
{resource.title}
|
||||||
|
{resource.hasAttachment && (
|
||||||
|
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
|
{formatDate(resource.date)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
|
{resource.views.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4 whitespace-nowrap">{resource.writer}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
|
||||||
|
{resources.length > ITEMS_PER_PAGE && (
|
||||||
|
<div className="pt-8 pb-[32px] flex items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center gap-[8px]">
|
||||||
|
{/* First (맨 앞으로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
aria-label="맨 앞 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
aria-label="이전 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Numbers */}
|
||||||
|
{(() => {
|
||||||
|
const pageGroup = Math.floor((currentPage - 1) / 10);
|
||||||
|
const startPage = pageGroup * 10 + 1;
|
||||||
|
const endPage = Math.min(startPage + 9, totalPages);
|
||||||
|
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||||
|
|
||||||
|
return visiblePages.map((n) => {
|
||||||
|
const active = n === currentPage;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(n)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={[
|
||||||
|
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||||
|
active ? 'bg-[#ecf0ff]' : 'bg-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
aria-label="다음 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Last (맨 뒤로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
aria-label="맨 뒤 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
41
src/app/svgs/backarrow.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type BackArrowProps = React.SVGProps<SVGSVGElement>;
|
||||||
|
|
||||||
|
export default function BackArrowSvg(props: BackArrowProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M16 4V4C22.628 4 28 9.372 28 16V16C28 22.628 22.628 28 16 28V28C9.372 28 4 22.628 4 16V16C4 9.372 9.372 4 16 4Z"
|
||||||
|
fill="#8C95A1"
|
||||||
|
stroke="#8C95A1"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10.6667 16.0002H21.3334"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14.6667 20L10.6667 16L14.6667 12"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
export default function BackCircleSvg(
|
export default function BackCircleSvg(
|
||||||
{
|
{
|
||||||
width = 32,
|
width = 32,
|
||||||
height = 32,
|
height = 32,
|
||||||
className = '',
|
className = '',
|
||||||
}: { width?: number | string; height?: number | string; className?: string }
|
}: { width?: number | string; height?: number | string; className?: string }
|
||||||
): JSX.Element {
|
): ReactElement {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
46
src/app/svgs/callendar.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const CalendarSvg: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M16 2V6"
|
||||||
|
stroke="#333C47"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M8 2V6"
|
||||||
|
stroke="#333C47"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3 9H21"
|
||||||
|
stroke="#333C47"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M19 4H5C3.895 4 3 4.895 3 6V19C3 20.105 3.895 21 5 21H19C20.105 21 21 20.105 21 19V6C21 4.895 20.105 4 19 4Z"
|
||||||
|
stroke="#333C47"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<rect x="3" y="4" width="18" height="5" rx="2" fill="#333C47" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CalendarSvg;
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
export default function ChevronDownSvg(
|
export default function ChevronDownSvg(
|
||||||
{
|
{
|
||||||
width = 24,
|
width = 24,
|
||||||
height = 24,
|
height = 24,
|
||||||
className = '',
|
className = '',
|
||||||
}: { width?: number | string; height?: number | string; className?: string }
|
}: { width?: number | string; height?: number | string; className?: string }
|
||||||
): JSX.Element {
|
): ReactElement {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
41
src/app/svgs/closexo.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type CloseXOSvgProps = React.SVGProps<SVGSVGElement>;
|
||||||
|
|
||||||
|
export default function CloseXOSvg(props: CloseXOSvgProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M8 14V14C4.686 14 2 11.314 2 8V8C2 4.686 4.686 2 8 2V2C11.314 2 14 4.686 14 8V8C14 11.314 11.314 14 8 14Z"
|
||||||
|
fill="#6C7682"
|
||||||
|
stroke="#6C7682"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M9.88661 6.11328L6.11328 9.88661"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M9.88661 9.88661L6.11328 6.11328"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/app/svgs/downloadicon.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type DownloadIconProps = React.SVGProps<SVGSVGElement>;
|
||||||
|
|
||||||
|
export default function DownloadIcon(props: DownloadIconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
35
src/app/svgs/dropdownicon.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function DropdownIcon({
|
||||||
|
width = 16,
|
||||||
|
height = 16,
|
||||||
|
className = '',
|
||||||
|
stroke = "#8C95A1",
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
width?: number | string;
|
||||||
|
height?: number | string;
|
||||||
|
className?: string;
|
||||||
|
stroke?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14 5L8 11L2 5"
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
export default function PaperClipSvg(
|
export default function PaperClipSvg(
|
||||||
{
|
{
|
||||||
width = 16,
|
width = 16,
|
||||||
height = 16,
|
height = 16,
|
||||||
className = '',
|
className = '',
|
||||||
}: { width?: number | string; height?: number | string; className?: string }
|
}: { width?: number | string; height?: number | string; className?: string }
|
||||||
): JSX.Element {
|
): ReactElement {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
57
src/middleware.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// 공개 경로 (인증 불필요)
|
||||||
|
const publicPaths = [
|
||||||
|
'/login',
|
||||||
|
'/register',
|
||||||
|
'/find-id',
|
||||||
|
'/reset-password',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 정적 파일 및 API 경로는 제외
|
||||||
|
if (
|
||||||
|
pathname.startsWith('/_next') ||
|
||||||
|
pathname.startsWith('/api') ||
|
||||||
|
pathname.startsWith('/fonts') ||
|
||||||
|
pathname.startsWith('/imgs') ||
|
||||||
|
pathname.match(/\.(ico|png|jpg|jpeg|svg|woff|woff2)$/)
|
||||||
|
) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공개 경로는 통과
|
||||||
|
if (publicPaths.some(path => pathname.startsWith(path))) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 토큰 확인 (쿠키에서)
|
||||||
|
const token = request.cookies.get('token')?.value;
|
||||||
|
|
||||||
|
// 토큰이 없으면 로그인 페이지로 리다이렉트
|
||||||
|
if (!token) {
|
||||||
|
const loginUrl = new URL('/login', request.url);
|
||||||
|
// 원래 요청한 경로를 쿼리 파라미터로 저장 (로그인 후 돌아갈 수 있도록)
|
||||||
|
loginUrl.searchParams.set('redirect', pathname);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except for the ones starting with:
|
||||||
|
* - api (API routes)
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimization files)
|
||||||
|
* - favicon.ico (favicon file)
|
||||||
|
*/
|
||||||
|
'/((?!api|_next/static|_next/image|favicon.ico).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||