Compare commits
202
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53c45da00a | ||
|
|
136af9caf2 | ||
|
|
fbc8e0b16a | ||
|
|
aa4686254b | ||
|
|
2d80ed05db | ||
|
|
d923237969 | ||
|
|
b2a28c6fd3 | ||
|
|
d927b95457 | ||
|
|
2dc9dd985a | ||
|
|
417ff3390b | ||
|
|
0c646ceb85 | ||
|
|
6bbc87479a | ||
|
|
80a6aa9fb5 | ||
|
|
1343404e01 | ||
|
|
5faba43728 | ||
|
|
a20f5eff85 | ||
|
|
23b6677323 | ||
|
|
45c75b3283 | ||
|
|
18e835450a | ||
|
|
f5ad55235c | ||
|
|
764eb34e47 | ||
|
|
fe4e9291fa | ||
|
|
ec9a81d7b5 | ||
|
|
e652c6ed0e | ||
|
|
d431564aac | ||
|
|
350d08a2b4 | ||
|
|
eb5e043357 | ||
|
|
d67e3e7244 | ||
|
|
a7196dd453 | ||
|
|
c83e54fae8 | ||
|
|
35a5761a7c | ||
|
|
b472fbdbbf | ||
|
|
4fcc96ac15 | ||
|
|
2dfcd3adad | ||
|
|
d79788258b | ||
|
|
427d2b69c7 | ||
|
|
7e5e4cc29b | ||
|
|
a1e89b882a | ||
|
|
a74f0181f1 | ||
|
|
a9a1e5450b | ||
|
|
5e65942060 | ||
|
|
d044d0ef50 | ||
|
|
d40a913ccc | ||
|
|
66178ccc18 | ||
|
|
8280ba2a64 | ||
|
|
8259020450 | ||
|
|
692a7a7258 | ||
|
|
1ee04ada14 | ||
|
|
193cb61b45 | ||
|
|
4777a34f23 | ||
|
|
4d3677ebd2 | ||
|
|
f0eb11010d | ||
|
|
e1f13b3872 | ||
|
|
fa30849d03 | ||
|
|
f8d592d811 | ||
|
|
c385aa1996 | ||
|
|
9a97eb3e8e | ||
|
|
70ee5300f7 | ||
|
|
35c9539c08 | ||
|
|
bc5b072ad8 | ||
|
|
66b8b064ba | ||
|
|
63643cc286 | ||
|
|
19bd44a7ca | ||
|
|
b3bdf25b5f | ||
|
|
cdd4fd4703 | ||
|
|
7d1b86acdb | ||
|
|
6e6c361eb8 | ||
|
|
62780c8889 | ||
|
|
1839f4cab0 | ||
|
|
c9f5b9e4a5 | ||
|
|
33365affa2 | ||
|
|
63db7ff6ad | ||
|
|
7f8b53edd4 | ||
|
|
eb0628ac3e | ||
|
|
cbb880a053 | ||
|
|
083a912e91 | ||
|
|
ab744b411d | ||
|
|
8947b4d05e | ||
|
|
7e3eaa6584 | ||
|
|
98a459c7d7 | ||
|
|
9d24a11c05 | ||
|
|
ee60787100 | ||
|
|
301a349776 | ||
|
|
81488ceb6b | ||
|
|
9e99fd61e7 | ||
|
|
d36c693615 | ||
|
|
20125a6f4a | ||
|
|
4bc5006c91 | ||
|
|
71769b71aa | ||
|
|
a2cf67d210 | ||
|
|
d9c4217fd3 | ||
|
|
719e36e46c | ||
|
|
bd052f4866 | ||
|
|
734e8540f2 | ||
|
|
1289555749 | ||
|
|
971fe7160f | ||
|
|
722caeec9d | ||
|
|
7404afe466 | ||
|
|
400daea60f | ||
|
|
40cd47c6be | ||
|
|
529d065a75 | ||
|
|
69d6d86342 | ||
|
|
31736cc403 | ||
|
|
1cb2443b58 | ||
|
|
9e9bfc2793 | ||
|
|
5a99f216cb | ||
|
|
efdbc65be6 | ||
|
|
f5905c0acc | ||
|
|
6348a4998b | ||
|
|
e067450fcb | ||
|
|
a281f279f8 | ||
|
|
a157e11e13 | ||
|
|
b38afd2b44 | ||
|
|
3c301ec2c4 | ||
|
|
65a6bb686c | ||
|
|
f3a19fa460 | ||
|
|
228f92e9ed | ||
|
|
dcd4ef88d6 | ||
|
|
ec13f2c693 | ||
|
|
302ee37ca1 | ||
|
|
b7282f9c99 | ||
|
|
2c347d1cc5 | ||
|
|
791f43986d | ||
|
|
9f25941a4a | ||
|
|
aaf5b4262d | ||
|
|
0f46244d2b | ||
|
|
dd03b3da85 | ||
|
|
9a0fee65a5 | ||
|
|
376e646c10 | ||
|
|
f8cd57a1cf | ||
|
|
5618a8e99b | ||
|
|
598bd22b5a | ||
|
|
246adb57a1 | ||
|
|
75839271ad | ||
|
|
6e78b49731 | ||
|
|
3aa552afd6 | ||
|
|
b389235971 | ||
|
|
86290cc820 | ||
|
|
41712be56a | ||
|
|
240e684b53 | ||
|
|
b1a53df4a9 | ||
|
|
0709c3a2bd | ||
|
|
f2e1cead83 | ||
|
|
1b795f2dfc | ||
|
|
ce115f63e4 | ||
|
|
c907c696f5 | ||
|
|
de76020709 | ||
|
|
1e391a2932 | ||
|
|
a7c67cb77f | ||
|
|
dafdfba490 | ||
|
|
a741fbb8d4 | ||
|
|
0e3dc101d5 | ||
|
|
e805e5fd41 | ||
|
|
5bdf8f5a4d | ||
|
|
07f1fb0208 | ||
|
|
3958ae1098 | ||
|
|
885d156833 | ||
|
|
2746af0b3f | ||
|
|
745a2d2873 | ||
|
|
8887e3b0a9 | ||
|
|
638b64b715 | ||
|
|
d61cb4a215 | ||
|
|
272db82c8c | ||
|
|
9108ddb742 | ||
|
|
628e168342 | ||
|
|
9aff9e2dc8 | ||
|
|
585ad482f2 | ||
|
|
988476b409 | ||
|
|
38e6d4643b | ||
|
|
79cb9d81fc | ||
|
|
64e702df5d | ||
|
|
280afd68ab | ||
|
|
9777b7bea9 | ||
|
|
6bad504d44 | ||
|
|
6b959a2f22 | ||
|
|
33c04707f8 | ||
|
|
f87dc4c782 | ||
|
|
b5428d8552 | ||
|
|
0a846b0512 | ||
|
|
3a950ea867 | ||
|
|
4693284b37 | ||
|
|
f8f796e071 | ||
|
|
fe1928e8ef | ||
|
|
9c7b8c1c63 | ||
|
|
2bd767d20b | ||
|
|
a6fea630a4 | ||
|
|
c05061becf | ||
|
|
a9cc728ade | ||
|
|
2602657201 | ||
|
|
c70e5706bc | ||
|
|
49ca2a9fb0 | ||
|
|
47ebd3ad73 | ||
|
|
d544e43abc | ||
|
|
d7a90f47d0 | ||
|
|
984a31c587 | ||
|
|
a575e534b5 | ||
|
|
989503f930 | ||
|
|
3b979119ed | ||
|
|
ecec423d30 | ||
|
|
3bbdba7015 | ||
|
|
f7a7acaf65 | ||
|
|
809eeee931 |
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npx nx run-many:*)",
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(npm view:*)",
|
||||||
|
"Bash(npx nx build landing)",
|
||||||
|
"Bash(npm uninstall:*)",
|
||||||
|
"Bash(dir:*)",
|
||||||
|
"Bash(ren page.tsx page-original.tsx)",
|
||||||
|
"Bash(ren:*)",
|
||||||
|
"Bash(npx supabase:*)",
|
||||||
|
"Bash(libs/service/src/types/supabase.ts)",
|
||||||
|
"Bash(npx nx build:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(git push)",
|
||||||
|
"Bash(findstr:*)",
|
||||||
|
"Bash(ls:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Nx
|
||||||
|
.nx
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
.editorconfig
|
||||||
|
.prettierrc
|
||||||
|
.eslintrc
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
VITE_SUPABASE_URL=https://your-project-id.supabase.co
|
||||||
|
VITE_SUPABASE_ANON_KEY=your-anon-key-here
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## Issue
|
||||||
|
|
||||||
|
<!-- #GitHubIssueNumber / Issue Link / Describe the problem here (if the issue hasn't been defined) -->
|
||||||
|
|
||||||
|
## What did you do?
|
||||||
|
|
||||||
|
<!--
|
||||||
|
- Describe what did you do in this code
|
||||||
|
- Example:
|
||||||
|
- [CORRECT] Add auth feature / Modify auth feature.
|
||||||
|
- [WRONG] Create file /controllers/auth.ts, /models/user.ts, modify /routes/web.ts
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Screenshot / Video
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Attach pictures of this work / video explanation about this work
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Note for Deployment
|
||||||
|
|
||||||
|
<!--
|
||||||
|
- Tell to DevOps what should he do to deploy this change
|
||||||
|
- example:
|
||||||
|
- Please run migration when deploy this
|
||||||
|
- Please upload these files into folder xxx
|
||||||
|
- Please create folder `xxx` and give permission `777`
|
||||||
|
- Please run this SQL after deploy ```sql INSERT INTO xxx (...) ```
|
||||||
|
- etc...
|
||||||
|
-->
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Deploy Backoffice
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
paths:
|
||||||
|
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-vps
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
port: ${{ secrets.SSH_PORT }}
|
||||||
|
command_timeout: 30m
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
|
||||||
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
|
||||||
|
else
|
||||||
|
cd ~/imphnen-frontend-service-backoffice && git fetch origin && git reset --hard origin/develop && git pull
|
||||||
|
fi
|
||||||
|
cat > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env << EOF
|
||||||
|
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||||
|
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||||
|
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||||
|
EOF
|
||||||
|
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
|
||||||
|
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build --progress=plain
|
||||||
|
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
|
||||||
|
docker image prune -af
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Deploy Dimentorin
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
paths:
|
||||||
|
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-vps
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
port: ${{ secrets.SSH_PORT }}
|
||||||
|
command_timeout: 30m
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
|
||||||
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
|
||||||
|
else
|
||||||
|
cd ~/imphnen-frontend-service-dimentorin && git fetch origin && git reset --hard origin/develop && git pull
|
||||||
|
fi
|
||||||
|
cat > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env << EOF
|
||||||
|
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||||
|
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||||
|
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||||
|
EOF
|
||||||
|
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
|
||||||
|
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build --progress=plain
|
||||||
|
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
|
||||||
|
docker image prune -af
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Deploy Gacha
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
paths:
|
||||||
|
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-vps
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
port: ${{ secrets.SSH_PORT }}
|
||||||
|
command_timeout: 30m
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
if [ ! -d ~/imphnen-frontend-service-gacha ]; then
|
||||||
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
|
||||||
|
else
|
||||||
|
cd ~/imphnen-frontend-service-gacha && git fetch origin && git reset --hard origin/develop && git pull
|
||||||
|
fi
|
||||||
|
cat > ~/imphnen-frontend-service-gacha/apps/gacha/.env << EOF
|
||||||
|
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||||
|
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||||
|
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||||
|
EOF
|
||||||
|
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
|
||||||
|
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build --progress=plain
|
||||||
|
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
|
||||||
|
docker image prune -af
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Deploy Hackathon
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
paths:
|
||||||
|
- 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-vps
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
port: ${{ secrets.SSH_PORT }}
|
||||||
|
command_timeout: 30m
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
if [ ! -d ~/imphnen-frontend-service-hackathon ]; then
|
||||||
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-hackathon
|
||||||
|
else
|
||||||
|
cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull
|
||||||
|
fi
|
||||||
|
cat > ~/imphnen-frontend-service-hackathon/.env.local << EOF
|
||||||
|
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||||
|
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||||
|
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||||
|
EOF
|
||||||
|
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true
|
||||||
|
docker builder prune -af || true
|
||||||
|
DOCKER_BUILDKIT=1 docker compose --env-file ~/imphnen-frontend-service-hackathon/.env.local -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml build --progress=plain
|
||||||
|
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml up -d
|
||||||
|
docker image prune -af
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Deploy Landing
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
paths:
|
||||||
|
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-vps
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
port: ${{ secrets.SSH_PORT }}
|
||||||
|
command_timeout: 30m
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
if [ ! -d ~/imphnen-frontend-service ]; then
|
||||||
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
|
||||||
|
else
|
||||||
|
cd ~/imphnen-frontend-service && git fetch origin && git reset --hard origin/develop && git pull
|
||||||
|
fi
|
||||||
|
cat > ~/imphnen-frontend-service/apps/landing/.env << EOF
|
||||||
|
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||||
|
EOF
|
||||||
|
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
|
||||||
|
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build --progress=plain
|
||||||
|
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
|
||||||
|
docker image prune -af
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Test and Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['develop']
|
||||||
|
pull_request:
|
||||||
|
branches: ['develop']
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: 22.14.0
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use Node.js ${{ env.NODE_VERSION }}
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set env vars
|
||||||
|
run: |
|
||||||
|
echo "CMS_SECRET=${{ secrets.CMS_SECRET }}" >> $GITHUB_ENV
|
||||||
|
echo "CMS_STORAGE_REGION=${{ secrets.CMS_STORAGE_REGION }}" >> $GITHUB_ENV
|
||||||
|
echo "CMS_STORAGE_ENDPOINT=${{ secrets.CMS_STORAGE_ENDPOINT }}" >> $GITHUB_ENV
|
||||||
|
echo "CMS_STORAGE_ACCESS_KEY_ID=${{ secrets.CMS_STORAGE_ACCESS_KEY_ID }}" >> $GITHUB_ENV
|
||||||
|
echo "CMS_STORAGE_SECRET_ACCESS_KEY=${{ secrets.CMS_STORAGE_SECRET_ACCESS_KEY }}" >> $GITHUB_ENV
|
||||||
|
echo "CMS_POSTGRES_URL=${{ secrets.CMS_POSTGRES_URL }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Nx Build
|
||||||
|
run: npx nx run-many --target=build --all
|
||||||
+21
@@ -45,3 +45,24 @@ vite.config.*.timestamp*
|
|||||||
vitest.config.*.timestamp*
|
vitest.config.*.timestamp*
|
||||||
|
|
||||||
storybook-static
|
storybook-static
|
||||||
|
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.prod
|
||||||
|
.env.develop
|
||||||
|
.env.staging
|
||||||
|
|
||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
.cursor/rules/nx-rules.mdc
|
||||||
|
.github/instructions/nx.instructions.md
|
||||||
|
|
||||||
|
.env.local
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"nx-mcp": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["nx", "mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+2
-1
@@ -2,6 +2,7 @@
|
|||||||
"recommendations": [
|
"recommendations": [
|
||||||
"nrwl.angular-console",
|
"nrwl.angular-console",
|
||||||
"esbenp.prettier-vscode",
|
"esbenp.prettier-vscode",
|
||||||
"dbaeumer.vscode-eslint"
|
"dbaeumer.vscode-eslint",
|
||||||
|
"ms-playwright.playwright"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!-- nx configuration start-->
|
||||||
|
<!-- Leave the start & end comments to automatically receive updates. -->
|
||||||
|
|
||||||
|
# General Guidelines for working with Nx
|
||||||
|
|
||||||
|
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
|
||||||
|
- You have access to the Nx MCP server and its tools, use them to help the user
|
||||||
|
- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable.
|
||||||
|
- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies
|
||||||
|
- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
|
||||||
|
- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors
|
||||||
|
|
||||||
|
<!-- nx configuration end-->
|
||||||
@@ -1,101 +1,138 @@
|
|||||||
# ImphnenFrontendService
|
# IMPHNEN Frontend Service
|
||||||
|
|
||||||
<a alt="Nx logo" href="https://nx.dev" target="_blank" rel="noreferrer"><img src="https://raw.githubusercontent.com/nrwl/nx/master/images/nx-logo.png" width="45"></a>
|
<p align="center">
|
||||||
|
<img src="docs/logo.svg" alt="IMPHNEN">
|
||||||
|
</p>
|
||||||
|
|
||||||
✨ Your new, shiny [Nx workspace](https://nx.dev) is ready ✨.
|
This repository is a **monorepo** for all frontend services of IMPHNEN. The monorepo includes three main applications:
|
||||||
|
|
||||||
[Learn more about this workspace setup and its capabilities](https://nx.dev/getting-started/tutorials/react-monorepo-tutorial?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or run `npx nx graph` to visually explore what was created. Now, let's get you up to speed!
|
1. **Gacha** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Gacha Website</a>.
|
||||||
|
2. **Backoffice** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Internal Management Website</a>.
|
||||||
|
3. **Dimentorin** - Application for <a href="https://dimentorin.imphnen.dev/" target="_blank">Mentoring Service</a>.
|
||||||
|
4. **Landing Page** - Application for <a href="https://imphnen.dev/" target="_blank">Landing Page</a>.
|
||||||
|
|
||||||
## Run tasks
|
## How to install
|
||||||
|
|
||||||
To run the dev server for your app, use:
|
1. Clone this repository:
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git
|
||||||
|
cd imphnen-frontend-service
|
||||||
|
```
|
||||||
|
2. Install all dependencies:
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to run
|
||||||
|
|
||||||
|
### Setup Environment Variables
|
||||||
|
|
||||||
|
Before running the applications, you need to set up the environment variables. You can do this by copying the `.env.example` file to `.env` and modifying the values according to your needs.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npx nx serve dimentorin
|
cd apps/{appname}
|
||||||
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
To create a production bundle:
|
### Development
|
||||||
|
|
||||||
```sh
|
Use the following commands to run in development mode:
|
||||||
npx nx build dimentorin
|
|
||||||
```
|
|
||||||
|
|
||||||
To see all available targets to run for a project, run:
|
- **Gacha**:
|
||||||
|
```sh
|
||||||
|
npm run gacha:dev
|
||||||
|
```
|
||||||
|
- **Backoffice**:
|
||||||
|
```sh
|
||||||
|
npm run backoffice:dev
|
||||||
|
```
|
||||||
|
- **Dimentorin**:
|
||||||
|
```sh
|
||||||
|
npm run dimentorin:dev
|
||||||
|
```
|
||||||
|
- **Landing Page**:
|
||||||
|
```sh
|
||||||
|
npm run landing:dev
|
||||||
|
```
|
||||||
|
|
||||||
```sh
|
### Build
|
||||||
npx nx show project dimentorin
|
|
||||||
```
|
|
||||||
|
|
||||||
These targets are either [inferred automatically](https://nx.dev/concepts/inferred-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or defined in the `project.json` or `package.json` files.
|
Use the following commands to build the applications:
|
||||||
|
|
||||||
[More about running tasks in the docs »](https://nx.dev/features/run-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
- **Gacha**:
|
||||||
|
```sh
|
||||||
|
npm run gacha:build
|
||||||
|
```
|
||||||
|
- **Backoffice**:
|
||||||
|
```sh
|
||||||
|
npm run backoffice:build
|
||||||
|
```
|
||||||
|
- **Dimentorin**:
|
||||||
|
```sh
|
||||||
|
npm run dimentorin:build
|
||||||
|
```
|
||||||
|
- **Landing Page**:
|
||||||
|
```sh
|
||||||
|
npm run landing:build
|
||||||
|
```
|
||||||
|
|
||||||
## Add new projects
|
### Production
|
||||||
|
|
||||||
While you could add new projects to your workspace manually, you might want to leverage [Nx plugins](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) and their [code generation](https://nx.dev/features/generate-code?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) feature.
|
Use the following commands to run the applications in production mode:
|
||||||
|
|
||||||
Use the plugin's generator to create new projects.
|
- **Gacha**:
|
||||||
|
```sh
|
||||||
|
npm run gacha:prod
|
||||||
|
```
|
||||||
|
- **Backoffice**:
|
||||||
|
```sh
|
||||||
|
npm run backoffice:prod
|
||||||
|
```
|
||||||
|
- **Dimentorin**:
|
||||||
|
```sh
|
||||||
|
npm run dimentorin:prod
|
||||||
|
```
|
||||||
|
- **Landing Page**:
|
||||||
|
```sh
|
||||||
|
npm run landing:prod
|
||||||
|
```
|
||||||
|
|
||||||
To generate a new application, use:
|
### Storybook
|
||||||
|
|
||||||
```sh
|
This repository uses Storybook to develop, test, and document UI components in an isolated and interactive environment. Below are the commands to work with Storybook:
|
||||||
npx nx g @nx/react:app demo
|
|
||||||
```
|
|
||||||
|
|
||||||
To generate a new library, use:
|
- **Run Storybook**
|
||||||
|
|
||||||
```sh
|
This command starts Storybook in development mode, allowing you to view and test UI components interactively.
|
||||||
npx nx g @nx/react:lib mylib
|
|
||||||
```
|
|
||||||
|
|
||||||
You can use `npx nx list` to get a list of installed plugins. Then, run `npx nx list <plugin-name>` to learn about more specific capabilities of a particular plugin. Alternatively, [install Nx Console](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) to browse plugins and generators in your IDE.
|
```sh
|
||||||
|
npm run ui:storybook
|
||||||
|
```
|
||||||
|
|
||||||
[Learn more about Nx plugins »](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) | [Browse the plugin registry »](https://nx.dev/plugin-registry?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
- **Run Unit Test**
|
||||||
|
|
||||||
## Set up CI!
|
This command runs unit tests for the UI components to ensure they function as expected.
|
||||||
|
|
||||||
### Step 1
|
```sh
|
||||||
|
npm run ui:test
|
||||||
|
```
|
||||||
|
|
||||||
To connect to Nx Cloud, run the following command:
|
- **Build Components**
|
||||||
|
|
||||||
```sh
|
This command generates a static build of Storybook, which can be deployed for sharing and documentation purposes.
|
||||||
npx nx connect
|
|
||||||
```
|
|
||||||
|
|
||||||
Connecting to Nx Cloud ensures a [fast and scalable CI](https://nx.dev/ci/intro/why-nx-cloud?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) pipeline. It includes features such as:
|
```sh
|
||||||
|
npm run ui:build
|
||||||
|
```
|
||||||
|
|
||||||
- [Remote caching](https://nx.dev/ci/features/remote-cache?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
## How to contribute
|
||||||
- [Task distribution across multiple machines](https://nx.dev/ci/features/distribute-task-execution?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
|
||||||
- [Automated e2e test splitting](https://nx.dev/ci/features/split-e2e-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
|
||||||
- [Task flakiness detection and rerunning](https://nx.dev/ci/features/flaky-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
|
||||||
|
|
||||||
### Step 2
|
1. Fork the repository and clone it locally.
|
||||||
|
2. Create a new branch for a new feature or fix:
|
||||||
|
```sh
|
||||||
|
git checkout -b feat/feature-name
|
||||||
|
```
|
||||||
|
3. Make changes, commit, and push to your forked repository.
|
||||||
|
4. Create a pull request to this repository `develop` branch.
|
||||||
|
|
||||||
Use the following command to configure a CI workflow for your workspace:
|
If you encounter any issues or problems, feel free to create a new Issue.
|
||||||
|
|
||||||
```sh
|
|
||||||
npx nx g ci-workflow
|
|
||||||
```
|
|
||||||
|
|
||||||
[Learn more about Nx on CI](https://nx.dev/ci/intro/ci-with-nx#ready-get-started-with-your-provider?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
|
||||||
|
|
||||||
## Install Nx Console
|
|
||||||
|
|
||||||
Nx Console is an editor extension that enriches your developer experience. It lets you run tasks, generate code, and improves code autocompletion in your IDE. It is available for VSCode and IntelliJ.
|
|
||||||
|
|
||||||
[Install Nx Console »](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
|
||||||
|
|
||||||
## Useful links
|
|
||||||
|
|
||||||
Learn more:
|
|
||||||
|
|
||||||
- [Learn more about this workspace setup](https://nx.dev/getting-started/tutorials/react-monorepo-tutorial?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
|
||||||
- [Learn about Nx on CI](https://nx.dev/ci/intro/ci-with-nx?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
|
||||||
- [Releasing Packages with Nx release](https://nx.dev/features/manage-releases?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
|
||||||
- [What are Nx plugins?](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
|
||||||
|
|
||||||
And join the Nx community:
|
|
||||||
- [Discord](https://go.nx.dev/community)
|
|
||||||
- [Follow us on X](https://twitter.com/nxdevtools) or [LinkedIn](https://www.linkedin.com/company/nrwl)
|
|
||||||
- [Our Youtube channel](https://www.youtube.com/@nxdevtools)
|
|
||||||
- [Our blog](https://nx.dev/blog?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Tue, Nov 25, 2025 4:21:27 PM
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Deployment trigger
|
||||||
|
# Updated to deploy circular dependency fixes and auth hooks
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<base href="/" />
|
<base href="/" />
|
||||||
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
|
||||||
<link rel="stylesheet" href="/src/styles.css" />
|
<link rel="stylesheet" href="/src/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,162 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface IModalEditAccount {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleEditAccount?: () => void;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalEditAccount = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
currentStep,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
resetStep,
|
||||||
|
handleEditAccount,
|
||||||
|
}: IModalEditAccount) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<StepTwo
|
||||||
|
onClose={onClose}
|
||||||
|
handleEditAccount={handleEditAccount}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepOneProps {
|
||||||
|
nextStep: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||||
|
const [fullName, setFullName] = useState('Ahmad Wiyana');
|
||||||
|
const [email, setEmail] = useState('fullname23@gmail.com');
|
||||||
|
const [phoneNumber, setPhoneNumber] = useState('081904423804');
|
||||||
|
const [address, setAddress] = useState('Jl. Pantai Cibaduyut Indah');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header>
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Edit Data Akun
|
||||||
|
</h2>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<InputField
|
||||||
|
label="Nama Lengkap"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Nama Lengkap"
|
||||||
|
value={fullName}
|
||||||
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Email"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Nomor Telepon"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Nomor Telepon"
|
||||||
|
value={phoneNumber}
|
||||||
|
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Alamat"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Alamat"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={nextStep}
|
||||||
|
>
|
||||||
|
Perbarui Data
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepTwoProps {
|
||||||
|
onClose: () => void;
|
||||||
|
handleEditAccount?: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepTwo = ({ onClose, handleEditAccount, resetStep }: IStepTwoProps) => (
|
||||||
|
<>
|
||||||
|
<Modal.Header className="mb-0 text-center items-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Update Data
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin dengan
|
||||||
|
<br /> perubahan yang dilakukan?
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
handleEditAccount && handleEditAccount();
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ModalEditAccount;
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||||
|
import {
|
||||||
|
FilterOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
useReactTable,
|
||||||
|
RowSelectionState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import ModalEditAccount from './_components/modal-edit-account';
|
||||||
|
import { useQueryState } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
interface Account {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock data for demonstration
|
||||||
|
const mockData: Account[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
||||||
|
email: 'fullname23@gmail.com',
|
||||||
|
phone: '081904423804',
|
||||||
|
address: 'Jl. Pantai Cibaduyut Indah',
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
const [showModalEditAccount, setShowModalEditAccount] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
step: currentStep,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
resetStep,
|
||||||
|
} = useQueryState('step', {
|
||||||
|
defaultValue: 1,
|
||||||
|
maxValue: 2,
|
||||||
|
minValue: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
||||||
|
const [showFilter, setShowFilter] = useState(false);
|
||||||
|
|
||||||
|
const columns: ColumnDef<Account>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'No',
|
||||||
|
accessorKey: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Nama Lengkap',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Email',
|
||||||
|
accessorKey: 'email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Nomor Telp',
|
||||||
|
accessorKey: 'phone',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Alamat Pengiriman',
|
||||||
|
accessorKey: 'address',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowModalEditAccount(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<EditOutlined /> Edit
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||||
|
<h1 className="text-p2 font-semibold">Data Akun</h1>
|
||||||
|
</header>
|
||||||
|
{/* Account Table Section */}
|
||||||
|
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||||
|
{/* Search and Filter */}
|
||||||
|
<div className="flex justify-between items-center gap-8 mb-2">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Cari berdasarkan nama lengkap, email"
|
||||||
|
className="pl-12 w-full max-h-full"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||||
|
<SearchOutlined />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="flex items-center gap-3"
|
||||||
|
disabled
|
||||||
|
onClick={() => setShowFilter(!showFilter)}
|
||||||
|
>
|
||||||
|
<FilterOutlined />
|
||||||
|
Filters
|
||||||
|
</Button>
|
||||||
|
{showFilter && (
|
||||||
|
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
||||||
|
<Filter onClose={() => setShowFilter(false)} options={[]} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Modal Edit Account */}
|
||||||
|
<ModalEditAccount
|
||||||
|
currentStep={currentStep}
|
||||||
|
isOpen={showModalEditAccount}
|
||||||
|
onClose={() => setShowModalEditAccount(false)}
|
||||||
|
handleEditAccount={() => {
|
||||||
|
console.log('Account updated');
|
||||||
|
}}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
|
||||||
|
import { PieLabelProps } from "recharts/types/polar/Pie"
|
||||||
|
|
||||||
|
type ChartProps = {
|
||||||
|
name: string
|
||||||
|
value: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData: ChartProps[] = [
|
||||||
|
{ name: "Active", value: 49, color: "#23A1EB" },
|
||||||
|
{ name: "Done", value: 24, color: "#81CBF8" },
|
||||||
|
{ name: "Canceled", value: 27, color: "#BCE1FB" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const RADIAN = Math.PI / 180;
|
||||||
|
const renderCustomizedLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }: PieLabelProps) => {
|
||||||
|
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||||
|
const x = cx + radius * Math.cos(-(midAngle ?? 0) * RADIAN);
|
||||||
|
const y = cy + radius * Math.sin(-(midAngle ?? 0) * RADIAN);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
|
||||||
|
{`${((percent ?? 1) * 100).toFixed(0)}%`}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SessionStatusChart = () => {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<PieChart width={500} height={320}>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={40}
|
||||||
|
outerRadius={100}
|
||||||
|
labelLine={false}
|
||||||
|
label={renderCustomizedLabel}
|
||||||
|
>
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
|
||||||
|
|
||||||
|
type ChartProps = {
|
||||||
|
name: string
|
||||||
|
activeUser: number
|
||||||
|
activeSession: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData: ChartProps[] = [
|
||||||
|
{ name: "2014", activeUser: 0, activeSession: 0 },
|
||||||
|
{ name: "2015", activeUser: 15, activeSession: 25 },
|
||||||
|
{ name: "2016", activeUser: 30, activeSession: 40 },
|
||||||
|
{ name: "2017", activeUser: 45, activeSession: 55 },
|
||||||
|
{ name: "2018", activeUser: 60, activeSession: 70 },
|
||||||
|
{ name: "2019", activeUser: 75, activeSession: 85 },
|
||||||
|
{ name: "2020", activeUser: 90, activeSession: 95 },
|
||||||
|
{ name: "2021", activeUser: 85, activeSession: 80 },
|
||||||
|
{ name: "2022", activeUser: 95, activeSession: 90 },
|
||||||
|
{ name: "2023", activeUser: 100, activeSession: 100 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const UserGrowthChart = () => {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<LineChart data={chartData} width={500} height={320} margin={{ left: -32 }}>
|
||||||
|
<CartesianGrid />
|
||||||
|
<XAxis dataKey="name" />
|
||||||
|
<YAxis tickCount={10} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Line dataKey="activeUser" stroke="#23A1EB" />
|
||||||
|
<Line dataKey="activeSession" stroke="#0877C1" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { BackofficeWrapper } from "@imphnen-frontend-service/ui/organisms";
|
||||||
|
import { For } from "@imphnen-frontend-service/utils";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import { UserGrowthChart } from "./_components/chart/user-growth";
|
||||||
|
import { SessionStatusChart } from "./_components/chart/session-status";
|
||||||
|
|
||||||
|
const Overview = () => {
|
||||||
|
return (
|
||||||
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">0</h3>
|
||||||
|
<p className="text-neutral-400 text-p3">Total Users</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Components(): ReactElement {
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="Dimentorin.dev">
|
||||||
|
<h1 className="text-p1 font-semibold text-neutral-700 mb-5">Overview</h1>
|
||||||
|
|
||||||
|
<div className="space-y-14">
|
||||||
|
<div>
|
||||||
|
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
|
||||||
|
Overview
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-5 gap-5">
|
||||||
|
<For data={Array.from({ length: 5 })}>
|
||||||
|
{(_, index) => <Overview key={index} />}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
|
||||||
|
Trends & Analytics
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-x-5">
|
||||||
|
<div className="bg-white px-6 py-4 rounded-lg col-span-5">
|
||||||
|
<div className="flex items-center justify-between mb-7">
|
||||||
|
<h2 className="font-semibold text-p3 text-neutral-700">User Growth</h2>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<UserGrowthChart />
|
||||||
|
</div>
|
||||||
|
<div className="bg-white px-6 py-4 rounded-lg col-span-2">
|
||||||
|
<h2 className="font-semibold text-p3 text-neutral-700 mb-7">Session Status</h2>
|
||||||
|
<SessionStatusChart />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-x-5">
|
||||||
|
<div className="bg-white px-7 py-4 rounded-lg">
|
||||||
|
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top 5 Mentors</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-label1 bg-primary-50 text-left rounded-full">
|
||||||
|
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
|
||||||
|
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
|
||||||
|
<th className="font-medium py-4 px-5 rounded-r-lg">Avg Rating</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<For data={Array.from({ length: 5 })}>
|
||||||
|
{(_, index) => (
|
||||||
|
<tr key={index} className="shadow rounded-lg">
|
||||||
|
<td className="py-4 px-5">{index + 1}</td>
|
||||||
|
<td className="py-4 px-5">Mursid Al-Catraz</td>
|
||||||
|
<td className="py-4 px-5">4.9</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white px-6 py-4 rounded-lg">
|
||||||
|
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top Booked Mentoring Topics</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-label1 bg-primary-50 text-left font-medium">
|
||||||
|
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
|
||||||
|
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
|
||||||
|
<th className="font-medium py-4 px-5 rounded-r-lg">Total Sesi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<For data={Array.from({ length: 5 })}>
|
||||||
|
{(_, index) => (
|
||||||
|
<tr key={index} className="shadow rounded-lg">
|
||||||
|
<td className="py-4 px-5">{index + 1}</td>
|
||||||
|
<td className="py-4 px-5">Mursid Al-Catraz</td>
|
||||||
|
<td className="py-4 px-5">1000</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { useConfirmItem, useItem } from '../_hook/use-item';
|
||||||
|
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
|
interface IModalAddItem {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleAddItem?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalAddItem = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
currentStep,
|
||||||
|
nextStep,
|
||||||
|
resetStep,
|
||||||
|
handleAddItem,
|
||||||
|
}: IModalAddItem) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<StepTwo
|
||||||
|
onClose={onClose}
|
||||||
|
handleAddItem={handleAddItem}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepOneProps {
|
||||||
|
nextStep: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||||
|
const { form, onSubmit } = useItem(nextStep);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header>
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Tambah Item Gacha
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Lengkapi detail di bawah ini untuk menambahkan item gacha
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Nama Hadiah"
|
||||||
|
name="itemName"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Nama Hadiah"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Quantity"
|
||||||
|
name="quantity"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="Masukkan Kuantitas Item"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Foto Barang"
|
||||||
|
type="file"
|
||||||
|
name="foto"
|
||||||
|
placeholder=".jpg, .jpeg, atau .png"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={nextStep}
|
||||||
|
>
|
||||||
|
Tambahkan Item
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepTwoProps {
|
||||||
|
onClose: () => void;
|
||||||
|
handleAddItem?: () => Promise<boolean>;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => {
|
||||||
|
const { onConfirm, onCancel } = useConfirmItem(
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleAddItem,
|
||||||
|
{
|
||||||
|
success: 'Item ditambahkan ke gacha item',
|
||||||
|
error: 'Item gagal ditambahkan ke gacha item',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header className="mb-0 text-center items-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Tambah Item
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin ingin
|
||||||
|
<br /> menambahkan item ini?
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Tambahkan
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalAddItem;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
|
||||||
|
interface IModalDeleteItem {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleDeleteItem?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalDeleteItem = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
handleDeleteItem,
|
||||||
|
}: IModalDeleteItem) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
closeButtonClassName="hidden"
|
||||||
|
>
|
||||||
|
<Modal.Header className="gap-8">
|
||||||
|
<img
|
||||||
|
src="/chibi-delete.webp"
|
||||||
|
alt="Delete item?"
|
||||||
|
width={148}
|
||||||
|
className="self-center"
|
||||||
|
/>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
|
||||||
|
Delete Item
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin untuk menghapus item ini?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Batal Hapus
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteItem && handleDeleteItem();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hapus Item
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalDeleteItem;
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { useConfirmItem, useItem } from '../_hook/use-item';
|
||||||
|
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
|
interface IModalEditItem {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleEditItem?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalEditItem = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
currentStep,
|
||||||
|
nextStep,
|
||||||
|
resetStep,
|
||||||
|
handleEditItem,
|
||||||
|
}: IModalEditItem) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<StepTwo
|
||||||
|
onClose={onClose}
|
||||||
|
handleEditItem={handleEditItem}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepOneProps {
|
||||||
|
nextStep: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||||
|
const initialValues = {
|
||||||
|
itemName: 'Hoodie IMPHNEN Official 2025',
|
||||||
|
quantity: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { form, onSubmit } = useItem(nextStep, initialValues);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header>
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Edit Item Gacha
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Silakan mengubah detail dari item yang diperlukan
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex flex-col gap-8">
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Nama Hadiah"
|
||||||
|
type="text"
|
||||||
|
name="itemName"
|
||||||
|
placeholder="Masukkan Nama Hadiah"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Quantity"
|
||||||
|
type="number"
|
||||||
|
name="quantity"
|
||||||
|
placeholder="Masukkan Kuantitas Item"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Foto Barang"
|
||||||
|
type="file"
|
||||||
|
name="foto"
|
||||||
|
placeholder=".jpg, .jpeg, atau .png"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={nextStep}
|
||||||
|
>
|
||||||
|
Perbarui Item
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepTwoProps {
|
||||||
|
onClose: () => void;
|
||||||
|
handleEditItem?: () => Promise<boolean>;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepTwo = ({ onClose, handleEditItem, resetStep }: IStepTwoProps) => {
|
||||||
|
const { onConfirm, onCancel } = useConfirmItem(
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleEditItem,
|
||||||
|
{
|
||||||
|
success: 'Perubahan item berhasil dilakukan',
|
||||||
|
error: 'Perubahan item gagal dilakukan',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header className="mb-0 text-center items-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Update Item
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin dengan
|
||||||
|
<br /> perubahan yang dilakukan?
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalEditItem;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import {
|
||||||
|
gachaItemSchema,
|
||||||
|
TGachaItem
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export const useItem = (
|
||||||
|
nextStep: () => void,
|
||||||
|
initialValues?: TGachaItem
|
||||||
|
) => {
|
||||||
|
const form = useForm<TGachaItem>({
|
||||||
|
resolver: zodResolver(gachaItemSchema),
|
||||||
|
mode: 'all',
|
||||||
|
defaultValues: initialValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit((data) => {
|
||||||
|
console.log('Form data:', data);
|
||||||
|
nextStep();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
onSubmit,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useConfirmItem = (
|
||||||
|
onClose: () => void,
|
||||||
|
resetStep: () => void,
|
||||||
|
actionFunction?: () => Promise<boolean>,
|
||||||
|
messages?: {
|
||||||
|
success?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const onConfirm = async () => {
|
||||||
|
try {
|
||||||
|
// const result = await actionFunction?.();
|
||||||
|
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||||
|
toast.success(messages?.success);
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
toast.error(messages?.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
UsergroupAddOutlined,
|
||||||
|
UsergroupDeleteOutlined,
|
||||||
|
UserSwitchOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||||
|
import ModalAddItem from './_components/modal-add-item';
|
||||||
|
import ModalEditItem from './_components/modal-edit-item';
|
||||||
|
import ModalDeleteItem from './_components/modal-delete-item';
|
||||||
|
import { useQueryState } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
const [showModalAddItem, setShowModalAddItem] = useState(false);
|
||||||
|
const [showModalEditItem, setShowModalEditItem] = useState(false);
|
||||||
|
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
step: currentStep,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
resetStep,
|
||||||
|
} = useQueryState('step', {
|
||||||
|
defaultValue: 1,
|
||||||
|
maxValue: 2,
|
||||||
|
minValue: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||||
|
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||||
|
<h1 className="text-p2 font-semibold">Dashboard</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex justify-between gap-[40px] p-8 bg-white rounded-md">
|
||||||
|
<div className="w-full flex flex-col gap-[40px]">
|
||||||
|
<section>
|
||||||
|
<h2 className="text-p2 font-medium text-primary-500 mb-8">
|
||||||
|
Summary
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||||
|
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||||
|
<UsergroupAddOutlined className="text-[20px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-p1 font-semibold">1000</h3>
|
||||||
|
<p className="text-label1 text-neutral-500">Participants</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||||
|
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||||
|
<ReloadOutlined className="text-[20px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-p1 font-semibold">1000</h3>
|
||||||
|
<p className="text-label1 text-neutral-500">
|
||||||
|
Roll and Reroll
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||||
|
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||||
|
<UserSwitchOutlined className="text-[20px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-p1 font-semibold">1000</h3>
|
||||||
|
<p className="text-label1 text-neutral-500">Redeem</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||||
|
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||||
|
<UsergroupDeleteOutlined className="text-[20px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-p1 font-semibold">1000</h3>
|
||||||
|
<p className="text-label1 text-neutral-500">
|
||||||
|
Inactive Users
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-8">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-p2 font-medium text-primary-500">
|
||||||
|
Gacha Items
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="items-end gap-3"
|
||||||
|
onClick={() => setShowModalAddItem(true)}
|
||||||
|
>
|
||||||
|
<span>Tambah Item</span>
|
||||||
|
<PlusOutlined className="text-[16px]" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 max-h-140 overflow-auto">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((item) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className="bg-white overflow-clip rounded-lg shadow-sm flex justify-between border border-neutral-100"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col py-4 px-6 gap-[8px]">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-p3 text-primary-500 font-medium">
|
||||||
|
Lanyard IMPHNEN
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center justify-start gap-10 text-label2 text-gray-500 mt-1">
|
||||||
|
<span>Prize {item}</span>
|
||||||
|
<span>Quantity: 10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-start gap-2">
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent hover:text-primary-500"
|
||||||
|
onClick={() => setShowModalEditItem(true)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
className="text-[10px] text-red-500 p-0 font-normal hover:bg-transparent hover:text-red-700"
|
||||||
|
onClick={() => setShowModalDeleteItem(true)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img src="gacha-clip.webp" alt="Lanyard IMPHNEN" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right-side illustration */}
|
||||||
|
<img
|
||||||
|
src="gacha.webp"
|
||||||
|
alt=""
|
||||||
|
className="rounded-lg hidden xl:block xl:min-w-[436px] h-auto object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Modal Add Item */}
|
||||||
|
<ModalAddItem
|
||||||
|
currentStep={currentStep}
|
||||||
|
isOpen={showModalAddItem}
|
||||||
|
onClose={() => setShowModalAddItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Edit Item */}
|
||||||
|
<ModalEditItem
|
||||||
|
currentStep={currentStep}
|
||||||
|
isOpen={showModalEditItem}
|
||||||
|
onClose={() => setShowModalEditItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Delete Item */}
|
||||||
|
<ModalDeleteItem
|
||||||
|
isOpen={showModalDeleteItem}
|
||||||
|
onClose={() => setShowModalDeleteItem(false)}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { SearchOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
|
||||||
|
import { cn, For } from "@imphnen-frontend-service/utils";
|
||||||
|
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
|
||||||
|
import { ReactElement, useState } from "react"
|
||||||
|
|
||||||
|
const TABS = {
|
||||||
|
MENTORING: 'Mentoring',
|
||||||
|
PLATFORM: 'Platform'
|
||||||
|
} as const
|
||||||
|
type Tabs = typeof TABS[keyof typeof TABS]
|
||||||
|
|
||||||
|
type FeedbackStatus = 'done' | 'todo';
|
||||||
|
|
||||||
|
interface FeedbackType {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
rating: number
|
||||||
|
status: FeedbackStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: FeedbackType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana',
|
||||||
|
email: 'fullname23@gmail.com',
|
||||||
|
rating: 4.5,
|
||||||
|
status: i % 2 === 0 ? 'done' : 'todo',
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default function Components(): ReactElement {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tabs>(TABS.MENTORING)
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: ColumnDef<FeedbackType>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
meta: { cellClassName: cn("w-20") },
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'email',
|
||||||
|
header: 'Email',
|
||||||
|
accessorKey: 'email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rating',
|
||||||
|
header: 'Rating',
|
||||||
|
accessorKey: 'rating',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status;
|
||||||
|
const statusColors: Record<FeedbackStatus, string> = {
|
||||||
|
done: 'bg-success-200 text-success-500',
|
||||||
|
todo: 'bg-primary-200 text-primary-500',
|
||||||
|
};
|
||||||
|
const statusText: Record<FeedbackStatus, string> = {
|
||||||
|
done: 'Done',
|
||||||
|
todo: 'To Do',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||||
|
>
|
||||||
|
{statusText[status]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
meta: { cellClassName: cn("w-72") },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-max"
|
||||||
|
>
|
||||||
|
<SearchOutlined className="text-[16px]" /> Lihat Feedback
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="Dimentorin.dev">
|
||||||
|
<div className="mb-8 flex justify-between items-center">
|
||||||
|
<h1 className="text-p1 font-semibold text-neutral-700">Feedback</h1>
|
||||||
|
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
||||||
|
<For data={Object.values(TABS)}>
|
||||||
|
{(tab) => (
|
||||||
|
<Button
|
||||||
|
key={tab}
|
||||||
|
variant="text"
|
||||||
|
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||||
|
<div className="flex justify-between items-center gap-5 mb-2">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Cari berdasarkan nama mentor/mentee"
|
||||||
|
className="pl-12 w-full max-h-full"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||||
|
<SearchOutlined />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Select>
|
||||||
|
<option selected disabled>Rating</option>
|
||||||
|
<option value="4.5">4.5</option>
|
||||||
|
<option value="5">5</option>
|
||||||
|
</Select>
|
||||||
|
<Select>
|
||||||
|
<option selected disabled>Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { useItem, useConfirmItem } from '../_hook/use-item';
|
||||||
|
|
||||||
|
interface IModalAddItem {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleAddItem?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalAddItem = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
currentStep,
|
||||||
|
nextStep,
|
||||||
|
resetStep,
|
||||||
|
handleAddItem,
|
||||||
|
}: IModalAddItem) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<StepTwo
|
||||||
|
onClose={onClose}
|
||||||
|
handleAddItem={handleAddItem}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepOneProps {
|
||||||
|
nextStep: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||||
|
const { form, onSubmit } = useItem(nextStep);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header>
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Tambah Item Roll Gacha
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Lengkapi detal di bawah ini, untuk menambahkan item gacha
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Pilih Item"
|
||||||
|
name="itemName"
|
||||||
|
type="text"
|
||||||
|
placeholder="Pilih item yang dimasukkan ke roll"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Quantity"
|
||||||
|
name="quantity"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="Masukkan Kuantitas Item"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Chance Rate"
|
||||||
|
name="chanceRate"
|
||||||
|
type="number"
|
||||||
|
value={0.1}
|
||||||
|
min={0.1}
|
||||||
|
step={0.1}
|
||||||
|
max={1}
|
||||||
|
placeholder="Masukkan Chance Rate (0,1 - 1)"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="primary" size="lg" className="w-full" type="submit">
|
||||||
|
Tambahkan Item
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepTwoProps {
|
||||||
|
onClose: () => void;
|
||||||
|
handleAddItem?: () => Promise<boolean>;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => {
|
||||||
|
const { onConfirm, onCancel } = useConfirmItem(
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleAddItem,
|
||||||
|
{
|
||||||
|
success: 'Item ditambahkan ke roll gacha',
|
||||||
|
error: 'Item gagal ditambahkan ke roll gacha',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header className="mb-0 text-center items-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Tambah ke Roll Gacha
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin ingin
|
||||||
|
<br /> menambahkan item ini ke roll gacha?
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Tambahkan
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalAddItem;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
|
||||||
|
interface IModalDeleteItem {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleDeleteItem?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalDeleteItem = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
handleDeleteItem,
|
||||||
|
}: IModalDeleteItem) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
closeButtonClassName="hidden"
|
||||||
|
>
|
||||||
|
<Modal.Header className="gap-8">
|
||||||
|
<img
|
||||||
|
src="/chibi-delete.webp"
|
||||||
|
alt="Delete item?"
|
||||||
|
width={148}
|
||||||
|
className="self-center"
|
||||||
|
/>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
|
||||||
|
Delete Item
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin untuk menghapus item ini?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Batal Hapus
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteItem && handleDeleteItem();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hapus Item
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalDeleteItem;
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { useConfirmItem, useItem } from '../_hook/use-item';
|
||||||
|
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
|
interface IModalUpdateItem {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleUpdateItem?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalUpdateItem = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
currentStep,
|
||||||
|
nextStep,
|
||||||
|
resetStep,
|
||||||
|
handleUpdateItem,
|
||||||
|
}: IModalUpdateItem) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<StepTwo
|
||||||
|
onClose={onClose}
|
||||||
|
handleUpdateItem={handleUpdateItem}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepOneProps {
|
||||||
|
nextStep: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||||
|
const initialValues = {
|
||||||
|
itemName: 'Hoodie IMPHNEN Official 2025',
|
||||||
|
quantity: 10,
|
||||||
|
chanceRate: 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { form, onSubmit } = useItem(nextStep, initialValues);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header>
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Update Item Roll Gacha
|
||||||
|
</h2>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Pilih Item"
|
||||||
|
name="itemName"
|
||||||
|
type="text"
|
||||||
|
placeholder="Pilih item yang dimasukkan ke roll"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Quantity"
|
||||||
|
name="quantity"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="Masukkan Kuantitas Item"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Chance Rate"
|
||||||
|
name="chanceRate"
|
||||||
|
type="number"
|
||||||
|
value={0.1}
|
||||||
|
min={0.1}
|
||||||
|
step={0.1}
|
||||||
|
max={1}
|
||||||
|
placeholder="Masukkan Chance Rate (0,1 - 1)"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={nextStep}
|
||||||
|
>
|
||||||
|
Perbarui Item
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepTwoProps {
|
||||||
|
onClose: () => void;
|
||||||
|
handleUpdateItem?: () => Promise<boolean>;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepTwo = ({ onClose, handleUpdateItem, resetStep }: IStepTwoProps) => {
|
||||||
|
const { onConfirm, onCancel } = useConfirmItem(
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleUpdateItem,
|
||||||
|
{
|
||||||
|
success: 'Perubahan item roll berhasil dilakukan',
|
||||||
|
error: 'Perubahan item roll gagal dilakukan',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header className="mb-0 text-center items-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Update Item
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin dengan
|
||||||
|
<br /> perubahan yang dilakukan?
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalUpdateItem;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import {
|
||||||
|
gachaRollItemSchema,
|
||||||
|
TGachaRollItem
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export const useItem = (
|
||||||
|
nextStep: () => void,
|
||||||
|
initialValues?: TGachaRollItem
|
||||||
|
) => {
|
||||||
|
const form = useForm<TGachaRollItem>({
|
||||||
|
resolver: zodResolver(gachaRollItemSchema),
|
||||||
|
mode: 'all',
|
||||||
|
defaultValues: initialValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit((data) => {
|
||||||
|
console.log('Form data:', data);
|
||||||
|
nextStep();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
onSubmit,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useConfirmItem = (
|
||||||
|
onClose: () => void,
|
||||||
|
resetStep: () => void,
|
||||||
|
actionFunction?: () => Promise<boolean>,
|
||||||
|
messages?: {
|
||||||
|
success?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const onConfirm = async () => {
|
||||||
|
try {
|
||||||
|
// const result = await actionFunction?.();
|
||||||
|
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||||
|
toast.success(messages?.success);
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
toast.error(messages?.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
useReactTable,
|
||||||
|
RowSelectionState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import ModalAddItem from './_components/modal-add-item';
|
||||||
|
import ModalUpdateItem from './_components/modal-update-item';
|
||||||
|
import ModalDeleteItem from './_components/modal-delete-item';
|
||||||
|
import { useQueryState } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
interface GachaItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
chanceRate: number;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: GachaItem[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: 'Hoodie IMPHNEN Official 2025',
|
||||||
|
chanceRate: 0.1,
|
||||||
|
quantity: 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
const [showModalAddItem, setShowModalAddItem] = useState(false);
|
||||||
|
const [showModalUpdateItem, setShowModalUpdateItem] = useState(false);
|
||||||
|
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
step: currentStep,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
resetStep,
|
||||||
|
} = useQueryState('step', {
|
||||||
|
defaultValue: 1,
|
||||||
|
maxValue: 2,
|
||||||
|
minValue: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
||||||
|
|
||||||
|
const columns: ColumnDef<GachaItem>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'No',
|
||||||
|
accessorKey: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Nama Item',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Chance Rate',
|
||||||
|
accessorKey: 'chanceRate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Quantity',
|
||||||
|
accessorKey: 'quantity',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: () => (
|
||||||
|
<div className="flex gap-[8px]">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowModalUpdateItem(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<EditOutlined /> Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowModalDeleteItem(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<DeleteOutlined /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||||
|
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||||
|
<h1 className="text-p2 font-semibold">Gacha Roll</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||||
|
<div className="flex justify-between items-center gap-8 mb-2">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Cari berdasarkan nama item"
|
||||||
|
className="pl-12 w-full max-h-full"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||||
|
<SearchOutlined />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="flex gap-3 text-nowrap"
|
||||||
|
onClick={() => {
|
||||||
|
setShowModalAddItem(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
Tambah Item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<ModalAddItem
|
||||||
|
currentStep={currentStep}
|
||||||
|
isOpen={showModalAddItem}
|
||||||
|
onClose={() => setShowModalAddItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
<ModalUpdateItem
|
||||||
|
currentStep={currentStep}
|
||||||
|
isOpen={showModalUpdateItem}
|
||||||
|
onClose={() => setShowModalUpdateItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
<ModalDeleteItem
|
||||||
|
isOpen={showModalDeleteItem}
|
||||||
|
onClose={() => setShowModalDeleteItem(false)}
|
||||||
|
handleDeleteItem={() => {
|
||||||
|
console.log('Item deleted');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { FC, ReactElement } from 'react';
|
||||||
|
|
||||||
|
export const HackathonDashboardPage: FC = (): ReactElement => {
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-5 gap-5">
|
||||||
|
{/* Participant */}
|
||||||
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||||
|
1261
|
||||||
|
</h3>
|
||||||
|
<p className="text-neutral-400 text-p3">Total Participants</p>
|
||||||
|
</div>
|
||||||
|
{/* Team */}
|
||||||
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">206</h3>
|
||||||
|
<p className="text-neutral-400 text-p3">Total Teams</p>
|
||||||
|
</div>
|
||||||
|
{/* Project Submitted */}
|
||||||
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">0</h3>
|
||||||
|
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonDashboardPage;
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { FC, ReactElement, useState } from 'react';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import { EditOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
export const HackathonUsersPage: FC = (): ReactElement => {
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockData: any[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
project_name: `Project ${i + 1}`,
|
||||||
|
repository_url: `https://github.com/user/repo${i + 1}`,
|
||||||
|
demo_url: `https://demo.example.com/project${i + 1}`,
|
||||||
|
presentation_url: `https://slides.example.com/project${i + 1}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
type UserStatus = 'active' | 'inactive';
|
||||||
|
|
||||||
|
interface SubmissionType {
|
||||||
|
id: number;
|
||||||
|
project_name: string;
|
||||||
|
repository_url: string;
|
||||||
|
demo_url: string;
|
||||||
|
presentation_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<SubmissionType>[] = [
|
||||||
|
{
|
||||||
|
header: 'Project Name',
|
||||||
|
accessorKey: 'project_name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Repository URL',
|
||||||
|
accessorKey: 'repository_url',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Demo URL',
|
||||||
|
accessorKey: 'demo_url',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Presentation URL',
|
||||||
|
accessorKey: 'presentation_url',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
meta: { cellClassName: cn('w-72') },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 w-max"
|
||||||
|
onClick={() => {
|
||||||
|
// View detail logic
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditOutlined className="text-base" /> View & Manage
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
|
Project Submission
|
||||||
|
</h1>
|
||||||
|
{/* Filters and actions */}
|
||||||
|
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64"
|
||||||
|
placeholder="Search name or email"
|
||||||
|
/>
|
||||||
|
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="suspended">Suspended</option>
|
||||||
|
</select>
|
||||||
|
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||||
|
<option value="all">All City</option>
|
||||||
|
<option value="jakarta">Jakarta</option>
|
||||||
|
<option value="bandung">Bandung</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Modals extracted into shared backoffice components */}
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonUsersPage;
|
||||||
+822
@@ -0,0 +1,822 @@
|
|||||||
|
import { FC, useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import TeamBannerPlaceholder from './team-banner-placeholder';
|
||||||
|
import { CityFilterSelect } from '../../../../components/city-filter-select';
|
||||||
|
import {
|
||||||
|
TeamOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
SaveOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
ExclamationOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
EyeInvisibleOutlined,
|
||||||
|
CrownOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
CameraOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface TeamMember {
|
||||||
|
id: string;
|
||||||
|
joined_at: string;
|
||||||
|
role: 'leader' | 'member';
|
||||||
|
status: 'pending' | 'accepted' | 'rejected';
|
||||||
|
team_id: string;
|
||||||
|
user: {
|
||||||
|
avatar?: string;
|
||||||
|
bio?: string;
|
||||||
|
created_at: string;
|
||||||
|
email: string;
|
||||||
|
fullname: string;
|
||||||
|
id: string;
|
||||||
|
is_active: boolean;
|
||||||
|
location: string;
|
||||||
|
phone_number?: string;
|
||||||
|
skills: string[];
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
city: string;
|
||||||
|
banner?: string;
|
||||||
|
logo?: string;
|
||||||
|
visibility: 'public' | 'private';
|
||||||
|
member_count: number;
|
||||||
|
has_submission: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
leader_id: string;
|
||||||
|
members: TeamMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
team: TeamType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||||
|
const [formData, setFormData] = useState<TeamType | null>(null);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<'details' | 'members'>('details');
|
||||||
|
const [showLogoMenu, setShowLogoMenu] = useState(false);
|
||||||
|
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const bannerInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Initialize form data when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (team) {
|
||||||
|
// Edit existing team
|
||||||
|
setFormData({ ...team });
|
||||||
|
} else {
|
||||||
|
// Create new team
|
||||||
|
setFormData({
|
||||||
|
id: '', // Will be generated by backend
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
city: '',
|
||||||
|
banner: undefined,
|
||||||
|
logo: undefined,
|
||||||
|
visibility: 'public',
|
||||||
|
member_count: 1,
|
||||||
|
has_submission: false,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
leader_id: '',
|
||||||
|
members: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, team]);
|
||||||
|
|
||||||
|
// Check if form has changes
|
||||||
|
const hasChanges = useMemo(() => {
|
||||||
|
if (!formData) return false;
|
||||||
|
if (!team) return true; // New team always has changes
|
||||||
|
return (
|
||||||
|
formData.name !== team.name ||
|
||||||
|
formData.description !== team.description ||
|
||||||
|
formData.city !== team.city ||
|
||||||
|
formData.visibility !== team.visibility ||
|
||||||
|
formData.logo !== team.logo ||
|
||||||
|
formData.banner !== team.banner
|
||||||
|
);
|
||||||
|
}, [formData, team]);
|
||||||
|
|
||||||
|
// Check if required fields are filled
|
||||||
|
const isFormValid = useMemo(() => {
|
||||||
|
if (!formData) return false;
|
||||||
|
return formData.name.trim() !== '' && formData.city.trim() !== '';
|
||||||
|
}, [formData]);
|
||||||
|
|
||||||
|
const canSave = hasChanges && isFormValid;
|
||||||
|
|
||||||
|
// Get leader and other members
|
||||||
|
const leader = team?.members.find((m) => m.role === 'leader');
|
||||||
|
const acceptedMembers =
|
||||||
|
team?.members.filter((m) => m.status === 'accepted') || [];
|
||||||
|
const pendingMembers =
|
||||||
|
team?.members.filter((m) => m.status === 'pending') || [];
|
||||||
|
|
||||||
|
if (!isOpen || !formData) return null;
|
||||||
|
|
||||||
|
const handleInputChange = (
|
||||||
|
field: keyof TeamType,
|
||||||
|
value: string | boolean | 'public' | 'private' | undefined
|
||||||
|
) => {
|
||||||
|
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!formData) return;
|
||||||
|
|
||||||
|
console.log('Saving team:', formData);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!team) return;
|
||||||
|
|
||||||
|
console.log('Deleting team:', team.id);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('Please select an image file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('Image size must be less than 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const logoUrl = e.target?.result as string;
|
||||||
|
handleInputChange('logo', logoUrl);
|
||||||
|
setShowLogoMenu(false);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBannerUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('Please select an image file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('Image size must be less than 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const bannerUrl = e.target?.result as string;
|
||||||
|
handleInputChange('banner', bannerUrl);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveLogo = () => {
|
||||||
|
handleInputChange('logo', undefined);
|
||||||
|
setShowLogoMenu(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveBanner = () => {
|
||||||
|
handleInputChange('banner', undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerLogoUpload = () => {
|
||||||
|
logoInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||||
|
<TeamOutlined className="text-primary-600 text-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-neutral-900">
|
||||||
|
{team ? 'Team Details' : 'Create New Team'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
{team
|
||||||
|
? 'View and manage team information'
|
||||||
|
: 'Add a new team to the hackathon'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setShowLogoMenu(false);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseOutlined className="text-neutral-400 text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}>
|
||||||
|
{/* Hidden File Inputs */}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={logoInputRef}
|
||||||
|
onChange={handleLogoUpload}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={bannerInputRef}
|
||||||
|
onChange={handleBannerUpload}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Interactive Banner Section */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Team Banner
|
||||||
|
<span className="text-xs text-neutral-500 ml-2">
|
||||||
|
(3:1 aspect ratio recommended)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<TeamBannerPlaceholder
|
||||||
|
banner={formData.banner}
|
||||||
|
teamName={formData.name || 'Team Name'}
|
||||||
|
className="rounded-lg border border-neutral-200 transition-all group-hover:border-primary-300"
|
||||||
|
/>
|
||||||
|
{/* Banner Action Buttons */}
|
||||||
|
<div className="absolute inset-0 bg-black/40 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
bannerInputRef.current?.click();
|
||||||
|
}}
|
||||||
|
className="bg-white/90 hover:bg-white text-neutral-700 border-transparent shadow-sm gap-2"
|
||||||
|
>
|
||||||
|
<UploadOutlined className="text-sm" />
|
||||||
|
{formData.banner ? 'Change Banner' : 'Add Banner'}
|
||||||
|
</Button>
|
||||||
|
{formData.banner && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemoveBanner();
|
||||||
|
}}
|
||||||
|
className="bg-white/90 hover:bg-white text-red-600 border-transparent shadow-sm hover:text-red-700 gap-2"
|
||||||
|
>
|
||||||
|
<DeleteOutlined className="text-sm" />
|
||||||
|
Delete Banner
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team Logo & Name Row */}
|
||||||
|
<div className="grid grid-cols-12 gap-4 items-start">
|
||||||
|
{/* Interactive Team Logo */}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||||
|
Logo
|
||||||
|
</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="w-24 h-24 rounded-full bg-neutral-100 flex items-center justify-center overflow-hidden border border-neutral-200 group-hover:border-primary-300 transition-colors">
|
||||||
|
{formData.logo ? (
|
||||||
|
<img
|
||||||
|
src={formData.logo}
|
||||||
|
alt={formData.name || 'Team Logo'}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TeamOutlined className="text-neutral-400 text-xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Logo Hover Overlay - Full circle */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowLogoMenu(!showLogoMenu);
|
||||||
|
}}
|
||||||
|
className="absolute inset-0 bg-neutral-300/80 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center w-24 h-24"
|
||||||
|
>
|
||||||
|
<CameraOutlined className="text-white text-lg" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Logo Menu Dropdown */}
|
||||||
|
{showLogoMenu && (
|
||||||
|
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
triggerLogoUpload();
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<UploadOutlined className="text-sm" />
|
||||||
|
{formData.logo ? 'Change Logo' : 'Upload Logo'}
|
||||||
|
</button>
|
||||||
|
{formData.logo && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemoveLogo();
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<DeleteOutlined className="text-sm" />
|
||||||
|
Remove Logo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team Name */}
|
||||||
|
<div className="col-span-10 space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Team Name <span className="text-danger-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full border border-neutral-200 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||||
|
placeholder="Enter team name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
Click logo to upload or change team logo (circular format)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team Description */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full border border-neutral-200 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
|
||||||
|
placeholder="Enter team description (optional)"
|
||||||
|
rows={3}
|
||||||
|
value={formData.description || ''}
|
||||||
|
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* City */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
City <span className="text-danger-500">*</span>
|
||||||
|
</label>
|
||||||
|
<CityFilterSelect
|
||||||
|
value={formData.city || 'all'}
|
||||||
|
onChange={(city) =>
|
||||||
|
handleInputChange('city', city === 'all' ? '' : city)
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Search cities..."
|
||||||
|
allOptionLabel="Select a city"
|
||||||
|
filterIcon={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visibility */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Team Visibility
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="visibility"
|
||||||
|
value="public"
|
||||||
|
checked={formData.visibility === 'public'}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('visibility', e.target.value as 'public')
|
||||||
|
}
|
||||||
|
className="text-primary-600"
|
||||||
|
/>
|
||||||
|
<EyeOutlined className="text-info-600" />
|
||||||
|
<span className="text-sm">Public</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="visibility"
|
||||||
|
value="private"
|
||||||
|
checked={formData.visibility === 'private'}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('visibility', e.target.value as 'private')
|
||||||
|
}
|
||||||
|
className="text-primary-600"
|
||||||
|
/>
|
||||||
|
<EyeInvisibleOutlined className="text-neutral-600" />
|
||||||
|
<span className="text-sm">Private</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team Information (Read-only for existing teams) */}
|
||||||
|
{team && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Members
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<UserOutlined className="text-neutral-500" />
|
||||||
|
<span className="text-sm text-neutral-700">
|
||||||
|
{team.member_count} member
|
||||||
|
{team.member_count !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Submission Status
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
team.has_submission ? 'bg-success-500' : 'bg-danger-500'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium',
|
||||||
|
team.has_submission
|
||||||
|
? 'text-success-700'
|
||||||
|
: 'text-danger-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{team.has_submission ? 'Submitted' : 'Not Submitted'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs for Details and Members */}
|
||||||
|
{team && (
|
||||||
|
<div>
|
||||||
|
<div className="flex border-b border-neutral-200">
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer',
|
||||||
|
activeTab === 'details'
|
||||||
|
? 'border-primary-500 text-primary-600'
|
||||||
|
: 'border-transparent text-neutral-500 hover:text-neutral-700'
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveTab('details')}
|
||||||
|
>
|
||||||
|
Team Details
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer',
|
||||||
|
activeTab === 'members'
|
||||||
|
? 'border-primary-500 text-primary-600'
|
||||||
|
: 'border-transparent text-neutral-500 hover:text-neutral-700'
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveTab('members')}
|
||||||
|
>
|
||||||
|
Members ({team.member_count})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
{activeTab === 'details' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Leader Information */}
|
||||||
|
{leader && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Team Leader
|
||||||
|
</label>
|
||||||
|
<div className="p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden">
|
||||||
|
{leader.user.avatar ? (
|
||||||
|
<img
|
||||||
|
src={leader.user.avatar}
|
||||||
|
alt={leader.user.fullname}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CrownOutlined className="text-yellow-600 text-sm" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900">
|
||||||
|
{leader.user.fullname}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
{leader.user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timestamps */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Created
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<CalendarOutlined className="text-neutral-500" />
|
||||||
|
<span className="text-sm text-neutral-700">
|
||||||
|
{new Date(team.created_at).toLocaleDateString(
|
||||||
|
'en-US',
|
||||||
|
{
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Last Updated
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<CalendarOutlined className="text-neutral-500" />
|
||||||
|
<span className="text-sm text-neutral-700">
|
||||||
|
{new Date(team.updated_at).toLocaleDateString(
|
||||||
|
'en-US',
|
||||||
|
{
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'members' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Accepted Members */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-neutral-700 mb-3">
|
||||||
|
Active Members ({acceptedMembers.length})
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{acceptedMembers.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden">
|
||||||
|
{member.user.avatar ? (
|
||||||
|
<img
|
||||||
|
src={member.user.avatar}
|
||||||
|
alt={member.user.fullname}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserOutlined className="text-neutral-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-neutral-900">
|
||||||
|
{member.user.fullname}
|
||||||
|
</p>
|
||||||
|
{member.role === 'leader' && (
|
||||||
|
<CrownOutlined className="text-yellow-600 text-xs" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
{member.user.email}
|
||||||
|
</p>
|
||||||
|
{member.user.skills.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{member.user.skills
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((skill, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="px-1 py-0.5 bg-primary-100 text-primary-700 text-xs rounded"
|
||||||
|
>
|
||||||
|
{skill}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{member.user.skills.length > 2 && (
|
||||||
|
<span className="text-xs text-neutral-400">
|
||||||
|
+{member.user.skills.length - 2}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500">
|
||||||
|
Joined{' '}
|
||||||
|
{new Date(member.joined_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pending Members */}
|
||||||
|
{pendingMembers.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-neutral-700 mb-3">
|
||||||
|
Pending Members ({pendingMembers.length})
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pendingMembers.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className="flex items-center gap-3 p-3 bg-orange-50 border border-orange-200 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden">
|
||||||
|
{member.user.avatar ? (
|
||||||
|
<img
|
||||||
|
src={member.user.avatar}
|
||||||
|
alt={member.user.fullname}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserOutlined className="text-neutral-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-neutral-900">
|
||||||
|
{member.user.fullname}
|
||||||
|
</p>
|
||||||
|
<ClockCircleOutlined className="text-orange-600 text-xs" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
{member.user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
onClick={() =>
|
||||||
|
console.log('Accept member:', member.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckCircleOutlined className="text-xs" />
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
onClick={() =>
|
||||||
|
console.log('Reject member:', member.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CloseCircleOutlined className="text-xs" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-t border-neutral-200">
|
||||||
|
<div>
|
||||||
|
{team && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="md"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
Delete Team
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="secondary" size="md" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<SaveOutlined />
|
||||||
|
{team ? 'Save Changes' : 'Create Team'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-danger-100 flex items-center justify-center">
|
||||||
|
<ExclamationOutlined className="text-danger-600 text-xl" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900">
|
||||||
|
Delete Team
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-neutral-700 mb-6">
|
||||||
|
Are you sure you want to delete "{team?.name}"? This will
|
||||||
|
permanently remove the team and all associated data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="md"
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
Delete Team
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalTeamDetail;
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import {
|
||||||
|
CloseOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
ProjectOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface SubmissionModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
teamId,
|
||||||
|
teamName,
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
// Mock submission data
|
||||||
|
const mockSubmission = {
|
||||||
|
id: `submission-${teamId}`,
|
||||||
|
project_name: `${teamName} Project`,
|
||||||
|
repository_url: `https://github.com/${teamName
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, '-')}/hackathon-project`,
|
||||||
|
demo_url: `https://${teamName
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, '-')}.vercel.app`,
|
||||||
|
presentation_url: `https://docs.google.com/presentation/d/${teamId}/edit`,
|
||||||
|
submitted_at: new Date().toISOString(),
|
||||||
|
status: 'submitted',
|
||||||
|
description:
|
||||||
|
'An innovative solution built during the IMPHNEN x Kolosal.ai Hackathon 2025.',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center">
|
||||||
|
<ProjectOutlined className="text-success-600 text-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-neutral-900">
|
||||||
|
Project Submission
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
{teamName} - Hackathon Submission Details
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<CloseOutlined className="text-neutral-400 text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Submission Status */}
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-success-50 border border-success-200 rounded-lg">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-success-500"></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-success-800">
|
||||||
|
Submission Completed
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-success-600">
|
||||||
|
Submitted on{' '}
|
||||||
|
{new Date(mockSubmission.submitted_at).toLocaleDateString(
|
||||||
|
'en-UK',
|
||||||
|
{
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Information */}
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Project Name
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-neutral-900 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
{mockSubmission.project_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Project Description
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-neutral-900 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
{mockSubmission.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Links Section */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Repository
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<span className="text-sm text-neutral-700 flex-1 truncate">
|
||||||
|
{mockSubmission.repository_url}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() =>
|
||||||
|
window.open(mockSubmission.repository_url, '_blank')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LinkOutlined className="text-xs" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Live Demo
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<span className="text-sm text-neutral-700 flex-1 truncate">
|
||||||
|
{mockSubmission.demo_url}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() =>
|
||||||
|
window.open(mockSubmission.demo_url, '_blank')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LinkOutlined className="text-xs" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Presentation
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<span className="text-sm text-neutral-700 flex-1 truncate">
|
||||||
|
{mockSubmission.presentation_url}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() =>
|
||||||
|
window.open(mockSubmission.presentation_url, '_blank')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LinkOutlined className="text-xs" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Note */}
|
||||||
|
<div className="p-4 bg-info-50 border border-info-200 rounded-lg">
|
||||||
|
<p className="text-sm text-info-800">
|
||||||
|
<strong>Note:</strong> This is a submission preview. The team has
|
||||||
|
successfully submitted their project. You can review the
|
||||||
|
submission details and access the project links above.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-end p-6 border-t border-neutral-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="secondary" size="md" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
onClick={() => {
|
||||||
|
// Navigate to hackathon-submissions page
|
||||||
|
console.log('Navigate to full submissions page');
|
||||||
|
// You can implement navigation here
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<ProjectOutlined />
|
||||||
|
View All Submissions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubmissionModal;
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { TeamOutlined } from '@ant-design/icons';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
interface TeamBannerPlaceholderProps {
|
||||||
|
banner?: string;
|
||||||
|
teamName: string;
|
||||||
|
className?: string;
|
||||||
|
showPlaceholder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
||||||
|
banner,
|
||||||
|
teamName,
|
||||||
|
className = '',
|
||||||
|
showPlaceholder = true,
|
||||||
|
}) => {
|
||||||
|
const aspectRatioClass = 'aspect-[3/1]'; // 3:1 aspect ratio
|
||||||
|
|
||||||
|
if (!banner && !showPlaceholder) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (banner) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full bg-gray-100 overflow-hidden relative',
|
||||||
|
aspectRatioClass,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={banner}
|
||||||
|
alt={`${teamName} banner`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
// Fallback to placeholder if image fails to load
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.style.display = 'none';
|
||||||
|
const placeholder = target.nextElementSibling as HTMLElement;
|
||||||
|
if (placeholder) {
|
||||||
|
placeholder.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Fallback placeholder (hidden by default, shown on image error) */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
|
||||||
|
'hidden' // Hidden by default
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<TeamOutlined className="text-4xl text-gray-400 mb-2" />
|
||||||
|
<p className="text-sm text-gray-500 font-medium">{teamName}</p>
|
||||||
|
<p className="text-xs text-gray-400">Team Banner</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No banner - show placeholder
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
|
||||||
|
aspectRatioClass,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<TeamOutlined className="text-4xl text-gray-400 mb-2" />
|
||||||
|
<p className="text-sm text-gray-500 font-medium">{teamName}</p>
|
||||||
|
<p className="text-xs text-gray-400">No Banner</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeamBannerPlaceholder;
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
import { FC, ReactElement, useState, useMemo, useCallback } from 'react';
|
||||||
|
import ModalTeamDetail from './_components/modal-team-detail-new';
|
||||||
|
import SubmissionModal from './_components/submission-modal';
|
||||||
|
import { CityFilterSelect } from '../../../components/city-filter-select';
|
||||||
|
import INDONESIAN_CITIES from '../../../constants/cities';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
EditOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
FilterOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
ArrowRightOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
// Define interface outside component
|
||||||
|
interface TeamMember {
|
||||||
|
id: string;
|
||||||
|
joined_at: string;
|
||||||
|
role: 'leader' | 'member';
|
||||||
|
status: 'pending' | 'accepted' | 'rejected';
|
||||||
|
team_id: string;
|
||||||
|
user: {
|
||||||
|
avatar?: string;
|
||||||
|
bio?: string;
|
||||||
|
created_at: string;
|
||||||
|
email: string;
|
||||||
|
fullname: string;
|
||||||
|
id: string;
|
||||||
|
is_active: boolean;
|
||||||
|
location: string;
|
||||||
|
phone_number?: string;
|
||||||
|
skills: string[];
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
city: string;
|
||||||
|
banner?: string;
|
||||||
|
logo?: string;
|
||||||
|
visibility: 'public' | 'private';
|
||||||
|
member_count: number;
|
||||||
|
has_submission: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
leader_id: string;
|
||||||
|
members: TeamMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move mock data outside component to prevent recreation
|
||||||
|
// Sample data for popular cities from the INDONESIAN_CITIES constant
|
||||||
|
const cities = INDONESIAN_CITIES.slice(0, 20); // Use first 20 cities for variety
|
||||||
|
const teamNames = [
|
||||||
|
'Innovators',
|
||||||
|
'Hackers',
|
||||||
|
'Builders',
|
||||||
|
'Creators',
|
||||||
|
'Pioneers',
|
||||||
|
'Developers',
|
||||||
|
'Engineers',
|
||||||
|
'Coders',
|
||||||
|
'Tech Stars',
|
||||||
|
'Digital Wizards',
|
||||||
|
];
|
||||||
|
|
||||||
|
const descriptions = [
|
||||||
|
'Building innovative solutions for modern problems with cutting-edge technology',
|
||||||
|
'Passionate developers creating the next generation of web applications',
|
||||||
|
'Focused on sustainable tech solutions that make a positive impact',
|
||||||
|
'Experienced team working on scalable fintech innovations',
|
||||||
|
'Creative minds developing user-centric mobile applications',
|
||||||
|
'Full-stack developers building comprehensive business solutions',
|
||||||
|
'AI enthusiasts creating intelligent automation tools',
|
||||||
|
'Open source advocates building community-driven platforms',
|
||||||
|
];
|
||||||
|
|
||||||
|
const skills = [
|
||||||
|
'Frontend Developer',
|
||||||
|
'Backend Developer',
|
||||||
|
'Full Stack Developer',
|
||||||
|
'DevOps Engineer',
|
||||||
|
'UI/UX Designer',
|
||||||
|
'Product Manager',
|
||||||
|
'Data Scientist',
|
||||||
|
'Mobile Developer',
|
||||||
|
];
|
||||||
|
|
||||||
|
const generateMembers = (
|
||||||
|
count: number,
|
||||||
|
teamId: string,
|
||||||
|
leaderId: string
|
||||||
|
): TeamMember[] => {
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
const isLeader = i === 0;
|
||||||
|
const memberId = isLeader ? leaderId : `user-${teamId}-${i}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `member-${teamId}-${i}`,
|
||||||
|
joined_at: new Date(
|
||||||
|
Date.now() - (count - i) * 86400000 * Math.random() * 5
|
||||||
|
).toISOString(),
|
||||||
|
role: isLeader ? 'leader' : 'member',
|
||||||
|
status: Math.random() > 0.8 ? 'pending' : 'accepted',
|
||||||
|
team_id: teamId,
|
||||||
|
user: {
|
||||||
|
id: memberId,
|
||||||
|
avatar:
|
||||||
|
Math.random() > 0.6
|
||||||
|
? `https://ui-avatars.com/api/?name=${encodeURIComponent(
|
||||||
|
`User ${i}`
|
||||||
|
)}`
|
||||||
|
: undefined,
|
||||||
|
bio:
|
||||||
|
Math.random() > 0.5
|
||||||
|
? `Passionate ${skills[
|
||||||
|
Math.floor(Math.random() * skills.length)
|
||||||
|
].toLowerCase()} with ${
|
||||||
|
Math.floor(Math.random() * 8) + 1
|
||||||
|
}+ years experience`
|
||||||
|
: undefined,
|
||||||
|
created_at: new Date(
|
||||||
|
Date.now() - Math.random() * 365 * 86400000
|
||||||
|
).toISOString(),
|
||||||
|
email: `user${i}.team${teamId}@example.com`,
|
||||||
|
fullname: `${
|
||||||
|
['Ahmad', 'Sofia', 'Budi', 'Sari', 'Rizki', 'Maya', 'Andi', 'Dina'][
|
||||||
|
Math.floor(Math.random() * 8)
|
||||||
|
]
|
||||||
|
} ${
|
||||||
|
[
|
||||||
|
'Wijuana',
|
||||||
|
'Santoso',
|
||||||
|
'Pratama',
|
||||||
|
'Dewi',
|
||||||
|
'Nugroho',
|
||||||
|
'Sari',
|
||||||
|
'Putra',
|
||||||
|
'Lestari',
|
||||||
|
][Math.floor(Math.random() * 8)]
|
||||||
|
}`,
|
||||||
|
is_active: true,
|
||||||
|
location: cities[Math.floor(Math.random() * cities.length)],
|
||||||
|
phone_number:
|
||||||
|
Math.random() > 0.7
|
||||||
|
? `+62${Math.floor(Math.random() * 9000000000) + 1000000000}`
|
||||||
|
: undefined,
|
||||||
|
skills: skills.slice(0, Math.floor(Math.random() * 3) + 1),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
user_id: memberId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockData: TeamType[] = Array.from({ length: 50 }, (_, i) => {
|
||||||
|
const teamId = `team-${String(i + 1).padStart(3, '0')}`;
|
||||||
|
const memberCount = Math.floor(Math.random() * 5) + 1; // 1-5 members
|
||||||
|
const leaderId = `leader-${teamId}`;
|
||||||
|
const members = generateMembers(memberCount, teamId, leaderId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: teamId,
|
||||||
|
name: `Team ${teamNames[i % teamNames.length]} ${
|
||||||
|
Math.floor(i / teamNames.length) + 1
|
||||||
|
}`,
|
||||||
|
description:
|
||||||
|
i % 4 === 0 ? undefined : descriptions[i % descriptions.length],
|
||||||
|
city: cities[i % cities.length],
|
||||||
|
banner:
|
||||||
|
i % 3 === 0 ? undefined : `https://picsum.photos/600/200?random=${i}`, // 3:1 aspect ratio
|
||||||
|
logo:
|
||||||
|
i % 4 === 0
|
||||||
|
? undefined
|
||||||
|
: `https://ui-avatars.com/api/?name=${encodeURIComponent(
|
||||||
|
teamNames[i % teamNames.length]
|
||||||
|
)}&background=random&size=120`,
|
||||||
|
visibility: i % 4 === 0 ? 'private' : 'public',
|
||||||
|
member_count: memberCount,
|
||||||
|
has_submission: i % 3 !== 0,
|
||||||
|
created_at: new Date(
|
||||||
|
Date.now() - i * 86400000 * (Math.random() * 15 + 1)
|
||||||
|
).toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
leader_id: leaderId,
|
||||||
|
members: members,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||||
|
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||||
|
const [showNewTeamModal, setShowNewTeamModal] = useState(false);
|
||||||
|
const [showSubmissionModal, setShowSubmissionModal] = useState(false);
|
||||||
|
const [selectedTeam, setSelectedTeam] = useState<TeamType | null>(null);
|
||||||
|
const [selectedSubmissionTeam, setSelectedSubmissionTeam] =
|
||||||
|
useState<TeamType | null>(null);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState('');
|
||||||
|
|
||||||
|
// Advanced filtering states
|
||||||
|
const [visibilityFilter, setVisibilityFilter] = useState('all');
|
||||||
|
const [cityFilter, setCityFilter] = useState('all');
|
||||||
|
const [submissionFilter, setSubmissionFilter] = useState('all');
|
||||||
|
const [memberCountFilter, setMemberCountFilter] = useState('all');
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
|
// Memoize the callback to prevent recreation
|
||||||
|
const handleShowDetailModal = useCallback((team: TeamType) => {
|
||||||
|
setSelectedTeam(team);
|
||||||
|
setShowDetailModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseDetailModal = useCallback(() => {
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedTeam(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShowNewTeamModal = useCallback(() => {
|
||||||
|
setShowNewTeamModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseNewTeamModal = useCallback(() => {
|
||||||
|
setShowNewTeamModal(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShowSubmissionModal = useCallback((team: TeamType) => {
|
||||||
|
setSelectedSubmissionTeam(team);
|
||||||
|
setShowSubmissionModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseSubmissionModal = useCallback(() => {
|
||||||
|
setShowSubmissionModal(false);
|
||||||
|
setSelectedSubmissionTeam(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter data based on current filter states
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
return mockData.filter((team) => {
|
||||||
|
// Global search filter
|
||||||
|
if (globalFilter) {
|
||||||
|
const searchTerm = globalFilter.toLowerCase();
|
||||||
|
const leaderName =
|
||||||
|
team.members.find((m) => m.role === 'leader')?.user.fullname || '';
|
||||||
|
const memberNames = team.members.map((m) => m.user.fullname).join(' ');
|
||||||
|
|
||||||
|
if (
|
||||||
|
!team.name.toLowerCase().includes(searchTerm) &&
|
||||||
|
!team.city.toLowerCase().includes(searchTerm) &&
|
||||||
|
!leaderName.toLowerCase().includes(searchTerm) &&
|
||||||
|
!memberNames.toLowerCase().includes(searchTerm)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visibility filter
|
||||||
|
if (visibilityFilter !== 'all' && team.visibility !== visibilityFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// City filter
|
||||||
|
if (cityFilter !== 'all' && team.city !== cityFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submission filter
|
||||||
|
if (submissionFilter !== 'all') {
|
||||||
|
const hasSubmission = submissionFilter === 'submitted';
|
||||||
|
if (team.has_submission !== hasSubmission) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member count filter
|
||||||
|
if (memberCountFilter !== 'all') {
|
||||||
|
const count = parseInt(memberCountFilter);
|
||||||
|
if (team.member_count !== count) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
globalFilter,
|
||||||
|
visibilityFilter,
|
||||||
|
cityFilter,
|
||||||
|
submissionFilter,
|
||||||
|
memberCountFilter,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Memoize columns to prevent recreation on every render
|
||||||
|
const columns: ColumnDef<TeamType>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Team',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const team = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Team Logo */}
|
||||||
|
<div className="w-10 h-10 rounded-full bg-neutral-100 flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
|
{team.logo ? (
|
||||||
|
<img
|
||||||
|
src={team.logo}
|
||||||
|
alt={team.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TeamOutlined className="text-neutral-400 text-lg" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Team Name & Description */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-neutral-900 truncate">
|
||||||
|
{team.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'city',
|
||||||
|
header: 'City',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-700">{row.original.city}</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'visibility',
|
||||||
|
header: 'Visibility',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isPublic = row.original.visibility === 'public';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
|
||||||
|
isPublic
|
||||||
|
? 'bg-success-100 text-success-800'
|
||||||
|
: 'bg-neutral-100 text-neutral-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPublic ? 'Public' : 'Private'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'member_count',
|
||||||
|
header: 'Members',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserOutlined className="text-neutral-400 text-sm" />
|
||||||
|
<span className="text-sm text-neutral-700">
|
||||||
|
{row.original.member_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leader',
|
||||||
|
header: 'Leader',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const leader = row.original.members.find(
|
||||||
|
(m) => m.role === 'leader'
|
||||||
|
)?.user;
|
||||||
|
return leader ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-neutral-900">
|
||||||
|
{leader.fullname}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500">{leader.email}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-400 italic">No leader</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'has_submission',
|
||||||
|
header: 'Submission',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const hasSubmission = row.original.has_submission;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
hasSubmission ? 'bg-success-500' : 'bg-danger-500'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium',
|
||||||
|
hasSubmission ? 'text-success-700' : 'text-danger-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasSubmission ? 'Submitted' : 'Not Submitted'}
|
||||||
|
</span>
|
||||||
|
{hasSubmission && (
|
||||||
|
<button
|
||||||
|
className="text-xs text-primary-600 hover:text-primary-800 text-left cursor-pointer"
|
||||||
|
onClick={() => handleShowSubmissionModal(row.original)}
|
||||||
|
>
|
||||||
|
View Submission <ArrowRightOutlined />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: (rowA, rowB) => {
|
||||||
|
const aSubmission = rowA.original.has_submission;
|
||||||
|
const bSubmission = rowB.original.has_submission;
|
||||||
|
if (aSubmission && !bSubmission) return -1;
|
||||||
|
if (!aSubmission && bSubmission) return 1;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: 'Created',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-900 text-sm">
|
||||||
|
{new Date(row.original.created_at).toLocaleDateString('en-UK', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
meta: { cellClassName: cn('w-48') },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||||
|
onClick={() => handleShowDetailModal(row.original)}
|
||||||
|
>
|
||||||
|
<EditOutlined className="text-sm" />
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[handleShowDetailModal, handleShowSubmissionModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
|
Team Management
|
||||||
|
</h1>
|
||||||
|
{/* Filters and actions */}
|
||||||
|
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||||
|
{/* Left side - Search & filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
||||||
|
placeholder="Search teams by name, city, or leader..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visibility Filter */}
|
||||||
|
<div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={visibilityFilter}
|
||||||
|
onChange={(e) => setVisibilityFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Visibility</option>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
<option value="private">Private</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* City Filter */}
|
||||||
|
<CityFilterSelect
|
||||||
|
value={cityFilter}
|
||||||
|
onChange={setCityFilter}
|
||||||
|
className="w-full sm:w-44"
|
||||||
|
placeholder="Search cities..."
|
||||||
|
allOptionLabel="All Cities"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Member Count Filter */}
|
||||||
|
<div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-55 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={memberCountFilter}
|
||||||
|
onChange={(e) => setMemberCountFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Member Count</option>
|
||||||
|
<option value="1">1 Member</option>
|
||||||
|
<option value="2">2 Members</option>
|
||||||
|
<option value="3">3 Members</option>
|
||||||
|
<option value="4">4 Members</option>
|
||||||
|
<option value="5">5 Members</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submission Filter */}
|
||||||
|
<div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={submissionFilter}
|
||||||
|
onChange={(e) => setSubmissionFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Submissions</option>
|
||||||
|
<option value="submitted">Submitted</option>
|
||||||
|
<option value="not_submitted">Not Submitted</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Add Team Button */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="flex items-center gap-2 px-4 py-2"
|
||||||
|
onClick={handleShowNewTeamModal}
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-sm" />
|
||||||
|
Add Team
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filters display */}
|
||||||
|
{(visibilityFilter !== 'all' ||
|
||||||
|
cityFilter !== 'all' ||
|
||||||
|
submissionFilter !== 'all' ||
|
||||||
|
memberCountFilter !== 'all') && (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||||
|
|
||||||
|
{/* Visibility filter badge */}
|
||||||
|
{visibilityFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||||
|
Visibility: {visibilityFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibilityFilter('all')}
|
||||||
|
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* City filter badge */}
|
||||||
|
{cityFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||||
|
City: {cityFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setCityFilter('all')}
|
||||||
|
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Member count filter badge */}
|
||||||
|
{memberCountFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-800 rounded-2xl text-sm">
|
||||||
|
Members: {memberCountFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setMemberCountFilter('all')}
|
||||||
|
className="text-blue-600 hover:text-blue-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submission filter badge */}
|
||||||
|
{submissionFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm">
|
||||||
|
Submission: {submissionFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setSubmissionFilter('all')}
|
||||||
|
className="text-purple-600 hover:text-purple-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Clear all filters */}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setVisibilityFilter('all');
|
||||||
|
setCityFilter('all');
|
||||||
|
setSubmissionFilter('all');
|
||||||
|
setMemberCountFilter('all');
|
||||||
|
setGlobalFilter('');
|
||||||
|
}}
|
||||||
|
className="text-sm text-neutral-600"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination-aware results display */}
|
||||||
|
{filteredData.length > 0 && (
|
||||||
|
<div className="text-sm text-neutral-600">
|
||||||
|
Showing {Math.min(pageSize, filteredData.length)} of{' '}
|
||||||
|
{filteredData.length} teams
|
||||||
|
{filteredData.length > pageSize}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable data={filteredData} columns={columns} pageSize={10} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Modals component */}
|
||||||
|
<ModalTeamDetail
|
||||||
|
isOpen={showDetailModal}
|
||||||
|
onClose={handleCloseDetailModal}
|
||||||
|
team={selectedTeam}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* New Team Modal */}
|
||||||
|
<ModalTeamDetail
|
||||||
|
isOpen={showNewTeamModal}
|
||||||
|
onClose={handleCloseNewTeamModal}
|
||||||
|
team={null} // null indicates creating new team
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Submission Modal */}
|
||||||
|
{selectedSubmissionTeam && (
|
||||||
|
<SubmissionModal
|
||||||
|
isOpen={showSubmissionModal}
|
||||||
|
onClose={handleCloseSubmissionModal}
|
||||||
|
teamId={selectedSubmissionTeam.id}
|
||||||
|
teamName={selectedSubmissionTeam.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonTeamsPage;
|
||||||
+651
@@ -0,0 +1,651 @@
|
|||||||
|
import { FC, useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
SaveOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
ExclamationOutlined,
|
||||||
|
CameraOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface UserType {
|
||||||
|
id: string;
|
||||||
|
avatar?: string;
|
||||||
|
fullname: string;
|
||||||
|
bio?: string;
|
||||||
|
location: string;
|
||||||
|
is_active: boolean;
|
||||||
|
skills: string[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
user: UserType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||||
|
const [formData, setFormData] = useState<UserType | null>(null);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Initialize form data when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (user) {
|
||||||
|
// Edit existing user
|
||||||
|
setFormData({ ...user });
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
setFormData({
|
||||||
|
id: '', // Will be generated by backend
|
||||||
|
fullname: '',
|
||||||
|
bio: '',
|
||||||
|
location: '',
|
||||||
|
is_active: true,
|
||||||
|
skills: [],
|
||||||
|
avatar: undefined,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, user]);
|
||||||
|
|
||||||
|
// Check if form has changes
|
||||||
|
const hasChanges = useMemo(() => {
|
||||||
|
if (!formData) return false;
|
||||||
|
if (!user) return true; // New user always has changes
|
||||||
|
return (
|
||||||
|
formData.fullname !== user.fullname ||
|
||||||
|
formData.location !== user.location ||
|
||||||
|
formData.is_active !== user.is_active ||
|
||||||
|
formData.avatar !== user.avatar ||
|
||||||
|
JSON.stringify(formData.skills) !== JSON.stringify(user.skills) ||
|
||||||
|
formData.bio !== user.bio
|
||||||
|
);
|
||||||
|
}, [formData, user]);
|
||||||
|
|
||||||
|
// Check if required fields are filled
|
||||||
|
const isFormValid = useMemo(() => {
|
||||||
|
if (!formData) return false;
|
||||||
|
return formData.fullname.trim() !== '' && formData.location.trim() !== '';
|
||||||
|
}, [formData]);
|
||||||
|
|
||||||
|
const canSave = hasChanges && isFormValid;
|
||||||
|
|
||||||
|
if (!isOpen || !formData) return null;
|
||||||
|
|
||||||
|
const handleInputChange = (
|
||||||
|
field: keyof UserType,
|
||||||
|
value: string | boolean | string[] | undefined
|
||||||
|
) => {
|
||||||
|
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkillsChange = (skills: string[]) => {
|
||||||
|
setFormData((prev) => (prev ? { ...prev, skills } : null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!formData) return;
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Update existing user
|
||||||
|
console.log('Update user data:', formData);
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
console.log('Create new user:', formData);
|
||||||
|
}
|
||||||
|
// Here you would typically make an API call to save the data
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (user) {
|
||||||
|
setFormData({ ...user }); // Reset to original for edit mode
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = () => {
|
||||||
|
if (!user) return; // Can't delete new user
|
||||||
|
console.log('Delete user:', user.id);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
// Validate file type
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('Please select an image file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (max 5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('Image size must be less than 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create preview URL
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const avatarUrl = e.target?.result as string;
|
||||||
|
handleInputChange('avatar', avatarUrl);
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAvatar = () => {
|
||||||
|
handleInputChange('avatar', undefined);
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerFileUpload = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableSkills = [
|
||||||
|
'Frontend Developer',
|
||||||
|
'Backend Developer',
|
||||||
|
'Full Stack Developer',
|
||||||
|
'DevOps Engineer',
|
||||||
|
'UI/UX Designer',
|
||||||
|
'Product Manager',
|
||||||
|
'Data Scientist',
|
||||||
|
'Mobile Developer',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50"
|
||||||
|
onClick={(e) => {
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Hidden File Input */}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Interactive User Avatar */}
|
||||||
|
<div className="relative group ">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-primary-300 transition-colors">
|
||||||
|
{formData.avatar ? (
|
||||||
|
<img
|
||||||
|
src={formData.avatar}
|
||||||
|
alt={formData.fullname}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserOutlined className="text-neutral-500 text-2xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar Hover Overlay */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAvatarMenu(!showAvatarMenu)}
|
||||||
|
className="absolute inset-0 bg-neutral-400 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CameraOutlined className="text-white text-lg" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Avatar Menu Dropdown */}
|
||||||
|
{showAvatarMenu && (
|
||||||
|
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
|
||||||
|
<button
|
||||||
|
onClick={triggerFileUpload}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<UploadOutlined className="text-sm" />
|
||||||
|
{formData.avatar ? 'Change Photo' : 'Upload Photo'}
|
||||||
|
</button>
|
||||||
|
{formData.avatar && (
|
||||||
|
<button
|
||||||
|
onClick={handleRemoveAvatar}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<DeleteOutlined className="text-sm" />
|
||||||
|
Remove Photo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h2 className="text-2xl font-bold text-neutral-900">
|
||||||
|
{user ? 'Edit User Profile' : 'Create New User'}
|
||||||
|
</h2>
|
||||||
|
{user && (
|
||||||
|
<span className="px-3 py-1 bg-info-100 text-info-700 text-xs font-medium rounded-2xl">
|
||||||
|
Hover avatar to change
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-neutral-500">
|
||||||
|
{user
|
||||||
|
? `Make changes to ${
|
||||||
|
formData.fullname || 'this user'
|
||||||
|
}'s profile information`
|
||||||
|
: 'Fill in the information below to create a new user account'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
handleCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseOutlined className="text-neutral-400 text-lg cursor-pointer" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* Left Column - Basic Info */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Basic Information
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Full Name - Required */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserOutlined className="text-neutral-400" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm text-neutral-500 block mb-1">
|
||||||
|
Full Name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.fullname}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('fullname', e.target.value)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none',
|
||||||
|
formData.fullname.trim() === ''
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-neutral-300'
|
||||||
|
)}
|
||||||
|
placeholder="Enter full name"
|
||||||
|
/>
|
||||||
|
{formData.fullname.trim() === '' && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
Full name is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location - Required */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<EnvironmentOutlined className="text-neutral-400" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm text-neutral-500 block mb-1">
|
||||||
|
Location <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.location}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('location', e.target.value)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white',
|
||||||
|
formData.location.trim() === ''
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-neutral-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">Select location</option>
|
||||||
|
<option value="Jakarta">Jakarta</option>
|
||||||
|
<option value="Bandung">Bandung</option>
|
||||||
|
<option value="Surabaya">Surabaya</option>
|
||||||
|
<option value="Medan">Medan</option>
|
||||||
|
<option value="Yogyakarta">Yogyakarta</option>
|
||||||
|
</select>
|
||||||
|
{formData.location.trim() === '' && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
Location is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Joined Date - Read Only - Only show for existing users */}
|
||||||
|
{user && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CalendarOutlined className="text-neutral-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
Joined Date
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{new Date(formData.created_at).toLocaleDateString(
|
||||||
|
'en-US',
|
||||||
|
{
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bio Section - Optional */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
|
||||||
|
Bio{' '}
|
||||||
|
<span className="text-neutral-400 text-sm font-normal">
|
||||||
|
(Optional)
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<textarea
|
||||||
|
value={formData.bio || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('bio', e.target.value || undefined)
|
||||||
|
}
|
||||||
|
placeholder="Tell us about yourself..."
|
||||||
|
rows={4}
|
||||||
|
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Skills & Status */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Account Status - Enhanced Tab Design */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Account Status
|
||||||
|
</h3>
|
||||||
|
<div className="flex bg-neutral-100 p-1 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => handleInputChange('is_active', true)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
|
||||||
|
formData.is_active
|
||||||
|
? 'bg-white text-success-700 shadow-sm ring-1 ring-success-200'
|
||||||
|
: 'text-neutral-600 hover:text-neutral-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
formData.is_active
|
||||||
|
? 'bg-success-500'
|
||||||
|
: 'bg-neutral-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
Active
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleInputChange('is_active', false)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
|
||||||
|
!formData.is_active
|
||||||
|
? 'bg-white text-neutral-700 shadow-sm ring-1 ring-neutral-200'
|
||||||
|
: 'text-neutral-600 hover:text-neutral-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
!formData.is_active
|
||||||
|
? 'bg-neutral-500'
|
||||||
|
: 'bg-neutral-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
Inactive
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-500 mt-2">
|
||||||
|
{formData.is_active
|
||||||
|
? 'User can access their account and participate in activities'
|
||||||
|
: 'User account is suspended and cannot access services'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skills Section - Optional */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Skills & Expertise{' '}
|
||||||
|
<span className="text-neutral-400 text-sm font-normal">
|
||||||
|
(Optional)
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2 min-h-10 p-3 border border-neutral-300 rounded-lg bg-neutral-50">
|
||||||
|
{formData.skills.length > 0 ? (
|
||||||
|
formData.skills.map((skill, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-2xl text-sm font-medium bg-blue-100 text-blue-800"
|
||||||
|
>
|
||||||
|
{skill}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleSkillsChange(
|
||||||
|
formData.skills.filter((_, i) => i !== index)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="text-blue-600 hover:text-blue-800 ml-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-400 text-sm">
|
||||||
|
No skills added yet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (
|
||||||
|
e.target.value &&
|
||||||
|
!formData.skills.includes(e.target.value)
|
||||||
|
) {
|
||||||
|
handleSkillsChange([
|
||||||
|
...formData.skills,
|
||||||
|
e.target.value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white"
|
||||||
|
>
|
||||||
|
<option value="">Add a skill...</option>
|
||||||
|
{availableSkills
|
||||||
|
.filter((skill) => !formData.skills.includes(skill))
|
||||||
|
.map((skill) => (
|
||||||
|
<option key={skill} value={skill}>
|
||||||
|
{skill}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Details - Read Only - Only show for existing users */}
|
||||||
|
{user && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Account Details
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3 bg-neutral-50 p-4 rounded-lg">
|
||||||
|
<div className="flex justify-between items-center py-1">
|
||||||
|
<span className="text-neutral-600 text-sm">
|
||||||
|
User ID
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-sm text-neutral-800">
|
||||||
|
{formData.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1">
|
||||||
|
<span className="text-neutral-600 text-sm">
|
||||||
|
Last Updated
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-neutral-800">
|
||||||
|
{new Date(formData.updated_at).toLocaleDateString(
|
||||||
|
'en-US',
|
||||||
|
{
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="border-t border-neutral-200 px-8 py-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-sm text-neutral-500">
|
||||||
|
{canSave
|
||||||
|
? 'Ready to save changes'
|
||||||
|
: hasChanges
|
||||||
|
? 'Please fill required fields'
|
||||||
|
: 'No changes made'}
|
||||||
|
</div>
|
||||||
|
{/* Delete Account Button - Only show for existing users */}
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="text-red-600 hover:text-red-700 text-sm font-medium transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Delete Account
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-6"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-6',
|
||||||
|
!canSave && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SaveOutlined className="text-sm" />
|
||||||
|
{user ? 'Save Changes' : 'Create User'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<div className="fixed inset-0 z-60">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<ExclamationOutlined className="text-red-600 text-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900">
|
||||||
|
Delete Account
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
This action cannot be undone
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-neutral-700 mb-6">
|
||||||
|
Are you sure you want to permanently delete{' '}
|
||||||
|
<strong>{formData.fullname}</strong>'s account? This will
|
||||||
|
remove all their data and cannot be reversed.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="px-4"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDeleteAccount}
|
||||||
|
className="px-4 bg-red-600 hover:bg-red-700 border-red-600"
|
||||||
|
>
|
||||||
|
Delete Account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalUserDetail;
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
import { FC, ReactElement, useState, useMemo, useCallback } from 'react';
|
||||||
|
import ModalUserDetail from './_components/modal-user-detail';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
EditOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
FilterOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { CityFilterSelect } from '../../../components/city-filter-select';
|
||||||
|
|
||||||
|
// Define interface outside component
|
||||||
|
interface UserType {
|
||||||
|
id: string; // UUID
|
||||||
|
avatar?: string;
|
||||||
|
fullname: string;
|
||||||
|
bio?: string;
|
||||||
|
location: string;
|
||||||
|
is_active: boolean; // admin can deactivate
|
||||||
|
skills: string[]; // Frontend Developer, Backend Developer, etc.
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move mock data outside component to prevent recreation
|
||||||
|
const skillsOptions = [
|
||||||
|
'Frontend Developer',
|
||||||
|
'Backend Developer',
|
||||||
|
'Full Stack Developer',
|
||||||
|
'DevOps Engineer',
|
||||||
|
'UI/UX Designer',
|
||||||
|
'Product Manager',
|
||||||
|
'Data Scientist',
|
||||||
|
'Mobile Developer',
|
||||||
|
];
|
||||||
|
|
||||||
|
const locations = ['Jakarta', 'Bandung', 'Surabaya', 'Medan', 'Yogyakarta'];
|
||||||
|
const bios = [
|
||||||
|
'Passionate developer with 5+ years experience',
|
||||||
|
'Tech enthusiast and problem solver',
|
||||||
|
'Building scalable solutions for modern problems',
|
||||||
|
'Creative designer with technical background',
|
||||||
|
'Data-driven decision maker',
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockData: UserType[] = Array.from({ length: 50 }, (_, i) => {
|
||||||
|
const randomSkillsCount = Math.floor(Math.random() * 3) + 1; // 1-3 skills
|
||||||
|
const randomSkills = skillsOptions
|
||||||
|
.sort(() => 0.5 - Math.random())
|
||||||
|
.slice(0, randomSkillsCount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `24db9e4d-ca4c-46aa-ac36-8ef04bbe01${String(i).padStart(2, '0')}`,
|
||||||
|
avatar:
|
||||||
|
i % 4 === 0
|
||||||
|
? `https://ui-avatars.com/api/?name=${encodeURIComponent(
|
||||||
|
i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana'
|
||||||
|
)}&background=random`
|
||||||
|
: undefined,
|
||||||
|
fullname:
|
||||||
|
i % 3 === 0
|
||||||
|
? 'Ahmad Wijuana'
|
||||||
|
: i % 3 === 1
|
||||||
|
? 'Sofia Wijuana'
|
||||||
|
: 'Budi Santoso',
|
||||||
|
bio: i % 4 === 0 ? bios[i % bios.length] : undefined,
|
||||||
|
location: locations[i % locations.length],
|
||||||
|
is_active: i % 7 !== 0, // More realistic distribution
|
||||||
|
skills: randomSkills,
|
||||||
|
created_at: new Date(
|
||||||
|
Date.now() - i * 86400000 * (Math.random() * 30 + 1)
|
||||||
|
).toISOString(), // Random within last 30-60 days
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HackathonUsersPage: FC = (): ReactElement => {
|
||||||
|
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||||
|
const [showNewUserModal, setShowNewUserModal] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState('');
|
||||||
|
|
||||||
|
// Advanced filtering states
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [cityFilter, setCityFilter] = useState('all');
|
||||||
|
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
|
// Memoize the callback to prevent recreation
|
||||||
|
const handleShowDetailModal = useCallback((user: UserType) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setShowDetailModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseDetailModal = useCallback(() => {
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShowNewUserModal = useCallback(() => {
|
||||||
|
setShowNewUserModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseNewUserModal = useCallback(() => {
|
||||||
|
setShowNewUserModal(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter data based on current filter states
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
return mockData.filter((user) => {
|
||||||
|
// Status filter
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
const isActive = statusFilter === 'active';
|
||||||
|
if (user.is_active !== isActive) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// City filter
|
||||||
|
if (cityFilter !== 'all' && user.location !== cityFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills filter
|
||||||
|
if (skillsFilter.length > 0) {
|
||||||
|
const hasMatchingSkill = skillsFilter.some((skill) =>
|
||||||
|
user.skills.includes(skill)
|
||||||
|
);
|
||||||
|
if (!hasMatchingSkill) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [statusFilter, cityFilter, skillsFilter]);
|
||||||
|
|
||||||
|
// Memoize columns to prevent recreation on every render
|
||||||
|
const columns: ColumnDef<UserType>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'fullname',
|
||||||
|
header: 'User',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
|
||||||
|
{row.original.avatar ? (
|
||||||
|
<img
|
||||||
|
src={row.original.avatar}
|
||||||
|
alt={row.original.fullname}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserOutlined className="text-neutral-500 text-lg" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Name only */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-neutral-900 truncate">
|
||||||
|
{row.original.fullname}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'skills',
|
||||||
|
header: 'Skills',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||||
|
{row.original.skills.slice(0, 2).map((skill, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
|
||||||
|
>
|
||||||
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{row.original.skills.length > 2 && (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
|
||||||
|
+{row.original.skills.length - 2}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'location',
|
||||||
|
header: 'Location',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-700">{row.original.location}</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'is_active',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium',
|
||||||
|
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: (rowA, rowB) => {
|
||||||
|
const aActive = rowA.original.is_active;
|
||||||
|
const bActive = rowB.original.is_active;
|
||||||
|
if (aActive && !bActive) return -1;
|
||||||
|
if (!aActive && bActive) return 1;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: 'Joined',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-900 text-sm">
|
||||||
|
{new Date(row.original.created_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
meta: { cellClassName: cn('w-48') },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||||
|
onClick={() => handleShowDetailModal(row.original)}
|
||||||
|
>
|
||||||
|
<EditOutlined className="text-sm" />
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="text-sm px-4 py-2"
|
||||||
|
onClick={() => {
|
||||||
|
// Toggle user status - implement later
|
||||||
|
console.log(`Toggle status for ${row.original.fullname}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.original.is_active ? 'Deactivate' : 'Activate'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[handleShowDetailModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
|
User Management
|
||||||
|
</h1>
|
||||||
|
{/* Filters and actions */}
|
||||||
|
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||||
|
{/* Left side - Search & filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
||||||
|
placeholder="Search users by name or location..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* City Filter */}
|
||||||
|
<CityFilterSelect
|
||||||
|
value={cityFilter}
|
||||||
|
onChange={setCityFilter}
|
||||||
|
className="w-full sm:w-44"
|
||||||
|
placeholder="Search cities..."
|
||||||
|
allOptionLabel="All Cities"
|
||||||
|
/>
|
||||||
|
{cityFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||||
|
Location: {cityFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setCityFilter('all')}
|
||||||
|
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skills Filter with Icon */}
|
||||||
|
<div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (
|
||||||
|
e.target.value &&
|
||||||
|
!skillsFilter.includes(e.target.value)
|
||||||
|
) {
|
||||||
|
setSkillsFilter((prev) => [...prev, e.target.value]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Add Skill Filter</option>
|
||||||
|
{skillsOptions.map((skill) => (
|
||||||
|
<option
|
||||||
|
key={skill}
|
||||||
|
value={skill}
|
||||||
|
disabled={skillsFilter.includes(skill)}
|
||||||
|
>
|
||||||
|
{skill}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Add User Button */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="flex items-center gap-2 px-4 py-2"
|
||||||
|
onClick={handleShowNewUserModal}
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-sm" />
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filters display */}
|
||||||
|
{(skillsFilter.length > 0 ||
|
||||||
|
statusFilter !== 'all' ||
|
||||||
|
cityFilter !== 'all') && (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||||
|
|
||||||
|
{/* Status filter badge */}
|
||||||
|
{statusFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||||
|
Status: {statusFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location filter badge */}
|
||||||
|
{cityFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||||
|
City: {cityFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setCityFilter('all')}
|
||||||
|
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skills filter badges */}
|
||||||
|
{skillsFilter.map((skill) => (
|
||||||
|
<span
|
||||||
|
key={skill}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
|
||||||
|
>
|
||||||
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
||||||
|
}
|
||||||
|
className="text-purple-600 hover:text-purple-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Clear all filters */}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter('all');
|
||||||
|
setCityFilter('all');
|
||||||
|
setSkillsFilter([]);
|
||||||
|
setGlobalFilter('');
|
||||||
|
}}
|
||||||
|
className="text-sm text-neutral-600"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination-aware results display */}
|
||||||
|
{filteredData.length > 0 && (
|
||||||
|
<div className="text-sm text-neutral-600">
|
||||||
|
Showing {Math.min(pageSize, filteredData.length)} of{' '}
|
||||||
|
{filteredData.length} users
|
||||||
|
{filteredData.length > pageSize}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable data={filteredData} columns={columns} pageSize={10} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Modals component */}
|
||||||
|
<ModalUserDetail
|
||||||
|
isOpen={showDetailModal}
|
||||||
|
onClose={handleCloseDetailModal}
|
||||||
|
user={selectedUser}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* New User Modal */}
|
||||||
|
<ModalUserDetail
|
||||||
|
isOpen={showNewUserModal}
|
||||||
|
onClose={handleCloseNewUserModal}
|
||||||
|
user={null} // null indicates creating new user
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonUsersPage;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { FC, ReactElement, useState } from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
|
export const AppLayout: FC = (): ReactElement => {
|
||||||
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||||
|
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||||
|
<BackofficeSidebar
|
||||||
|
isOpen={mobileSidebarOpen}
|
||||||
|
onClose={() => setMobileSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{/* Sticky top header */}
|
||||||
|
<header
|
||||||
|
className={
|
||||||
|
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
|
||||||
|
(mobileSidebarOpen ? 'z-0' : 'z-30')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Mobile menu button (shown on small screens) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700"
|
||||||
|
onClick={() => setMobileSidebarOpen(true)}
|
||||||
|
aria-label="Open sidebar"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h1 className="text-p3 font-semibold text-primary-700">
|
||||||
|
IMPHNEN Backoffice
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppLayout;
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { useItem, useConfirmItem } from '../_hook/use-item';
|
||||||
|
|
||||||
|
interface IModalAddPermission {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleAddItem?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalAddPermission = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
currentStep,
|
||||||
|
nextStep,
|
||||||
|
resetStep,
|
||||||
|
handleAddItem,
|
||||||
|
}: IModalAddPermission) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-3 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<StepTwo
|
||||||
|
onClose={onClose}
|
||||||
|
handleAddItem={handleAddItem}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepOneProps {
|
||||||
|
nextStep: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||||
|
const { form, onSubmit } = useItem(nextStep);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header>
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500">
|
||||||
|
Tambah Permissions
|
||||||
|
</h2>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nama Permission"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button variant="primary" size="lg" className="w-full" type="submit">
|
||||||
|
Tambah Permission
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepTwoProps {
|
||||||
|
onClose: () => void;
|
||||||
|
handleAddItem?: () => Promise<boolean>;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => {
|
||||||
|
const { onConfirm, onCancel } = useConfirmItem(
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleAddItem,
|
||||||
|
{
|
||||||
|
success: 'Data permissions berhasil ditambahkan',
|
||||||
|
error: 'Data permissions gagal ditambahkan',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header className="mb-7 text-center items-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Tambah Permissions
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-center text-neutral-400">
|
||||||
|
Apakah kamu yakin ingin
|
||||||
|
<br /> menambahkan permission ini?
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex mb-0 gap-4">
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Tambahkan
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalAddPermission;
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { useConfirmItem } from '../_hook/use-item';
|
||||||
|
|
||||||
|
interface IModalDeletePermission {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleDelete?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalDeletePermission = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleDelete,
|
||||||
|
}: IModalDeletePermission) => {
|
||||||
|
const { onConfirm } = useConfirmItem(onClose, resetStep, handleDelete, {
|
||||||
|
success: 'Data permissions berhasil dihapus',
|
||||||
|
error: 'Data permissions gagal dihapus',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
closeButtonClassName="hidden"
|
||||||
|
>
|
||||||
|
<Modal.Header className="gap-8">
|
||||||
|
<img
|
||||||
|
src="/chibi-delete.webp"
|
||||||
|
alt="Delete?"
|
||||||
|
width={148}
|
||||||
|
className="self-center"
|
||||||
|
/>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
|
||||||
|
Delete Permissions
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin untuk menghapus permission ini? Menghapus data ini
|
||||||
|
mungkin akan mempengaruhi fungsional sistem
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Batal Hapus
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full bg-danger-500 hover:bg-danger-600"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Hapus Item
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalDeletePermission;
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { useConfirmItem } from '../_hook/use-item';
|
||||||
|
|
||||||
|
interface IModalUpdatePermission {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleUpdate?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalUpdatePermission = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleUpdate,
|
||||||
|
}: IModalUpdatePermission) => {
|
||||||
|
const { onConfirm } = useConfirmItem(onClose, resetStep, handleUpdate, {
|
||||||
|
success: 'Perubahan permissions berhasil dilakukan',
|
||||||
|
error: 'Perubahan permissions gagal dilakukan',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-3 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
<Modal.Header>
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500">
|
||||||
|
Update Permissions
|
||||||
|
</h2>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex flex-col gap-8">
|
||||||
|
<InputField
|
||||||
|
label="Name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nama Permission"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Update Permission
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalUpdatePermission;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
// import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
// import {
|
||||||
|
// gachaRollItemSchema,
|
||||||
|
// TGachaRollItem
|
||||||
|
// } from '@imphnen-frontend-service/service';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export const useItem = (
|
||||||
|
nextStep: () => void,
|
||||||
|
initialValues?: any
|
||||||
|
) => {
|
||||||
|
const form = useForm<any>({
|
||||||
|
// resolver: zodResolver(),
|
||||||
|
mode: 'all',
|
||||||
|
defaultValues: initialValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit((data) => {
|
||||||
|
console.log('Form data:', data);
|
||||||
|
nextStep();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
onSubmit,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useConfirmItem = (
|
||||||
|
onClose: () => void,
|
||||||
|
resetStep: () => void,
|
||||||
|
actionFunction?: () => Promise<boolean>,
|
||||||
|
messages?: {
|
||||||
|
success?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const onConfirm = async () => {
|
||||||
|
try {
|
||||||
|
// const result = await actionFunction?.();
|
||||||
|
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||||
|
toast.success(messages?.success);
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
toast.error(messages?.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import ModalAddPermission from './_components/modal-add-permission';
|
||||||
|
import ModalUpdatePermission from './_components/modal-update-permission';
|
||||||
|
import ModalDeletePermission from './_components/modal-delete-permission';
|
||||||
|
import { useQueryState } from '@imphnen-frontend-service/utils';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface Permission {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: Permission[] = [
|
||||||
|
{ id: 1, name: 'Read' },
|
||||||
|
{ id: 2, name: 'Create' },
|
||||||
|
{ id: 3, name: 'Update' },
|
||||||
|
{ id: 4, name: 'Delete' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
const [showModalAddItem, setShowModalAddItem] = useState(false);
|
||||||
|
const [showModalUpdateItem, setShowModalUpdateItem] = useState(false);
|
||||||
|
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
step: currentStep,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
resetStep,
|
||||||
|
} = useQueryState('step', {
|
||||||
|
defaultValue: 1,
|
||||||
|
maxValue: 2,
|
||||||
|
minValue: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
||||||
|
|
||||||
|
const columns: ColumnDef<Permission>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'No',
|
||||||
|
accessorKey: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: () => (
|
||||||
|
<div className="flex gap-[8px]">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowModalUpdateItem(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<EditOutlined /> Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowModalDeleteItem(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<DeleteOutlined /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||||
|
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||||
|
<h1 className="text-p2 font-semibold">Permissions</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||||
|
<div className="flex justify-between items-center gap-8 mb-2">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Cari berdasarkan nama permissions"
|
||||||
|
className="pl-12 w-full max-h-full"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||||
|
<SearchOutlined />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="flex gap-3 text-nowrap"
|
||||||
|
onClick={() => {
|
||||||
|
setShowModalAddItem(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
Tambah Permissionss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
data={mockData}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={9}
|
||||||
|
table={table}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<ModalAddPermission
|
||||||
|
currentStep={currentStep}
|
||||||
|
isOpen={showModalAddItem}
|
||||||
|
onClose={() => setShowModalAddItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
<ModalUpdatePermission
|
||||||
|
isOpen={showModalUpdateItem}
|
||||||
|
onClose={() => setShowModalUpdateItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
<ModalDeletePermission
|
||||||
|
isOpen={showModalDeleteItem}
|
||||||
|
onClose={() => setShowModalDeleteItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user