Compare commits
159
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5cfed24ca | ||
|
|
f018da21c1 | ||
|
|
a610e180ba | ||
|
|
ce8cb8188c | ||
|
|
3aaf022393 | ||
|
|
ebe27a3f05 | ||
|
|
f846a5a785 | ||
|
|
708cbde596 | ||
|
|
9d37cba65f | ||
|
|
8728d0abd7 | ||
|
|
1f4f4ddaa1 | ||
|
|
4fcc249ff4 | ||
|
|
c2bed98b06 | ||
|
|
ae1864c134 | ||
|
|
3f30fbe89c | ||
|
|
951db0f28a | ||
|
|
ad59d39f55 | ||
|
|
1283689d84 | ||
|
|
7d1370d7f2 | ||
|
|
ab0b56780f | ||
|
|
1a39ac37db | ||
|
|
e5dc784301 | ||
|
|
b2de22b0ef | ||
|
|
ad20eda182 | ||
|
|
da65aa4109 | ||
|
|
1f0798752c | ||
|
|
5f28b9b406 | ||
|
|
9e8fc7c07f | ||
|
|
ca51f8aca2 | ||
|
|
c086b1371c | ||
|
|
398350795d | ||
|
|
5be9ec1edf | ||
|
|
f77967a092 | ||
|
|
fe172cc128 | ||
|
|
38ee9a9549 | ||
|
|
3ad828e759 | ||
|
|
782cce820b | ||
|
|
f5882e3cdc | ||
|
|
afaaff4a7f | ||
|
|
da8b08cd73 | ||
|
|
1dbe704230 | ||
|
|
59c1bb0366 | ||
|
|
c518784810 | ||
|
|
0267de664b | ||
|
|
134ff14946 | ||
|
|
ed3b711c89 | ||
|
|
6b7b1c4d20 | ||
|
|
b4b19e02fa | ||
|
|
e61979cbb6 | ||
|
|
0c7887268c | ||
|
|
76933c0be7 | ||
|
|
abc4c26d5d | ||
|
|
a3d35716a6 | ||
|
|
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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
if [[ ! -d "/Users/ms/Development/personal/imphnen-frontend-service" ]]; then
|
||||||
|
echo "Cannot find source directory; Did you move it?"
|
||||||
|
echo "(Looking for "/Users/ms/Development/personal/imphnen-frontend-service")"
|
||||||
|
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# rebuild the cache forcefully
|
||||||
|
_nix_direnv_force_reload=1 direnv exec "/Users/ms/Development/personal/imphnen-frontend-service" true
|
||||||
|
|
||||||
|
# Update the mtime for .envrc.
|
||||||
|
# This will cause direnv to reload again - but without re-building.
|
||||||
|
touch "/Users/ms/Development/personal/imphnen-frontend-service/.envrc"
|
||||||
|
|
||||||
|
# Also update the timestamp of whatever profile_rc we have.
|
||||||
|
# This makes sure that we know we are up to date.
|
||||||
|
touch -r "/Users/ms/Development/personal/imphnen-frontend-service/.envrc" "/Users/ms/Development/personal/imphnen-frontend-service/.direnv"/*.rc
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/8hx8jwa24q6rkzhca9iqy5ckk9rbphi1-source
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/jizz7ld5r2w11y03wrmnvwsskc45krj9-source
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/y4p9szcab9xd7v1c7k9ghr86hb6myyg4-nix-shell-env
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
name: Deploy Backoffice
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
paths:
|
|
||||||
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
|
||||||
|
|
||||||
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 }}
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
|
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
|
|
||||||
else
|
|
||||||
cd ~/imphnen-frontend-service-backoffice && git pull
|
|
||||||
fi
|
|
||||||
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env
|
|
||||||
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 compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
|
|
||||||
docker image prune -af
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
name: Deploy Dimentorin
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
paths:
|
|
||||||
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
|
||||||
|
|
||||||
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 }}
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
|
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
|
|
||||||
else
|
|
||||||
cd ~/imphnen-frontend-service-dimentorin && git pull
|
|
||||||
fi
|
|
||||||
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env
|
|
||||||
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 compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
|
|
||||||
docker image prune -af
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
name: Deploy Gacha
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
paths:
|
|
||||||
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
|
||||||
|
|
||||||
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 }}
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
if [ ! -d ~/imphnen-frontend-service-gacha ]; then
|
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
|
|
||||||
else
|
|
||||||
cd ~/imphnen-frontend-service-gacha && git pull
|
|
||||||
fi
|
|
||||||
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-gacha/apps/gacha/.env
|
|
||||||
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 compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
|
|
||||||
docker image prune -af
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
name: Deploy Landing
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
paths:
|
|
||||||
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
|
||||||
|
|
||||||
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 }}
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
if [ ! -d ~/imphnen-frontend-service ]; then
|
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
|
|
||||||
else
|
|
||||||
cd ~/imphnen-frontend-service && git pull
|
|
||||||
fi
|
|
||||||
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" > ~/imphnen-frontend-service/apps/landing/.env
|
|
||||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
|
|
||||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build
|
|
||||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
|
|
||||||
docker image prune -af
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: Nix Build & Cache
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['develop']
|
||||||
|
pull_request:
|
||||||
|
branches: ['develop']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Nix
|
||||||
|
uses: DeterminateSystems/nix-installer-action@main
|
||||||
|
|
||||||
|
- name: Setup Cachix
|
||||||
|
uses: cachix/cachix-action@v15
|
||||||
|
with:
|
||||||
|
name: msdqn
|
||||||
|
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
||||||
|
|
||||||
|
- name: Build all packages
|
||||||
|
run: |
|
||||||
|
nix build .#landing -o result-landing
|
||||||
|
nix build .#backoffice -o result-backoffice
|
||||||
|
nix build .#gacha -o result-gacha
|
||||||
|
nix build .#dimentorin -o result-dimentorin
|
||||||
|
nix build .#hackathon -o result-hackathon
|
||||||
|
nix build .#infra -o result-infra
|
||||||
|
|
||||||
|
- name: Show build outputs
|
||||||
|
run: |
|
||||||
|
echo "Landing: $(readlink result-landing)"
|
||||||
|
echo "Backoffice: $(readlink result-backoffice)"
|
||||||
|
echo "Gacha: $(readlink result-gacha)"
|
||||||
|
echo "Dimentorin: $(readlink result-dimentorin)"
|
||||||
|
echo "Hackathon: $(readlink result-hackathon)"
|
||||||
|
echo "Infra: $(readlink result-infra)"
|
||||||
@@ -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-->
|
||||||
@@ -10,6 +10,7 @@ This repository is a **monorepo** for all frontend services of IMPHNEN. The mono
|
|||||||
2. **Backoffice** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Internal Management Website</a>.
|
2. **Backoffice** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Internal Management Website</a>.
|
||||||
3. **Dimentorin** - Application for <a href="https://dimentorin.imphnen.dev/" target="_blank">Mentoring Service</a>.
|
3. **Dimentorin** - Application for <a href="https://dimentorin.imphnen.dev/" target="_blank">Mentoring Service</a>.
|
||||||
4. **Landing Page** - Application for <a href="https://imphnen.dev/" target="_blank">Landing Page</a>.
|
4. **Landing Page** - Application for <a href="https://imphnen.dev/" target="_blank">Landing Page</a>.
|
||||||
|
5. **QR Campaign** - Application for QR Campaign Management.
|
||||||
|
|
||||||
## How to install
|
## How to install
|
||||||
|
|
||||||
@@ -54,6 +55,10 @@ Use the following commands to run in development mode:
|
|||||||
```sh
|
```sh
|
||||||
npm run landing:dev
|
npm run landing:dev
|
||||||
```
|
```
|
||||||
|
- **QR Campaign**:
|
||||||
|
```sh
|
||||||
|
npm run qrcampaign:dev
|
||||||
|
```
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
@@ -75,6 +80,10 @@ Use the following commands to build the applications:
|
|||||||
```sh
|
```sh
|
||||||
npm run landing:build
|
npm run landing:build
|
||||||
```
|
```
|
||||||
|
- **QR Campaign**:
|
||||||
|
```sh
|
||||||
|
npm run qrcampaign:build
|
||||||
|
```
|
||||||
|
|
||||||
### Production
|
### Production
|
||||||
|
|
||||||
@@ -96,6 +105,10 @@ Use the following commands to run the applications in production mode:
|
|||||||
```sh
|
```sh
|
||||||
npm run landing:prod
|
npm run landing:prod
|
||||||
```
|
```
|
||||||
|
- **QR Campaign**:
|
||||||
|
```sh
|
||||||
|
npm run qrcampaign:prod
|
||||||
|
```
|
||||||
|
|
||||||
### Storybook
|
### Storybook
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Backoffice</title>
|
<title>Backoffice</title>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { FC, ReactElement } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getAdminUsers,
|
||||||
|
getAdminTeams,
|
||||||
|
getAdminSubmissions,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
|
export const HackathonDashboardPage: FC = (): ReactElement => {
|
||||||
|
// Fetch total participants
|
||||||
|
const { data: usersData } = useQuery({
|
||||||
|
queryKey: ['admin-users-count'],
|
||||||
|
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch total teams
|
||||||
|
const { data: teamsData } = useQuery({
|
||||||
|
queryKey: ['admin-teams-count'],
|
||||||
|
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch total submissions
|
||||||
|
const { data: submissionsData } = useQuery({
|
||||||
|
queryKey: ['admin-submissions-count'],
|
||||||
|
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalParticipants = usersData?.meta?.total_data ?? '??';
|
||||||
|
const totalTeams = teamsData?.meta?.total_data ?? '??';
|
||||||
|
const totalSubmissions = submissionsData?.meta?.total_data ?? '??';
|
||||||
|
|
||||||
|
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">
|
||||||
|
{totalParticipants}
|
||||||
|
</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">
|
||||||
|
{totalTeams}
|
||||||
|
</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">
|
||||||
|
{totalSubmissions}
|
||||||
|
</h3>
|
||||||
|
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonDashboardPage;
|
||||||
+249
@@ -0,0 +1,249 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import {
|
||||||
|
CloseOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
ProjectOutlined,
|
||||||
|
FileImageOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { TAdminSubmissionItem } from '@imphnen-frontend-service/service';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
interface SubmissionModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
submission: TAdminSubmissionItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
submission,
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'submitted':
|
||||||
|
return 'bg-success-50 border-success-200 text-success-800';
|
||||||
|
case 'pending':
|
||||||
|
return 'bg-orange-50 border-orange-200 text-orange-800';
|
||||||
|
case 'approved':
|
||||||
|
return 'bg-blue-50 border-blue-200 text-blue-800';
|
||||||
|
case 'rejected':
|
||||||
|
return 'bg-error-50 border-error-200 text-error-800';
|
||||||
|
default:
|
||||||
|
return 'bg-neutral-50 border-neutral-200 text-neutral-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center">
|
||||||
|
<ProjectOutlined className="text-success-600 text-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-neutral-900">
|
||||||
|
{submission.project_name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
Team ID: {submission.team_id} • Submitted{' '}
|
||||||
|
{new Date(submission.submitted_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-neutral-400 hover:text-neutral-600 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<CloseOutlined className="text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Submission Status */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 p-4 border rounded-lg',
|
||||||
|
getStatusColor(submission.status)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-3 h-3 rounded-full',
|
||||||
|
submission.status === 'submitted' && 'bg-success-500',
|
||||||
|
submission.status === 'pending' && 'bg-orange-500',
|
||||||
|
submission.status === 'approved' && 'bg-blue-500',
|
||||||
|
submission.status === 'rejected' && 'bg-error-500'
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Status:{' '}
|
||||||
|
{submission.status.charAt(0).toUpperCase() +
|
||||||
|
submission.status.slice(1)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs">Submitted by: {submission.submitted_by}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Description */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-neutral-700 mb-2">
|
||||||
|
Project Description
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-600 leading-relaxed">
|
||||||
|
{submission.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Links */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-700">
|
||||||
|
Project Links
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Repository URL */}
|
||||||
|
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<LinkOutlined className="text-primary-500 mt-1" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-neutral-600 mb-1">
|
||||||
|
Repository
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={submission.repository_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
|
||||||
|
>
|
||||||
|
{submission.repository_url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Demo URL */}
|
||||||
|
{submission.demo_url && (
|
||||||
|
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<LinkOutlined className="text-primary-500 mt-1" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-neutral-600 mb-1">
|
||||||
|
Live Demo
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={submission.demo_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
|
||||||
|
>
|
||||||
|
{submission.demo_url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Presentation URL */}
|
||||||
|
{submission.presentation_url && (
|
||||||
|
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<LinkOutlined className="text-primary-500 mt-1" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-neutral-600 mb-1">
|
||||||
|
Presentation
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={submission.presentation_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
|
||||||
|
>
|
||||||
|
{submission.presentation_url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Screenshots */}
|
||||||
|
{submission.screenshots && submission.screenshots.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-700 flex items-center gap-2">
|
||||||
|
<FileImageOutlined className="text-primary-500" />
|
||||||
|
Screenshots ({submission.screenshots.length})
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{submission.screenshots.map((screenshot, index) => (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={screenshot}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block rounded-lg overflow-hidden border border-neutral-200 hover:border-primary-300 transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={screenshot}
|
||||||
|
alt={`Screenshot ${index + 1}`}
|
||||||
|
className="w-full h-40 object-cover"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-neutral-200">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-neutral-500 mb-1">Created</p>
|
||||||
|
<p className="text-sm text-neutral-900">
|
||||||
|
{new Date(submission.created_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-neutral-500 mb-1">Last Updated</p>
|
||||||
|
<p className="text-sm text-neutral-900">
|
||||||
|
{new Date(submission.updated_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-neutral-200 bg-neutral-50">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
{/* <Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
console.log('Edit submission:', submission.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit Status
|
||||||
|
</Button> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubmissionModal;
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import {
|
||||||
|
FC,
|
||||||
|
ReactElement,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
} from 'react';
|
||||||
|
import SubmissionModal from './_components/submission-modal';
|
||||||
|
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 {
|
||||||
|
SearchOutlined,
|
||||||
|
FilterOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getAdminSubmissions,
|
||||||
|
TAdminSubmissionItem,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
type SubmissionType = TAdminSubmissionItem;
|
||||||
|
|
||||||
|
export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const currentPage = Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(searchParams.get('page') || '1', 10)
|
||||||
|
);
|
||||||
|
const searchQuery = searchParams.get('search') || '';
|
||||||
|
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
|
||||||
|
const statusFilter = searchParams.get('status') || 'all';
|
||||||
|
|
||||||
|
const [showSubmissionModal, setShowSubmissionModal] = useState(false);
|
||||||
|
const [selectedSubmission, setSelectedSubmission] =
|
||||||
|
useState<SubmissionType | null>(null);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
||||||
|
|
||||||
|
// Fetch submissions from API
|
||||||
|
const {
|
||||||
|
data: submissionsResponse,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
'admin-submissions',
|
||||||
|
currentPage,
|
||||||
|
perPage,
|
||||||
|
statusFilter,
|
||||||
|
searchQuery,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
getAdminSubmissions({
|
||||||
|
page: currentPage,
|
||||||
|
per_page: perPage,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
}),
|
||||||
|
staleTime: 30000, // 30 seconds cache
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalData = submissionsResponse?.meta?.total_data || 0;
|
||||||
|
const totalPages = submissionsResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
|
// Handle page change
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', newPage.toString());
|
||||||
|
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||||
|
if (searchQuery) params.set('search', searchQuery);
|
||||||
|
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||||
|
setSearchParams(params);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
},
|
||||||
|
[setSearchParams, perPage, searchQuery, statusFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate page number
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||||
|
setSearchParams({ page: totalPages.toString() });
|
||||||
|
}
|
||||||
|
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
||||||
|
|
||||||
|
// Sync globalFilter with URL
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalFilter(searchQuery);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Handle search
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', '1');
|
||||||
|
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||||
|
if (globalFilter.trim()) {
|
||||||
|
params.set('search', globalFilter.trim());
|
||||||
|
}
|
||||||
|
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||||
|
setSearchParams(params);
|
||||||
|
}, [globalFilter, setSearchParams, perPage, statusFilter]);
|
||||||
|
|
||||||
|
const handleSearchKeyPress = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSearch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle per page change
|
||||||
|
const handlePerPageChange = useCallback(
|
||||||
|
(newPerPage: number) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', '1');
|
||||||
|
params.set('per_page', newPerPage.toString());
|
||||||
|
if (searchQuery) params.set('search', searchQuery);
|
||||||
|
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||||
|
setSearchParams(params);
|
||||||
|
},
|
||||||
|
[setSearchParams, searchQuery, statusFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle status filter change
|
||||||
|
// const handleStatusFilterChange = useCallback(
|
||||||
|
// (newStatus: string) => {
|
||||||
|
// const params = new URLSearchParams();
|
||||||
|
// params.set('page', '1');
|
||||||
|
// if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||||
|
// if (searchQuery) params.set('search', searchQuery);
|
||||||
|
// if (newStatus !== 'all') params.set('status', newStatus);
|
||||||
|
// setSearchParams(params);
|
||||||
|
// },
|
||||||
|
// [setSearchParams, perPage, searchQuery]
|
||||||
|
// );
|
||||||
|
|
||||||
|
// Handle modal
|
||||||
|
const handleShowSubmissionModal = useCallback(
|
||||||
|
(submission: SubmissionType) => {
|
||||||
|
setSelectedSubmission(submission);
|
||||||
|
setShowSubmissionModal(true);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCloseSubmissionModal = useCallback(() => {
|
||||||
|
setShowSubmissionModal(false);
|
||||||
|
setSelectedSubmission(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get submissions data
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
return submissionsResponse?.data || [];
|
||||||
|
}, [submissionsResponse]);
|
||||||
|
|
||||||
|
// Memoize columns
|
||||||
|
const columns: ColumnDef<SubmissionType>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'project_name',
|
||||||
|
header: 'Project Name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium text-neutral-900">
|
||||||
|
{row.original.project_name}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'team_id',
|
||||||
|
header: 'Team ID',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-neutral-700 font-mono">
|
||||||
|
{row.original.team_id}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
|
||||||
|
status === 'submitted'
|
||||||
|
? 'bg-success-100 text-success-800'
|
||||||
|
: status === 'pending'
|
||||||
|
? 'bg-orange-100 text-orange-800'
|
||||||
|
: 'bg-neutral-100 text-neutral-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'submitted_at',
|
||||||
|
header: 'Submitted',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-900 text-sm">
|
||||||
|
{new Date(row.original.submitted_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 }) => (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||||
|
onClick={() => handleShowSubmissionModal(row.original)}
|
||||||
|
>
|
||||||
|
<EyeOutlined className="text-sm" />
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[handleShowSubmissionModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
|
Project Submissions
|
||||||
|
</h1>
|
||||||
|
<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">
|
||||||
|
<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 by project name..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
onKeyPress={handleSearchKeyPress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per Page Dropdown */}
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={perPage}
|
||||||
|
onChange={(e) =>
|
||||||
|
handlePerPageChange(parseInt(e.target.value, 10))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value={10}>10 / page</option>
|
||||||
|
<option value={20}>20 / page</option>
|
||||||
|
<option value={50}>50 / page</option>
|
||||||
|
<option value={100}>100 / page</option>
|
||||||
|
</select>
|
||||||
|
</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-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => handleStatusFilterChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="submitted">Submitted</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="approved">Approved</option>
|
||||||
|
<option value="rejected">Rejected</option>
|
||||||
|
</select>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filters */}
|
||||||
|
{/* {statusFilter !== 'all' && (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||||
|
<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={() => handleStatusFilterChange('all')}
|
||||||
|
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
handleStatusFilterChange('all');
|
||||||
|
setGlobalFilter('');
|
||||||
|
}}
|
||||||
|
className="text-sm text-neutral-600"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{/* Loading & results */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||||
|
<span className="ml-3 text-neutral-600">
|
||||||
|
Loading submissions...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : filteredData.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-neutral-600">
|
||||||
|
Showing {filteredData.length} of {totalData} submissions (Page{' '}
|
||||||
|
{currentPage} of {totalPages})
|
||||||
|
{isFetching && (
|
||||||
|
<span className="ml-2 text-primary-500">(Updating...)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
data={filteredData}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={perPage}
|
||||||
|
manualPagination={true}
|
||||||
|
pageCount={totalPages}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-neutral-500">
|
||||||
|
No submissions found. Try adjusting your filters.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Submission Modal */}
|
||||||
|
{selectedSubmission && (
|
||||||
|
<SubmissionModal
|
||||||
|
isOpen={showSubmissionModal}
|
||||||
|
onClose={handleCloseSubmissionModal}
|
||||||
|
submission={selectedSubmission}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonSubmissionsPage;
|
||||||
+514
@@ -0,0 +1,514 @@
|
|||||||
|
import { FC, useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { CityFilterSelect } from '../../../../components/city-filter-select';
|
||||||
|
import TeamBannerPlaceholder from './team-banner-placeholder';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import { TAdminTeamItem } from '@imphnen-frontend-service/service';
|
||||||
|
import {
|
||||||
|
TeamOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
SaveOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
CrownOutlined,
|
||||||
|
ExclamationOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
CameraOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
EyeInvisibleOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
type TeamType = TAdminTeamItem;
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
team: TeamType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||||
|
const [formData, setFormData] = useState<TeamType | null>(null);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [showLogoMenu, setShowLogoMenu] = useState(false);
|
||||||
|
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const bannerInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Initialize form data when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (team) {
|
||||||
|
setFormData({ ...team });
|
||||||
|
} else {
|
||||||
|
setFormData({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
city: '',
|
||||||
|
banner: null,
|
||||||
|
logo: null,
|
||||||
|
visibility: 'public',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
leader_id: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, team]);
|
||||||
|
|
||||||
|
// Check if form has changes
|
||||||
|
const hasChanges = useMemo(() => {
|
||||||
|
if (!formData || !team) return !!formData;
|
||||||
|
return (
|
||||||
|
formData.name !== team.name ||
|
||||||
|
formData.description !== team.description ||
|
||||||
|
formData.city !== team.city ||
|
||||||
|
formData.visibility !== team.visibility ||
|
||||||
|
formData.logo !== team.logo ||
|
||||||
|
formData.banner !== team.banner
|
||||||
|
);
|
||||||
|
}, [formData, team]);
|
||||||
|
|
||||||
|
// Check if required fields are filled
|
||||||
|
const isFormValid = useMemo(() => {
|
||||||
|
if (!formData) return false;
|
||||||
|
return (
|
||||||
|
formData.name.trim() !== '' &&
|
||||||
|
formData.city.trim() !== '' &&
|
||||||
|
formData.description.trim() !== ''
|
||||||
|
);
|
||||||
|
}, [formData]);
|
||||||
|
|
||||||
|
const canSave = hasChanges && isFormValid;
|
||||||
|
|
||||||
|
if (!isOpen || !formData) return null;
|
||||||
|
|
||||||
|
const handleInputChange = (field: keyof TeamType, value: string | null) => {
|
||||||
|
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
console.log('Saving team:', formData);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!team) return;
|
||||||
|
console.log('Deleting team:', team.id);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('Please select an image file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('Image size must be less than 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const logoUrl = e.target?.result as string;
|
||||||
|
handleInputChange('logo', logoUrl);
|
||||||
|
setShowLogoMenu(false);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBannerUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('Please select an image file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('Image size must be less than 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const bannerUrl = e.target?.result as string;
|
||||||
|
handleInputChange('banner', bannerUrl);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||||
|
<TeamOutlined className="text-primary-600 text-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-neutral-900">
|
||||||
|
{team ? 'Team Details' : 'Create New Team'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
{team
|
||||||
|
? 'View and manage team information'
|
||||||
|
: 'Add a new team to the hackathon'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setShowLogoMenu(false);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseOutlined className="text-neutral-400 text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={logoInputRef}
|
||||||
|
onChange={handleLogoUpload}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={bannerInputRef}
|
||||||
|
onChange={handleBannerUpload}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Banner Section */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Team Banner{' '}
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
(3:1 aspect ratio recommended)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<TeamBannerPlaceholder
|
||||||
|
banner={formData.banner || undefined}
|
||||||
|
teamName={formData.name || 'Team Name'}
|
||||||
|
className="rounded-lg border border-neutral-200 transition-all group-hover:border-primary-300"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/40 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
bannerInputRef.current?.click();
|
||||||
|
}}
|
||||||
|
className="bg-white/90 hover:bg-white text-neutral-700 border-transparent shadow-sm gap-2"
|
||||||
|
>
|
||||||
|
<UploadOutlined className="text-sm" />
|
||||||
|
{formData.banner ? 'Change Banner' : 'Add Banner'}
|
||||||
|
</Button>
|
||||||
|
{formData.banner && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleInputChange('banner', null);
|
||||||
|
}}
|
||||||
|
className="bg-white/90 hover:bg-white text-red-600 border-transparent shadow-sm hover:text-red-700 gap-2"
|
||||||
|
>
|
||||||
|
<DeleteOutlined className="text-sm" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logo & Name */}
|
||||||
|
<div className="grid grid-cols-12 gap-4 items-start">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||||
|
Logo
|
||||||
|
</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="w-24 h-24 rounded-full bg-neutral-100 flex items-center justify-center overflow-hidden border border-neutral-200 group-hover:border-primary-300 transition-colors">
|
||||||
|
{formData.logo ? (
|
||||||
|
<img
|
||||||
|
src={formData.logo}
|
||||||
|
alt={formData.name || 'Team Logo'}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TeamOutlined className="text-neutral-400 text-xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowLogoMenu(!showLogoMenu);
|
||||||
|
}}
|
||||||
|
className="absolute inset-0 bg-neutral-300/80 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center w-24 h-24"
|
||||||
|
>
|
||||||
|
<CameraOutlined className="text-white text-lg" />
|
||||||
|
</button>
|
||||||
|
{showLogoMenu && (
|
||||||
|
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
logoInputRef.current?.click();
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<UploadOutlined className="text-sm" />
|
||||||
|
{formData.logo ? 'Change Logo' : 'Upload Logo'}
|
||||||
|
</button>
|
||||||
|
{formData.logo && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleInputChange('logo', null);
|
||||||
|
setShowLogoMenu(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<DeleteOutlined className="text-sm" />
|
||||||
|
Remove Logo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-10 space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Team Name <span className="text-danger-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full border border-neutral-200 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||||
|
placeholder="Enter team name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Description <span className="text-danger-500">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full border border-neutral-200 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
|
||||||
|
placeholder="Enter team description"
|
||||||
|
rows={3}
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* City */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
City <span className="text-danger-500">*</span>
|
||||||
|
</label>
|
||||||
|
<CityFilterSelect
|
||||||
|
value={formData.city || 'all'}
|
||||||
|
onChange={(city) =>
|
||||||
|
handleInputChange('city', city === 'all' ? '' : city)
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Search cities..."
|
||||||
|
allOptionLabel="Select a city"
|
||||||
|
filterIcon={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visibility */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Team Visibility
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="visibility"
|
||||||
|
value="public"
|
||||||
|
checked={formData.visibility === 'public'}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('visibility', e.target.value)
|
||||||
|
}
|
||||||
|
className="text-primary-600"
|
||||||
|
/>
|
||||||
|
<EyeOutlined className="text-info-600" />
|
||||||
|
<span className="text-sm">Public</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="visibility"
|
||||||
|
value="private"
|
||||||
|
checked={formData.visibility === 'private'}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('visibility', e.target.value)
|
||||||
|
}
|
||||||
|
className="text-primary-600"
|
||||||
|
/>
|
||||||
|
<EyeInvisibleOutlined className="text-neutral-600" />
|
||||||
|
<span className="text-sm">Private</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team Details */}
|
||||||
|
{team && (
|
||||||
|
<div className="space-y-4 border-t border-neutral-200 pt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Team Leader ID
|
||||||
|
</label>
|
||||||
|
<div className="p-3 bg-neutral-50 rounded-lg flex items-center gap-3">
|
||||||
|
<CrownOutlined className="text-yellow-600 text-lg" />
|
||||||
|
<span className="text-sm text-neutral-700 font-mono">
|
||||||
|
{team.leader_id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Created
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<CalendarOutlined className="text-neutral-500" />
|
||||||
|
<span className="text-sm text-neutral-700">
|
||||||
|
{new Date(team.created_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Last Updated
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<CalendarOutlined className="text-neutral-500" />
|
||||||
|
<span className="text-sm text-neutral-700">
|
||||||
|
{new Date(team.updated_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-t border-neutral-200">
|
||||||
|
<div>
|
||||||
|
{team && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="md"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
Delete Team
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="secondary" size="md" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<SaveOutlined />
|
||||||
|
{team ? 'Save Changes' : 'Create Team'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-danger-100 flex items-center justify-center">
|
||||||
|
<ExclamationOutlined className="text-danger-600 text-xl" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900">
|
||||||
|
Delete Team
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-neutral-700 mb-6">
|
||||||
|
Are you sure you want to delete "{team?.name}"? This will
|
||||||
|
permanently remove the team and all associated data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="md"
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
Delete Team
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalTeamDetail;
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { TeamOutlined } from '@ant-design/icons';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
interface TeamBannerPlaceholderProps {
|
||||||
|
banner?: string;
|
||||||
|
teamName: string;
|
||||||
|
className?: string;
|
||||||
|
showPlaceholder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
||||||
|
banner,
|
||||||
|
teamName,
|
||||||
|
className = '',
|
||||||
|
showPlaceholder = true,
|
||||||
|
}) => {
|
||||||
|
const aspectRatioClass = 'aspect-[3/1]'; // 3:1 aspect ratio
|
||||||
|
|
||||||
|
if (!banner && !showPlaceholder) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (banner) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full bg-gray-100 overflow-hidden relative',
|
||||||
|
aspectRatioClass,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={banner}
|
||||||
|
alt={`${teamName} banner`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
// Fallback to placeholder if image fails to load
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.style.display = 'none';
|
||||||
|
const placeholder = target.nextElementSibling as HTMLElement;
|
||||||
|
if (placeholder) {
|
||||||
|
placeholder.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Fallback placeholder (hidden by default, shown on image error) */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
|
||||||
|
'hidden' // Hidden by default
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<TeamOutlined className="text-4xl text-gray-400 mb-2" />
|
||||||
|
<p className="text-sm text-gray-500 font-medium">{teamName}</p>
|
||||||
|
<p className="text-xs text-gray-400">Team Banner</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No banner - show placeholder
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
|
||||||
|
aspectRatioClass,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<TeamOutlined className="text-4xl text-gray-400 mb-2" />
|
||||||
|
<p className="text-sm text-gray-500 font-medium">{teamName}</p>
|
||||||
|
<p className="text-xs text-gray-400">No Banner</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeamBannerPlaceholder;
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
import {
|
||||||
|
FC,
|
||||||
|
ReactElement,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
} from 'react';
|
||||||
|
import ModalTeamDetail from './_components/modal-team-detail-new';
|
||||||
|
import { CityFilterSelect } from '../../../components/city-filter-select';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
EditOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
FilterOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getAdminTeams,
|
||||||
|
TAdminTeamItem,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
type TeamType = TAdminTeamItem;
|
||||||
|
|
||||||
|
export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const currentPage = Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(searchParams.get('page') || '1', 10)
|
||||||
|
);
|
||||||
|
const searchQuery = searchParams.get('search') || '';
|
||||||
|
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
|
||||||
|
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||||
|
const [showNewTeamModal, setShowNewTeamModal] = useState(false);
|
||||||
|
const [selectedTeam, setSelectedTeam] = useState<TeamType | null>(null);
|
||||||
|
useState<TeamType | null>(null);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
||||||
|
|
||||||
|
// Advanced filtering states
|
||||||
|
const [visibilityFilter, setVisibilityFilter] = useState('all');
|
||||||
|
const [cityFilter, setCityFilter] = useState('all');
|
||||||
|
|
||||||
|
// Fetch teams from API
|
||||||
|
const {
|
||||||
|
data: teamsResponse,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
'admin-teams',
|
||||||
|
currentPage,
|
||||||
|
perPage,
|
||||||
|
cityFilter,
|
||||||
|
visibilityFilter,
|
||||||
|
searchQuery,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
getAdminTeams({
|
||||||
|
page: currentPage,
|
||||||
|
per_page: perPage,
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
}),
|
||||||
|
staleTime: 30000, // 30 seconds cache
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalData = teamsResponse?.meta?.total_data || 0;
|
||||||
|
const totalPages = teamsResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
|
// Handle page change - update URL query params
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', newPage.toString());
|
||||||
|
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||||
|
if (searchQuery) params.set('search', searchQuery);
|
||||||
|
setSearchParams(params);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
},
|
||||||
|
[setSearchParams, perPage, searchQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate page number doesn't exceed total pages
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||||
|
setSearchParams({ page: totalPages.toString() });
|
||||||
|
}
|
||||||
|
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
||||||
|
|
||||||
|
// Sync globalFilter with URL search param on mount
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalFilter(searchQuery);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Handle search teams
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', '1');
|
||||||
|
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||||
|
if (globalFilter.trim()) {
|
||||||
|
params.set('search', globalFilter.trim());
|
||||||
|
}
|
||||||
|
setSearchParams(params);
|
||||||
|
}, [globalFilter, setSearchParams, perPage]);
|
||||||
|
|
||||||
|
// Handle Enter key press in search input
|
||||||
|
const handleSearchKeyPress = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSearch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle per page change
|
||||||
|
const handlePerPageChange = useCallback(
|
||||||
|
(newPerPage: number) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', '1');
|
||||||
|
params.set('per_page', newPerPage.toString());
|
||||||
|
if (searchQuery) params.set('search', searchQuery);
|
||||||
|
setSearchParams(params);
|
||||||
|
},
|
||||||
|
[setSearchParams, searchQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize the callback to prevent recreation
|
||||||
|
const handleShowDetailModal = useCallback((team: TeamType) => {
|
||||||
|
setSelectedTeam(team);
|
||||||
|
setShowDetailModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseDetailModal = useCallback(() => {
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedTeam(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShowNewTeamModal = useCallback(() => {
|
||||||
|
setShowNewTeamModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseNewTeamModal = useCallback(() => {
|
||||||
|
setShowNewTeamModal(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get teams data from API response
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
return teamsResponse?.data || [];
|
||||||
|
}, [teamsResponse]);
|
||||||
|
|
||||||
|
// Memoize columns to prevent recreation on every render
|
||||||
|
const columns: ColumnDef<TeamType>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Team',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const team = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Team Logo */}
|
||||||
|
<div className="w-10 h-10 rounded-full bg-neutral-100 flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
|
{team.logo ? (
|
||||||
|
<img
|
||||||
|
src={team.logo}
|
||||||
|
alt={team.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TeamOutlined className="text-neutral-400 text-lg" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Team Name */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
className="font-medium text-neutral-900 truncate max-w-sm"
|
||||||
|
title={team.name}
|
||||||
|
>
|
||||||
|
{team.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'city',
|
||||||
|
header: 'City',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-700">{row.original.city}</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'visibility',
|
||||||
|
header: 'Visibility',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isPublic = row.original.visibility === 'public';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
|
||||||
|
isPublic
|
||||||
|
? 'bg-success-100 text-success-800'
|
||||||
|
: 'bg-neutral-100 text-neutral-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPublic ? 'Public' : 'Private'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leader',
|
||||||
|
header: 'Leader ID',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="text-sm text-neutral-700 font-mono">
|
||||||
|
{row.original.leader_id}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: 'Created',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-900 text-sm">
|
||||||
|
{new Date(row.original.created_at).toLocaleDateString('en-UK', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
meta: { cellClassName: cn('w-48') },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||||
|
onClick={() => handleShowDetailModal(row.original)}
|
||||||
|
>
|
||||||
|
<EditOutlined className="text-sm" />
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[handleShowDetailModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
|
Team Management
|
||||||
|
</h1>
|
||||||
|
{/* Filters and actions */}
|
||||||
|
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||||
|
{/* Left side - Search & filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
||||||
|
placeholder="Search teams by name or city..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
onKeyPress={handleSearchKeyPress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per Page Dropdown */}
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={perPage}
|
||||||
|
onChange={(e) =>
|
||||||
|
handlePerPageChange(parseInt(e.target.value, 10))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value={10}>10 / page</option>
|
||||||
|
<option value={20}>20 / page</option>
|
||||||
|
<option value={50}>50 / page</option>
|
||||||
|
<option value={100}>100 / page</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visibility Filter */}
|
||||||
|
{/* <div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={visibilityFilter}
|
||||||
|
onChange={(e) => setVisibilityFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Visibility</option>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
<option value="private">Private</option>
|
||||||
|
</select>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* City Filter */}
|
||||||
|
{/* <CityFilterSelect
|
||||||
|
value={cityFilter}
|
||||||
|
onChange={setCityFilter}
|
||||||
|
className="w-full sm:w-44"
|
||||||
|
placeholder="Search cities..."
|
||||||
|
allOptionLabel="All Cities"
|
||||||
|
/> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Add Team Button */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="flex items-center gap-2 px-4 py-2"
|
||||||
|
onClick={handleShowNewTeamModal}
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-sm" />
|
||||||
|
Add Team
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filters display */}
|
||||||
|
{(visibilityFilter !== 'all' || cityFilter !== 'all') && (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||||
|
|
||||||
|
{/* Visibility filter badge */}
|
||||||
|
{visibilityFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||||
|
Visibility: {visibilityFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibilityFilter('all')}
|
||||||
|
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* City filter badge */}
|
||||||
|
{cityFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||||
|
City: {cityFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setCityFilter('all')}
|
||||||
|
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Clear all filters */}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setVisibilityFilter('all');
|
||||||
|
setCityFilter('all');
|
||||||
|
setGlobalFilter('');
|
||||||
|
}}
|
||||||
|
className="text-sm text-neutral-600"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading & results display */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||||
|
<span className="ml-3 text-neutral-600">Loading teams...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredData.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-neutral-600">
|
||||||
|
Showing {filteredData.length} of {totalData} teams (Page{' '}
|
||||||
|
{currentPage} of {totalPages})
|
||||||
|
{isFetching && (
|
||||||
|
<span className="ml-2 text-primary-500">(Updating...)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
data={filteredData}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={perPage}
|
||||||
|
manualPagination={true}
|
||||||
|
pageCount={totalPages}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-neutral-500">
|
||||||
|
No teams found. Try adjusting your filters.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Modals component */}
|
||||||
|
<ModalTeamDetail
|
||||||
|
isOpen={showDetailModal}
|
||||||
|
onClose={handleCloseDetailModal}
|
||||||
|
team={selectedTeam}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* New Team Modal */}
|
||||||
|
<ModalTeamDetail
|
||||||
|
isOpen={showNewTeamModal}
|
||||||
|
onClose={handleCloseNewTeamModal}
|
||||||
|
team={null} // null indicates creating new team
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonTeamsPage;
|
||||||
+655
@@ -0,0 +1,655 @@
|
|||||||
|
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 | null;
|
||||||
|
fullname: string;
|
||||||
|
bio?: string;
|
||||||
|
location: string | null;
|
||||||
|
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 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
handleCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseOutlined className="text-neutral-400 text-lg" />
|
||||||
|
</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 ||
|
||||||
|
formData.fullname.trim() === ''
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-neutral-300'
|
||||||
|
)}
|
||||||
|
placeholder="Enter full name"
|
||||||
|
/>
|
||||||
|
{(!formData.fullname ||
|
||||||
|
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 ||
|
||||||
|
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 ||
|
||||||
|
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,573 @@
|
|||||||
|
import {
|
||||||
|
FC,
|
||||||
|
ReactElement,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
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,
|
||||||
|
LoadingOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { CityFilterSelect } from '../../../components/city-filter-select';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getAdminUsers,
|
||||||
|
TAdminUserItem,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
type UserType = TAdminUserItem;
|
||||||
|
|
||||||
|
// Skills options for filter
|
||||||
|
const skillsOptions = [
|
||||||
|
'Frontend Developer',
|
||||||
|
'Backend Developer',
|
||||||
|
'Full Stack Developer',
|
||||||
|
'DevOps Engineer',
|
||||||
|
'UI/UX Designer',
|
||||||
|
'Product Manager',
|
||||||
|
'Data Scientist',
|
||||||
|
'Mobile Developer',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const HackathonUsersPage: FC = (): ReactElement => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const currentPage = Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(searchParams.get('page') || '1', 10)
|
||||||
|
);
|
||||||
|
const searchQuery = searchParams.get('search') || '';
|
||||||
|
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
|
||||||
|
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||||
|
const [showNewUserModal, setShowNewUserModal] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
||||||
|
|
||||||
|
// Advanced filtering states
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [cityFilter, setCityFilter] = useState('all');
|
||||||
|
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Fetch users from API
|
||||||
|
const {
|
||||||
|
data: usersResponse,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
'admin-users',
|
||||||
|
currentPage,
|
||||||
|
perPage,
|
||||||
|
cityFilter,
|
||||||
|
statusFilter,
|
||||||
|
searchQuery,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
getAdminUsers({
|
||||||
|
page: currentPage,
|
||||||
|
per_page: perPage,
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
}),
|
||||||
|
staleTime: 30000, // 30 seconds cache
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalData = usersResponse?.meta?.total_data || 0;
|
||||||
|
const totalPages = usersResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
|
// Handle page change - update URL query params
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', newPage.toString());
|
||||||
|
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||||
|
if (searchQuery) params.set('search', searchQuery);
|
||||||
|
setSearchParams(params);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
},
|
||||||
|
[setSearchParams, perPage, searchQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate page number doesn't exceed total pages
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||||
|
setSearchParams({ page: totalPages.toString() });
|
||||||
|
}
|
||||||
|
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
||||||
|
|
||||||
|
// Sync globalFilter with URL search param on mount
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalFilter(searchQuery);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Handle search users
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', '1');
|
||||||
|
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||||
|
if (globalFilter.trim()) {
|
||||||
|
params.set('search', globalFilter.trim());
|
||||||
|
}
|
||||||
|
setSearchParams(params);
|
||||||
|
}, [globalFilter, setSearchParams, perPage]);
|
||||||
|
|
||||||
|
// Handle Enter key press in search input
|
||||||
|
const handleSearchKeyPress = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSearch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle per page change
|
||||||
|
const handlePerPageChange = useCallback(
|
||||||
|
(newPerPage: number) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', '1');
|
||||||
|
params.set('per_page', newPerPage.toString());
|
||||||
|
if (searchQuery) params.set('search', searchQuery);
|
||||||
|
setSearchParams(params);
|
||||||
|
},
|
||||||
|
[setSearchParams, searchQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(() => {
|
||||||
|
const usersData = usersResponse?.data || [];
|
||||||
|
return usersData.filter((user: UserType) => {
|
||||||
|
// Status filter
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
const isActive = statusFilter === 'active';
|
||||||
|
if (user.is_active !== isActive) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills filter
|
||||||
|
if (skillsFilter.length > 0) {
|
||||||
|
const userSkills = user.skills || [];
|
||||||
|
const hasMatchingSkill = skillsFilter.some((skill) =>
|
||||||
|
userSkills.includes(skill)
|
||||||
|
);
|
||||||
|
if (!hasMatchingSkill) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [usersResponse, statusFilter, 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 }) => {
|
||||||
|
const skills = row.original.skills || [];
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||||
|
{skills.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
{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">
|
||||||
|
+{skills.length - 2}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-400">-</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)}
|
||||||
|
onKeyPress={handleSearchKeyPress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per Page Dropdown */}
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={perPage}
|
||||||
|
onChange={(e) =>
|
||||||
|
handlePerPageChange(parseInt(e.target.value, 10))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value={10}>10 / page</option>
|
||||||
|
<option value={20}>20 / page</option>
|
||||||
|
<option value={50}>50 / page</option>
|
||||||
|
<option value={100}>100 / page</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
{/* <div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* City Filter */}
|
||||||
|
{/* <CityFilterSelect
|
||||||
|
value={cityFilter}
|
||||||
|
onChange={setCityFilter}
|
||||||
|
className="w-full sm:w-44"
|
||||||
|
placeholder="Search cities..."
|
||||||
|
allOptionLabel="All Cities"
|
||||||
|
/>
|
||||||
|
{cityFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||||
|
Location: {cityFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setCityFilter('all')}
|
||||||
|
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{/* Skills Filter with Icon */}
|
||||||
|
{/* <div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (
|
||||||
|
e.target.value &&
|
||||||
|
!skillsFilter.includes(e.target.value)
|
||||||
|
) {
|
||||||
|
setSkillsFilter((prev) => [...prev, e.target.value]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Add Skill Filter</option>
|
||||||
|
{skillsOptions.map((skill) => (
|
||||||
|
<option
|
||||||
|
key={skill}
|
||||||
|
value={skill}
|
||||||
|
disabled={skillsFilter.includes(skill)}
|
||||||
|
>
|
||||||
|
{skill}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Add User Button */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="flex items-center gap-2 px-4 py-2"
|
||||||
|
onClick={handleShowNewUserModal}
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-sm" />
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filters display */}
|
||||||
|
{(skillsFilter.length > 0 ||
|
||||||
|
statusFilter !== 'all' ||
|
||||||
|
cityFilter !== 'all') && (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||||
|
|
||||||
|
{/* Status filter badge */}
|
||||||
|
{statusFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||||
|
Status: {statusFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location filter badge */}
|
||||||
|
{cityFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||||
|
City: {cityFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setCityFilter('all')}
|
||||||
|
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skills filter badges */}
|
||||||
|
{skillsFilter.map((skill) => (
|
||||||
|
<span
|
||||||
|
key={skill}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
|
||||||
|
>
|
||||||
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
||||||
|
}
|
||||||
|
className="text-purple-600 hover:text-purple-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Clear all filters */}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter('all');
|
||||||
|
setCityFilter('all');
|
||||||
|
setSkillsFilter([]);
|
||||||
|
setGlobalFilter('');
|
||||||
|
}}
|
||||||
|
className="text-sm text-neutral-600"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading & results display */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||||
|
<span className="ml-3 text-neutral-600">Loading users...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredData.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-neutral-600">
|
||||||
|
Showing {filteredData.length} of {totalData} users (Page{' '}
|
||||||
|
{currentPage} of {totalPages})
|
||||||
|
{isFetching && (
|
||||||
|
<span className="ml-2 text-primary-500">(Updating...)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
data={filteredData}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={perPage}
|
||||||
|
manualPagination={true}
|
||||||
|
pageCount={totalPages}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-neutral-500">
|
||||||
|
No users found. Try adjusting your filters.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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 cursor-pointer"
|
||||||
|
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>
|
||||||
|
|||||||
@@ -2,22 +2,39 @@ import { useForm } from 'react-hook-form';
|
|||||||
import {
|
import {
|
||||||
authLoginSchema,
|
authLoginSchema,
|
||||||
TLoginRequest,
|
TLoginRequest,
|
||||||
|
useBackofficeLogin,
|
||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useSession } from '@imphnen-frontend-service/utils';
|
import { useNavigate } from 'react-router';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export const useLogin = () => {
|
export const useLogin = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const loginMutation = useBackofficeLogin();
|
||||||
|
|
||||||
const form = useForm<TLoginRequest>({
|
const form = useForm<TLoginRequest>({
|
||||||
resolver: zodResolver(authLoginSchema),
|
resolver: zodResolver(authLoginSchema),
|
||||||
mode: 'all',
|
mode: 'all',
|
||||||
|
defaultValues: {
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { signIn } = useSession();
|
const onSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
const onSubmit = form.handleSubmit((data) => signIn(data));
|
await loginMutation.mutateAsync(data);
|
||||||
|
toast.success('Login berhasil!');
|
||||||
|
navigate('/hackathon-dashboard');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Backoffice Login] Error:', error);
|
||||||
|
toast.error((error as Error).message || 'Login gagal');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
form,
|
form,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
isLoading: loginMutation.isPending,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useLogin } from './_hooks/use-login';
|
|||||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
export const Components: FC = (): ReactElement => {
|
export const Components: FC = (): ReactElement => {
|
||||||
const { form, onSubmit } = useLogin();
|
const { form, onSubmit, isLoading } = useLogin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center min-h-screen">
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
@@ -22,6 +22,7 @@ export const Components: FC = (): ReactElement => {
|
|||||||
name="email"
|
name="email"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
<ControlledInputField
|
<ControlledInputField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -31,10 +32,11 @@ export const Components: FC = (): ReactElement => {
|
|||||||
name="password"
|
name="password"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={
|
||||||
form.formState.isSubmitting ||
|
isLoading ||
|
||||||
form.formState.isValidating ||
|
form.formState.isValidating ||
|
||||||
!form.formState.isValid
|
!form.formState.isValid
|
||||||
}
|
}
|
||||||
@@ -42,7 +44,7 @@ export const Components: FC = (): ReactElement => {
|
|||||||
size="md"
|
size="md"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Login
|
{isLoading ? 'Loading...' : 'Login'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1>
|
||||||
|
<h2 className="text-3xl font-semibold text-gray-900 dark:text-gray-300 mb-4">
|
||||||
|
Page Not Found
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||||
|
The page you are looking for doesn't exist or has been moved.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/hackathon-dashboard"
|
||||||
|
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
Go Back Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function ErrorPage() {
|
||||||
|
const error = useRouteError();
|
||||||
|
let errorMessage: string;
|
||||||
|
|
||||||
|
if (isRouteErrorResponse(error)) {
|
||||||
|
errorMessage = error.statusText;
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
} else if (typeof error === 'string') {
|
||||||
|
errorMessage = error;
|
||||||
|
} else {
|
||||||
|
console.error(error);
|
||||||
|
errorMessage = 'Unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-6xl font-bold text-red-600 mb-4">Oops!</h1>
|
||||||
|
<p className="text-xl text-gray-700 mb-2">
|
||||||
|
Sorry, an unexpected error has occurred.
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 italic">{errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { FC, useState, useRef, useEffect } from 'react';
|
||||||
|
import { FilterOutlined } from '@ant-design/icons';
|
||||||
|
import INDONESIAN_CITIES from '../constants/cities';
|
||||||
|
|
||||||
|
interface CityFilterSelectProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
allOptionLabel?: string;
|
||||||
|
filterIcon?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CityFilterSelect: FC<CityFilterSelectProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className = '',
|
||||||
|
placeholder = 'Search cities...',
|
||||||
|
allOptionLabel = 'All Cities',
|
||||||
|
filterIcon = true,
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Filter cities based on search query
|
||||||
|
const filteredCities = INDONESIAN_CITIES.filter((city) =>
|
||||||
|
city.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectCity = (city: string) => {
|
||||||
|
onChange(city);
|
||||||
|
setSearchQuery('');
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputClick = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearSelection = () => {
|
||||||
|
onChange('all');
|
||||||
|
setSearchQuery('');
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayValue = value === 'all' ? allOptionLabel : value;
|
||||||
|
const showClearButton = value !== 'all' && !isOpen;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`} ref={dropdownRef}>
|
||||||
|
<div className="relative">
|
||||||
|
{filterIcon && (
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={isOpen ? searchQuery : displayValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
if (!isOpen) setIsOpen(true);
|
||||||
|
}}
|
||||||
|
onClick={handleInputClick}
|
||||||
|
onFocus={handleInputClick}
|
||||||
|
placeholder={isOpen ? placeholder : displayValue}
|
||||||
|
className={`border border-neutral-200 rounded-lg pr-10 py-2.5 text-sm w-full focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer ${
|
||||||
|
filterIcon ? ' pl-10' : 'pl-3'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{showClearButton && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClearSelection();
|
||||||
|
}}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 text-xs cursor-pointer z-20"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute z-50 w-full mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||||
|
{/* All Cities Option */}
|
||||||
|
<div
|
||||||
|
onClick={() => handleSelectCity('all')}
|
||||||
|
className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 border-b border-neutral-100 ${
|
||||||
|
value === 'all'
|
||||||
|
? 'bg-primary-50 text-primary-700 font-medium'
|
||||||
|
: 'text-neutral-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{allOptionLabel}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtered Cities */}
|
||||||
|
{filteredCities.length > 0 ? (
|
||||||
|
<div className="py-1">
|
||||||
|
{filteredCities.slice(0, 100).map((city) => (
|
||||||
|
<div
|
||||||
|
key={city}
|
||||||
|
onClick={() => handleSelectCity(city)}
|
||||||
|
className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 text-sm ${
|
||||||
|
value === city
|
||||||
|
? 'bg-primary-50 text-primary-700 font-medium'
|
||||||
|
: 'text-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{city}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filteredCities.length > 100 && (
|
||||||
|
<div className="px-3 py-2 text-xs text-neutral-500 border-t border-neutral-100">
|
||||||
|
Showing first 100 results. Continue typing to refine...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : searchQuery ? (
|
||||||
|
<div className="px-3 py-2 text-neutral-500 text-sm">
|
||||||
|
No cities found matching "{searchQuery}"
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
const INDONESIAN_CITIES: string[] = [
|
||||||
|
'Aceh Selatan',
|
||||||
|
'Aceh Tenggara',
|
||||||
|
'Aceh Timur',
|
||||||
|
'Aceh Tengah',
|
||||||
|
'Aceh Barat',
|
||||||
|
'Aceh Besar',
|
||||||
|
'Pidie',
|
||||||
|
'Aceh Utara',
|
||||||
|
'Simeulue',
|
||||||
|
'Aceh Singkil',
|
||||||
|
'Bireuen',
|
||||||
|
'Aceh Barat Daya',
|
||||||
|
'Gayo Lues',
|
||||||
|
'Aceh Jaya',
|
||||||
|
'Nagan Raya',
|
||||||
|
'Aceh Tamiang',
|
||||||
|
'Bener Meriah',
|
||||||
|
'Pidie Jaya',
|
||||||
|
'Kota Banda Aceh',
|
||||||
|
'Kota Sabang',
|
||||||
|
'Kota Lhokseumawe',
|
||||||
|
'Kota Langsa',
|
||||||
|
'Kota Subulussalam',
|
||||||
|
'Tapanuli Tengah',
|
||||||
|
'Tapanuli Utara',
|
||||||
|
'Tapanuli Selatan',
|
||||||
|
'Nias',
|
||||||
|
'Langkat',
|
||||||
|
'Karo',
|
||||||
|
'Deli Serdang',
|
||||||
|
'Simalungun',
|
||||||
|
'Asahan',
|
||||||
|
'Labuhanbatu',
|
||||||
|
'Dairi',
|
||||||
|
'Toba',
|
||||||
|
'Mandailing Natal',
|
||||||
|
'Nias Selatan',
|
||||||
|
'Pakpak Bharat',
|
||||||
|
'Humbang Hasundutan',
|
||||||
|
'Samosir',
|
||||||
|
'Serdang Bedagai',
|
||||||
|
'Batu Bara',
|
||||||
|
'Padang Lawas Utara',
|
||||||
|
'Padang Lawas',
|
||||||
|
'Labuhanbatu Selatan',
|
||||||
|
'Labuhanbatu Utara',
|
||||||
|
'Nias Utara',
|
||||||
|
'Nias Barat',
|
||||||
|
'Kota Medan',
|
||||||
|
'Kota Pematangsiantar',
|
||||||
|
'Kota Sibolga',
|
||||||
|
'Kota Tanjung Balai',
|
||||||
|
'Kota Binjai',
|
||||||
|
'Kota Tebing Tinggi',
|
||||||
|
'Kota Padangsidimpuan',
|
||||||
|
'Kota Gunungsitoli',
|
||||||
|
'Pesisir Selatan',
|
||||||
|
'Solok',
|
||||||
|
'Sijunjung',
|
||||||
|
'Tanah Datar',
|
||||||
|
'Padang Pariaman',
|
||||||
|
'Agam',
|
||||||
|
'Lima Puluh Kota',
|
||||||
|
'Pasaman',
|
||||||
|
'Kepulauan Mentawai',
|
||||||
|
'Dharmasraya',
|
||||||
|
'Solok Selatan',
|
||||||
|
'Pasaman Barat',
|
||||||
|
'Kota Padang',
|
||||||
|
'Kota Solok',
|
||||||
|
'Kota Sawahlunto',
|
||||||
|
'Kota Padang Panjang',
|
||||||
|
'Kota Bukittinggi',
|
||||||
|
'Kota Payakumbuh',
|
||||||
|
'Kota Pariaman',
|
||||||
|
'Kampar',
|
||||||
|
'Indragiri Hulu',
|
||||||
|
'Bengkalis',
|
||||||
|
'Indragiri Hilir',
|
||||||
|
'Pelalawan',
|
||||||
|
'Rokan Hulu',
|
||||||
|
'Rokan Hilir',
|
||||||
|
'Siak',
|
||||||
|
'Kuantan Singingi',
|
||||||
|
'Kepulauan Meranti',
|
||||||
|
'Kota Pekanbaru',
|
||||||
|
'Kota Dumai',
|
||||||
|
'Kerinci',
|
||||||
|
'Merangin',
|
||||||
|
'Sarolangun',
|
||||||
|
'Batanghari',
|
||||||
|
'Muaro Jambi',
|
||||||
|
'Tanjung Jabung Barat',
|
||||||
|
'Tanjung Jabung Timur',
|
||||||
|
'Bungo',
|
||||||
|
'Tebo',
|
||||||
|
'Kota Jambi',
|
||||||
|
'Kota Sungai Penuh',
|
||||||
|
'Ogan Komering Ulu',
|
||||||
|
'Ogan Komering Ilir',
|
||||||
|
'Muara Enim',
|
||||||
|
'Lahat',
|
||||||
|
'Musi Rawas',
|
||||||
|
'Musi Banyuasin',
|
||||||
|
'Banyuasin',
|
||||||
|
'Ogan Komering Ulu Timur',
|
||||||
|
'Ogan Komering Ulu Selatan',
|
||||||
|
'Ogan Ilir',
|
||||||
|
'Empat Lawang',
|
||||||
|
'Penukal Abab Lematang Ilir',
|
||||||
|
'Musi Rawas Utara',
|
||||||
|
'Kota Palembang',
|
||||||
|
'Kota Pagar Alam',
|
||||||
|
'Kota Lubuk Linggau',
|
||||||
|
'Kota Prabumulih',
|
||||||
|
'Bengkulu Selatan',
|
||||||
|
'Rejang Lebong',
|
||||||
|
'Bengkulu Utara',
|
||||||
|
'Kaur',
|
||||||
|
'Seluma',
|
||||||
|
'Muko Muko',
|
||||||
|
'Lebong',
|
||||||
|
'Kepahiang',
|
||||||
|
'Bengkulu Tengah',
|
||||||
|
'Kota Bengkulu',
|
||||||
|
'Lampung Selatan',
|
||||||
|
'Lampung Tengah',
|
||||||
|
'Lampung Utara',
|
||||||
|
'Lampung Barat',
|
||||||
|
'Tulang Bawang',
|
||||||
|
'Tanggamus',
|
||||||
|
'Lampung Timur',
|
||||||
|
'Way Kanan',
|
||||||
|
'Pesawaran',
|
||||||
|
'Pringsewu',
|
||||||
|
'Mesuji',
|
||||||
|
'Tulang Bawang Barat',
|
||||||
|
'Pesisir Barat',
|
||||||
|
'Kota Bandar Lampung',
|
||||||
|
'Kota Metro',
|
||||||
|
'Bangka',
|
||||||
|
'Belitung',
|
||||||
|
'Bangka Selatan',
|
||||||
|
'Bangka Tengah',
|
||||||
|
'Bangka Barat',
|
||||||
|
'Belitung Timur',
|
||||||
|
'Kota Pangkal Pinang',
|
||||||
|
'Bintan',
|
||||||
|
'Karimun',
|
||||||
|
'Natuna',
|
||||||
|
'Lingga',
|
||||||
|
'Kepulauan Anambas',
|
||||||
|
'Kota Batam',
|
||||||
|
'Kota Tanjung Pinang',
|
||||||
|
'Kepulauan Seribu',
|
||||||
|
'Kota Jakarta Pusat',
|
||||||
|
'Kota Jakarta Utara',
|
||||||
|
'Kota Jakarta Barat',
|
||||||
|
'Kota Jakarta Selatan',
|
||||||
|
'Kota Jakarta Timur',
|
||||||
|
'Bogor',
|
||||||
|
'Sukabumi',
|
||||||
|
'Cianjur',
|
||||||
|
'Bandung',
|
||||||
|
'Garut',
|
||||||
|
'Tasikmalaya',
|
||||||
|
'Ciamis',
|
||||||
|
'Kuningan',
|
||||||
|
'Cirebon',
|
||||||
|
'Majalengka',
|
||||||
|
'Sumedang',
|
||||||
|
'Indramayu',
|
||||||
|
'Subang',
|
||||||
|
'Purwakarta',
|
||||||
|
'Karawang',
|
||||||
|
'Bekasi',
|
||||||
|
'Bandung Barat',
|
||||||
|
'Pangandaran',
|
||||||
|
'Kota Bogor',
|
||||||
|
'Kota Sukabumi',
|
||||||
|
'Kota Bandung',
|
||||||
|
'Kota Cirebon',
|
||||||
|
'Kota Bekasi',
|
||||||
|
'Kota Depok',
|
||||||
|
'Kota Cimahi',
|
||||||
|
'Kota Tasikmalaya',
|
||||||
|
'Kota Banjar',
|
||||||
|
'Cilacap',
|
||||||
|
'Banyumas',
|
||||||
|
'Purbalingga',
|
||||||
|
'Banjarnegara',
|
||||||
|
'Kebumen',
|
||||||
|
'Purworejo',
|
||||||
|
'Wonosobo',
|
||||||
|
'Magelang',
|
||||||
|
'Boyolali',
|
||||||
|
'Klaten',
|
||||||
|
'Sukoharjo',
|
||||||
|
'Wonogiri',
|
||||||
|
'Karanganyar',
|
||||||
|
'Sragen',
|
||||||
|
'Grobogan',
|
||||||
|
'Blora',
|
||||||
|
'Rembang',
|
||||||
|
'Pati',
|
||||||
|
'Kudus',
|
||||||
|
'Jepara',
|
||||||
|
'Demak',
|
||||||
|
'Semarang',
|
||||||
|
'Temanggung',
|
||||||
|
'Kendal',
|
||||||
|
'Batang',
|
||||||
|
'Pekalongan',
|
||||||
|
'Pemalang',
|
||||||
|
'Tegal',
|
||||||
|
'Brebes',
|
||||||
|
'Kota Magelang',
|
||||||
|
'Kota Surakarta',
|
||||||
|
'Kota Salatiga',
|
||||||
|
'Kota Semarang',
|
||||||
|
'Kota Pekalongan',
|
||||||
|
'Kota Tegal',
|
||||||
|
'Kulon Progo',
|
||||||
|
'Bantul',
|
||||||
|
'Gunungkidul',
|
||||||
|
'Sleman',
|
||||||
|
'Kota Yogyakarta',
|
||||||
|
'Pacitan',
|
||||||
|
'Ponorogo',
|
||||||
|
'Trenggalek',
|
||||||
|
'Tulungagung',
|
||||||
|
'Blitar',
|
||||||
|
'Kediri',
|
||||||
|
'Malang',
|
||||||
|
'Lumajang',
|
||||||
|
'Jember',
|
||||||
|
'Banyuwangi',
|
||||||
|
'Bondowoso',
|
||||||
|
'Situbondo',
|
||||||
|
'Probolinggo',
|
||||||
|
'Pasuruan',
|
||||||
|
'Sidoarjo',
|
||||||
|
'Mojokerto',
|
||||||
|
'Jombang',
|
||||||
|
'Nganjuk',
|
||||||
|
'Madiun',
|
||||||
|
'Magetan',
|
||||||
|
'Ngawi',
|
||||||
|
'Bojonegoro',
|
||||||
|
'Tuban',
|
||||||
|
'Lamongan',
|
||||||
|
'Gresik',
|
||||||
|
'Bangkalan',
|
||||||
|
'Sampang',
|
||||||
|
'Pamekasan',
|
||||||
|
'Sumenep',
|
||||||
|
'Kota Kediri',
|
||||||
|
'Kota Blitar',
|
||||||
|
'Kota Malang',
|
||||||
|
'Kota Probolinggo',
|
||||||
|
'Kota Pasuruan',
|
||||||
|
'Kota Mojokerto',
|
||||||
|
'Kota Madiun',
|
||||||
|
'Kota Surabaya',
|
||||||
|
'Kota Batu',
|
||||||
|
'Pandeglang',
|
||||||
|
'Lebak',
|
||||||
|
'Tangerang',
|
||||||
|
'Serang',
|
||||||
|
'Kota Tangerang',
|
||||||
|
'Kota Cilegon',
|
||||||
|
'Kota Serang',
|
||||||
|
'Kota Tangerang Selatan',
|
||||||
|
'Jembrana',
|
||||||
|
'Tabanan',
|
||||||
|
'Badung',
|
||||||
|
'Gianyar',
|
||||||
|
'Klungkung',
|
||||||
|
'Bangli',
|
||||||
|
'Karangasem',
|
||||||
|
'Buleleng',
|
||||||
|
'Kota Denpasar',
|
||||||
|
'Lombok Barat',
|
||||||
|
'Lombok Tengah',
|
||||||
|
'Lombok Timur',
|
||||||
|
'Sumbawa',
|
||||||
|
'Dompu',
|
||||||
|
'Bima',
|
||||||
|
'Sumbawa Barat',
|
||||||
|
'Lombok Utara',
|
||||||
|
'Kota Mataram',
|
||||||
|
'Kota Bima',
|
||||||
|
'Kupang',
|
||||||
|
'Timor Tengah Selatan',
|
||||||
|
'Timor Tengah Utara',
|
||||||
|
'Belu',
|
||||||
|
'Alor',
|
||||||
|
'Flores Timur',
|
||||||
|
'Sikka',
|
||||||
|
'Ende',
|
||||||
|
'Ngada',
|
||||||
|
'Manggarai',
|
||||||
|
'Sumba Timur',
|
||||||
|
'Sumba Barat',
|
||||||
|
'Lembata',
|
||||||
|
'Rote Ndao',
|
||||||
|
'Manggarai Barat',
|
||||||
|
'Nagekeo',
|
||||||
|
'Sumba Tengah',
|
||||||
|
'Sumba Barat Daya',
|
||||||
|
'Manggarai Timur',
|
||||||
|
'Sabu Raijua',
|
||||||
|
'Malaka',
|
||||||
|
'Kota Kupang',
|
||||||
|
'Sambas',
|
||||||
|
'Mempawah',
|
||||||
|
'Sanggau',
|
||||||
|
'Ketapang',
|
||||||
|
'Sintang',
|
||||||
|
'Kapuas Hulu',
|
||||||
|
'Bengkayang',
|
||||||
|
'Landak',
|
||||||
|
'Sekadau',
|
||||||
|
'Melawi',
|
||||||
|
'Kayong Utara',
|
||||||
|
'Kubu Raya',
|
||||||
|
'Kota Pontianak',
|
||||||
|
'Kota Singkawang',
|
||||||
|
'Kotawaringin Barat',
|
||||||
|
'Kotawaringin Timur',
|
||||||
|
'Kapuas',
|
||||||
|
'Barito Selatan',
|
||||||
|
'Barito Utara',
|
||||||
|
'Katingan',
|
||||||
|
'Seruyan',
|
||||||
|
'Sukamara',
|
||||||
|
'Lamandau',
|
||||||
|
'Gunung Mas',
|
||||||
|
'Pulang Pisau',
|
||||||
|
'Murung Raya',
|
||||||
|
'Barito Timur',
|
||||||
|
'Kota Palangkaraya',
|
||||||
|
'Tanah Laut',
|
||||||
|
'Kotabaru',
|
||||||
|
'Banjar',
|
||||||
|
'Barito Kuala',
|
||||||
|
'Tapin',
|
||||||
|
'Hulu Sungai Selatan',
|
||||||
|
'Hulu Sungai Tengah',
|
||||||
|
'Hulu Sungai Utara',
|
||||||
|
'Tabalong',
|
||||||
|
'Tanah Bumbu',
|
||||||
|
'Balangan',
|
||||||
|
'Kota Banjarmasin',
|
||||||
|
'Kota Banjarbaru',
|
||||||
|
'Paser',
|
||||||
|
'Kutai Kartanegara',
|
||||||
|
'Berau',
|
||||||
|
'Kutai Barat',
|
||||||
|
'Kutai Timur',
|
||||||
|
'Penajam Paser Utara',
|
||||||
|
'Mahakam Ulu',
|
||||||
|
'Kota Balikpapan',
|
||||||
|
'Kota Samarinda',
|
||||||
|
'Kota Bontang',
|
||||||
|
'Bulungan',
|
||||||
|
'Malinau',
|
||||||
|
'Nunukan',
|
||||||
|
'Tana Tidung',
|
||||||
|
'Kota Tarakan',
|
||||||
|
'Bolaang Mongondow',
|
||||||
|
'Minahasa',
|
||||||
|
'Kepulauan Sangihe',
|
||||||
|
'Kepulauan Talaud',
|
||||||
|
'Minahasa Selatan',
|
||||||
|
'Minahasa Utara',
|
||||||
|
'Minahasa Tenggara',
|
||||||
|
'Bolaang Mongondow Utara',
|
||||||
|
'Kepulauan Siau Tagulandang Biaro (Sitaro)',
|
||||||
|
'Bolaang Mongondow Timur',
|
||||||
|
'Bolaang Mongondow Selatan',
|
||||||
|
'Kota Manado',
|
||||||
|
'Kota Bitung',
|
||||||
|
'Kota Tomohon',
|
||||||
|
'Kota Kotamobagu',
|
||||||
|
'Banggai',
|
||||||
|
'Poso',
|
||||||
|
'Donggala',
|
||||||
|
'Toli Toli',
|
||||||
|
'Buol',
|
||||||
|
'Morowali',
|
||||||
|
'Banggai Kepulauan',
|
||||||
|
'Parigi Moutong',
|
||||||
|
'Tojo Una Una',
|
||||||
|
'Sigi',
|
||||||
|
'Banggai Laut',
|
||||||
|
'Morowali Utara',
|
||||||
|
'Kota Palu',
|
||||||
|
'Kepulauan Selayar',
|
||||||
|
'Bulukumba',
|
||||||
|
'Bantaeng',
|
||||||
|
'Jeneponto',
|
||||||
|
'Takalar',
|
||||||
|
'Gowa',
|
||||||
|
'Sinjai',
|
||||||
|
'Bone',
|
||||||
|
'Maros',
|
||||||
|
'Pangkajene Kepulauan',
|
||||||
|
'Barru',
|
||||||
|
'Soppeng',
|
||||||
|
'Wajo',
|
||||||
|
'Sidenreng Rappang',
|
||||||
|
'Pinrang',
|
||||||
|
'Enrekang',
|
||||||
|
'Luwu',
|
||||||
|
'Tana Toraja',
|
||||||
|
'Luwu Utara',
|
||||||
|
'Luwu Timur',
|
||||||
|
'Toraja Utara',
|
||||||
|
'Kota Makassar',
|
||||||
|
'Kota Pare Pare',
|
||||||
|
'Kota Palopo',
|
||||||
|
'Kolaka',
|
||||||
|
'Konawe',
|
||||||
|
'Muna',
|
||||||
|
'Buton',
|
||||||
|
'Konawe Selatan',
|
||||||
|
'Bombana',
|
||||||
|
'Wakatobi',
|
||||||
|
'Kolaka Utara',
|
||||||
|
'Konawe Utara',
|
||||||
|
'Buton Utara',
|
||||||
|
'Kolaka Timur',
|
||||||
|
'Konawe Kepulauan',
|
||||||
|
'Muna Barat',
|
||||||
|
'Buton Tengah',
|
||||||
|
'Buton Selatan',
|
||||||
|
'Kota Kendari',
|
||||||
|
'Kota Bau Bau',
|
||||||
|
'Gorontalo',
|
||||||
|
'Boalemo',
|
||||||
|
'Bone Bolango',
|
||||||
|
'Pahuwato',
|
||||||
|
'Gorontalo Utara',
|
||||||
|
'Kota Gorontalo',
|
||||||
|
'Pasangkayu (Mamuju Utara)',
|
||||||
|
'Mamuju',
|
||||||
|
'Mamasa',
|
||||||
|
'Polewali Mandar',
|
||||||
|
'Majene',
|
||||||
|
'Mamuju Tengah',
|
||||||
|
'Maluku Tengah',
|
||||||
|
'Maluku Tenggara',
|
||||||
|
'Kepulauan Tanimbar (Maluku Tenggara Barat)',
|
||||||
|
'Buru',
|
||||||
|
'Seram Bagian Timur',
|
||||||
|
'Seram Bagian Barat',
|
||||||
|
'Kepulauan Aru',
|
||||||
|
'Maluku Barat Daya',
|
||||||
|
'Buru Selatan',
|
||||||
|
'Kota Ambon',
|
||||||
|
'Kota Tual',
|
||||||
|
'Halmahera Barat',
|
||||||
|
'Halmahera Tengah',
|
||||||
|
'Halmahera Utara',
|
||||||
|
'Halmahera Selatan',
|
||||||
|
'Kepulauan Sula',
|
||||||
|
'Halmahera Timur',
|
||||||
|
'Pulau Morotai',
|
||||||
|
'Pulau Taliabu',
|
||||||
|
'Kota Ternate',
|
||||||
|
'Kota Tidore Kepulauan',
|
||||||
|
'Jayapura',
|
||||||
|
'Kepulauan Yapen',
|
||||||
|
'Biak Numfor',
|
||||||
|
'Sarmi',
|
||||||
|
'Keerom',
|
||||||
|
'Waropen',
|
||||||
|
'Supiori',
|
||||||
|
'Mamberamo Raya',
|
||||||
|
'Kota Jayapura',
|
||||||
|
'Manokwari',
|
||||||
|
'Fak Fak',
|
||||||
|
'Teluk Bintuni',
|
||||||
|
'Teluk Wondama',
|
||||||
|
'Kaimana',
|
||||||
|
'Manokwari Selatan',
|
||||||
|
'Pegunungan Arfak',
|
||||||
|
'Merauke',
|
||||||
|
'Boven Digoel',
|
||||||
|
'Mappi',
|
||||||
|
'Asmat',
|
||||||
|
'Nabire',
|
||||||
|
'Puncak Jaya',
|
||||||
|
'Paniai',
|
||||||
|
'Mimika',
|
||||||
|
'Puncak',
|
||||||
|
'Dogiyai',
|
||||||
|
'Intan Jaya',
|
||||||
|
'Deiyai',
|
||||||
|
'Jayawijaya',
|
||||||
|
'Pegunungan Bintang',
|
||||||
|
'Yahukimo',
|
||||||
|
'Tolikara',
|
||||||
|
'Mamberamo Tengah',
|
||||||
|
'Yalimo',
|
||||||
|
'Lanny Jaya',
|
||||||
|
'Nduga',
|
||||||
|
'Sorong',
|
||||||
|
'Sorong Selatan',
|
||||||
|
'Raja Ampat',
|
||||||
|
'Tambrauw',
|
||||||
|
'Maybrat',
|
||||||
|
'Kota Sorong',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default INDONESIAN_CITIES;
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||||
|
|
||||||
if (mappingPublicRoutes.includes(pathname)) {
|
if (mappingPublicRoutes.includes(pathname)) {
|
||||||
if (token) return redirect('/dashboard');
|
if (token) return redirect('/hackathon-dashboard');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
|
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
|
||||||
|
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
return '/dashboard';
|
return '/hackathon-dashboard';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default defineConfig(() => ({
|
|||||||
root: __dirname,
|
root: __dirname,
|
||||||
cacheDir: '../../node_modules/.vite/apps/backoffice',
|
cacheDir: '../../node_modules/.vite/apps/backoffice',
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3003,
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { EditOutlined } from '@ant-design/icons';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useProfile } from '../contexts/profile-context';
|
||||||
|
|
||||||
|
interface ProfileHeaderProps {
|
||||||
|
onEditProfileClick: () => void;
|
||||||
|
isViewOnly?: boolean; // Add isViewOnly prop
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileHeader: FC<ProfileHeaderProps> = ({ onEditProfileClick, isViewOnly = false }) => {
|
||||||
|
const { profileData, profileType } = useProfile();
|
||||||
|
|
||||||
|
|
||||||
|
const avatarSrc = (profileType === 'user' && profileData && 'avatar' in profileData)
|
||||||
|
? profileData.avatar || "/image/testimonial.webp"
|
||||||
|
: "/image/testimonial.webp";
|
||||||
|
|
||||||
|
|
||||||
|
const displayFullname = profileData?.fullname ||
|
||||||
|
(profileType === 'mentor' && profileData && 'legal_name' in profileData
|
||||||
|
? profileData.legal_name
|
||||||
|
: 'User Name');
|
||||||
|
let displayJob = 'Role';
|
||||||
|
if (profileData) {
|
||||||
|
if (profileType === 'mentor' && 'current_role' in profileData) {
|
||||||
|
displayJob = profileData.current_role || 'Mentor';
|
||||||
|
} else if (profileType === 'user' && 'role' in profileData && profileData.role) {
|
||||||
|
displayJob = profileData.role.name || 'User';
|
||||||
|
} else if ('current_role' in profileData) {
|
||||||
|
|
||||||
|
displayJob = profileData.current_role || 'Role';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const joinDate = profileData && 'created_at' in profileData
|
||||||
|
? new Date(profileData.created_at).toLocaleDateString('id-ID', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long'
|
||||||
|
})
|
||||||
|
: 'April 2024';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-lg p-6 md:p-8 shadow-sm"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center gap-6">
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<div className="w-24 h-24 md:w-32 md:h-32 rounded-full overflow-hidden bg-primary-100 flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src={avatarSrc}
|
||||||
|
alt="Profile"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl md:text-2xl font-semibold text-neutral-800 mb-2">
|
||||||
|
{displayFullname}
|
||||||
|
</h1>
|
||||||
|
<p className="text-neutral-600 mb-1">{displayJob}</p>
|
||||||
|
{}
|
||||||
|
<p className="text-sm text-neutral-500 mt-2">
|
||||||
|
Bergabung sejak {joinDate}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isViewOnly && ( // Conditionally render the button
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 self-start"
|
||||||
|
onClick={onEditProfileClick}
|
||||||
|
>
|
||||||
|
<EditOutlined className="text-sm" />
|
||||||
|
Edit Profile
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{profileType === 'mentor' && (
|
||||||
|
<div className="flex justify-around md:justify-start md:gap-12 mt-6 pt-6 border-t border-neutral-100">
|
||||||
|
<div className="text-center md:text-left">
|
||||||
|
<p className="text-lg md:text-xl font-semibold text-primary-500">
|
||||||
|
{profileData && 'mentoring_sessions' in profileData ? profileData.mentoring_sessions || 'N/A' : 'N/A'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs md:text-sm text-neutral-600">Mentoring Sessions</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center md:text-left">
|
||||||
|
<p className="text-lg md:text-xl font-semibold text-primary-500">
|
||||||
|
{profileData && 'rating' in profileData ? profileData.rating || 'N/A' : 'N/A'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs md:text-sm text-neutral-600">Rating</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center md:text-left">
|
||||||
|
<p className="text-lg md:text-xl font-semibold text-primary-500">
|
||||||
|
0
|
||||||
|
</p>
|
||||||
|
<p className="text-xs md:text-sm text-neutral-600">Certificates</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { useState, FC, useEffect } from 'react';
|
||||||
|
import { PersonalInfoSection } from '../sections/personal-info-section';
|
||||||
|
import { SkillsSection } from '../sections/skills-section';
|
||||||
|
import { LanguagesSection } from '../sections/languages-section';
|
||||||
|
import { SocialMediaSection } from '../sections/social-media-section';
|
||||||
|
import { NotificationType } from '../modals/notification-modal';
|
||||||
|
import { useProfile } from '../contexts/profile-context';
|
||||||
|
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
|
interface Language {
|
||||||
|
name: string;
|
||||||
|
level: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SocialLink {
|
||||||
|
platform: string;
|
||||||
|
placeholder: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileInfoProps {
|
||||||
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileInfo: FC<ProfileInfoProps> = ({ showNotification }) => {
|
||||||
|
const { profileData, updateProfile, profileType } = useProfile();
|
||||||
|
|
||||||
|
const [contactInfo, setContactInfo] = useState({
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
location: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [currentSkills, setCurrentSkills] = useState<string[]>([]);
|
||||||
|
const [currentLanguages, setCurrentLanguages] = useState<Language[]>([]);
|
||||||
|
const [currentSocialLinks, setCurrentSocialLinks] = 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 extractContactInfo = (profileData: MentorUpdateRequestDto | UserUpdateRequestDto) => {
|
||||||
|
const email = 'email' in profileData && typeof profileData.email === 'string' ? 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 || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { email, phone, location };
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractSkills = (profileData: MentorUpdateRequestDto | UserUpdateRequestDto) => {
|
||||||
|
if ('skills' in profileData) {
|
||||||
|
return profileData.skills || [];
|
||||||
|
} else if ('expertise' in profileData) {
|
||||||
|
return profileData.expertise || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractLanguages = (profileData: MentorUpdateRequestDto | UserUpdateRequestDto): Language[] => {
|
||||||
|
return 'languages' in profileData
|
||||||
|
? (profileData.languages || []).map((lang: string) => ({ name: lang, level: 'Intermediate' }))
|
||||||
|
: [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractSocialLinks = (profileData: MentorUpdateRequestDto | UserUpdateRequestDto, profileType: string): SocialLink[] => {
|
||||||
|
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 || '' : '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ 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 }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData) {
|
||||||
|
setContactInfo(extractContactInfo(profileData));
|
||||||
|
setCurrentSkills(extractSkills(profileData));
|
||||||
|
setCurrentLanguages(extractLanguages(profileData));
|
||||||
|
setCurrentSocialLinks(extractSocialLinks(profileData, profileType));
|
||||||
|
}
|
||||||
|
}, [profileData, profileType]);
|
||||||
|
|
||||||
|
|
||||||
|
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
|
||||||
|
try {
|
||||||
|
await updateProfile(updates);
|
||||||
|
showNotification('success', 'Perubahan Berhasil Disimpan');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Profile update error:', err);
|
||||||
|
showNotification('error', 'Gagal menyimpan perubahan', 'Silakan coba lagi');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PersonalInfoSection
|
||||||
|
initialContactInfo={contactInfo}
|
||||||
|
onSave={async (newContactInfo) => {
|
||||||
|
setContactInfo(newContactInfo);
|
||||||
|
await handleProfileUpdate({
|
||||||
|
phone_for_verification: newContactInfo.phone,
|
||||||
|
location: newContactInfo.location
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
showNotification={showNotification}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SocialMediaSection
|
||||||
|
initialSocialLinks={currentSocialLinks}
|
||||||
|
onSave={async (newSocialLinks) => {
|
||||||
|
setCurrentSocialLinks(newSocialLinks);
|
||||||
|
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: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
showNotification={showNotification}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SkillsSection
|
||||||
|
initialSkills={currentSkills}
|
||||||
|
onSave={async (newSkills) => {
|
||||||
|
setCurrentSkills(newSkills);
|
||||||
|
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {};
|
||||||
|
|
||||||
|
if (profileType === 'user') {
|
||||||
|
(updates as UserUpdateRequestDto).skills = newSkills;
|
||||||
|
} else if (profileType === 'mentor') {
|
||||||
|
(updates as MentorUpdateRequestDto).expertise = newSkills;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleProfileUpdate(updates);
|
||||||
|
}}
|
||||||
|
showNotification={showNotification}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LanguagesSection
|
||||||
|
initialLanguages={currentLanguages}
|
||||||
|
onSave={async (newLanguages) => {
|
||||||
|
setCurrentLanguages(newLanguages);
|
||||||
|
await handleProfileUpdate({
|
||||||
|
languages: newLanguages.map(lang => lang.name)
|
||||||
|
} as MentorUpdateRequestDto | UserUpdateRequestDto);
|
||||||
|
}}
|
||||||
|
showNotification={showNotification}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState, FC, useEffect } from 'react';
|
||||||
|
import { PersonalInfoSection } from '../sections/personal-info-section';
|
||||||
|
import { SkillsSection } from '../sections/skills-section';
|
||||||
|
import { LanguagesSection } from '../sections/languages-section';
|
||||||
|
import { SocialMediaSection } from '../sections/social-media-section';
|
||||||
|
import { NotificationType } from '../modals/notification-modal';
|
||||||
|
import { useProfile } from '../contexts/profile-context';
|
||||||
|
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
|
interface Language {
|
||||||
|
name: string;
|
||||||
|
level: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SocialLink {
|
||||||
|
platform: string;
|
||||||
|
placeholder: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileInfoProps {
|
||||||
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileInfo: FC<ProfileInfoProps> = ({ showNotification }) => {
|
||||||
|
const { profileData, updateProfile, profileType } = useProfile();
|
||||||
|
|
||||||
|
const [contactInfo, setContactInfo] = useState({
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
location: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [currentSkills, setCurrentSkills] = useState<string[]>([]);
|
||||||
|
const [currentLanguages, setCurrentLanguages] = useState<Language[]>([]);
|
||||||
|
const [currentSocialLinks, setCurrentSocialLinks] = 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: '' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData) {
|
||||||
|
let skills: string[] = [];
|
||||||
|
if ('skills' in profileData) {
|
||||||
|
skills = profileData.skills || [];
|
||||||
|
} else if ('expertise' in profileData) {
|
||||||
|
skills = profileData.expertise || [];
|
||||||
|
}
|
||||||
|
setCurrentSkills(skills);
|
||||||
|
}
|
||||||
|
}, [profileData]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData) {
|
||||||
|
const languages: Language[] = 'languages' in profileData
|
||||||
|
? (profileData.languages || []).map(lang => ({ name: lang, level: 'Intermediate' }))
|
||||||
|
: [];
|
||||||
|
setCurrentLanguages(languages);
|
||||||
|
}
|
||||||
|
}, [profileData]);
|
||||||
|
|
||||||
|
|
||||||
|
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 || '' : '';
|
||||||
|
|
||||||
|
setCurrentSocialLinks([
|
||||||
|
{ 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]);
|
||||||
|
|
||||||
|
|
||||||
|
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
|
||||||
|
try {
|
||||||
|
await updateProfile(updates);
|
||||||
|
showNotification('success', 'Perubahan Berhasil Disimpan');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Profile update error:', err);
|
||||||
|
showNotification('error', 'Gagal menyimpan perubahan', 'Silakan coba lagi');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PersonalInfoSection
|
||||||
|
initialContactInfo={contactInfo}
|
||||||
|
onSave={async (newContactInfo) => {
|
||||||
|
setContactInfo(newContactInfo);
|
||||||
|
await handleProfileUpdate({
|
||||||
|
phone_for_verification: newContactInfo.phone,
|
||||||
|
location: newContactInfo.location
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
showNotification={showNotification}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SocialMediaSection
|
||||||
|
initialSocialLinks={currentSocialLinks}
|
||||||
|
onSave={async (newSocialLinks) => {
|
||||||
|
setCurrentSocialLinks(newSocialLinks);
|
||||||
|
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: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
showNotification={showNotification}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SkillsSection
|
||||||
|
initialSkills={currentSkills}
|
||||||
|
onSave={async (newSkills) => {
|
||||||
|
setCurrentSkills(newSkills);
|
||||||
|
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {};
|
||||||
|
|
||||||
|
if (profileType === 'user') {
|
||||||
|
(updates as UserUpdateRequestDto).skills = newSkills;
|
||||||
|
} else if (profileType === 'mentor') {
|
||||||
|
(updates as MentorUpdateRequestDto).expertise = newSkills;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleProfileUpdate(updates);
|
||||||
|
}}
|
||||||
|
showNotification={showNotification}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LanguagesSection
|
||||||
|
initialLanguages={currentLanguages}
|
||||||
|
onSave={async (newLanguages) => {
|
||||||
|
setCurrentLanguages(newLanguages);
|
||||||
|
await handleProfileUpdate({
|
||||||
|
languages: newLanguages.map(lang => lang.name)
|
||||||
|
} as MentorUpdateRequestDto | UserUpdateRequestDto);
|
||||||
|
}}
|
||||||
|
showNotification={showNotification}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { FC, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Select } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { SectionWrapper } from '../shared/section-wrapper';
|
||||||
|
import { NotificationType } from '../modals/notification-modal';
|
||||||
|
import { PersonalInfoSection } from '../sections/personal-info-section';
|
||||||
|
import { SkillsSection } from '../sections/skills-section';
|
||||||
|
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||||
|
import { useProfile } from '../contexts/profile-context';
|
||||||
|
|
||||||
|
interface ProfileSidebarProps {
|
||||||
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
|
isViewOnly?: boolean; // Add isViewOnly prop
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isViewOnly = false }) => {
|
||||||
|
const { profileData, updateProfile, profileType, isLoading, isUpdating } = useProfile();
|
||||||
|
|
||||||
|
|
||||||
|
const getCareerStatus = useCallback(() => {
|
||||||
|
if (!profileData) {
|
||||||
|
return 'Career Status';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileType === 'user' && 'career_status' in profileData) {
|
||||||
|
const status = profileData.career_status;
|
||||||
|
if (status && typeof status === 'string' && status.trim() !== '') {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileType === 'mentor' && 'availability_commitment' in profileData) {
|
||||||
|
const commitment = profileData.availability_commitment;
|
||||||
|
if (commitment && typeof commitment === 'string' && commitment.trim() !== '') {
|
||||||
|
return commitment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Career Status';
|
||||||
|
}, [profileType, profileData]);
|
||||||
|
|
||||||
|
const getEmail = useCallback(() => {
|
||||||
|
if (profileData && 'email' in profileData) {
|
||||||
|
return profileData.email || 'email@example.com';
|
||||||
|
}
|
||||||
|
return 'email@example.com';
|
||||||
|
}, [profileData]);
|
||||||
|
|
||||||
|
const getPhone = useCallback(() => {
|
||||||
|
if (profileData && 'phone_for_verification' in profileData && profileData.phone_for_verification) {
|
||||||
|
return profileData.phone_for_verification;
|
||||||
|
}
|
||||||
|
if (profileType === 'user' && profileData && 'phone_number' in profileData && profileData.phone_number) {
|
||||||
|
return profileData.phone_number;
|
||||||
|
}
|
||||||
|
return '+62 (88) 8888 8888';
|
||||||
|
}, [profileType, profileData]);
|
||||||
|
|
||||||
|
const getLocation = useCallback(() => {
|
||||||
|
if (profileData && 'domicile' in profileData && profileData.domicile) {
|
||||||
|
return profileData.domicile;
|
||||||
|
}
|
||||||
|
if (profileType === 'user' && profileData && 'location' in profileData && profileData.location) {
|
||||||
|
return profileData.location;
|
||||||
|
}
|
||||||
|
return 'Location';
|
||||||
|
}, [profileType, profileData]);
|
||||||
|
|
||||||
|
const getSkills = useCallback(() => {
|
||||||
|
if (profileType === 'mentor' && profileData && 'expertise' in profileData && profileData.expertise) {
|
||||||
|
return Array.isArray(profileData.expertise) ? profileData.expertise : [];
|
||||||
|
}
|
||||||
|
if (profileType === 'user' && profileData && 'skills' in profileData && profileData.skills) {
|
||||||
|
return Array.isArray(profileData.skills) ? profileData.skills : [];
|
||||||
|
}
|
||||||
|
return [''];
|
||||||
|
}, [profileType, profileData]);
|
||||||
|
|
||||||
|
const [careerStatus, setCareerStatus] = useState<string>('Career Status');
|
||||||
|
const [isUpdatingCareerStatus, setIsUpdatingCareerStatus] = useState(false);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
|
const [personalInfo, setPersonalInfo] = useState({
|
||||||
|
email: 'email@example.com',
|
||||||
|
phone: '+62 (88) 8888 8888',
|
||||||
|
location: 'Location'
|
||||||
|
});
|
||||||
|
|
||||||
|
const [skills, setSkills] = useState<string[]>(['']);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData && !isLoading && careerStatus === 'Career Status') {
|
||||||
|
const initialCareerStatus = getCareerStatus();
|
||||||
|
|
||||||
|
setCareerStatus(initialCareerStatus);
|
||||||
|
setIsInitialized(true);
|
||||||
|
}
|
||||||
|
}, [profileData, isLoading, careerStatus, getCareerStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData && !isLoading) {
|
||||||
|
if (!isUpdatingCareerStatus && isInitialized) {
|
||||||
|
const newCareerStatus = getCareerStatus();
|
||||||
|
console.log('ProfileSidebar: Updating career status from API:', newCareerStatus);
|
||||||
|
setCareerStatus(newCareerStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPersonalInfo({
|
||||||
|
email: getEmail(),
|
||||||
|
phone: getPhone(),
|
||||||
|
location: getLocation()
|
||||||
|
});
|
||||||
|
|
||||||
|
setSkills(getSkills());
|
||||||
|
}
|
||||||
|
}, [profileData, profileType, isLoading, isInitialized, getCareerStatus, getEmail, getPhone, getLocation, getSkills, isUpdatingCareerStatus]);
|
||||||
|
|
||||||
|
|
||||||
|
const tryParseJsonMessage = (msg: string): string => {
|
||||||
|
if (msg.trim().startsWith('{') && msg.trim().endsWith('}')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (parsed && typeof parsed.message === 'string') {
|
||||||
|
return parsed.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const extractApiMessage = (err: unknown): string => {
|
||||||
|
if (typeof err !== 'object' || err === null) return '';
|
||||||
|
|
||||||
|
const maybeAxiosError = err as { response?: { data?: { message?: string } } };
|
||||||
|
if (maybeAxiosError.response?.data?.message) {
|
||||||
|
return maybeAxiosError.response.data.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('message' in err && typeof (err as { message?: string }).message === 'string') {
|
||||||
|
const msg = (err as { message?: string }).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 {
|
||||||
|
await updateProfile(updates);
|
||||||
|
showNotification('success', 'Perubahan Berhasil Disimpan');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Profile update error:', err);
|
||||||
|
const apiMessage = extractApiMessage(err);
|
||||||
|
showNotification('error', 'Gagal menyimpan perubahan', apiMessage || 'Silakan coba lagi');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SectionWrapper title="Career Status">
|
||||||
|
<Select
|
||||||
|
value={careerStatus}
|
||||||
|
onChange={async (e) => {
|
||||||
|
if (isUpdatingCareerStatus) return; // Prevent multiple clicks
|
||||||
|
const newStatus = e.target.value;
|
||||||
|
console.log('ProfileSidebar: User selected career status:', newStatus);
|
||||||
|
setCareerStatus(newStatus);
|
||||||
|
setIsUpdatingCareerStatus(true);
|
||||||
|
try {
|
||||||
|
console.log('ProfileSidebar: Updating career status on backend...');
|
||||||
|
|
||||||
|
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {};
|
||||||
|
if (profileType === 'mentor') {
|
||||||
|
(updates as MentorUpdateRequestDto).availability_commitment = newStatus;
|
||||||
|
} else {
|
||||||
|
(updates as UserUpdateRequestDto).career_status = newStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleProfileUpdate(updates);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ProfileSidebar: Career status update failed:', error);
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingCareerStatus(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full min-w-[200px]"
|
||||||
|
disabled={isUpdatingCareerStatus || isViewOnly} // Disable if in view-only mode
|
||||||
|
>
|
||||||
|
<option value="Career Status">Career Status</option>
|
||||||
|
<option value="Student">Student</option>
|
||||||
|
<option value="Fresh Graduate">Fresh Graduate</option>
|
||||||
|
<option value="Junior Developer">Junior Developer</option>
|
||||||
|
<option value="Senior Developer">Senior Developer</option>
|
||||||
|
<option value="Team Lead">Team Lead</option>
|
||||||
|
<option value="Freelancer">Freelancer</option>
|
||||||
|
</Select>
|
||||||
|
</SectionWrapper>
|
||||||
|
|
||||||
|
<PersonalInfoSection
|
||||||
|
initialContactInfo={personalInfo}
|
||||||
|
onSave={async (newPersonalInfo) => {
|
||||||
|
setPersonalInfo(newPersonalInfo);
|
||||||
|
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {
|
||||||
|
phone_for_verification: newPersonalInfo.phone || null,
|
||||||
|
domicile: newPersonalInfo.location || null
|
||||||
|
};
|
||||||
|
await handleProfileUpdate(updates);
|
||||||
|
}}
|
||||||
|
showNotification={showNotification}
|
||||||
|
isLoading={isUpdating}
|
||||||
|
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SkillsSection
|
||||||
|
initialSkills={skills}
|
||||||
|
onSave={async (newSkills) => {
|
||||||
|
setSkills(newSkills);
|
||||||
|
|
||||||
|
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {};
|
||||||
|
if (profileType === 'mentor') {
|
||||||
|
(updates as MentorUpdateRequestDto).expertise = newSkills;
|
||||||
|
} else if (profileType === 'user') {
|
||||||
|
(updates as UserUpdateRequestDto).skills = newSkills;
|
||||||
|
}
|
||||||
|
await handleProfileUpdate(updates);
|
||||||
|
}}
|
||||||
|
showNotification={showNotification}
|
||||||
|
isLoading={isUpdating}
|
||||||
|
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { FC, useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { BookOutlined, TrophyOutlined, ClockCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
|
||||||
|
import { For } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
type TabType = 'overview' | 'certificates' | 'activity' | 'mentoring';
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'overview', label: 'Overview', icon: BookOutlined },
|
||||||
|
{ id: 'certificates', label: 'Certificates', icon: SafetyCertificateOutlined },
|
||||||
|
{ id: 'activity', label: 'Activity', icon: ClockCircleOutlined },
|
||||||
|
{ id: 'mentoring', label: 'Mentoring', icon: TrophyOutlined },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const certificates = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'UI/UX Design Fundamentals',
|
||||||
|
issuer: 'Google',
|
||||||
|
date: 'March 2024',
|
||||||
|
image: '/image/certificate-placeholder.jpg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Advanced Figma Techniques',
|
||||||
|
issuer: 'Coursera',
|
||||||
|
date: 'February 2024',
|
||||||
|
image: '/image/certificate-placeholder.jpg'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const activities = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'mentoring',
|
||||||
|
title: 'Completed mentoring session with Riko',
|
||||||
|
date: '2 hours ago',
|
||||||
|
icon: TrophyOutlined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'certificate',
|
||||||
|
title: 'Earned UI/UX Design Fundamentals certificate',
|
||||||
|
date: '1 day ago',
|
||||||
|
icon: SafetyCertificateOutlined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
type: 'mentoring',
|
||||||
|
title: 'Started new mentoring session',
|
||||||
|
date: '3 days ago',
|
||||||
|
icon: TrophyOutlined
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const mentoringStats = [
|
||||||
|
{ label: 'Total Sessions', value: '15', change: '+3 this month' },
|
||||||
|
{ label: 'Average Rating', value: '4.8/5', change: '+0.2 from last month' },
|
||||||
|
{ label: 'Total Hours', value: '45h', change: '+12h this month' },
|
||||||
|
{ label: 'Active Mentees', value: '8', change: '+2 this month' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ProfileTabs: FC = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||||
|
|
||||||
|
const renderTabContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'overview':
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-800 mb-4">About Me</h3>
|
||||||
|
<p className="text-neutral-600 leading-relaxed">
|
||||||
|
Passionate UI/UX Designer with 5+ years of experience creating user-centered designs
|
||||||
|
for web and mobile applications. I love mentoring aspiring designers and sharing
|
||||||
|
knowledge about design thinking, prototyping, and user research methodologies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-800 mb-4">Recent Achievements</h3>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="p-4 bg-primary-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<TrophyOutlined className="text-primary-500" />
|
||||||
|
<h4 className="font-medium text-neutral-800">Top Mentor</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-600">Ranked #1 in UI/UX mentoring this month</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-green-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<SafetyCertificateOutlined className="text-green-500" />
|
||||||
|
<h4 className="font-medium text-neutral-800">New Certificate</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-600">Google UX Design Professional Certificate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'certificates':
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-800 mb-4">My Certificates</h3>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<For data={certificates}>
|
||||||
|
{(cert) => (
|
||||||
|
<div key={cert.id} className="border border-neutral-200 rounded-lg p-4">
|
||||||
|
<div className="w-full h-32 bg-neutral-100 rounded-lg mb-4 flex items-center justify-center">
|
||||||
|
<SafetyCertificateOutlined className="text-4xl text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-medium text-neutral-800 mb-1">{cert.title}</h4>
|
||||||
|
<p className="text-sm text-neutral-600">{cert.issuer}</p>
|
||||||
|
<p className="text-xs text-neutral-500 mt-2">{cert.date}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'activity':
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-800 mb-4">Recent Activity</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<For data={activities}>
|
||||||
|
{(activity) => (
|
||||||
|
<div key={activity.id} className="flex items-start gap-4 p-4 border border-neutral-100 rounded-lg">
|
||||||
|
<div className="w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center">
|
||||||
|
<activity.icon className="text-primary-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-neutral-800">{activity.title}</p>
|
||||||
|
<p className="text-sm text-neutral-500">{activity.date}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'mentoring':
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-800 mb-4">Mentoring Statistics</h3>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<For data={mentoringStats}>
|
||||||
|
{(stat) => (
|
||||||
|
<div key={stat.label} className="p-4 border border-neutral-200 rounded-lg">
|
||||||
|
<h4 className="text-2xl font-bold text-primary-500 mb-1">{stat.value}</h4>
|
||||||
|
<p className="font-medium text-neutral-800 mb-1">{stat.label}</p>
|
||||||
|
<p className="text-sm text-green-600">{stat.change}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-800 mb-4">Mentoring Feedback</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border border-neutral-100 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="flex text-yellow-400">
|
||||||
|
<span>★★★★★</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-neutral-600">5.0</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-neutral-700 mb-2">
|
||||||
|
"Excellent mentor! Very patient and explains concepts clearly."
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-neutral-500">- Riko, Junior Developer</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm">
|
||||||
|
<div className="border-b border-neutral-200">
|
||||||
|
<nav className="flex space-x-8 px-6 py-4 overflow-x-auto">
|
||||||
|
<For data={tabs}>
|
||||||
|
{(tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id as TabType)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-primary-500 text-primary-600'
|
||||||
|
: 'border-transparent text-neutral-600 hover:text-neutral-800 hover:border-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="text-sm" />
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{renderTabContent()}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { MailOutlined, PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
||||||
|
import { PersonalInfoModal } from '../modals';
|
||||||
|
import { SectionWrapper } from '../shared/section-wrapper';
|
||||||
|
import { NotificationType } from '../modals/notification-modal';
|
||||||
|
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||||
|
|
||||||
|
interface ContactInfo {
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
location: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactInfoSectionProps {
|
||||||
|
initialContactInfo: ContactInfo;
|
||||||
|
onSave: (newContactInfo: ContactInfo) => Promise<void>;
|
||||||
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContactInfoSection: FC<ContactInfoSectionProps> = ({
|
||||||
|
initialContactInfo,
|
||||||
|
onSave,
|
||||||
|
showNotification,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
|
const [isPersonalInfoModalOpen, setIsPersonalInfoModalOpen] = useState(false);
|
||||||
|
const [contactInfo, setContactInfo] = useState<ContactInfo>(initialContactInfo);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setContactInfo(initialContactInfo);
|
||||||
|
}, [initialContactInfo]);
|
||||||
|
|
||||||
|
const handleSave = async (newInfo: { email: string; phone: string; location: string }) => {
|
||||||
|
|
||||||
|
const newContactInfo = { email: newInfo.email, phone: newInfo.phone, location: newInfo.location };
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
await onSave(newContactInfo);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper
|
||||||
|
title="Personal Informations"
|
||||||
|
editButton={
|
||||||
|
<EditSectionButton onClick={() => setIsPersonalInfoModalOpen(true)} />
|
||||||
|
}
|
||||||
|
delay={0.1}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||||
|
<MailOutlined className="text-[#23A1EB] text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{contactInfo.email}</p>
|
||||||
|
<p className="text-sm text-gray-600">Email Address</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||||
|
<PhoneOutlined className="text-[#23A1EB] text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{contactInfo.phone}</p>
|
||||||
|
<p className="text-sm text-gray-600">Phone Number</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||||
|
<EnvironmentOutlined className="text-[#23A1EB] text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{contactInfo.location}</p>
|
||||||
|
<p className="text-sm text-gray-600">Location</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PersonalInfoModal
|
||||||
|
isOpen={isPersonalInfoModalOpen}
|
||||||
|
onClose={() => setIsPersonalInfoModalOpen(false)}
|
||||||
|
initialValue={contactInfo}
|
||||||
|
onSave={handleSave}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { DownloadOutlined } from '@ant-design/icons';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { CVModal } from '../modals';
|
||||||
|
import { SectionWrapper } from '../shared/section-wrapper';
|
||||||
|
import { NotificationType } from '../modals/notification-modal';
|
||||||
|
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||||
|
|
||||||
|
|
||||||
|
interface CvResumeSectionProps {
|
||||||
|
initialFileName: string;
|
||||||
|
fullname: string;
|
||||||
|
onSave: (cvData: { fileName: string; fileUrl?: string }) => Promise<void>;
|
||||||
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isViewOnly?: boolean; // Add isViewOnly prop
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CvResumeSection: FC<CvResumeSectionProps> = ({
|
||||||
|
initialFileName,
|
||||||
|
fullname,
|
||||||
|
onSave,
|
||||||
|
showNotification,
|
||||||
|
isLoading = false,
|
||||||
|
isViewOnly = false, // Default to false
|
||||||
|
}) => {
|
||||||
|
const [isCVModalOpen, setIsCVModalOpen] = useState(false);
|
||||||
|
const [fileName, setFileName] = useState(initialFileName);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFileName(initialFileName);
|
||||||
|
}, [initialFileName]);
|
||||||
|
|
||||||
|
const handleSave = async (cvData: { fileName: string; fileUrl?: string }) => {
|
||||||
|
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||||
|
await onSave(cvData);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
let displayFileName = 'Belum ada CV';
|
||||||
|
let fileUrl = '';
|
||||||
|
if (fileName) {
|
||||||
|
if (fileName.startsWith('http') && fullname) {
|
||||||
|
displayFileName = `${fullname}.pdf`;
|
||||||
|
fileUrl = fileName;
|
||||||
|
} else {
|
||||||
|
displayFileName = fileName;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (fileUrl) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = fileUrl;
|
||||||
|
link.download = displayFileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper
|
||||||
|
title="CV/Resume"
|
||||||
|
editButton={
|
||||||
|
!isViewOnly ? ( // Conditionally render the edit button
|
||||||
|
<EditSectionButton
|
||||||
|
onClick={() => setIsCVModalOpen(true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
delay={0.3}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div className="w-10 h-10 bg-gray-300 rounded flex items-center justify-center">
|
||||||
|
<span className="text-gray-600 text-xs font-medium">PDF</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-gray-900">{displayFileName}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
disabled={!fileUrl}
|
||||||
|
onClick={handleDownload}
|
||||||
|
>
|
||||||
|
<DownloadOutlined />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CVModal
|
||||||
|
isOpen={isCVModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||||
|
onClose={() => setIsCVModalOpen(false)}
|
||||||
|
initialValue={{ fileName, fileUrl: fileName }}
|
||||||
|
onSave={handleSave}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { DescriptionModal } from '../modals';
|
||||||
|
import { SectionWrapper } from '../shared/section-wrapper';
|
||||||
|
import { NotificationType } from '../modals/notification-modal';
|
||||||
|
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||||
|
|
||||||
|
interface DescriptionSectionProps {
|
||||||
|
initialDescription: string;
|
||||||
|
onSave: (newDescription: string) => Promise<void>;
|
||||||
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isViewOnly?: boolean; // Add isViewOnly prop
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DescriptionSection: FC<DescriptionSectionProps> = ({
|
||||||
|
initialDescription,
|
||||||
|
onSave,
|
||||||
|
showNotification,
|
||||||
|
isLoading = false,
|
||||||
|
isViewOnly = false, // Default to false
|
||||||
|
}) => {
|
||||||
|
const [isDescriptionModalOpen, setIsDescriptionModalOpen] = useState(false);
|
||||||
|
const [description, setDescription] = useState(initialDescription);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDescription(initialDescription);
|
||||||
|
}, [initialDescription]);
|
||||||
|
|
||||||
|
const handleSave = async (newDescription: string) => {
|
||||||
|
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||||
|
await onSave(newDescription);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper
|
||||||
|
title="Description"
|
||||||
|
editButton={
|
||||||
|
!isViewOnly ? ( // Conditionally render the edit button
|
||||||
|
<EditSectionButton
|
||||||
|
onClick={() => setIsDescriptionModalOpen(true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
delay={0.2}
|
||||||
|
>
|
||||||
|
<div className="text-gray-700 leading-relaxed whitespace-pre-wrap min-h-[150px] p-4 border border-gray-200 rounded-md bg-gray-50">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DescriptionModal
|
||||||
|
isOpen={isDescriptionModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||||
|
onClose={() => setIsDescriptionModalOpen(false)}
|
||||||
|
initialValue={description}
|
||||||
|
onSave={handleSave}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { EducationModal } from '../modals';
|
||||||
|
import { SectionWrapper } from '../shared/section-wrapper';
|
||||||
|
import { NotificationType } from '../modals/notification-modal';
|
||||||
|
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||||
|
|
||||||
|
interface Education {
|
||||||
|
id: string;
|
||||||
|
institution: string;
|
||||||
|
degree: string;
|
||||||
|
field: string;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EducationSectionProps {
|
||||||
|
initialEducation: Education[];
|
||||||
|
onSave: (newEducation: Education[]) => Promise<void>;
|
||||||
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isViewOnly?: boolean; // Add isViewOnly prop
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EducationSection: FC<EducationSectionProps> = ({
|
||||||
|
initialEducation,
|
||||||
|
onSave,
|
||||||
|
showNotification,
|
||||||
|
isLoading = false,
|
||||||
|
isViewOnly = false, // Default to false
|
||||||
|
}) => {
|
||||||
|
const [isEducationModalOpen, setIsEducationModalOpen] = useState(false);
|
||||||
|
const [education, setEducation] = useState<Education[]>(initialEducation);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEducation(initialEducation);
|
||||||
|
}, [initialEducation]);
|
||||||
|
|
||||||
|
const handleSave = async (newEducation: Education[]) => {
|
||||||
|
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||||
|
await onSave(newEducation);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper
|
||||||
|
title="Education"
|
||||||
|
editButton={
|
||||||
|
!isViewOnly ? ( // Conditionally render the edit button
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<EditSectionButton
|
||||||
|
onClick={() => setIsEducationModalOpen(true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
delay={0.5}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{education.map((edu) => (
|
||||||
|
<div key={edu.id} className="flex items-start gap-4 p-4 border border-gray-200 rounded-lg">
|
||||||
|
<div className="w-10 h-10 bg-gray-200 rounded-full flex-shrink-0"></div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-semibold text-gray-900">{edu.institution}</h4>
|
||||||
|
<p className="text-gray-700">{edu.degree}</p>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||||
|
<span>{edu.field}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{edu.period}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EducationModal
|
||||||
|
isOpen={isEducationModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||||
|
onClose={() => setIsEducationModalOpen(false)}
|
||||||
|
initialValue={education}
|
||||||
|
onSave={handleSave}
|
||||||
|
isLoading={isLoading}
|
||||||
|
showNotification={showNotification}
|
||||||
|
/>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { ExperienceModal } from '../modals';
|
||||||
|
import { SectionWrapper } from '../shared/section-wrapper';
|
||||||
|
import { NotificationType } from '../modals/notification-modal';
|
||||||
|
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||||
|
|
||||||
|
interface Experience {
|
||||||
|
id: string;
|
||||||
|
company: string;
|
||||||
|
position: string;
|
||||||
|
duration: string;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExperiencesSectionProps {
|
||||||
|
initialExperiences: Experience[];
|
||||||
|
onSave: (newExperiences: Experience[]) => Promise<void>;
|
||||||
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isViewOnly?: boolean; // Add isViewOnly prop
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExperiencesSection: FC<ExperiencesSectionProps> = ({
|
||||||
|
initialExperiences,
|
||||||
|
onSave,
|
||||||
|
showNotification,
|
||||||
|
isLoading = false,
|
||||||
|
isViewOnly = false, // Default to false
|
||||||
|
}) => {
|
||||||
|
const [isExperienceModalOpen, setIsExperienceModalOpen] = useState(false);
|
||||||
|
const [experiences, setExperiences] = useState<Experience[]>(initialExperiences);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExperiences(initialExperiences);
|
||||||
|
}, [initialExperiences]);
|
||||||
|
|
||||||
|
const handleSave = async (newExperiences: Experience[]) => {
|
||||||
|
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||||
|
await onSave(newExperiences);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper
|
||||||
|
title="Experiences"
|
||||||
|
editButton={
|
||||||
|
!isViewOnly ? ( // Conditionally render the edit button
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<EditSectionButton
|
||||||
|
onClick={() => setIsExperienceModalOpen(true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
delay={0.4}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{experiences.map((exp) => (
|
||||||
|
<div key={exp.id} className="flex items-start gap-4 p-4 border border-gray-200 rounded-lg">
|
||||||
|
<div className="w-10 h-10 bg-gray-200 rounded-full flex-shrink-0"></div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-semibold text-gray-900">{exp.company}</h4>
|
||||||
|
<p className="text-gray-700">{exp.position}</p>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||||
|
<span>{exp.duration}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{exp.period}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ExperienceModal
|
||||||
|
isOpen={isExperienceModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||||
|
onClose={() => setIsExperienceModalOpen(false)}
|
||||||
|
initialValue={experiences}
|
||||||
|
onSave={handleSave}
|
||||||
|
isLoading={isLoading}
|
||||||
|
showNotification={showNotification}
|
||||||
|
/>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { SocialMediaSection } from './social-media-section';
|
||||||
|
export { DescriptionSection } from './description-section';
|
||||||
|
export { CvResumeSection } from './cv-resume-section';
|
||||||
|
export { ExperiencesSection } from './experiences-section';
|
||||||
|
export { EducationSection } from './education-section';
|
||||||
|
export { PersonalInfoSection } from './personal-info-section';
|
||||||
|
export { SkillsSection } from './skills-section';
|
||||||
|
export { LanguagesSection } from './languages-section';
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { LanguagesModal } from '../modals';
|
||||||
|
import { SectionWrapper } from '../shared/section-wrapper';
|
||||||
|
import { NotificationType } from '../modals/notification-modal';
|
||||||
|
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||||
|
|
||||||
|
interface Language {
|
||||||
|
name: string;
|
||||||
|
level: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LanguagesSectionProps {
|
||||||
|
initialLanguages: Language[];
|
||||||
|
onSave: (newLanguages: Language[]) => void;
|
||||||
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LanguagesSection: FC<LanguagesSectionProps> = ({
|
||||||
|
initialLanguages,
|
||||||
|
onSave,
|
||||||
|
showNotification,
|
||||||
|
isLoading = false,
|
||||||
|
}) => {
|
||||||
|
const [isLanguagesModalOpen, setIsLanguagesModalOpen] = useState(false);
|
||||||
|
const [languages, setLanguages] = useState<Language[]>(initialLanguages);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLanguages(initialLanguages);
|
||||||
|
}, [initialLanguages]);
|
||||||
|
|
||||||
|
const handleSave = (newLanguages: Language[]) => {
|
||||||
|
|
||||||
|
|
||||||
|
onSave(newLanguages);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper
|
||||||
|
title="Languages"
|
||||||
|
editButton={
|
||||||
|
<EditSectionButton
|
||||||
|
onClick={() => setIsLanguagesModalOpen(true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
delay={0.4}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{languages.map((language) => (
|
||||||
|
<div key={language.name} className="flex justify-between items-center">
|
||||||
|
<span className="text-sm font-medium text-neutral-800">{language.name}</span>
|
||||||
|
<span className="text-xs text-neutral-600 bg-neutral-100 px-2 py-1 rounded">
|
||||||
|
{language.level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LanguagesModal
|
||||||
|
isOpen={isLanguagesModalOpen}
|
||||||
|
onClose={() => setIsLanguagesModalOpen(false)}
|
||||||
|
initialValue={languages}
|
||||||
|
onSave={handleSave}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { MailOutlined, PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
||||||
|
import { PersonalInfoModal } from '../modals';
|
||||||
|
import { SectionWrapper } from '../shared/section-wrapper';
|
||||||
|
import { NotificationType } from '../modals/notification-modal';
|
||||||
|
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||||
|
|
||||||
|
interface PersonalInfo {
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
location: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersonalInfoSectionProps {
|
||||||
|
initialContactInfo: PersonalInfo;
|
||||||
|
onSave: (newContactInfo: PersonalInfo) => Promise<void>;
|
||||||
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isViewOnly?: boolean; // Add isViewOnly prop
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PersonalInfoSection: FC<PersonalInfoSectionProps> = ({
|
||||||
|
initialContactInfo,
|
||||||
|
onSave,
|
||||||
|
showNotification,
|
||||||
|
isLoading = false,
|
||||||
|
isViewOnly = false, // Default to false
|
||||||
|
}) => {
|
||||||
|
const [isPersonalInfoModalOpen, setIsPersonalInfoModalOpen] = useState(false);
|
||||||
|
const [personalInfo, setPersonalInfo] = useState<PersonalInfo>(initialContactInfo);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPersonalInfo(initialContactInfo);
|
||||||
|
}, [initialContactInfo]);
|
||||||
|
|
||||||
|
const handleSave = async (newInfo: { email: string; phone: string; location: string }) => {
|
||||||
|
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||||
|
const newPersonalInfo = { email: newInfo.email, phone: newInfo.phone, location: newInfo.location };
|
||||||
|
|
||||||
|
await onSave(newPersonalInfo);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper
|
||||||
|
title="Personal Informations"
|
||||||
|
editButton={
|
||||||
|
!isViewOnly ? ( // Conditionally render the edit button
|
||||||
|
<EditSectionButton
|
||||||
|
onClick={() => setIsPersonalInfoModalOpen(true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
delay={0.1}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||||
|
<MailOutlined className="text-[#23A1EB] text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{personalInfo.email}</p>
|
||||||
|
<p className="text-sm text-gray-600">Email Address</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||||
|
<PhoneOutlined className="text-[#23A1EB] text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{personalInfo.phone}</p>
|
||||||
|
<p className="text-sm text-gray-600">Phone Number</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||||
|
<EnvironmentOutlined className="text-[#23A1EB] text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{personalInfo.location}</p>
|
||||||
|
<p className="text-sm text-gray-600">Location</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PersonalInfoModal
|
||||||
|
isOpen={isPersonalInfoModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||||
|
onClose={() => setIsPersonalInfoModalOpen(false)}
|
||||||
|
initialValue={personalInfo}
|
||||||
|
onSave={handleSave}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { SkillsModal } from '../modals';
|
||||||
|
import { SectionWrapper } from '../shared/section-wrapper';
|
||||||
|
import { NotificationType } from '../modals/notification-modal';
|
||||||
|
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||||
|
|
||||||
|
interface Skill {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillsSectionProps {
|
||||||
|
initialSkills: string[];
|
||||||
|
onSave: (newSkills: string[]) => Promise<void>;
|
||||||
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isViewOnly?: boolean; // Add isViewOnly prop
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SkillsSection: FC<SkillsSectionProps> = ({
|
||||||
|
initialSkills,
|
||||||
|
onSave,
|
||||||
|
showNotification,
|
||||||
|
isLoading = false,
|
||||||
|
isViewOnly = false, // Default to false
|
||||||
|
}) => {
|
||||||
|
const [isSkillsModalOpen, setIsSkillsModalOpen] = useState(false);
|
||||||
|
const [skills, setSkills] = useState<string[]>(initialSkills);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSkills(initialSkills);
|
||||||
|
}, [initialSkills]);
|
||||||
|
|
||||||
|
const handleSave = async (newSkills: Skill[]) => {
|
||||||
|
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||||
|
const stringSkills = newSkills.map(skill => skill.name);
|
||||||
|
|
||||||
|
await onSave(stringSkills);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper
|
||||||
|
title="Skills"
|
||||||
|
editButton={
|
||||||
|
!isViewOnly ? ( // Conditionally render the edit button
|
||||||
|
<EditSectionButton
|
||||||
|
onClick={() => setIsSkillsModalOpen(true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
delay={0.3}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<span
|
||||||
|
key={skill}
|
||||||
|
className="inline-block px-3 py-1 bg-[#23A1EB]/10 text-[#23A1EB] text-sm rounded-md border border-[#23A1EB]/20"
|
||||||
|
>
|
||||||
|
{skill}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SkillsModal
|
||||||
|
isOpen={isSkillsModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||||
|
onClose={() => setIsSkillsModalOpen(false)}
|
||||||
|
initialValue={skills.map(skill => ({ id: skill, name: skill }))}
|
||||||
|
onSave={handleSave}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { SocialMediaModal } from '../modals';
|
||||||
|
import { SectionWrapper } from '../shared/section-wrapper';
|
||||||
|
import { NotificationType } from '../modals/notification-modal';
|
||||||
|
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||||
|
|
||||||
|
interface SocialLink {
|
||||||
|
platform: string;
|
||||||
|
placeholder: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SocialMediaSectionProps {
|
||||||
|
initialSocialLinks: SocialLink[];
|
||||||
|
onSave: (newSocialLinks: SocialLink[]) => Promise<void>;
|
||||||
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SocialMediaSection: FC<SocialMediaSectionProps> = ({
|
||||||
|
initialSocialLinks,
|
||||||
|
onSave,
|
||||||
|
showNotification,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
|
const [isSocialMediaModalOpen, setIsSocialMediaModalOpen] = useState(false);
|
||||||
|
const [socialLinks, setSocialLinks] = useState<SocialLink[]>(initialSocialLinks);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSocialLinks(initialSocialLinks);
|
||||||
|
}, [initialSocialLinks]);
|
||||||
|
|
||||||
|
const handleSave = async (newSocialLinks: SocialLink[]) => {
|
||||||
|
|
||||||
|
await onSave(newSocialLinks);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWrapper
|
||||||
|
title="Social Media"
|
||||||
|
editButton={
|
||||||
|
<EditSectionButton onClick={() => setIsSocialMediaModalOpen(true)} />
|
||||||
|
}
|
||||||
|
delay={0.1}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{socialLinks.map((link) => (
|
||||||
|
<div key={link.platform} className="border border-gray-200 rounded-md p-4 bg-gray-50">
|
||||||
|
<div className="text-sm font-medium text-gray-700 mb-2">{link.platform}</div>
|
||||||
|
<div className="text-gray-900 text-sm">
|
||||||
|
{link.value || <span className="text-gray-400 italic">{link.placeholder}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SocialMediaModal
|
||||||
|
isOpen={isSocialMediaModalOpen}
|
||||||
|
onClose={() => setIsSocialMediaModalOpen(false)}
|
||||||
|
initialValue={socialLinks}
|
||||||
|
onSave={handleSave}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { FC, useRef, useState } from 'react';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { UploadOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface FileUploaderProps {
|
||||||
|
accept?: string;
|
||||||
|
maxSize?: number;
|
||||||
|
onFileSelect?: (file: File) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
dragAndDrop?: boolean;
|
||||||
|
buttonText?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileUploader: FC<FileUploaderProps> = ({
|
||||||
|
accept = "*/*",
|
||||||
|
maxSize = 10 * 1024 * 1024,
|
||||||
|
onFileSelect,
|
||||||
|
isLoading = false,
|
||||||
|
className = "",
|
||||||
|
children,
|
||||||
|
dragAndDrop = false,
|
||||||
|
buttonText = "Choose File",
|
||||||
|
description = "Click to select a file"
|
||||||
|
}) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const validateFile = (file: File): string | null => {
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return `File size must be less than ${Math.round(maxSize / (1024 * 1024))}MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accept !== "*/*" && !accept.split(',').some(type => {
|
||||||
|
const trimmedType = type.trim();
|
||||||
|
if (trimmedType.startsWith('.')) {
|
||||||
|
return file.name.toLowerCase().endsWith(trimmedType.toLowerCase());
|
||||||
|
}
|
||||||
|
return new RegExp(trimmedType.replace('*', '.*')).exec(file.type);
|
||||||
|
})) {
|
||||||
|
return `File type not supported. Accepted types: ${accept}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (file: File) => {
|
||||||
|
const error = validateFile(file);
|
||||||
|
if (error) {
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileSelect?.(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileSelect(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileSelect(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerFileSelect = () => {
|
||||||
|
if (!isLoading) {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (children) {
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||||
|
disabled={isLoading}
|
||||||
|
title="Select file to upload"
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragAndDrop) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="hidden"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`w-full border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||||
|
isDragging
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'
|
||||||
|
} ${isLoading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={triggerFileSelect}
|
||||||
|
disabled={isLoading}
|
||||||
|
aria-label="Upload file by clicking or drag and drop"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<LoadingOutlined className="text-2xl text-blue-500 mb-2" />
|
||||||
|
<p className="text-sm text-gray-600">Uploading...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UploadOutlined className="text-2xl text-gray-400 mb-2" />
|
||||||
|
<p className="text-sm text-gray-600 mb-1">{description}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{accept === "*/*" ? "Any file type" : accept} • Max {Math.round(maxSize / (1024 * 1024))}MB
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="hidden"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={triggerFileSelect}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<LoadingOutlined className="mr-2" />
|
||||||
|
Uploading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UploadOutlined className="mr-2" />
|
||||||
|
{buttonText}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SectionWrapper } from './section-wrapper';
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { FC, ReactElement, ReactNode } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface SectionWrapperProps {
|
||||||
|
title: string;
|
||||||
|
editButton?: ReactElement;
|
||||||
|
children: ReactNode;
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SectionWrapper: FC<SectionWrapperProps> = ({ title, editButton, children, delay = 0 }): ReactElement => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-lg p-6 shadow-lg hover:shadow-xl transition-shadow duration-300"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||||
|
{editButton}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FC, ReactElement, useState } from 'react';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
return (
|
||||||
|
<ProfileProvider profileType="user">
|
||||||
|
<ProfileContent />
|
||||||
|
</ProfileProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProfileContent: FC = (): ReactElement => {
|
||||||
|
const { isLoading, error } = 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 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
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">Please try again later.</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">Your Profile</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} />
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-8 order-1">
|
||||||
|
<ProfileForm showNotification={showNotification} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-4 order-2">
|
||||||
|
<ProfileSidebar showNotification={showNotification} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NotificationModal
|
||||||
|
isOpen={notification.isOpen}
|
||||||
|
onClose={hideNotification}
|
||||||
|
type={notification.type}
|
||||||
|
title={notification.title}
|
||||||
|
message={notification.message}
|
||||||
|
header="Profile"
|
||||||
|
/>
|
||||||
|
{}
|
||||||
|
<EditProfileModal
|
||||||
|
isOpen={isEditProfileModalOpen}
|
||||||
|
onClose={closeEditProfileModal}
|
||||||
|
showNotification={showNotification}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
|
export interface GoogleLoginResponse {
|
||||||
|
access_token?: string;
|
||||||
|
token?: string | {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
};
|
||||||
|
accessToken?: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
fullname: string;
|
||||||
|
avatar: string;
|
||||||
|
birthdate?: string;
|
||||||
|
gender?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
phone_number?: string;
|
||||||
|
role?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
permissions: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGoogleLogin = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setSession } = useAuthStore();
|
||||||
|
|
||||||
|
const handleGoogleLogin = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:4099';
|
||||||
|
const callbackUrl = `${window.location.origin}/auth/google-oauth-popup`;
|
||||||
|
|
||||||
|
let authUrl;
|
||||||
|
if (baseUrl.endsWith('/v1')) {
|
||||||
|
authUrl = `${baseUrl}/auth/google/login?redirect_uri=${encodeURIComponent(callbackUrl)}`;
|
||||||
|
} else {
|
||||||
|
authUrl = `${baseUrl}/v1/auth/google/login?redirect_uri=${encodeURIComponent(callbackUrl)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const popup = window.open(
|
||||||
|
authUrl,
|
||||||
|
'google-oauth',
|
||||||
|
'width=500,height=600,scrollbars=yes,resizable=yes'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!popup) {
|
||||||
|
toast.error('Popup diblokir. Silakan aktifkan popup untuk situs ini.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
if (event.origin !== window.location.origin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.type === 'GOOGLE_OAUTH_SUCCESS') {
|
||||||
|
const { payload } = event.data as { payload: GoogleLoginResponse };
|
||||||
|
|
||||||
|
const tokenObj = typeof payload.token === 'object' ? payload.token : null;
|
||||||
|
const accessToken = tokenObj?.access_token || payload.access_token;
|
||||||
|
const refreshToken = tokenObj?.refresh_token || payload.refresh_token;
|
||||||
|
const user = payload.user;
|
||||||
|
|
||||||
|
if (accessToken && refreshToken && user && typeof accessToken === 'string' && typeof refreshToken === 'string') {
|
||||||
|
// Convert Google user data to match TUserItem structure
|
||||||
|
const convertedUser = {
|
||||||
|
id: user.id,
|
||||||
|
avatar: user.avatar || '',
|
||||||
|
birthdate: user.birthdate || '',
|
||||||
|
email: user.email,
|
||||||
|
fullname: user.fullname,
|
||||||
|
gender: user.gender || '',
|
||||||
|
is_active: user.is_active ?? true,
|
||||||
|
phone_number: user.phone_number || '',
|
||||||
|
role: user.role || {
|
||||||
|
id: '',
|
||||||
|
name: 'User',
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
permissions: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use setSession like credential login does
|
||||||
|
setSession({
|
||||||
|
token: {
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
},
|
||||||
|
user: convertedUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success('Login berhasil!');
|
||||||
|
navigate(0); // Same as credential login
|
||||||
|
} else {
|
||||||
|
toast.error('Data login tidak lengkap');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
} else if (event.data.type === 'GOOGLE_OAUTH_ERROR') {
|
||||||
|
const { error } = event.data;
|
||||||
|
toast.error(`Login gagal: ${error}`);
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
toast.error('Login timeout. Silakan coba lagi.');
|
||||||
|
}, 300000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Google login error:', error);
|
||||||
|
toast.error('Terjadi kesalahan saat login dengan Google');
|
||||||
|
}
|
||||||
|
}, [navigate, setSession]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleGoogleLogin,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { FC, ReactElement, useEffect } from 'react';
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useGoogleCallback, useAuthStore } from '@imphnen-frontend-service/service';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export const GoogleCallbackPage: FC = (): ReactElement => {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setSession, clearSession } = useAuthStore();
|
||||||
|
const { mutate: googleCallback } = useGoogleCallback();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleCallback = async () => {
|
||||||
|
const code = searchParams.get('code');
|
||||||
|
const state = searchParams.get('state');
|
||||||
|
const error = searchParams.get('error');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error('Google login dibatalkan atau terjadi kesalahan');
|
||||||
|
navigate('/auth/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
toast.error('Parameter login Google tidak valid');
|
||||||
|
navigate('/auth/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
googleCallback(
|
||||||
|
{ code, state },
|
||||||
|
{
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (response.token && response.user) {
|
||||||
|
setSession({
|
||||||
|
token: response.token,
|
||||||
|
user: response.user,
|
||||||
|
});
|
||||||
|
toast.success('Login Google berhasil!');
|
||||||
|
navigate('/dashboard');
|
||||||
|
} else {
|
||||||
|
throw new Error('Response data tidak valid');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Google OAuth callback error:', error);
|
||||||
|
toast.error('Login Google gagal');
|
||||||
|
clearSession();
|
||||||
|
navigate('/auth/login');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Google OAuth callback error:', error);
|
||||||
|
toast.error('Login Google gagal');
|
||||||
|
clearSession();
|
||||||
|
navigate('/auth/login');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCallback();
|
||||||
|
}, [searchParams, navigate, setSession, clearSession, googleCallback]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-500 mx-auto mb-4"></div>
|
||||||
|
<h2 className="text-2xl font-semibold text-primary-500 mb-2">
|
||||||
|
Menyelesaikan Login Google...
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Mohon tunggu sebentar, kami sedang memproses login Anda.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GoogleCallbackPage;
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { FC, ReactElement, useEffect } from 'react';
|
||||||
|
|
||||||
|
let globalIsProcessed = false;
|
||||||
|
|
||||||
|
export const GoogleOAuthPopupPage: FC = (): ReactElement => {
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (globalIsProcessed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callBackend = async (code: string, state: string) => {
|
||||||
|
if (globalIsProcessed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalIsProcessed = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:4099';
|
||||||
|
let callbackUrl;
|
||||||
|
|
||||||
|
if (baseUrl.endsWith('/v1')) {
|
||||||
|
callbackUrl = `${baseUrl}/auth/google/callback`;
|
||||||
|
} else {
|
||||||
|
callbackUrl = `${baseUrl}/v1/auth/google/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(callbackUrl);
|
||||||
|
url.searchParams.append('code', code);
|
||||||
|
url.searchParams.append('state', state);
|
||||||
|
url.searchParams.append('redirect_uri', `${window.location.origin}/auth/google-oauth-popup`);
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
mode: 'cors',
|
||||||
|
credentials: 'omit',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
window.opener?.postMessage(
|
||||||
|
{
|
||||||
|
type: 'GOOGLE_OAUTH_SUCCESS',
|
||||||
|
payload: data,
|
||||||
|
},
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
window.close();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
globalIsProcessed = false;
|
||||||
|
|
||||||
|
window.opener?.postMessage(
|
||||||
|
{
|
||||||
|
type: 'GOOGLE_OAUTH_ERROR',
|
||||||
|
error: `Failed to process OAuth callback: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
},
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOAuthResponse = () => {
|
||||||
|
const isPopup = window.opener && window.opener !== window;
|
||||||
|
|
||||||
|
const detectJsonResponse = () => {
|
||||||
|
try {
|
||||||
|
const bodyText = document.body.innerText || document.body.textContent || '';
|
||||||
|
const trimmedText = bodyText.trim();
|
||||||
|
|
||||||
|
if (trimmedText.startsWith('{') && trimmedText.endsWith('}')) {
|
||||||
|
const parsedJson = JSON.parse(trimmedText);
|
||||||
|
|
||||||
|
if (parsedJson && typeof parsedJson === 'object') {
|
||||||
|
const hasAccessToken = parsedJson.access_token || parsedJson.token || parsedJson.accessToken;
|
||||||
|
|
||||||
|
if (hasAccessToken) {
|
||||||
|
return parsedJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkOAuthParams = () => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const code = urlParams.get('code');
|
||||||
|
const state = urlParams.get('state');
|
||||||
|
const error = urlParams.get('error');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (isPopup) {
|
||||||
|
window.opener?.postMessage(
|
||||||
|
{
|
||||||
|
type: 'GOOGLE_OAUTH_ERROR',
|
||||||
|
error: error,
|
||||||
|
},
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code && state) {
|
||||||
|
if (isPopup) {
|
||||||
|
callBackend(code, state);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const immediateJson = detectJsonResponse();
|
||||||
|
if (immediateJson && isPopup) {
|
||||||
|
window.opener?.postMessage(
|
||||||
|
{
|
||||||
|
type: 'GOOGLE_OAUTH_SUCCESS',
|
||||||
|
payload: immediateJson,
|
||||||
|
},
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
window.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkOAuthParams()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 50;
|
||||||
|
|
||||||
|
const checkForJson = () => {
|
||||||
|
attempts++;
|
||||||
|
const jsonResponse = detectJsonResponse();
|
||||||
|
|
||||||
|
if (jsonResponse && isPopup) {
|
||||||
|
window.opener?.postMessage(
|
||||||
|
{
|
||||||
|
type: 'GOOGLE_OAUTH_SUCCESS',
|
||||||
|
payload: jsonResponse,
|
||||||
|
},
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
window.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts < maxAttempts) {
|
||||||
|
setTimeout(checkForJson, 500);
|
||||||
|
} else if (isPopup) {
|
||||||
|
window.opener?.postMessage(
|
||||||
|
{
|
||||||
|
type: 'GOOGLE_OAUTH_ERROR',
|
||||||
|
error: 'Timeout waiting for response',
|
||||||
|
},
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(checkForJson, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Small delay to ensure DOM is ready
|
||||||
|
setTimeout(handleOAuthResponse, 100);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-primary-500 mx-auto mb-4"></div>
|
||||||
|
<h2 className="text-lg font-semibold text-primary-500 mb-2">
|
||||||
|
Memproses Login Google...
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Jangan tutup jendela ini.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GoogleOAuthPopupPage;
|
||||||
@@ -2,10 +2,12 @@ import { FC, ReactElement } from 'react';
|
|||||||
import { ControlledInputField, LoginBanner } from '@imphnen-frontend-service/ui/organisms';
|
import { ControlledInputField, LoginBanner } from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { useLogin } from '../../_hooks/use-login';
|
import { useLogin } from '../../_hooks/use-login';
|
||||||
|
import { useGoogleLogin } from '../../_hooks/use-google-login';
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
export const Components: FC = (): ReactElement => {
|
export const Components: FC = (): ReactElement => {
|
||||||
const { form, onSubmit, isLoading } = useLogin();
|
const { form, onSubmit, isLoading } = useLogin();
|
||||||
|
const { handleGoogleLogin } = useGoogleLogin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
|
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
|
||||||
@@ -25,8 +27,8 @@ export const Components: FC = (): ReactElement => {
|
|||||||
label="Email"
|
label="Email"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full mb-2"
|
className="w-full mb-2"
|
||||||
placeholder="Masukkan email-mu, Senpai~! ✨ (Pastikan tidak typo, ya~ 😆)"
|
placeholder="Masukkan email-mu, Senpai~! ✨ (Pastikan tidak typo, ya~ 😆)"
|
||||||
name={'email'}
|
name={'email'}
|
||||||
/>
|
/>
|
||||||
<ControlledInputField
|
<ControlledInputField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -44,7 +46,7 @@ export const Components: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
<Button className="w-full" type='submit' disabled={(!form.formState.isValid || isLoading)}>Enter Isekai</Button>
|
<Button className="w-full" type='submit' disabled={(!form.formState.isValid || isLoading)}>Enter Isekai</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="flex my-3 gap-3 justify-center">
|
<div className="flex my-3 gap-3 justify-center">
|
||||||
<h5>Belum Punya akun ?</h5>
|
<h5>Belum Punya akun ?</h5>
|
||||||
<a href="/auth/register" className="text-primary-500 font-medium">
|
<a href="/auth/register" className="text-primary-500 font-medium">
|
||||||
@@ -64,6 +66,7 @@ export const Components: FC = (): ReactElement => {
|
|||||||
<Button
|
<Button
|
||||||
className="w-full my-3 text-gray-500 gap-2"
|
className="w-full my-3 text-gray-500 gap-2"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
onClick={handleGoogleLogin}
|
||||||
>
|
>
|
||||||
<p>Log In With Google</p>
|
<p>Log In With Google</p>
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -10,17 +10,17 @@ const mappingPublicRoutes = [
|
|||||||
'/auth/login',
|
'/auth/login',
|
||||||
'/auth/forgot',
|
'/auth/forgot',
|
||||||
'/auth/forgot/otp',
|
'/auth/forgot/otp',
|
||||||
'/auth/register',
|
'/auth/register',
|
||||||
'/auth/register/otp',
|
'/auth/register/otp',
|
||||||
'/auth/register/success',
|
'/auth/register/success',
|
||||||
'/auth/new-password',
|
'/auth/new-password',
|
||||||
'/auth/register-mentor',
|
'/auth/register-mentor',
|
||||||
'/auth/register-mentor/pending',
|
'/auth/register-mentor/pending',
|
||||||
'/auth/register-mentor/success',
|
'/auth/register-mentor/success',
|
||||||
|
'/auth/google-callback',
|
||||||
|
'/auth/google-oauth-popup',
|
||||||
'/resources',
|
'/resources',
|
||||||
];
|
];const mappingRoutePermissions = [
|
||||||
|
|
||||||
const mappingRoutePermissions = [
|
|
||||||
{
|
{
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
permissions: [],
|
permissions: [],
|
||||||
@@ -96,7 +96,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
const token = session_token?.token?.access_token;
|
const token = session_token?.token?.access_token;
|
||||||
const userPermissions =
|
const userPermissions =
|
||||||
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||||
|
|
||||||
// Allow to access the landing page without authentication
|
// Allow to access the landing page without authentication
|
||||||
// So, if the route prefix is in the mappingPublicPrefixRoutes, we return null
|
// So, if the route prefix is in the mappingPublicPrefixRoutes, we return null
|
||||||
// to indicate that we don't need to authenticate the user
|
// to indicate that we don't need to authenticate the user
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3,9 +3,9 @@ import {
|
|||||||
authLoginSchema,
|
authLoginSchema,
|
||||||
TLoginRequest,
|
TLoginRequest,
|
||||||
usePostLogin,
|
usePostLogin,
|
||||||
|
useAuthStore,
|
||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useAuthStore } from '@imphnen-frontend-service/utils';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useVerifyEmail } from './use-verify-email';
|
import { useVerifyEmail } from './use-verify-email';
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Tue, Nov 25, 2025 16:42:27 PM
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import nx from '@nx/eslint-plugin';
|
||||||
|
import baseConfig from '../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...baseConfig,
|
||||||
|
...nx.configs['flat/react'],
|
||||||
|
{
|
||||||
|
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
||||||
|
// Override or add rules here
|
||||||
|
rules: {
|
||||||
|
'jsx-a11y/accessible-emoji': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>IMPHNEN x Kolosal.ai Hackathon 2025</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Ikuti IMPHNEN x Kolosal.ai Hackathon 2025! Kompetisi coding online gratis dengan total hadiah Rp14.500.000. Tema: Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif."
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="keywords"
|
||||||
|
content="hackathon, lomba coding, kompetisi it, imphnen, kolosal.ai, ai hackathon, lomba programming 2025"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
href="/images/imphnen-logo-simple.svg"
|
||||||
|
/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="/src/index.css" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://hackathon.imphnen.dev/" />
|
||||||
|
<meta property="og:title" content="IMPHNEN x Kolosal.ai Hackathon 2025" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="Total hadiah Rp14.500.000! Daftar sekarang dan wujudkan inovasi AI-mu untuk membantu usaha lokal."
|
||||||
|
/>
|
||||||
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
|
<meta property="twitter:url" content="https://hackathon.imphnen.dev/" />
|
||||||
|
<meta
|
||||||
|
property="twitter:title"
|
||||||
|
content="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {
|
||||||
|
base: join(import.meta.dirname, '../../'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user