Compare commits
109
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38ec9a28f7 | ||
|
|
97a3ac90eb | ||
|
|
8f8c720632 | ||
|
|
ac22bd6cab | ||
|
|
27ed5605ce | ||
|
|
2c6389a67a | ||
|
|
a4c75770ae | ||
|
|
d923237969 | ||
|
|
b2a28c6fd3 | ||
|
|
417ff3390b | ||
|
|
0c646ceb85 | ||
|
|
6bbc87479a | ||
|
|
23b6677323 | ||
|
|
45c75b3283 | ||
|
|
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 |
@@ -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
|
||||||
@@ -7,6 +7,10 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-vps
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -21,15 +25,20 @@ jobs:
|
|||||||
username: ${{ secrets.SSH_USER }}
|
username: ${{ secrets.SSH_USER }}
|
||||||
key: ${{ secrets.SSH_KEY }}
|
key: ${{ secrets.SSH_KEY }}
|
||||||
port: ${{ secrets.SSH_PORT }}
|
port: ${{ secrets.SSH_PORT }}
|
||||||
|
command_timeout: 30m
|
||||||
script: |
|
script: |
|
||||||
set -e
|
set -e
|
||||||
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
|
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
|
||||||
else
|
else
|
||||||
cd ~/imphnen-frontend-service-backoffice && git pull
|
cd ~/imphnen-frontend-service-backoffice && git fetch origin && git reset --hard origin/develop && git pull
|
||||||
fi
|
fi
|
||||||
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env
|
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 compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
|
||||||
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build
|
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 compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
|
||||||
docker image prune -af
|
docker image prune -af
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-vps
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -21,15 +25,20 @@ jobs:
|
|||||||
username: ${{ secrets.SSH_USER }}
|
username: ${{ secrets.SSH_USER }}
|
||||||
key: ${{ secrets.SSH_KEY }}
|
key: ${{ secrets.SSH_KEY }}
|
||||||
port: ${{ secrets.SSH_PORT }}
|
port: ${{ secrets.SSH_PORT }}
|
||||||
|
command_timeout: 30m
|
||||||
script: |
|
script: |
|
||||||
set -e
|
set -e
|
||||||
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
|
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
|
||||||
else
|
else
|
||||||
cd ~/imphnen-frontend-service-dimentorin && git pull
|
cd ~/imphnen-frontend-service-dimentorin && git fetch origin && git reset --hard origin/develop && git pull
|
||||||
fi
|
fi
|
||||||
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env
|
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 compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
|
||||||
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build
|
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 compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
|
||||||
docker image prune -af
|
docker image prune -af
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-vps
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -21,15 +25,20 @@ jobs:
|
|||||||
username: ${{ secrets.SSH_USER }}
|
username: ${{ secrets.SSH_USER }}
|
||||||
key: ${{ secrets.SSH_KEY }}
|
key: ${{ secrets.SSH_KEY }}
|
||||||
port: ${{ secrets.SSH_PORT }}
|
port: ${{ secrets.SSH_PORT }}
|
||||||
|
command_timeout: 30m
|
||||||
script: |
|
script: |
|
||||||
set -e
|
set -e
|
||||||
if [ ! -d ~/imphnen-frontend-service-gacha ]; then
|
if [ ! -d ~/imphnen-frontend-service-gacha ]; then
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
|
||||||
else
|
else
|
||||||
cd ~/imphnen-frontend-service-gacha && git pull
|
cd ~/imphnen-frontend-service-gacha && git fetch origin && git reset --hard origin/develop && git pull
|
||||||
fi
|
fi
|
||||||
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-gacha/apps/gacha/.env
|
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 compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
|
||||||
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build
|
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 compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
|
||||||
docker image prune -af
|
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
|
||||||
@@ -7,6 +7,10 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-vps
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -21,15 +25,20 @@ jobs:
|
|||||||
username: ${{ secrets.SSH_USER }}
|
username: ${{ secrets.SSH_USER }}
|
||||||
key: ${{ secrets.SSH_KEY }}
|
key: ${{ secrets.SSH_KEY }}
|
||||||
port: ${{ secrets.SSH_PORT }}
|
port: ${{ secrets.SSH_PORT }}
|
||||||
|
command_timeout: 30m
|
||||||
script: |
|
script: |
|
||||||
set -e
|
set -e
|
||||||
if [ ! -d ~/imphnen-frontend-service ]; then
|
if [ ! -d ~/imphnen-frontend-service ]; then
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
|
||||||
else
|
else
|
||||||
cd ~/imphnen-frontend-service && git pull
|
cd ~/imphnen-frontend-service && git fetch origin && git reset --hard origin/develop && git pull
|
||||||
fi
|
fi
|
||||||
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" > ~/imphnen-frontend-service/apps/landing/.env
|
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 compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
|
||||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build
|
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 compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
|
||||||
docker image prune -af
|
docker image prune -af
|
||||||
|
|||||||
@@ -62,3 +62,7 @@ storybook-static
|
|||||||
# Next.js
|
# Next.js
|
||||||
.next
|
.next
|
||||||
out
|
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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-->
|
||||||
@@ -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,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;
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
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 { useTeams } from '@imphnen-frontend-service/service';
|
||||||
|
import { EditOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||||
|
const { data: teamsData } = useTeams();
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockData: TeamType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: `team-${i + 1}`,
|
||||||
|
name: `Team ${i + 1} - ${i % 3 === 0 ? 'Innovators' : 'Hackers'}`,
|
||||||
|
city: i % 2 === 0 ? 'Jakarta' : 'Bandung',
|
||||||
|
visibility: i % 4 === 0 ? 'private' : 'public',
|
||||||
|
member_count: Math.floor(Math.random() * 4) + 1,
|
||||||
|
has_submission: i % 3 !== 0,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
leader: {
|
||||||
|
user: {
|
||||||
|
fullname: `Leader User ${i}`,
|
||||||
|
email: `leader${i}@example.com`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface TeamType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
city: string;
|
||||||
|
visibility: 'public' | 'private';
|
||||||
|
member_count: number;
|
||||||
|
has_submission: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
leader?: {
|
||||||
|
user: {
|
||||||
|
fullname: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<TeamType>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'id',
|
||||||
|
header: 'ID',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Team Name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'city',
|
||||||
|
header: 'City',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'visibility',
|
||||||
|
header: 'Visibility',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isPublic = row.original.visibility === 'public';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'py-2 px-4 text-sm rounded-2xl text-center',
|
||||||
|
isPublic
|
||||||
|
? 'bg-info-200 text-info-700'
|
||||||
|
: 'bg-gray-200 text-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPublic ? 'Public' : 'Private'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'member_count',
|
||||||
|
header: 'Members',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leader',
|
||||||
|
header: 'Leader',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const leader = row.original.leader?.user;
|
||||||
|
return leader ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{leader.fullname}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">{leader.email}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 italic">-</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'has_submission',
|
||||||
|
header: 'Submitted',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const hasSubmission = row.original.has_submission;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'py-2 px-4 text-sm rounded-2xl text-center',
|
||||||
|
hasSubmission
|
||||||
|
? 'bg-success-200 text-success-700'
|
||||||
|
: 'bg-danger-200 text-danger-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasSubmission ? 'Yes' : 'No'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'updated_at',
|
||||||
|
header: 'Last Updated',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return new Date(row.original.updated_at).toLocaleDateString();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Action',
|
||||||
|
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 w-max"
|
||||||
|
onClick={() => {
|
||||||
|
// View detail logic
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditOutlined className="text-base" /> View & Manage
|
||||||
|
</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 (
|
||||||
|
<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">
|
||||||
|
<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 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,476 @@
|
|||||||
|
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';
|
||||||
|
// Removed unused SearchOutlined icon after schema revision
|
||||||
|
|
||||||
|
// 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 [locationFilter, setLocationFilter] = 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location filter
|
||||||
|
if (locationFilter !== 'all' && user.location !== locationFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills filter
|
||||||
|
if (skillsFilter.length > 0) {
|
||||||
|
const hasMatchingSkill = skillsFilter.some((skill) =>
|
||||||
|
user.skills.includes(skill)
|
||||||
|
);
|
||||||
|
if (!hasMatchingSkill) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [statusFilter, locationFilter, 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>
|
||||||
|
|
||||||
|
{/* Location 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={locationFilter}
|
||||||
|
onChange={(e) => setLocationFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Locations</option>
|
||||||
|
{locations.map((location) => (
|
||||||
|
<option key={location} value={location}>
|
||||||
|
{location}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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' ||
|
||||||
|
locationFilter !== '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 */}
|
||||||
|
{locationFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||||
|
Location: {locationFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setLocationFilter('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');
|
||||||
|
setLocationFilter('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;
|
||||||
@@ -1,13 +1,51 @@
|
|||||||
import { FC, ReactElement } from 'react';
|
import { FC, ReactElement, useState } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
export const AppLayout: FC = (): ReactElement => {
|
export const AppLayout: FC = (): ReactElement => {
|
||||||
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||||
<BackofficeSidebar />
|
<BackofficeSidebar
|
||||||
|
isOpen={mobileSidebarOpen}
|
||||||
|
onClose={() => setMobileSidebarOpen(false)}
|
||||||
|
/>
|
||||||
<div className="flex-1 overflow-auto">
|
<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 />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
import { SearchOutlined } from "@ant-design/icons";
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
|
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
|
import {
|
||||||
import { cn, For } from "@imphnen-frontend-service/utils";
|
BackofficeWrapper,
|
||||||
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
|
DataTable,
|
||||||
import { ReactElement, useState } from "react";
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { ModalDetailUser } from "./_components/modal/detail";
|
import { cn, For } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { ReactElement, useState } from 'react';
|
||||||
|
import { ModalDetailUser } from './_components/modal/detail';
|
||||||
|
|
||||||
type UserStatus = 'active' | 'inactive';
|
type UserStatus = 'active' | 'inactive';
|
||||||
|
|
||||||
interface UserType {
|
interface UserType {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
email: string
|
email: string;
|
||||||
rating: number
|
rating: number;
|
||||||
status: UserStatus
|
status: UserStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
|
const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
@@ -22,15 +32,15 @@ const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
|
|||||||
email: 'fullname23@gmail.com',
|
email: 'fullname23@gmail.com',
|
||||||
rating: 4.5,
|
rating: 4.5,
|
||||||
status: i % 2 === 0 ? 'active' : 'inactive',
|
status: i % 2 === 0 ? 'active' : 'inactive',
|
||||||
}))
|
}));
|
||||||
|
|
||||||
export default function Components(): ReactElement {
|
export default function Components(): ReactElement {
|
||||||
const TABS = ['mentor', 'mentee'] as const
|
const TABS = ['mentor', 'mentee'] as const;
|
||||||
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor')
|
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor');
|
||||||
const [showDetail, setShowDetail] = useState(false)
|
const [showDetail, setShowDetail] = useState(false);
|
||||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||||
|
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 9,
|
||||||
@@ -39,7 +49,7 @@ export default function Components(): ReactElement {
|
|||||||
const columns: ColumnDef<UserType>[] = [
|
const columns: ColumnDef<UserType>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
meta: { cellClassName: cn("w-20") },
|
meta: { cellClassName: cn('w-20') },
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -97,7 +107,7 @@ export default function Components(): ReactElement {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
meta: { cellClassName: cn("w-72") },
|
meta: { cellClassName: cn('w-72') },
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -113,7 +123,7 @@ export default function Components(): ReactElement {
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: mockData,
|
data: mockData,
|
||||||
@@ -134,14 +144,19 @@ export default function Components(): ReactElement {
|
|||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="Dimentorin.dev">
|
<BackofficeWrapper title="Dimentorin.dev">
|
||||||
<div className="mb-8 flex justify-between items-center">
|
<div className="mb-8 flex justify-between items-center">
|
||||||
<h1 className="text-p1 font-semibold text-neutral-700">User Management</h1>
|
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">
|
||||||
|
User Management
|
||||||
|
</h1>
|
||||||
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
||||||
<For data={TABS}>
|
<For data={TABS}>
|
||||||
{(tab) => (
|
{(tab) => (
|
||||||
<Button
|
<Button
|
||||||
key={tab}
|
key={tab}
|
||||||
variant="text"
|
variant="text"
|
||||||
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
|
className={cn(
|
||||||
|
'px-3 py-2 capitalize',
|
||||||
|
activeTab === tab && 'bg-white'
|
||||||
|
)}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
@@ -163,12 +178,16 @@ export default function Components(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Select>
|
<Select>
|
||||||
<option selected disabled>Rating</option>
|
<option selected disabled>
|
||||||
|
Rating
|
||||||
|
</option>
|
||||||
<option value="4.5">4.5</option>
|
<option value="4.5">4.5</option>
|
||||||
<option value="5">5</option>
|
<option value="5">5</option>
|
||||||
</Select>
|
</Select>
|
||||||
<Select>
|
<Select>
|
||||||
<option selected disabled>Status</option>
|
<option selected disabled>
|
||||||
|
Status
|
||||||
|
</option>
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
<option value="inactive">Inactive</option>
|
<option value="inactive">Inactive</option>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@
|
|||||||
html {
|
html {
|
||||||
font-family: 'Bai Jamjuree', sans-serif;
|
font-family: 'Bai Jamjuree', sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,7 +88,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) return redirect('/auth/login');
|
// if (!session) return redirect('/auth/login');
|
||||||
|
|
||||||
const matchedRoute = mappingRoutePermissions.find(
|
const matchedRoute = mappingRoutePermissions.find(
|
||||||
(route) => route.path === pathname
|
(route) => route.path === pathname
|
||||||
|
|||||||
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FC, ReactElement, useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { ProfileForm, ProfileSidebar, ProfileHeader } from '../_components';
|
||||||
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { NotificationModal, NotificationType } from '../_components/modals/notification-modal';
|
||||||
|
import { ProfileProvider, useProfile } from '../_components/contexts/profile-context';
|
||||||
|
import { EditProfileModal } from '../_components/modals/edit-profile-modal';
|
||||||
|
|
||||||
|
const ProfileByIdPage: FC = (): ReactElement => {
|
||||||
|
const params = useParams();
|
||||||
|
const id = (params && params.id) ? params.id as string : undefined;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-600 text-lg">Profile ID not found.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileProvider profileId={id} profileType="user">
|
||||||
|
<ProfileByIdContent />
|
||||||
|
</ProfileProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProfileByIdContent: FC = (): ReactElement => {
|
||||||
|
const { profileData, isLoading, error, profileType } = useProfile();
|
||||||
|
|
||||||
|
const [notification, setNotification] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
type: 'success' | 'error';
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
type: 'success',
|
||||||
|
title: '',
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isEditProfileModalOpen, setIsEditProfileModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const getProfileTitle = () => {
|
||||||
|
if (profileData?.fullname) {
|
||||||
|
return `${profileData.fullname}'s Profile`;
|
||||||
|
}
|
||||||
|
return profileType === 'user' ? 'User Profile' : 'Mentor Profile';
|
||||||
|
};
|
||||||
|
|
||||||
|
const showNotification = (type: NotificationType['type'], title: string, message?: string) => {
|
||||||
|
setNotification({
|
||||||
|
isOpen: true,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
message
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideNotification = () => {
|
||||||
|
setNotification(prev => ({ ...prev, isOpen: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditProfileModal = () => {
|
||||||
|
setIsEditProfileModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditProfileModal = () => {
|
||||||
|
setIsEditProfileModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set isViewOnly to true for this page
|
||||||
|
const isViewOnly = true;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600">Loading profile...</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-600 text-lg">Failed to load profile</p>
|
||||||
|
<p className="text-gray-600 mt-2">Profile not found or you don't have permission to view it.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen">
|
||||||
|
<div className="">
|
||||||
|
<div className="w-full px-8 md:px-[60px] lg:px-20 py-4">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<Button variant="primary" className="flex items-center gap-2">
|
||||||
|
<ArrowLeftOutlined />
|
||||||
|
Kembali ke Dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full px-8 md:px-[60px] lg:px-20 py-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">
|
||||||
|
{getProfileTitle()}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full px-8 md:px-[60px] lg:px-20 pb-12">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="grid gap-8 lg:grid-cols-12">
|
||||||
|
<div className="lg:col-span-12">
|
||||||
|
<ProfileHeader onEditProfileClick={openEditProfileModal} isViewOnly={isViewOnly} />
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-8 order-1">
|
||||||
|
<ProfileForm
|
||||||
|
showNotification={showNotification}
|
||||||
|
isViewOnly={isViewOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-4 order-2">
|
||||||
|
<ProfileSidebar
|
||||||
|
showNotification={showNotification}
|
||||||
|
isViewOnly={isViewOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NotificationModal
|
||||||
|
isOpen={notification.isOpen}
|
||||||
|
onClose={hideNotification}
|
||||||
|
type={notification.type}
|
||||||
|
title={notification.title}
|
||||||
|
message={notification.message}
|
||||||
|
header="Profile"
|
||||||
|
/>
|
||||||
|
{/* Only render EditProfileModal if not in view-only mode */}
|
||||||
|
{!isViewOnly && (
|
||||||
|
<EditProfileModal
|
||||||
|
isOpen={isEditProfileModalOpen}
|
||||||
|
onClose={closeEditProfileModal}
|
||||||
|
showNotification={showNotification}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileByIdPage;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { EditOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface EditSectionButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditSectionButton: FC<EditSectionButtonProps> = ({ onClick, disabled = false }) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`flex items-center gap-1 px-3 py-1 text-sm transition-colors duration-200 rounded-md ${
|
||||||
|
disabled
|
||||||
|
? 'text-gray-400 cursor-not-allowed opacity-50'
|
||||||
|
: 'text-[#23A1EB] hover:text-[#1e90d6] hover:bg-[#23A1EB]/10'
|
||||||
|
}`}
|
||||||
|
aria-label="Edit section"
|
||||||
|
>
|
||||||
|
<span>Edit</span>
|
||||||
|
<EditOutlined className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { EditSectionButton } from './edit-section-button';
|
||||||
|
export { ModalButton } from './modal-button';
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { FC, ReactNode } from 'react';
|
||||||
|
|
||||||
|
type ButtonVariant = 'primary' | 'secondary' | 'danger';
|
||||||
|
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
interface ModalButtonProps {
|
||||||
|
children: ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
type?: 'button' | 'submit' | 'reset';
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVariantClasses = (variant: ButtonVariant): string => {
|
||||||
|
switch (variant) {
|
||||||
|
case 'primary':
|
||||||
|
return 'bg-[#23A1EB] hover:bg-[#1e90d6] text-white shadow-lg hover:shadow-xl';
|
||||||
|
case 'secondary':
|
||||||
|
return 'bg-white hover:bg-gray-200 text-[#23A1EB] shadow-md hover:shadow-lg';
|
||||||
|
case 'danger':
|
||||||
|
return 'bg-red-600 hover:bg-red-500 text-white shadow-lg hover:shadow-xl';
|
||||||
|
default:
|
||||||
|
return 'bg-[#23A1EB] hover:bg-[#1e90d6] text-white shadow-lg hover:shadow-xl';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSizeClasses = (size: ButtonSize): string => {
|
||||||
|
switch (size) {
|
||||||
|
case 'sm':
|
||||||
|
return 'px-3 py-1.5 text-sm';
|
||||||
|
case 'md':
|
||||||
|
return 'px-4 py-2 text-sm';
|
||||||
|
case 'lg':
|
||||||
|
return 'px-6 py-3 text-base';
|
||||||
|
default:
|
||||||
|
return 'px-4 py-2 text-sm';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ModalButton: FC<ModalButtonProps> = ({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
type = 'button',
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const baseClasses = 'font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#23A1EB] disabled:opacity-50 disabled:cursor-not-allowed';
|
||||||
|
const variantClasses = getVariantClasses(variant);
|
||||||
|
const sizeClasses = getSizeClasses(size);
|
||||||
|
|
||||||
|
const combinedClasses = `${baseClasses} ${variantClasses} ${sizeClasses} ${className}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
className={combinedClasses}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
<span>Loading...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './profile-context';
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useMemo, useCallback } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
useAuthStore,
|
||||||
|
useUserMe,
|
||||||
|
useUserById,
|
||||||
|
useUpdateUserMe,
|
||||||
|
useUpdateUserById,
|
||||||
|
UserDetailResponseDto,
|
||||||
|
UserUpdateRequestDto,
|
||||||
|
|
||||||
|
useMentorMe,
|
||||||
|
useMentorById,
|
||||||
|
useUpdateMentorMe,
|
||||||
|
useUpdateMentorById,
|
||||||
|
MentorDetailResponseDto,
|
||||||
|
MentorUpdateRequestDto
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
|
|
||||||
|
type ProfileData = UserDetailResponseDto | MentorDetailResponseDto;
|
||||||
|
type ProfileUpdateData = UserUpdateRequestDto | MentorUpdateRequestDto;
|
||||||
|
|
||||||
|
|
||||||
|
const canAccessMentorFeatures = (user: { role?: { name?: string; permissions?: Array<{ name?: string }> } } | null) => {
|
||||||
|
if (!user?.role) return false;
|
||||||
|
|
||||||
|
const roleName = user.role.name?.toLowerCase() || '';
|
||||||
|
const isMentorRole = roleName.includes('mentor') || roleName.includes('admin');
|
||||||
|
|
||||||
|
if (isMentorRole) return true;
|
||||||
|
|
||||||
|
|
||||||
|
const permissions = user.role.permissions || [];
|
||||||
|
const hasMentorPermission = permissions.some((permission: { name?: string }) =>
|
||||||
|
permission.name?.toLowerCase().includes('mentor')
|
||||||
|
);
|
||||||
|
|
||||||
|
return hasMentorPermission;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProfileContextType {
|
||||||
|
profileData: ProfileData | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: unknown;
|
||||||
|
isOwnProfile: boolean;
|
||||||
|
profileId: string | null;
|
||||||
|
profileType: 'user' | 'mentor';
|
||||||
|
updateProfile: (data: ProfileUpdateData) => Promise<void>;
|
||||||
|
isUpdating: boolean;
|
||||||
|
canAccessMentor: boolean;
|
||||||
|
}const ProfileContext = createContext<ProfileContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface ProfileProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
profileId?: string;
|
||||||
|
profileType?: 'user' | 'mentor';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileProvider: React.FC<ProfileProviderProps> = ({
|
||||||
|
children,
|
||||||
|
profileId,
|
||||||
|
profileType: forcedProfileType
|
||||||
|
}) => {
|
||||||
|
const params = useParams();
|
||||||
|
const { session } = useAuthStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|
||||||
|
const canAccessMentor = useMemo(() => {
|
||||||
|
return canAccessMentorFeatures(session?.user || null);
|
||||||
|
}, [session?.user]);
|
||||||
|
|
||||||
|
|
||||||
|
const isMentorRole = useMemo(() => {
|
||||||
|
const roleName = session?.user?.role?.name?.toLowerCase() || '';
|
||||||
|
return roleName === 'mentor';
|
||||||
|
}, [session?.user?.role?.name]);
|
||||||
|
|
||||||
|
|
||||||
|
const profileType: 'user' | 'mentor' = useMemo(() => {
|
||||||
|
if (forcedProfileType) {
|
||||||
|
|
||||||
|
if (forcedProfileType === 'mentor' && !isMentorRole) {
|
||||||
|
return 'user';
|
||||||
|
}
|
||||||
|
return forcedProfileType;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if ((params?.mentor || (typeof window !== 'undefined' && window.location.pathname.includes('/mentor'))) && isMentorRole) {
|
||||||
|
return 'mentor';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'user';
|
||||||
|
}, [forcedProfileType, params, isMentorRole]);
|
||||||
|
|
||||||
|
|
||||||
|
const id = profileId || (params?.id as string) || undefined;
|
||||||
|
const isOwnProfile = !id;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const userMeQuery = useUserMe({
|
||||||
|
queryKey: ['user-me'],
|
||||||
|
enabled: isOwnProfile && profileType === 'user',
|
||||||
|
});
|
||||||
|
const userByIdQuery = useUserById(id || '', {
|
||||||
|
queryKey: ['user-by-id', id],
|
||||||
|
enabled: !isOwnProfile && !!id && profileType === 'user',
|
||||||
|
});
|
||||||
|
const updateUserMeMutation = useUpdateUserMe();
|
||||||
|
const updateUserByIdMutation = useUpdateUserById();
|
||||||
|
|
||||||
|
const mentorMeQuery = useMentorMe({
|
||||||
|
queryKey: ['mentor-me'],
|
||||||
|
enabled: isOwnProfile && profileType === 'mentor' && canAccessMentor,
|
||||||
|
});
|
||||||
|
const mentorByIdQuery = useMentorById(id || '', {
|
||||||
|
queryKey: ['mentor-by-id', id],
|
||||||
|
enabled: !isOwnProfile && !!id && profileType === 'mentor' && canAccessMentor,
|
||||||
|
});
|
||||||
|
const updateMentorMeMutation = useUpdateMentorMe();
|
||||||
|
const updateMentorByIdMutation = useUpdateMentorById();
|
||||||
|
|
||||||
|
|
||||||
|
const selectedUserQuery = isOwnProfile ? userMeQuery : userByIdQuery;
|
||||||
|
const selectedMentorQuery = isOwnProfile ? mentorMeQuery : mentorByIdQuery;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: profileData,
|
||||||
|
isLoading,
|
||||||
|
error
|
||||||
|
} = useMemo(() => {
|
||||||
|
|
||||||
|
if (canAccessMentor && profileType === 'mentor') {
|
||||||
|
return selectedMentorQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedUserQuery;
|
||||||
|
}, [profileType, canAccessMentor, selectedUserQuery, selectedMentorQuery]);
|
||||||
|
|
||||||
|
|
||||||
|
const selectedUserMutation = isOwnProfile ? updateUserMeMutation : updateUserByIdMutation;
|
||||||
|
const selectedMentorMutation = isOwnProfile ? updateMentorMeMutation : updateMentorByIdMutation;
|
||||||
|
|
||||||
|
const updateMutation = useMemo(() => {
|
||||||
|
|
||||||
|
if (canAccessMentor && profileType === 'mentor') {
|
||||||
|
return selectedMentorMutation;
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedUserMutation;
|
||||||
|
}, [profileType, canAccessMentor, selectedUserMutation, selectedMentorMutation]);
|
||||||
|
|
||||||
|
|
||||||
|
const updateProfile = useCallback(async (data: ProfileUpdateData) => {
|
||||||
|
try {
|
||||||
|
if (canAccessMentor && profileType === 'mentor') {
|
||||||
|
|
||||||
|
if (isOwnProfile) {
|
||||||
|
await updateMentorMeMutation.mutateAsync(data as MentorUpdateRequestDto);
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['mentor-me'] });
|
||||||
|
} else if (id) {
|
||||||
|
await updateMentorByIdMutation.mutateAsync({ id, data: data as MentorUpdateRequestDto });
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['mentor-by-id', id] });
|
||||||
|
}
|
||||||
|
} else if (isOwnProfile) {
|
||||||
|
|
||||||
|
await updateUserMeMutation.mutateAsync(data as UserUpdateRequestDto);
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['user-me'] });
|
||||||
|
} else if (id) {
|
||||||
|
await updateUserByIdMutation.mutateAsync({ id, data: data as UserUpdateRequestDto });
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['user-by-id', id] });
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Failed to update profile:', error);
|
||||||
|
|
||||||
|
let apiMessage = '';
|
||||||
|
if (typeof error === 'object' && error !== null) {
|
||||||
|
const errObj = error as { response?: { data?: unknown } };
|
||||||
|
const data = errObj.response?.data;
|
||||||
|
if (data) {
|
||||||
|
try {
|
||||||
|
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
||||||
|
if (parsed && typeof parsed.message === 'string') {
|
||||||
|
apiMessage = parsed.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
apiMessage = typeof data === 'string' ? data : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (apiMessage) {
|
||||||
|
throw new Error(apiMessage);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
profileType,
|
||||||
|
isOwnProfile,
|
||||||
|
canAccessMentor,
|
||||||
|
id,
|
||||||
|
queryClient,
|
||||||
|
updateUserMeMutation,
|
||||||
|
updateUserByIdMutation,
|
||||||
|
updateMentorMeMutation,
|
||||||
|
updateMentorByIdMutation
|
||||||
|
]); const isUpdating = updateMutation.isPending;
|
||||||
|
|
||||||
|
const value: ProfileContextType = useMemo(() => ({
|
||||||
|
profileData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isOwnProfile,
|
||||||
|
profileId: isOwnProfile ? null : (id || null),
|
||||||
|
profileType,
|
||||||
|
updateProfile,
|
||||||
|
isUpdating,
|
||||||
|
canAccessMentor
|
||||||
|
}), [profileData, isLoading, error, isOwnProfile, id, profileType, updateProfile, isUpdating, canAccessMentor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ProfileContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const useProfile = (): ProfileContextType => {
|
||||||
|
const context = useContext(ProfileContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useProfile must be used within a ProfileProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type { ProfileContextType };
|
||||||
|
export type { ProfileData, ProfileUpdateData };
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Guard } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
interface MentorGuardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MentorGuard: React.FC<MentorGuardProps> = ({
|
||||||
|
children,
|
||||||
|
fallback = (
|
||||||
|
<div className="p-4 text-center text-red-500">
|
||||||
|
<p>Akses ditolak: Anda tidak memiliki izin untuk mengakses fitur mentor.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Guard
|
||||||
|
permissions={['mentor', 'admin']}
|
||||||
|
fallback={fallback}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Guard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
export * from './profile';
|
||||||
|
|
||||||
|
|
||||||
|
export * from './sections';
|
||||||
|
|
||||||
|
|
||||||
|
export * from './buttons';
|
||||||
|
|
||||||
|
|
||||||
|
export * from './shared';
|
||||||
|
|
||||||
|
|
||||||
|
export * from './modals';
|
||||||
|
|
||||||
|
|
||||||
|
export * from './contexts';
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { ModalButton } from '../buttons/modal-button';
|
||||||
|
import { useUploadCV } from '@imphnen-frontend-service/service';
|
||||||
|
import { FileUploader } from '../shared/file-uploader';
|
||||||
|
|
||||||
|
interface CVData {
|
||||||
|
fileName: string;
|
||||||
|
fileUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CVModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialValue: CVData;
|
||||||
|
onSave: (value: CVData) => Promise<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CVModal: FC<CVModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
isLoading = false,
|
||||||
|
}) => {
|
||||||
|
const [cvData, setCvData] = useState(initialValue);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const uploadCVMutation = useUploadCV();
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCvData(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await onSave(cvData);
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save failed:', error);
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setCvData(initialValue);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = async (file: File) => {
|
||||||
|
try {
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
|
||||||
|
if (!file.type.includes('pdf')) {
|
||||||
|
throw new Error('Please select a PDF file');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const uploadResult = await uploadCVMutation.mutateAsync(file);
|
||||||
|
|
||||||
|
console.log('CV upload response:', uploadResult);
|
||||||
|
|
||||||
|
|
||||||
|
interface UploadData {
|
||||||
|
original_filename?: string;
|
||||||
|
filename?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadData = ('data' in uploadResult ? (uploadResult as { data: UploadData }).data : uploadResult as UploadData);
|
||||||
|
|
||||||
|
setCvData({
|
||||||
|
fileName: uploadData.original_filename || uploadData.filename || file.name,
|
||||||
|
fileUrl: uploadData.url || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('CV uploaded successfully, URL:', uploadData.url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('CV upload error:', error);
|
||||||
|
|
||||||
|
const fileInput = document.getElementById('cv-upload') as HTMLInputElement;
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
aria-label="Close modal"
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
|
||||||
|
|
||||||
|
<div className="p-6 pb-4 border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit CV/Resume</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="px-6 py-6 space-y-6">
|
||||||
|
{}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Upload CV/Resume
|
||||||
|
</h3>
|
||||||
|
<FileUploader
|
||||||
|
accept=".pdf"
|
||||||
|
maxSize={10 * 1024 * 1024}
|
||||||
|
onFileSelect={handleFileSelect}
|
||||||
|
isLoading={isUploading}
|
||||||
|
dragAndDrop={true}
|
||||||
|
description="Klik atau tarik file PDF yang ingin di upload"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Format yang didukung: PDF • Maksimal ukuran: 10MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{}
|
||||||
|
{cvData.fileName && (
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-blue-900 mb-3">File Terpilih</h4>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-white text-xs font-bold">PDF</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-blue-900 text-sm">{cvData.fileName}</p>
|
||||||
|
<p className="text-xs text-blue-600">Siap untuk disimpan</p>
|
||||||
|
</div>
|
||||||
|
{cvData.fileUrl && (
|
||||||
|
<a
|
||||||
|
href={cvData.fileUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:text-blue-800 text-sm font-medium underline"
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{}
|
||||||
|
<div className="flex gap-3 p-6 pt-4 border-t border-gray-100">
|
||||||
|
<ModalButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isLoading || isUploading}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</ModalButton>
|
||||||
|
<ModalButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isLoading || isUploading || !cvData.fileName}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Menyimpan...' : 'Simpan CV'}
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { ModalButton } from '../buttons/modal-button';
|
||||||
|
|
||||||
|
interface DescriptionModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialValue: string;
|
||||||
|
onSave: (value: string) => Promise<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DescriptionModal: FC<DescriptionModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
isLoading = false,
|
||||||
|
}) => {
|
||||||
|
const [description, setDescription] = useState(initialValue);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDescription(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await onSave(description);
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save failed:', error);
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setDescription(initialValue);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
aria-label="Close modal"
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
|
||||||
|
<div className="p-6 pb-4 border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Description</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="description-textarea" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description-textarea"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={8}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors resize-none"
|
||||||
|
placeholder="Write your description here..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 p-6 pt-4">
|
||||||
|
<ModalButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</ModalButton>
|
||||||
|
<ModalButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import React, { FC, useState, useEffect } from 'react';
|
||||||
|
import { Modal, InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { useProfile } from '../contexts/profile-context';
|
||||||
|
import { CameraOutlined } from '@ant-design/icons';
|
||||||
|
import { useUploadAvatar } from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
|
interface EditProfileModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
showNotification: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, showNotification }) => {
|
||||||
|
const { profileData, profileType, updateProfile } = useProfile();
|
||||||
|
const uploadAvatarMutation = useUploadAvatar();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
fullname: '',
|
||||||
|
avatar: ''
|
||||||
|
});
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string>('/image/testimonial.webp');
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData) {
|
||||||
|
const fullname = profileData.fullname ||
|
||||||
|
(profileType === 'mentor' && 'legal_name' in profileData ? profileData.legal_name : '') || '';
|
||||||
|
|
||||||
|
|
||||||
|
const avatar = (profileType === 'user' && 'avatar' in profileData)
|
||||||
|
? profileData.avatar || '/image/testimonial.webp'
|
||||||
|
: '/image/testimonial.webp';
|
||||||
|
|
||||||
|
console.log('Modal - Profile data avatar URL:', avatar);
|
||||||
|
console.log('Modal - Profile data:', profileData);
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
fullname,
|
||||||
|
avatar: (profileType === 'user' && 'avatar' in profileData) ? profileData.avatar || '' : ''
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
setPreviewUrl(avatar);
|
||||||
|
} else {
|
||||||
|
|
||||||
|
setPreviewUrl('/image/testimonial.webp');
|
||||||
|
}
|
||||||
|
}, [profileData, profileType]);
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
console.log('Image failed to load:', previewUrl);
|
||||||
|
setPreviewUrl('/image/testimonial.webp');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData(prev => ({ ...prev, fullname: e.target.value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageUpload = async (file: File) => {
|
||||||
|
try {
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string;
|
||||||
|
setPreviewUrl(result);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
|
||||||
|
const uploadResult = await uploadAvatarMutation.mutateAsync(file);
|
||||||
|
|
||||||
|
console.log('Avatar upload response:', uploadResult);
|
||||||
|
|
||||||
|
|
||||||
|
interface UploadData {
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadData = ('data' in uploadResult ? (uploadResult as { data: UploadData }).data : uploadResult as UploadData);
|
||||||
|
|
||||||
|
|
||||||
|
setFormData(prev => ({ ...prev, avatar: uploadData.url || '' }));
|
||||||
|
|
||||||
|
|
||||||
|
setPreviewUrl(uploadData.url || '/image/testimonial.webp');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Avatar upload error:', error);
|
||||||
|
showNotification('error', 'Upload Failed', 'Failed to upload avatar image');
|
||||||
|
|
||||||
|
const originalAvatar = (profileType === 'user' && profileData && 'avatar' in profileData) ? profileData.avatar : '';
|
||||||
|
setPreviewUrl(originalAvatar || '/image/testimonial.webp');
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const updates: Record<string, string> = {};
|
||||||
|
|
||||||
|
|
||||||
|
if (formData.fullname.trim() !== '') {
|
||||||
|
if (profileType === 'user') {
|
||||||
|
updates.fullname = formData.fullname;
|
||||||
|
} else if (profileType === 'mentor') {
|
||||||
|
updates.legal_name = formData.fullname;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (formData.avatar && formData.avatar !== (profileData && 'avatar' in profileData ? profileData.avatar : '')) {
|
||||||
|
updates.avatar = formData.avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await updateProfile(updates);
|
||||||
|
showNotification('success', 'Profile Updated', 'Your profile has been successfully updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('Profile update error:', err);
|
||||||
|
let apiMessage = '';
|
||||||
|
if (typeof err === 'object' && err !== null) {
|
||||||
|
const errObj = err as { response?: { data?: { message?: string } } };
|
||||||
|
let backendMsg = '';
|
||||||
|
if (errObj.response?.data?.message) {
|
||||||
|
backendMsg = errObj.response.data.message;
|
||||||
|
}
|
||||||
|
let msg = '';
|
||||||
|
if ('message' in err && typeof (err as { message?: string }).message === 'string') {
|
||||||
|
msg = (err as { message?: string }).message || '';
|
||||||
|
|
||||||
|
if (msg.trim().startsWith('{') && msg.trim().endsWith('}')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(msg);
|
||||||
|
if (parsed && typeof parsed.message === 'string') {
|
||||||
|
msg = parsed.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore JSON parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (backendMsg && msg && backendMsg !== msg) {
|
||||||
|
apiMessage = backendMsg + '\n' + msg;
|
||||||
|
} else if (backendMsg) {
|
||||||
|
apiMessage = backendMsg;
|
||||||
|
} else if (msg) {
|
||||||
|
apiMessage = msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showNotification('error', 'Failed to save changes', apiMessage || 'Please try again.');
|
||||||
|
}
|
||||||
|
}; return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose}>
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Title>Edit Profile</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content>
|
||||||
|
{}
|
||||||
|
<div className="flex justify-center pt-6 pb-4">
|
||||||
|
<div className="relative">
|
||||||
|
{}
|
||||||
|
<input
|
||||||
|
id="avatar-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleImageUpload(file);
|
||||||
|
}}
|
||||||
|
className="hidden"
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="avatar-upload" className="cursor-pointer block relative group">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Profile"
|
||||||
|
className="w-24 h-24 rounded-full object-cover border-4 border-blue-100 shadow-lg transition-all duration-300 group-hover:border-blue-200"
|
||||||
|
onError={handleImageError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hover overlay */}
|
||||||
|
<div className="absolute inset-0 rounded-full bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-300 flex items-center justify-center">
|
||||||
|
<span className="text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
|
Change Photo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{}
|
||||||
|
<label htmlFor="avatar-upload" className="cursor-pointer">
|
||||||
|
<div className="absolute bottom-0 right-0 w-8 h-8 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center transition-all duration-300 shadow-lg border-2 border-white">
|
||||||
|
{isUploading ? (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||||
|
) : (
|
||||||
|
<CameraOutlined className="text-xs" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div> <div className="px-6 pb-6 space-y-6">
|
||||||
|
{}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<InputField
|
||||||
|
label="Full Name"
|
||||||
|
name="fullname"
|
||||||
|
value={formData.fullname}
|
||||||
|
onChange={handleNameChange}
|
||||||
|
placeholder="Enter your full name"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 pl-1">
|
||||||
|
This name will be displayed on your profile
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal.Content>
|
||||||
|
<Modal.Footer>
|
||||||
|
<div className="flex gap-3 w-full">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save Changes'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { ModalButton } from '../buttons/modal-button';
|
||||||
|
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
|
||||||
|
interface Education {
|
||||||
|
id: string;
|
||||||
|
institution: string;
|
||||||
|
degree: string;
|
||||||
|
field: string;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EducationModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialValue: Education[];
|
||||||
|
onSave: (value: Education[]) => Promise<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
showNotification?: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EducationModal: FC<EducationModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
isLoading = false,
|
||||||
|
showNotification,
|
||||||
|
}) => {
|
||||||
|
const [educations, setEducations] = useState<Education[]>(initialValue);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEducations(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
|
||||||
|
const hasEmpty = educations.some(edu =>
|
||||||
|
!edu.institution.trim() || !edu.degree.trim() || !edu.field.trim() || !edu.period.trim()
|
||||||
|
);
|
||||||
|
if (hasEmpty) {
|
||||||
|
if (showNotification) {
|
||||||
|
showNotification('error', 'Data Tidak Lengkap', 'Semua field harus diisi pada setiap pendidikan.');
|
||||||
|
} else {
|
||||||
|
alert('Semua field harus diisi pada setiap pendidikan.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await onSave(educations);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEducations(initialValue);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEducation = () => {
|
||||||
|
const newEducation: Education = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
institution: '',
|
||||||
|
degree: '',
|
||||||
|
field: '',
|
||||||
|
period: '',
|
||||||
|
};
|
||||||
|
setEducations([...educations, newEducation]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEducation = (id: string) => {
|
||||||
|
setEducations(educations.filter(edu => edu.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEducation = (id: string, field: keyof Education, value: string) => {
|
||||||
|
setEducations(educations.map(edu =>
|
||||||
|
edu.id === id ? { ...edu, [field]: value } : edu
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
aria-label="Close modal"
|
||||||
|
></button>
|
||||||
|
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl max-w-5xl w-full max-h-[95vh] overflow-hidden">
|
||||||
|
|
||||||
|
<div className="p-6 border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Education</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{educations.map((education, index) => (
|
||||||
|
<div key={education.id} className="border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Education {index + 1}</h3>
|
||||||
|
<ModalButton
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeEducation(education.id)}
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<InputField
|
||||||
|
label="Institution"
|
||||||
|
value={education.institution}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'institution', e.target.value)}
|
||||||
|
placeholder="Enter institution name"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Degree"
|
||||||
|
value={education.degree}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'degree', e.target.value)}
|
||||||
|
placeholder="Enter degree"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Field"
|
||||||
|
value={education.field}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'field', e.target.value)}
|
||||||
|
placeholder="Enter field of study"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Period"
|
||||||
|
value={education.period}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'period', e.target.value)}
|
||||||
|
placeholder="e.g., Sep 2022 - Current"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<ModalButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={addEducation}
|
||||||
|
className="w-full border-2 border-dashed border-gray-300 rounded-lg p-4 text-gray-500 hover:border-[#23A1EB] hover:text-[#23A1EB] transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
Add Education
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50">
|
||||||
|
<ModalButton
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-white shadow-md"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</ModalButton>
|
||||||
|
<ModalButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { ModalButton } from '../buttons/modal-button';
|
||||||
|
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
|
||||||
|
|
||||||
|
interface Experience {
|
||||||
|
id: string;
|
||||||
|
company: string;
|
||||||
|
position: string;
|
||||||
|
duration: string;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExperienceModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialValue: Experience[];
|
||||||
|
onSave: (value: Experience[]) => Promise<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
showNotification?: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExperienceModal: FC<ExperienceModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
isLoading = false,
|
||||||
|
showNotification,
|
||||||
|
}) => {
|
||||||
|
const [experiences, setExperiences] = useState<Experience[]>(initialValue);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExperiences(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
|
||||||
|
const hasEmpty = experiences.some(exp =>
|
||||||
|
!exp.company.trim() || !exp.position.trim() || !exp.duration.trim() || !exp.period.trim()
|
||||||
|
);
|
||||||
|
if (hasEmpty) {
|
||||||
|
if (showNotification) {
|
||||||
|
showNotification('error', 'Data Tidak Lengkap', 'Semua field harus diisi pada setiap pengalaman kerja.');
|
||||||
|
} else {
|
||||||
|
alert('Semua field harus diisi pada setiap pengalaman kerja.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await onSave(experiences);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setExperiences(initialValue);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addExperience = () => {
|
||||||
|
const newExperience: Experience = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
company: '',
|
||||||
|
position: '',
|
||||||
|
duration: '',
|
||||||
|
period: '',
|
||||||
|
};
|
||||||
|
setExperiences([...experiences, newExperience]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeExperience = (id: string) => {
|
||||||
|
setExperiences(experiences.filter(exp => exp.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateExperience = (id: string, field: keyof Experience, value: string) => {
|
||||||
|
setExperiences(experiences.map(exp =>
|
||||||
|
exp.id === id ? { ...exp, [field]: value } : exp
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
aria-label="Close modal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl max-w-5xl w-full max-h-[95vh] overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Experience</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{experiences.map((experience, index) => (
|
||||||
|
<div key={experience.id} className="border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Experience {index + 1}</h3>
|
||||||
|
<ModalButton
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeExperience(experience.id)}
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<InputField
|
||||||
|
label="Company"
|
||||||
|
value={experience.company}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'company', e.target.value)}
|
||||||
|
placeholder="Enter company name"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Position"
|
||||||
|
value={experience.position}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'position', e.target.value)}
|
||||||
|
placeholder="Enter position"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Duration"
|
||||||
|
value={experience.duration}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'duration', e.target.value)}
|
||||||
|
placeholder="e.g., 7 Months"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Period"
|
||||||
|
value={experience.period}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'period', e.target.value)}
|
||||||
|
placeholder="e.g., Jan 2024 - Present"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<ModalButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={addExperience}
|
||||||
|
className="w-full border-2 border-dashed border-gray-300 rounded-lg p-4 text-gray-500 hover:border-[#23A1EB] hover:text-[#23A1EB] transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
Add Experience
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50">
|
||||||
|
<ModalButton
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-white shadow-md"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</ModalButton>
|
||||||
|
<ModalButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export { NotificationModal } from './notification-modal';
|
||||||
|
export { CVModal } from './cv-modal';
|
||||||
|
export { DescriptionModal } from './description-modal';
|
||||||
|
export { EducationModal } from './education-modal';
|
||||||
|
export { ExperienceModal } from './experience-modal';
|
||||||
|
export { SocialMediaModal } from './social-media-modal';
|
||||||
|
export { SkillsModal } from './skills-modal';
|
||||||
|
export { EditProfileModal } from './edit-profile-modal';
|
||||||
|
export { LanguagesModal } from './languages-modal';
|
||||||
|
export { PersonalInfoModal } from './personal-info-modal';
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { Input } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { ModalButton } from '../buttons/modal-button';
|
||||||
|
|
||||||
|
interface Language {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
level: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LanguagesModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialValue: Language[];
|
||||||
|
onSave: (languages: Language[]) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LanguagesModal: FC<LanguagesModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
isLoading = false,
|
||||||
|
}) => {
|
||||||
|
const [languages, setLanguages] = useState<Language[]>(initialValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const languagesWithIds = initialValue.map(lang => ({
|
||||||
|
...lang,
|
||||||
|
id: lang.id || `lang-${Date.now()}-${Math.random()}`
|
||||||
|
}));
|
||||||
|
setLanguages(languagesWithIds);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
const handleAddLanguage = () => {
|
||||||
|
setLanguages([...languages, {
|
||||||
|
id: `lang-${Date.now()}-${Math.random()}`,
|
||||||
|
name: '',
|
||||||
|
level: ''
|
||||||
|
}]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLanguageChange = (index: number, field: keyof Language, value: string) => {
|
||||||
|
const newLanguages = [...languages];
|
||||||
|
newLanguages[index] = { ...newLanguages[index], [field]: value };
|
||||||
|
setLanguages(newLanguages);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveLanguage = (index: number) => {
|
||||||
|
const newLanguages = languages.filter((_, i) => i !== index);
|
||||||
|
setLanguages(newLanguages);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
|
||||||
|
const languagesToSave = languages.map(({ id, ...lang }) => lang);
|
||||||
|
onSave(languagesToSave);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
const languagesWithIds = initialValue.map(lang => ({
|
||||||
|
...lang,
|
||||||
|
id: lang.id || `lang-${Date.now()}-${Math.random()}`
|
||||||
|
}));
|
||||||
|
setLanguages(languagesWithIds);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{}
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
type="button"
|
||||||
|
aria-label="Close modal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{}
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto max-h-[95vh] overflow-hidden">
|
||||||
|
{}
|
||||||
|
<div className="p-6 pb-4 border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Languages</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{}
|
||||||
|
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{languages.map((language, index) => (
|
||||||
|
<div key={language.id} className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-700 mb-1">Language Name</p>
|
||||||
|
<Input
|
||||||
|
value={language.name}
|
||||||
|
onChange={(e) => handleLanguageChange(index, 'name', e.target.value)}
|
||||||
|
placeholder="e.g., English"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-700 mb-1">Level</p>
|
||||||
|
<Input
|
||||||
|
value={language.level}
|
||||||
|
onChange={(e) => handleLanguageChange(index, 'level', e.target.value)}
|
||||||
|
placeholder="e.g., Fluent"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:flex-shrink-0">
|
||||||
|
<ModalButton variant="danger" onClick={() => handleRemoveLanguage(index)} className="w-full sm:w-auto">
|
||||||
|
Remove
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<ModalButton variant="secondary" onClick={handleAddLanguage} className="w-full">
|
||||||
|
Add Language
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{}
|
||||||
|
<div className="flex gap-3 p-6 pt-4">
|
||||||
|
<ModalButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="flex-1 bg-white shadow-md"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</ModalButton>
|
||||||
|
<ModalButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Saving...' : 'Save'}
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { CloseOutlined } from '@ant-design/icons';
|
||||||
|
import { ModalButton } from '../buttons/modal-button';
|
||||||
|
|
||||||
|
export type NotificationType = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
type: 'success' | 'error';
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
header: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationModalProps extends NotificationType {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
type: 'success' | 'error';
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
header: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotificationModal: FC<NotificationModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
header,
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const isSuccess = type === 'success';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close modal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl max-w-md w-full overflow-hidden">
|
||||||
|
<div className="p-6 pb-4 border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">{header}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
{isSuccess ? (
|
||||||
|
<div className="w-30 h-30 mx-auto mb-4">
|
||||||
|
<img
|
||||||
|
src="/image/success.png"
|
||||||
|
alt="Success"
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-20 h-20 bg-red-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<CloseOutlined className="text-white text-3xl" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 className={`text-lg font-medium ${isSuccess ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{!isSuccess && message && (
|
||||||
|
<div className="mt-2 text-sm text-red-500 whitespace-pre-line">{message}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full bg-[#23A1EB] hover:bg-[#23A1EB]/90"
|
||||||
|
>
|
||||||
|
Selesai
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { ModalButton } from '../buttons/modal-button';
|
||||||
|
|
||||||
|
interface PersonalInfo {
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
location: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersonalInfoModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialValue: PersonalInfo;
|
||||||
|
onSave: (value: PersonalInfo) => Promise<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PersonalInfoModal: FC<PersonalInfoModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
isLoading = false,
|
||||||
|
}) => {
|
||||||
|
const [personalInfo, setPersonalInfo] = useState(initialValue);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPersonalInfo(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await onSave(personalInfo);
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save failed:', error);
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setPersonalInfo(initialValue);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
aria-label="Close modal"
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
|
||||||
|
|
||||||
|
<div className="p-6 pb-4 border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Personal Information</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="personal-email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="personal-email"
|
||||||
|
type="email"
|
||||||
|
value={personalInfo.email}
|
||||||
|
onChange={(e) => setPersonalInfo({ ...personalInfo, email: e.target.value })}
|
||||||
|
placeholder="Enter your email"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="personal-phone" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Phone
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="personal-phone"
|
||||||
|
type="tel"
|
||||||
|
value={personalInfo.phone}
|
||||||
|
onChange={(e) => setPersonalInfo({ ...personalInfo, phone: e.target.value })}
|
||||||
|
placeholder="Enter your phone number"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="personal-location" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Location
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="personal-location"
|
||||||
|
type="text"
|
||||||
|
value={personalInfo.location}
|
||||||
|
onChange={(e) => setPersonalInfo({ ...personalInfo, location: e.target.value })}
|
||||||
|
placeholder="Enter your location"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex gap-3 p-6 pt-4">
|
||||||
|
<ModalButton variant="secondary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="flex-1 bg-white shadow-md"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</ModalButton>
|
||||||
|
<ModalButton variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
import { FC, useState } from 'react';
|
||||||
|
import { ModalButton } from '../buttons/modal-button';
|
||||||
|
|
||||||
|
interface ProfileBasicInfo {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileBasicInfoModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialValue: ProfileBasicInfo;
|
||||||
|
onSave: (value: ProfileBasicInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileBasicInfoModal: FC<ProfileBasicInfoModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
}) => {
|
||||||
|
const [profileInfo, setProfileInfo] = useState(initialValue);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(profileInfo);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setProfileInfo(initialValue);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
aria-label="Close modal"
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
|
||||||
|
|
||||||
|
<div className="p-6 pb-4 border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Profile</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="profile-name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Full Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="profile-name"
|
||||||
|
type="text"
|
||||||
|
value={profileInfo.name}
|
||||||
|
onChange={(e) => setProfileInfo({ ...profileInfo, name: e.target.value })}
|
||||||
|
placeholder="Enter your full name"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="profile-title" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Professional Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="profile-title"
|
||||||
|
type="text"
|
||||||
|
value={profileInfo.title}
|
||||||
|
onChange={(e) => setProfileInfo({ ...profileInfo, title: e.target.value })}
|
||||||
|
placeholder="Enter your professional title"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 p-6 pt-4">
|
||||||
|
<ModalButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</ModalButton>
|
||||||
|
<ModalButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Simpan
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { ModalButton } from '../buttons/modal-button';
|
||||||
|
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
|
||||||
|
interface Skill {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialValue: Skill[];
|
||||||
|
onSave: (value: Skill[]) => Promise<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SkillsModal: FC<SkillsModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
isLoading = false,
|
||||||
|
}) => {
|
||||||
|
const [skills, setSkills] = useState<Skill[]>(initialValue);
|
||||||
|
const [newSkill, setNewSkill] = useState({ name: '' });
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSkills(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await onSave(skills);
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save failed:', error);
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setSkills(initialValue);
|
||||||
|
setNewSkill({ name: '' });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSkill = () => {
|
||||||
|
if (newSkill.name.trim()) {
|
||||||
|
const skill: Skill = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
name: newSkill.name.trim(),
|
||||||
|
};
|
||||||
|
setSkills([...skills, skill]);
|
||||||
|
setNewSkill({ name: '' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSkill = (id: string) => {
|
||||||
|
setSkills(skills.filter(skill => skill.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
type="button"
|
||||||
|
aria-label="Close modal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto max-h-[95vh] overflow-hidden">
|
||||||
|
|
||||||
|
<div className="p-6 pb-4 border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Skills</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||||
|
|
||||||
|
<div className="mb-6 p-4 border border-gray-200 rounded-lg bg-gray-50">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">Add New Skill</h3>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<InputField
|
||||||
|
label="Skill Name"
|
||||||
|
value={newSkill.name}
|
||||||
|
onChange={(e) => setNewSkill({ ...newSkill, name: e.target.value })}
|
||||||
|
placeholder="e.g., React, JavaScript, etc."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex sm:items-end">
|
||||||
|
<ModalButton
|
||||||
|
onClick={addSkill}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
<span className="sm:hidden ml-2">Add Skill</span>
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">Current Skills</h3>
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-sm py-4">No skills added yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<div
|
||||||
|
key={skill.id}
|
||||||
|
className="flex items-center justify-between p-3 border border-gray-200 rounded-md bg-white"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-900">{skill.name}</span>
|
||||||
|
</div>
|
||||||
|
<ModalButton
|
||||||
|
onClick={() => removeSkill(skill.id)}
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex gap-3 p-6 pt-4">
|
||||||
|
<ModalButton variant="secondary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</ModalButton>
|
||||||
|
<ModalButton variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { ModalButton } from '../buttons/modal-button';
|
||||||
|
|
||||||
|
interface SocialLink {
|
||||||
|
platform: string;
|
||||||
|
placeholder: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SocialMediaModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialValue: SocialLink[];
|
||||||
|
onSave: (value: SocialLink[]) => Promise<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SocialMediaModal: FC<SocialMediaModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
isLoading = false,
|
||||||
|
}) => {
|
||||||
|
const [socialLinks, setSocialLinks] = useState<SocialLink[]>(initialValue);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSocialLinks(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await onSave(socialLinks);
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save failed:', error);
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setSocialLinks(initialValue);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSocialLinkChange = (index: number, value: string) => {
|
||||||
|
const updated = [...socialLinks];
|
||||||
|
updated[index].value = value;
|
||||||
|
setSocialLinks(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
aria-label="Close modal"
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
|
||||||
|
|
||||||
|
<div className="p-6 pb-4 border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Social Media</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="px-6 py-4 space-y-4 max-h-[60vh] overflow-y-auto">
|
||||||
|
{socialLinks.map((link, index) => (
|
||||||
|
<div key={link.platform}>
|
||||||
|
<label htmlFor={`social-${index}`} className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{link.platform}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`social-${index}`}
|
||||||
|
type="text"
|
||||||
|
placeholder={link.placeholder}
|
||||||
|
value={link.value}
|
||||||
|
onChange={(e) => handleSocialLinkChange(index, e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex gap-3 p-6 pt-4">
|
||||||
|
<ModalButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</ModalButton>
|
||||||
|
<ModalButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { ProfileHeader } from './profile-header';
|
||||||
|
export { ProfileInfo } from './profile-info';
|
||||||
|
export { ProfileTabs } from './profile-tabs';
|
||||||
|
export { ProfileForm } from './profile-form';
|
||||||
|
export { ProfileSidebar } from './profile-sidebar';
|
||||||
|
export type { SocialLink, Experience, Education, Language, PersonalInfo, ContactInfo, CvResume, NotificationState, ProfileFormProps } from './profile-form-types';
|
||||||
|
export { useProfileFormState, useProfileDataSync } from './profile-form-hooks';
|
||||||
|
export { useProfileHandlers } from './profile-form-handlers';
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||||
|
import { useProfile } from '../contexts/profile-context';
|
||||||
|
import type { SocialLink, ProfileUpdateData, Experience, Education } from './profile-form-types';
|
||||||
|
|
||||||
|
export const useProfileHandlers = (
|
||||||
|
showNotification: (type: 'success' | 'error', title: string, message?: string) => void
|
||||||
|
) => {
|
||||||
|
const { updateProfile, profileType } = useProfile();
|
||||||
|
|
||||||
|
const handleProfileUpdate = async (updates: ProfileUpdateData) => {
|
||||||
|
try {
|
||||||
|
const result = await updateProfile(updates);
|
||||||
|
showNotification('success', 'Perubahan Berhasil Disimpan');
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Profile update error:', err);
|
||||||
|
showNotification('error', 'Gagal menyimpan perubahan', 'Silakan coba lagi');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePersonalInfoSave = async (personalData: { phone: string; location: string }) => {
|
||||||
|
await handleProfileUpdate({
|
||||||
|
phone_for_verification: personalData.phone,
|
||||||
|
location: personalData.location
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContactInfoSave = async (contactData: { phone: string; location: string }) => {
|
||||||
|
await handleProfileUpdate({
|
||||||
|
phone_for_verification: contactData.phone,
|
||||||
|
location: contactData.location
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSocialMediaSave = async (newSocialLinks: SocialLink[]) => {
|
||||||
|
const linkedIn = newSocialLinks.find(link => link.platform === 'LinkedIn')?.value;
|
||||||
|
const github = newSocialLinks.find(link => link.platform === 'Github')?.value;
|
||||||
|
const portfolio = newSocialLinks.find(link => link.platform === 'Portfolio')?.value;
|
||||||
|
const twitter = newSocialLinks.find(link => link.platform === 'Twitter')?.value;
|
||||||
|
|
||||||
|
const updates: ProfileUpdateData = {
|
||||||
|
linkedin_url: linkedIn || undefined,
|
||||||
|
github_url: github || undefined,
|
||||||
|
twitter_url: twitter || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (profileType === 'mentor') {
|
||||||
|
(updates as MentorUpdateRequestDto).portfolio_url = portfolio || undefined;
|
||||||
|
} else if (profileType === 'user') {
|
||||||
|
(updates as UserUpdateRequestDto).website_url = portfolio || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleProfileUpdate(updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDescriptionSave = async (newDescription: string) => {
|
||||||
|
await handleProfileUpdate({
|
||||||
|
bio: newDescription || null
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkillsSave = async (newSkills: string[]) => {
|
||||||
|
const updates: ProfileUpdateData = {};
|
||||||
|
|
||||||
|
if (profileType === 'user') {
|
||||||
|
(updates as UserUpdateRequestDto).skills = newSkills;
|
||||||
|
} else if (profileType === 'mentor') {
|
||||||
|
(updates as MentorUpdateRequestDto).expertise = newSkills;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleProfileUpdate(updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLanguagesSave = async (newLanguages: Array<{ name: string; level: string }>) => {
|
||||||
|
await handleProfileUpdate({
|
||||||
|
languages: newLanguages.map(lang => lang.name)
|
||||||
|
} as MentorUpdateRequestDto | UserUpdateRequestDto);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExperiencesSave = async (newExperiences: Experience[]) => {
|
||||||
|
try {
|
||||||
|
await handleProfileUpdate({
|
||||||
|
experience: newExperiences
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Experience update error:', error);
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEducationSave = async (newEducations: Education[]) => {
|
||||||
|
try {
|
||||||
|
await handleProfileUpdate({
|
||||||
|
education: newEducations
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Education update error:', error);
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCvResumeSave = async (cvData: { fileName?: string; fileUrl?: string }) => {
|
||||||
|
await handleProfileUpdate({
|
||||||
|
cv_url: cvData.fileUrl || cvData.fileName || null
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
handlePersonalInfoSave,
|
||||||
|
handleContactInfoSave,
|
||||||
|
handleSocialMediaSave,
|
||||||
|
handleDescriptionSave,
|
||||||
|
handleSkillsSave,
|
||||||
|
handleLanguagesSave,
|
||||||
|
handleExperiencesSave,
|
||||||
|
handleEducationSave,
|
||||||
|
handleCvResumeSave
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useProfile } from '../contexts/profile-context';
|
||||||
|
import type {
|
||||||
|
SocialLink,
|
||||||
|
Experience,
|
||||||
|
Education,
|
||||||
|
Language,
|
||||||
|
PersonalInfo,
|
||||||
|
ContactInfo,
|
||||||
|
CvResume,
|
||||||
|
NotificationState
|
||||||
|
} from './profile-form-types';
|
||||||
|
|
||||||
|
export const useProfileFormState = () => {
|
||||||
|
const { profileData, profileType } = useProfile();
|
||||||
|
|
||||||
|
const [notification, setNotification] = useState<NotificationState>({
|
||||||
|
isOpen: false,
|
||||||
|
type: 'success',
|
||||||
|
title: '',
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [socialLinks, setSocialLinks] = useState<SocialLink[]>([
|
||||||
|
{
|
||||||
|
platform: 'LinkedIn',
|
||||||
|
placeholder: 'linkedin.com/in/yourprofile',
|
||||||
|
value: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: 'Github',
|
||||||
|
placeholder: 'github.com/yourusername',
|
||||||
|
value: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: 'Portfolio',
|
||||||
|
placeholder: 'yourportfolio.com',
|
||||||
|
value: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: 'Twitter',
|
||||||
|
placeholder: 'twitter.com/yourusername',
|
||||||
|
value: ''
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [experiences, setExperiences] = useState<Experience[]>([]);
|
||||||
|
const [education, setEducation] = useState<Education[]>([]);
|
||||||
|
const [skills, setSkills] = useState<string[]>([]);
|
||||||
|
const [languages, setLanguages] = useState<Language[]>([]);
|
||||||
|
|
||||||
|
const [personalInfo, setPersonalInfo] = useState<PersonalInfo>({
|
||||||
|
fullname: '',
|
||||||
|
title: '',
|
||||||
|
bio: '',
|
||||||
|
birthdate: '',
|
||||||
|
gender: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [contactInfo, setContactInfo] = useState<ContactInfo>({
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
location: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [cvResume, setCvResume] = useState<CvResume>({
|
||||||
|
cvUrl: '',
|
||||||
|
resumeUrl: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
profileData,
|
||||||
|
profileType,
|
||||||
|
notification,
|
||||||
|
setNotification,
|
||||||
|
socialLinks,
|
||||||
|
setSocialLinks,
|
||||||
|
experiences,
|
||||||
|
setExperiences,
|
||||||
|
education,
|
||||||
|
setEducation,
|
||||||
|
skills,
|
||||||
|
setSkills,
|
||||||
|
languages,
|
||||||
|
setLanguages,
|
||||||
|
personalInfo,
|
||||||
|
setPersonalInfo,
|
||||||
|
contactInfo,
|
||||||
|
setContactInfo,
|
||||||
|
cvResume,
|
||||||
|
setCvResume
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useProfileDataSync = (state: ReturnType<typeof useProfileFormState>) => {
|
||||||
|
const {
|
||||||
|
profileData,
|
||||||
|
profileType,
|
||||||
|
setSocialLinks,
|
||||||
|
setExperiences,
|
||||||
|
setEducation,
|
||||||
|
setSkills,
|
||||||
|
setLanguages,
|
||||||
|
setPersonalInfo,
|
||||||
|
setContactInfo,
|
||||||
|
setCvResume
|
||||||
|
} = state;
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData) {
|
||||||
|
const linkedinUrl = 'linkedin_url' in profileData ? profileData.linkedin_url || '' : '';
|
||||||
|
const githubUrl = 'github_url' in profileData ? profileData.github_url || '' : '';
|
||||||
|
|
||||||
|
|
||||||
|
let portfolioUrl = '';
|
||||||
|
if (profileType === 'mentor' && 'portfolio_url' in profileData) {
|
||||||
|
portfolioUrl = profileData.portfolio_url || '';
|
||||||
|
} else if (profileType === 'user' && 'website_url' in profileData) {
|
||||||
|
portfolioUrl = profileData.website_url || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const twitterUrl = 'twitter_url' in profileData ? profileData.twitter_url || '' : '';
|
||||||
|
|
||||||
|
setSocialLinks([
|
||||||
|
{
|
||||||
|
platform: 'LinkedIn',
|
||||||
|
placeholder: 'linkedin.com/in/yourprofile',
|
||||||
|
value: linkedinUrl
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: 'Github',
|
||||||
|
placeholder: 'github.com/yourusername',
|
||||||
|
value: githubUrl
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: 'Portfolio',
|
||||||
|
placeholder: 'yourportfolio.com',
|
||||||
|
value: portfolioUrl
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: 'Twitter',
|
||||||
|
placeholder: 'twitter.com/yourusername',
|
||||||
|
value: twitterUrl
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}, [profileData, profileType, setSocialLinks]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData) {
|
||||||
|
const experiences = 'experience' in profileData ? profileData.experience || [] : [];
|
||||||
|
setExperiences(experiences);
|
||||||
|
}
|
||||||
|
}, [profileData, setExperiences]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData) {
|
||||||
|
const education = 'education' in profileData ? profileData.education || [] : [];
|
||||||
|
setEducation(education);
|
||||||
|
}
|
||||||
|
}, [profileData, setEducation]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData) {
|
||||||
|
let skills: string[] = [];
|
||||||
|
if ('skills' in profileData) {
|
||||||
|
skills = profileData.skills || [];
|
||||||
|
} else if ('expertise' in profileData) {
|
||||||
|
skills = profileData.expertise || [];
|
||||||
|
}
|
||||||
|
setSkills(skills);
|
||||||
|
}
|
||||||
|
}, [profileData, setSkills]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData) {
|
||||||
|
const languages: Language[] = 'languages' in profileData
|
||||||
|
? (profileData.languages || []).map(lang => ({ name: lang, level: 'Intermediate' }))
|
||||||
|
: [];
|
||||||
|
setLanguages(languages);
|
||||||
|
}
|
||||||
|
}, [profileData, setLanguages]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData) {
|
||||||
|
const bio = 'bio' in profileData ? profileData.bio || '' : '';
|
||||||
|
|
||||||
|
let fullname = '';
|
||||||
|
if ('fullname' in profileData) {
|
||||||
|
fullname = profileData.fullname || '';
|
||||||
|
} else if ('legal_name' in profileData) {
|
||||||
|
fullname = profileData.legal_name || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = 'current_role' in profileData ? profileData.current_role || '' : '';
|
||||||
|
const birthdate = 'birthdate' in profileData ? profileData.birthdate || '' : '';
|
||||||
|
const gender = 'gender' in profileData ? profileData.gender || '' : '';
|
||||||
|
|
||||||
|
setPersonalInfo({
|
||||||
|
fullname,
|
||||||
|
title,
|
||||||
|
bio,
|
||||||
|
birthdate,
|
||||||
|
gender
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [profileData, setPersonalInfo]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData) {
|
||||||
|
const email = 'email' in profileData ? profileData.email || '' : '';
|
||||||
|
|
||||||
|
let phone = '';
|
||||||
|
if ('phone_number' in profileData) {
|
||||||
|
phone = profileData.phone_number || '';
|
||||||
|
} else if ('phone_for_verification' in profileData) {
|
||||||
|
phone = profileData.phone_for_verification || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let location = '';
|
||||||
|
if ('location' in profileData) {
|
||||||
|
location = profileData.location || '';
|
||||||
|
} else if ('domicile' in profileData) {
|
||||||
|
location = profileData.domicile || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
setContactInfo({
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
location
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [profileData, setContactInfo]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData) {
|
||||||
|
const cvUrl = profileType === 'mentor' && 'cv_url' in profileData ? profileData.cv_url || '' : '';
|
||||||
|
|
||||||
|
setCvResume({
|
||||||
|
cvUrl,
|
||||||
|
resumeUrl: ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [profileData, profileType, setCvResume]);
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
|
export interface SocialLink {
|
||||||
|
platform: string;
|
||||||
|
placeholder: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Experience {
|
||||||
|
id: string;
|
||||||
|
company: string;
|
||||||
|
position: string;
|
||||||
|
duration: string;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Education {
|
||||||
|
id: string;
|
||||||
|
institution: string;
|
||||||
|
degree: string;
|
||||||
|
field: string;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Language {
|
||||||
|
name: string;
|
||||||
|
level: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalInfo {
|
||||||
|
fullname: string;
|
||||||
|
title: string;
|
||||||
|
bio: string;
|
||||||
|
birthdate: string;
|
||||||
|
gender: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactInfo {
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
location: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CvResume {
|
||||||
|
cvUrl: string;
|
||||||
|
resumeUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationState {
|
||||||
|
isOpen: boolean;
|
||||||
|
type: 'success' | 'error';
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileFormProps {
|
||||||
|
showNotification: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProfileUpdateData = Partial<MentorUpdateRequestDto | UserUpdateRequestDto>;
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { NotificationModal } from '../modals';
|
||||||
|
import { ExperiencesSection } from '../sections/experiences-section';
|
||||||
|
import { CvResumeSection } from '../sections/cv-resume-section';
|
||||||
|
import { DescriptionSection } from '../sections/description-section';
|
||||||
|
import { EducationSection } from '../sections/education-section';
|
||||||
|
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||||
|
import { useProfile } from '../contexts/profile-context';
|
||||||
|
|
||||||
|
interface Experience {
|
||||||
|
id: string;
|
||||||
|
company: string;
|
||||||
|
position: string;
|
||||||
|
duration: string;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Education {
|
||||||
|
id: string;
|
||||||
|
institution: string;
|
||||||
|
degree: string;
|
||||||
|
field: string;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileFormProps {
|
||||||
|
showNotification: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||||
|
isViewOnly?: boolean; // Add isViewOnly prop
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly = false }) => {
|
||||||
|
const { profileData, updateProfile, isUpdating } = useProfile();
|
||||||
|
|
||||||
|
const [notification, setNotification] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
type: 'success' | 'error';
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
type: 'success',
|
||||||
|
title: '',
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [experiences, setExperiences] = useState<Experience[]>([]);
|
||||||
|
const [education, setEducation] = useState<Education[]>([]);
|
||||||
|
|
||||||
|
|
||||||
|
const [personalInfo, setPersonalInfo] = useState({
|
||||||
|
fullname: '',
|
||||||
|
title: '',
|
||||||
|
bio: '',
|
||||||
|
birthdate: '',
|
||||||
|
gender: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [cvResume, setCvResume] = useState({
|
||||||
|
cvUrl: '',
|
||||||
|
resumeUrl: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData) {
|
||||||
|
const experiences = 'experience' in profileData ? profileData.experience || [] : [];
|
||||||
|
setExperiences(experiences);
|
||||||
|
}
|
||||||
|
}, [profileData]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData) {
|
||||||
|
const education = 'education' in profileData ? profileData.education || [] : [];
|
||||||
|
setEducation(education);
|
||||||
|
}
|
||||||
|
}, [profileData]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData) {
|
||||||
|
const bio = 'bio' in profileData ? profileData.bio || '' : '';
|
||||||
|
|
||||||
|
let fullname = '';
|
||||||
|
if ('fullname' in profileData) {
|
||||||
|
fullname = profileData.fullname || '';
|
||||||
|
} else if ('legal_name' in profileData) {
|
||||||
|
fullname = profileData.legal_name || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = 'current_role' in profileData ? profileData.current_role || '' : '';
|
||||||
|
const birthdate = 'birthdate' in profileData ? profileData.birthdate || '' : '';
|
||||||
|
const gender = 'gender' in profileData ? profileData.gender || '' : '';
|
||||||
|
|
||||||
|
setPersonalInfo({
|
||||||
|
fullname,
|
||||||
|
title,
|
||||||
|
bio,
|
||||||
|
birthdate,
|
||||||
|
gender
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [profileData]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData) {
|
||||||
|
const cvUrl = 'cv_url' in profileData ? profileData.cv_url || '' : '';
|
||||||
|
|
||||||
|
setCvResume({
|
||||||
|
cvUrl,
|
||||||
|
resumeUrl: ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [profileData]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function isErrorWithResponse(err: unknown): err is { response: { data: { message: string } } } {
|
||||||
|
return (
|
||||||
|
typeof err === 'object' &&
|
||||||
|
err !== null &&
|
||||||
|
|
||||||
|
typeof (err as { response?: { data?: { message?: unknown } } }).response?.data?.message === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isErrorWithMessage(err: unknown): err is { message: string } {
|
||||||
|
return (
|
||||||
|
typeof err === 'object' &&
|
||||||
|
err !== null &&
|
||||||
|
'message' in err &&
|
||||||
|
typeof (err as { message?: unknown }).message === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJsonMessage(msg: string): string {
|
||||||
|
const trimmed = msg.trim();
|
||||||
|
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (parsed && typeof parsed.message === 'string') {
|
||||||
|
return parsed.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractApiMessage(err: unknown): string {
|
||||||
|
if (isErrorWithResponse(err)) {
|
||||||
|
return err.response.data.message;
|
||||||
|
}
|
||||||
|
if (isErrorWithMessage(err)) {
|
||||||
|
const msg = err.message || '';
|
||||||
|
return tryParseJsonMessage(msg);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
|
||||||
|
if (isViewOnly) { // Prevent updates if in view-only mode
|
||||||
|
showNotification('error', 'Akses Ditolak', 'Anda tidak memiliki izin untuk mengedit profil ini.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await updateProfile(updates);
|
||||||
|
showNotification('success', 'Perubahan Berhasil Disimpan');
|
||||||
|
return result;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('Profile update error:', err);
|
||||||
|
const apiMessage = extractApiMessage(err);
|
||||||
|
showNotification('error', 'Gagal menyimpan perubahan', apiMessage || 'Silakan coba lagi');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{}
|
||||||
|
<DescriptionSection
|
||||||
|
initialDescription={personalInfo.bio}
|
||||||
|
onSave={async (newDescription) => {
|
||||||
|
await handleProfileUpdate({
|
||||||
|
bio: newDescription || null
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
showNotification={showNotification}
|
||||||
|
isLoading={isUpdating}
|
||||||
|
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||||
|
/>
|
||||||
|
{}
|
||||||
|
<CvResumeSection
|
||||||
|
initialFileName={cvResume.cvUrl}
|
||||||
|
fullname={personalInfo.fullname}
|
||||||
|
onSave={async (cvData) => {
|
||||||
|
await handleProfileUpdate({
|
||||||
|
cv_url: cvData.fileUrl || null
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
showNotification={showNotification}
|
||||||
|
isLoading={isUpdating}
|
||||||
|
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||||
|
/>
|
||||||
|
{}
|
||||||
|
<ExperiencesSection
|
||||||
|
initialExperiences={experiences}
|
||||||
|
onSave={async (newExperiences) => {
|
||||||
|
try {
|
||||||
|
await handleProfileUpdate({
|
||||||
|
experience: newExperiences
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Experience update error:', error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
showNotification={showNotification}
|
||||||
|
isLoading={isUpdating}
|
||||||
|
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||||
|
/>
|
||||||
|
|
||||||
|
{}
|
||||||
|
<EducationSection
|
||||||
|
initialEducation={education}
|
||||||
|
onSave={async (newEducations) => {
|
||||||
|
try {
|
||||||
|
await handleProfileUpdate({
|
||||||
|
education: newEducations
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Education update error:', error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
showNotification={showNotification}
|
||||||
|
isLoading={isUpdating}
|
||||||
|
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NotificationModal
|
||||||
|
isOpen={notification.isOpen}
|
||||||
|
onClose={() => setNotification(prev => ({ ...prev, isOpen: false }))}
|
||||||
|
type={notification.type}
|
||||||
|
title={notification.title}
|
||||||
|
message={notification.message}
|
||||||
|
header="Profile"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user