Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38ec9a28f7 | ||
|
|
97a3ac90eb | ||
|
|
8f8c720632 |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx nx run-many:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npm view:*)",
|
||||
"Bash(npx nx build landing)",
|
||||
"Bash(npm uninstall:*)",
|
||||
"Bash(dir:*)",
|
||||
"Bash(ren page.tsx page-original.tsx)",
|
||||
"Bash(ren:*)",
|
||||
"Bash(npx supabase:*)",
|
||||
"Bash(libs/service/src/types/supabase.ts)",
|
||||
"Bash(npx nx build:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(ls:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
.next
|
||||
out
|
||||
build
|
||||
coverage
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Nx
|
||||
.nx
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# Misc
|
||||
*.md
|
||||
!README.md
|
||||
.editorconfig
|
||||
.prettierrc
|
||||
.eslintrc
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
VITE_SUPABASE_URL=https://your-project-id.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=your-anon-key-here
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Deploy Backoffice
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
|
||||
else
|
||||
cd ~/imphnen-frontend-service-backoffice && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
cat > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env << EOF
|
||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
|
||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
|
||||
docker image prune -af
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Deploy Dimentorin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
|
||||
else
|
||||
cd ~/imphnen-frontend-service-dimentorin && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
cat > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env << EOF
|
||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
|
||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
|
||||
docker image prune -af
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Deploy Gacha
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service-gacha ]; then
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
|
||||
else
|
||||
cd ~/imphnen-frontend-service-gacha && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
cat > ~/imphnen-frontend-service-gacha/apps/gacha/.env << EOF
|
||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
|
||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
|
||||
docker image prune -af
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Deploy Hackathon
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service-hackathon ]; then
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-hackathon
|
||||
else
|
||||
cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
cat > ~/imphnen-frontend-service-hackathon/.env.local << EOF
|
||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true
|
||||
docker builder prune -af || true
|
||||
DOCKER_BUILDKIT=1 docker compose --env-file ~/imphnen-frontend-service-hackathon/.env.local -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml up -d
|
||||
docker image prune -af
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Deploy Landing
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service ]; then
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
|
||||
else
|
||||
cd ~/imphnen-frontend-service && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
cat > ~/imphnen-frontend-service/apps/landing/.env << EOF
|
||||
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
|
||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
|
||||
docker image prune -af
|
||||
@@ -1,109 +0,0 @@
|
||||
name: Nix Build & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['develop']
|
||||
pull_request:
|
||||
branches: ['develop']
|
||||
|
||||
jobs:
|
||||
detect:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
affected: ${{ steps.affected.outputs.apps }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Detect affected apps
|
||||
id: affected
|
||||
run: |
|
||||
AFFECTED=$(npx nx show projects --affected --base=HEAD~1 --type=app 2>/dev/null | tr '\n' ' ')
|
||||
echo "Affected apps: $AFFECTED"
|
||||
APPS="[]"
|
||||
for app in landing backoffice gacha dimentorin hackathon infra qrcampaign; do
|
||||
if echo "$AFFECTED" | grep -qw "$app"; then
|
||||
APPS=$(echo "$APPS" | jq -c ". + [\"$app\"]")
|
||||
fi
|
||||
done
|
||||
echo "apps=$APPS" >> "$GITHUB_OUTPUT"
|
||||
echo "Matrix: $APPS"
|
||||
|
||||
build:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.affected != '[]'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
app: ${{ fromJson(needs.detect.outputs.affected) }}
|
||||
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 ${{ matrix.app }}
|
||||
run: nix build .#${{ matrix.app }} -o result-${{ matrix.app }}
|
||||
|
||||
- name: Push to Cachix
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
|
||||
run: cachix push msdqn result-${{ matrix.app }}
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
|
||||
- name: Setup SSH
|
||||
env:
|
||||
INFRA_DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${INFRA_DEPLOY_KEY}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||
ssh-keyscan 167.235.70.37 >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Update infra flake.lock
|
||||
run: |
|
||||
export GIT_SSH_COMMAND="ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes"
|
||||
git clone git@github.com:IMPHNEN/imphnen-infrastructure.git /tmp/infra
|
||||
cd /tmp/infra
|
||||
nix flake update imphnen-frontend
|
||||
if git diff --quiet flake.lock; then
|
||||
echo "flake.lock unchanged, skipping"
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add flake.lock
|
||||
git commit -m "chore: update imphnen-frontend-service to ${GITHUB_SHA::7}"
|
||||
git push
|
||||
fi
|
||||
|
||||
- name: Deploy to server
|
||||
continue-on-error: true
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key -o ConnectTimeout=15 -o StrictHostKeyChecking=accept-new root@167.235.70.37 \
|
||||
'nixos-rebuild switch --flake github:IMPHNEN/imphnen-infrastructure#hetzner --refresh 2>&1 | tail -30'
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/deploy_key
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Test and Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['develop']
|
||||
pull_request:
|
||||
branches: ['develop']
|
||||
|
||||
env:
|
||||
NODE_VERSION: 22.14.0
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js ${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Set env vars
|
||||
run: |
|
||||
echo "CMS_SECRET=${{ secrets.CMS_SECRET }}" >> $GITHUB_ENV
|
||||
echo "CMS_STORAGE_REGION=${{ secrets.CMS_STORAGE_REGION }}" >> $GITHUB_ENV
|
||||
echo "CMS_STORAGE_ENDPOINT=${{ secrets.CMS_STORAGE_ENDPOINT }}" >> $GITHUB_ENV
|
||||
echo "CMS_STORAGE_ACCESS_KEY_ID=${{ secrets.CMS_STORAGE_ACCESS_KEY_ID }}" >> $GITHUB_ENV
|
||||
echo "CMS_STORAGE_SECRET_ACCESS_KEY=${{ secrets.CMS_STORAGE_SECRET_ACCESS_KEY }}" >> $GITHUB_ENV
|
||||
echo "CMS_POSTGRES_URL=${{ secrets.CMS_POSTGRES_URL }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Nx Build
|
||||
run: npx nx run-many --target=build --all
|
||||
+1
-15
@@ -4,7 +4,7 @@
|
||||
dist
|
||||
tmp
|
||||
out-tsc
|
||||
docs/FigmaImage/
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
@@ -66,17 +66,3 @@ out
|
||||
.github/instructions/nx.instructions.md
|
||||
|
||||
.env.local
|
||||
|
||||
# Nix
|
||||
.direnv
|
||||
result
|
||||
|
||||
.claude/worktrees
|
||||
.claude/settings.local.json
|
||||
|
||||
# Screenshots and Test Reports
|
||||
**/screenshots
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"nx-mcp": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["nx", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# path to a directory with all packages
|
||||
storage: ../tmp/local-registry/storage
|
||||
|
||||
# a list of other known repositories we can talk to
|
||||
uplinks:
|
||||
npmjs:
|
||||
url: https://registry.npmjs.org/
|
||||
maxage: 60m
|
||||
|
||||
packages:
|
||||
'**':
|
||||
# give all users (including non-authenticated users) full access
|
||||
# because it is a local registry
|
||||
access: $all
|
||||
publish: $all
|
||||
unpublish: $all
|
||||
|
||||
# if package is not available locally, proxy requests to npm registry
|
||||
proxy: npmjs
|
||||
|
||||
# log settings
|
||||
log:
|
||||
type: stdout
|
||||
format: pretty
|
||||
level: warn
|
||||
|
||||
publish:
|
||||
allow_offline: true # set offline to true to allow publish offline
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"nrwl.angular-console",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"ms-playwright.playwright"
|
||||
]
|
||||
}
|
||||
@@ -1,93 +1,13 @@
|
||||
# IMPHNEN Frontend Service
|
||||
<!-- nx configuration start-->
|
||||
<!-- Leave the start & end comments to automatically receive updates. -->
|
||||
|
||||
Nx monorepo for IMPHNEN (Ingin Menjadi Programmer Handal Namun Enggan Ngoding) — Indonesia's largest programmer community.
|
||||
# General Guidelines for working with Nx
|
||||
|
||||
## Tech Stack
|
||||
- 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
|
||||
|
||||
- **Monorepo**: Nx 22.6
|
||||
- **Framework**: Next.js 16 (landing), Vite + React 19 (all other apps)
|
||||
- **Routing**: react-router v7 (Vite apps), Next.js App Router (landing)
|
||||
- **Styling**: Tailwind CSS v4, class-variance-authority (CVA)
|
||||
- **State**: Zustand, TanStack React Query
|
||||
- **Forms**: react-hook-form + zod
|
||||
- **Build/Deploy**: Nix flakes, Cachix binary cache, NixOS modules
|
||||
- **CI/CD**: GitHub Actions with `nx affected` + matrix strategy
|
||||
- **Node**: v22
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Apps
|
||||
| App | Framework | Description |
|
||||
|-----|-----------|-------------|
|
||||
| `landing` | Next.js 16 | Public website |
|
||||
| `backoffice` | Vite + React | Admin dashboard |
|
||||
| `hackathon` | Vite + React | Hackathon platform |
|
||||
| `dimentorin` | Vite + React | Mentoring platform |
|
||||
| `gacha` | Vite + React | Merch gacha system |
|
||||
| `qrcampaign` | Vite + React | QR campaign tool |
|
||||
| `infra` | Vite + React | Infrastructure dashboard |
|
||||
|
||||
### Shared Libraries
|
||||
| Lib | Purpose | Depends on |
|
||||
|-----|---------|------------|
|
||||
| `utils` | Pure utilities only: `cn`, `For`, `Show`, `useQueryState`, `useModalLogin`, react-query helpers, react-router file-based routing | nothing |
|
||||
| `service` | Business logic: API clients, auth hooks, storage (SessionToken/SessionUser), constants (PERMISSIONS, cities) | `utils` |
|
||||
| `ui` | UI components: atoms (Button, Input, Card, Dialog, Drawer, Form, Label), molecules, organisms (Navbar, Sidebar, Datatable) | `utils`, `service` |
|
||||
|
||||
**Dependency rule**: `ui` → `service` → `utils` (never the reverse)
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Dev
|
||||
nx dev <app> # Start dev server
|
||||
nx build <app> # Build single app
|
||||
nx run-many -t build --all # Build everything
|
||||
|
||||
# Test
|
||||
nx test <lib> # Run unit tests (vitest)
|
||||
nx e2e <app>-e2e # Run e2e tests (playwright)
|
||||
nx lint <project> # Lint
|
||||
|
||||
# Build all affected
|
||||
nx affected -t build # Only build what changed
|
||||
nx affected -t test # Only test what changed
|
||||
|
||||
# Nix
|
||||
nix build .#<app> # Build Nix package for an app
|
||||
nix develop # Enter dev shell (node 22, bun, git, jq)
|
||||
```
|
||||
|
||||
## Nix / Deployment
|
||||
|
||||
All Nix config is in a single `flake.nix`:
|
||||
- `mkViteApp`: Builds Vite apps as Nix packages
|
||||
- `mkLandingApp`: Builds the Next.js landing app
|
||||
- `mkLandingModule`: NixOS module (systemd service for Next.js)
|
||||
- `mkStaticAppModule`: NixOS module (nginx for static Vite apps)
|
||||
- `npmDepsHash`: Must be updated when `package-lock.json` changes. Use `lib.fakeHash` to get the new hash from a failed build.
|
||||
|
||||
## CI/CD Pipeline (.github/workflows/nix-build.yml)
|
||||
|
||||
1. **detect**: Uses `nx affected` to find changed apps
|
||||
2. **build**: Matrix strategy builds only affected apps with Nix, pushes to Cachix
|
||||
3. **deploy**: Clones `imphnen-infrastructure`, updates `flake.lock`, pushes, then runs `clan machines update hetzner` to deploy to the Hetzner server
|
||||
|
||||
Required GitHub secrets: `CACHIX_AUTH_TOKEN`, `INFRA_DEPLOY_KEY` (SSH key for both GitHub and server access)
|
||||
|
||||
The server (167.235.70.37) pulls pre-built packages from Cachix during `nixos-rebuild`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- Vite apps: `VITE_API_URL`, `VITE_GITHUB_CLIENT_ID`
|
||||
- Next.js (landing): `NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_GITHUB_CLIENT_ID`
|
||||
- The `getBaseURL()` function in `libs/service/src/api/index.ts` handles both environments
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- Atomic design: atoms → molecules → organisms
|
||||
- Components have their own folder with `component.tsx`, `index.ts`, `spec.tsx`, `stories.tsx`
|
||||
- Use `cn()` from `@imphnen-frontend-service/utils` for className merging
|
||||
- Use `'use client'` directive on any component using React hooks (for Next.js compatibility)
|
||||
- Button variants via CVA: `primary`, `secondary`, `text`, `bordered`, `success`, `danger`
|
||||
- Landing app uses `container` class — Tailwind v4 requires explicit `margin-inline: auto` (defined in globals.css)
|
||||
<!-- nx configuration end-->
|
||||
|
||||
@@ -4,117 +4,135 @@
|
||||
<img src="docs/logo.svg" alt="IMPHNEN">
|
||||
</p>
|
||||
|
||||
Monorepo for all frontend services of [IMPHNEN](https://imphnen.dev) (Ingin Menjadi Programmer Handal Namun Enggan Ngoding) — Indonesia's largest programmer community.
|
||||
This repository is a **monorepo** for all frontend services of IMPHNEN. The monorepo includes three main applications:
|
||||
|
||||
## Apps
|
||||
1. **Gacha** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Gacha Website</a>.
|
||||
2. **Backoffice** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Internal Management Website</a>.
|
||||
3. **Dimentorin** - Application for <a href="https://dimentorin.imphnen.dev/" target="_blank">Mentoring Service</a>.
|
||||
4. **Landing Page** - Application for <a href="https://imphnen.dev/" target="_blank">Landing Page</a>.
|
||||
|
||||
| App | Framework | URL |
|
||||
|-----|-----------|-----|
|
||||
| **Landing** | Next.js 16 | [imphnen.dev](https://imphnen.dev) |
|
||||
| **Backoffice** | Vite + React | [backoffice.imphnen.dev](https://backoffice.imphnen.dev) |
|
||||
| **Hackathon** | Vite + React | [hackathon.imphnen.dev](https://dimentorin.imphnen.dev) |
|
||||
| **Dimentorin** | Vite + React | [dimentorin.imphnen.dev](https://dimentorin.imphnen.dev) |
|
||||
| **Gacha** | Vite + React | [gacha.imphnen.dev](https://gacha.imphnen.dev) |
|
||||
| **QR Campaign** | Vite + React | [qr.imphnen.dev](https://qr.imphnen.dev) |
|
||||
| **Infra** | Vite + React | [infra.imphnen.dev](https://infra.imphnen.dev) |
|
||||
## How to install
|
||||
|
||||
## Shared Libraries
|
||||
1. Clone this repository:
|
||||
```sh
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git
|
||||
cd imphnen-frontend-service
|
||||
```
|
||||
2. Install all dependencies:
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
| Lib | Purpose |
|
||||
|-----|---------|
|
||||
| `utils` | Pure utilities — `cn()`, `For`, `Show`, `useQueryState`, `useModalLogin` |
|
||||
| `service` | Business logic — API clients, auth hooks, storage, constants |
|
||||
| `ui` | UI components — atoms, molecules, organisms (atomic design) |
|
||||
## How to run
|
||||
|
||||
## Getting Started
|
||||
### Setup Environment Variables
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 22
|
||||
- [Nix](https://nixos.org/download/) (optional, for reproducible builds)
|
||||
|
||||
### Setup
|
||||
Before running the applications, you need to set up the environment variables. You can do this by copying the `.env.example` file to `.env` and modifying the values according to your needs.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git
|
||||
cd imphnen-frontend-service
|
||||
npm install
|
||||
```
|
||||
|
||||
Or with Nix:
|
||||
|
||||
```sh
|
||||
nix develop # enters dev shell with node 22, bun, git, jq
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env` in the app directory:
|
||||
|
||||
```sh
|
||||
cp apps/<app>/.env.example apps/<app>/.env
|
||||
cd apps/{appname}
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```sh
|
||||
nx dev <app> # e.g. nx dev landing, nx dev backoffice
|
||||
```
|
||||
Use the following commands to run in development mode:
|
||||
|
||||
- **Gacha**:
|
||||
```sh
|
||||
npm run gacha:dev
|
||||
```
|
||||
- **Backoffice**:
|
||||
```sh
|
||||
npm run backoffice:dev
|
||||
```
|
||||
- **Dimentorin**:
|
||||
```sh
|
||||
npm run dimentorin:dev
|
||||
```
|
||||
- **Landing Page**:
|
||||
```sh
|
||||
npm run landing:dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```sh
|
||||
nx build <app> # build single app
|
||||
nx run-many -t build --all # build everything
|
||||
nx affected -t build # build only what changed
|
||||
```
|
||||
Use the following commands to build the applications:
|
||||
|
||||
### Nix Build
|
||||
- **Gacha**:
|
||||
```sh
|
||||
npm run gacha:build
|
||||
```
|
||||
- **Backoffice**:
|
||||
```sh
|
||||
npm run backoffice:build
|
||||
```
|
||||
- **Dimentorin**:
|
||||
```sh
|
||||
npm run dimentorin:build
|
||||
```
|
||||
- **Landing Page**:
|
||||
```sh
|
||||
npm run landing:build
|
||||
```
|
||||
|
||||
```sh
|
||||
nix build .#<app> # e.g. nix build .#landing, .#dimentorin
|
||||
```
|
||||
### Production
|
||||
|
||||
All Nix config lives in `flake.nix`. When `package-lock.json` changes, update `npmDepsHash` using `lib.fakeHash`.
|
||||
Use the following commands to run the applications in production mode:
|
||||
|
||||
### Testing
|
||||
|
||||
```sh
|
||||
nx test <project> # unit tests (vitest)
|
||||
nx e2e <app>-e2e # e2e tests (playwright)
|
||||
nx lint <project> # eslint
|
||||
```
|
||||
- **Gacha**:
|
||||
```sh
|
||||
npm run gacha:prod
|
||||
```
|
||||
- **Backoffice**:
|
||||
```sh
|
||||
npm run backoffice:prod
|
||||
```
|
||||
- **Dimentorin**:
|
||||
```sh
|
||||
npm run dimentorin:prod
|
||||
```
|
||||
- **Landing Page**:
|
||||
```sh
|
||||
npm run landing:prod
|
||||
```
|
||||
|
||||
### Storybook
|
||||
|
||||
```sh
|
||||
nx storybook ui # run storybook for ui lib
|
||||
nx build-storybook ui # build static storybook
|
||||
```
|
||||
This repository uses Storybook to develop, test, and document UI components in an isolated and interactive environment. Below are the commands to work with Storybook:
|
||||
|
||||
## CI/CD
|
||||
- **Run Storybook**
|
||||
|
||||
GitHub Actions pipeline (`.github/workflows/nix-build.yml`):
|
||||
This command starts Storybook in development mode, allowing you to view and test UI components interactively.
|
||||
|
||||
1. **detect** — uses `nx affected` to find changed apps
|
||||
2. **build** — matrix strategy builds only affected apps with Nix, pushes to [Cachix](https://app.cachix.org/cache/msdqn)
|
||||
3. **deploy** — updates `flake.lock` in [imphnen-infrastructure](https://github.com/IMPHNEN/imphnen-infrastructure) and deploys to the server using [clan](https://clan.lol)
|
||||
```sh
|
||||
npm run ui:storybook
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
- **Run Unit Test**
|
||||
|
||||
- **Monorepo**: Nx 22.6
|
||||
- **Frontend**: React 19, TypeScript
|
||||
- **Styling**: Tailwind CSS v4, CVA
|
||||
- **State**: Zustand, TanStack React Query
|
||||
- **Forms**: react-hook-form + zod
|
||||
- **Build**: Nix flakes, Cachix
|
||||
- **CI**: GitHub Actions
|
||||
This command runs unit tests for the UI components to ensure they function as expected.
|
||||
|
||||
## Contributing
|
||||
```sh
|
||||
npm run ui:test
|
||||
```
|
||||
|
||||
1. Fork and clone the repository
|
||||
2. Create a branch: `git checkout -b feat/feature-name`
|
||||
3. Make changes, commit, and push
|
||||
4. Open a pull request to the `develop` branch
|
||||
- **Build Components**
|
||||
|
||||
Issues and feedback welcome via [GitHub Issues](https://github.com/IMPHNEN/imphnen-frontend-service/issues).
|
||||
This command generates a static build of Storybook, which can be deployed for sharing and documentation purposes.
|
||||
|
||||
```sh
|
||||
npm run ui:build
|
||||
```
|
||||
|
||||
## How to contribute
|
||||
|
||||
1. Fork the repository and clone it locally.
|
||||
2. Create a new branch for a new feature or fix:
|
||||
```sh
|
||||
git checkout -b feat/feature-name
|
||||
```
|
||||
3. Make changes, commit, and push to your forked repository.
|
||||
4. Create a pull request to this repository `develop` branch.
|
||||
|
||||
If you encounter any issues or problems, feel free to create a new Issue.
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Use this section to tell people about which versions of your project are
|
||||
currently being supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 5.1.x | :white_check_mark: |
|
||||
| 5.0.x | :x: |
|
||||
| 4.0.x | :white_check_mark: |
|
||||
| < 4.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Use this section to tell people how to report a vulnerability.
|
||||
|
||||
Tell them where to go, how often they can expect to get an update on a
|
||||
reported vulnerability, what to expect if the vulnerability is accepted or
|
||||
declined, etc.
|
||||
@@ -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,2 +1 @@
|
||||
VITE_API_URL=
|
||||
VITE_GITHUB_CLIENT_ID=
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Backoffice</title>
|
||||
@@ -7,9 +7,10 @@
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
|
||||
<link rel="stylesheet" href="/src/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"><div style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#f9fafb"><div style="width:40px;height:40px;border:3px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:spin .8s linear infinite"></div></div><style>@keyframes spin{to{transform:rotate(360deg)}}</style></div>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface IModalEditAccount {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleEditAccount?: () => void;
|
||||
currentStep?: number;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const ModalEditAccount = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
resetStep,
|
||||
handleEditAccount,
|
||||
}: IModalEditAccount) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
disableEscapeKeyDown={true}
|
||||
>
|
||||
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||
{currentStep === 2 && (
|
||||
<StepTwo
|
||||
onClose={onClose}
|
||||
handleEditAccount={handleEditAccount}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepOneProps {
|
||||
nextStep: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||
const [fullName, setFullName] = useState('Ahmad Wiyana');
|
||||
const [email, setEmail] = useState('fullname23@gmail.com');
|
||||
const [phoneNumber, setPhoneNumber] = useState('081904423804');
|
||||
const [address, setAddress] = useState('Jl. Pantai Cibaduyut Indah');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header>
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Edit Data Akun
|
||||
</h2>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<InputField
|
||||
label="Nama Lengkap"
|
||||
type="text"
|
||||
placeholder="Masukkan Nama Lengkap"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<InputField
|
||||
label="Email"
|
||||
type="text"
|
||||
placeholder="Masukkan Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<InputField
|
||||
label="Nomor Telepon"
|
||||
type="text"
|
||||
placeholder="Masukkan Nomor Telepon"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<InputField
|
||||
label="Alamat"
|
||||
type="text"
|
||||
placeholder="Masukkan Alamat"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={nextStep}
|
||||
>
|
||||
Perbarui Data
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepTwoProps {
|
||||
onClose: () => void;
|
||||
handleEditAccount?: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const StepTwo = ({ onClose, handleEditAccount, resetStep }: IStepTwoProps) => (
|
||||
<>
|
||||
<Modal.Header className="mb-0 text-center items-center">
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Update Data
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin dengan
|
||||
<br /> perubahan yang dilakukan?
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
handleEditAccount && handleEditAccount();
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
|
||||
export default ModalEditAccount;
|
||||
@@ -0,0 +1,195 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||
import {
|
||||
FilterOutlined,
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
useReactTable,
|
||||
RowSelectionState,
|
||||
} from '@tanstack/react-table';
|
||||
import ModalEditAccount from './_components/modal-edit-account';
|
||||
import { useQueryState } from '@imphnen-frontend-service/utils';
|
||||
|
||||
interface Account {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
// Mock data for demonstration
|
||||
const mockData: Account[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
||||
email: 'fullname23@gmail.com',
|
||||
phone: '081904423804',
|
||||
address: 'Jl. Pantai Cibaduyut Indah',
|
||||
}));
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const [showModalEditAccount, setShowModalEditAccount] = useState(false);
|
||||
|
||||
const {
|
||||
step: currentStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
resetStep,
|
||||
} = useQueryState('step', {
|
||||
defaultValue: 1,
|
||||
maxValue: 2,
|
||||
minValue: 1,
|
||||
});
|
||||
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
});
|
||||
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
|
||||
const columns: ColumnDef<Account>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'No',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'Nama Lengkap',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Email',
|
||||
accessorKey: 'email',
|
||||
},
|
||||
{
|
||||
header: 'Nomor Telp',
|
||||
accessorKey: 'phone',
|
||||
},
|
||||
{
|
||||
header: 'Alamat Pengiriman',
|
||||
accessorKey: 'address',
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowModalEditAccount(true);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<EditOutlined /> Edit
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||
manualPagination: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
{/* Header */}
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Data Akun</h1>
|
||||
</header>
|
||||
{/* Account Table Section */}
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
{/* Search and Filter */}
|
||||
<div className="flex justify-between items-center gap-8 mb-2">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama lengkap, email"
|
||||
className="pl-12 w-full max-h-full"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex items-center gap-3"
|
||||
disabled
|
||||
onClick={() => setShowFilter(!showFilter)}
|
||||
>
|
||||
<FilterOutlined />
|
||||
Filters
|
||||
</Button>
|
||||
{showFilter && (
|
||||
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
||||
<Filter onClose={() => setShowFilter(false)} options={[]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Table */}
|
||||
<DataTable data={mockData} columns={columns} table={table} />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Modal Edit Account */}
|
||||
<ModalEditAccount
|
||||
currentStep={currentStep}
|
||||
isOpen={showModalEditAccount}
|
||||
onClose={() => setShowModalEditAccount(false)}
|
||||
handleEditAccount={() => {
|
||||
console.log('Account updated');
|
||||
}}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
+5
-10
@@ -1,4 +1,5 @@
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
|
||||
import { PieLabelProps } from "recharts/types/polar/Pie"
|
||||
|
||||
type ChartProps = {
|
||||
name: string
|
||||
@@ -13,20 +14,14 @@ const chartData: ChartProps[] = [
|
||||
]
|
||||
|
||||
const RADIAN = Math.PI / 180;
|
||||
const renderCustomizedLabel = (props: any) => {
|
||||
const cx = props.cx ?? 0
|
||||
const cy = props.cy ?? 0
|
||||
const midAngle = props.midAngle ?? 0
|
||||
const innerRadius = props.innerRadius ?? 0
|
||||
const outerRadius = props.outerRadius ?? 0
|
||||
const percent = props.percent ?? 0
|
||||
const renderCustomizedLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }: PieLabelProps) => {
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
const x = cx + radius * Math.cos(-(midAngle ?? 0) * RADIAN);
|
||||
const y = cy + radius * Math.sin(-(midAngle ?? 0) * RADIAN);
|
||||
|
||||
return (
|
||||
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
{`${((percent ?? 1) * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||
import { BackofficeWrapper } from "@imphnen-frontend-service/ui/organisms";
|
||||
import { For } from "@imphnen-frontend-service/utils";
|
||||
import { ReactElement } from "react";
|
||||
import { UserGrowthChart } from "./_components/chart/user-growth";
|
||||
import { SessionStatusChart } from "./_components/chart/session-status";
|
||||
|
||||
const Overview = () => {
|
||||
return (
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">0</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Users</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Components(): ReactElement {
|
||||
return (
|
||||
<BackofficeWrapper title="Dimentorin.dev">
|
||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-5">Overview</h1>
|
||||
|
||||
<div className="space-y-14">
|
||||
<div>
|
||||
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
|
||||
Overview
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-5 gap-5">
|
||||
<For data={Array.from({ length: 5 })}>
|
||||
{(_, index) => <Overview key={index} />}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
|
||||
Trends & Analytics
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-7 gap-x-5">
|
||||
<div className="bg-white px-6 py-4 rounded-lg col-span-5">
|
||||
<div className="flex items-center justify-between mb-7">
|
||||
<h2 className="font-semibold text-p3 text-neutral-700">User Growth</h2>
|
||||
<div></div>
|
||||
</div>
|
||||
<UserGrowthChart />
|
||||
</div>
|
||||
<div className="bg-white px-6 py-4 rounded-lg col-span-2">
|
||||
<h2 className="font-semibold text-p3 text-neutral-700 mb-7">Session Status</h2>
|
||||
<SessionStatusChart />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-5">
|
||||
<div className="bg-white px-7 py-4 rounded-lg">
|
||||
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top 5 Mentors</h2>
|
||||
|
||||
<div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-label1 bg-primary-50 text-left rounded-full">
|
||||
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
|
||||
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
|
||||
<th className="font-medium py-4 px-5 rounded-r-lg">Avg Rating</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<For data={Array.from({ length: 5 })}>
|
||||
{(_, index) => (
|
||||
<tr key={index} className="shadow rounded-lg">
|
||||
<td className="py-4 px-5">{index + 1}</td>
|
||||
<td className="py-4 px-5">Mursid Al-Catraz</td>
|
||||
<td className="py-4 px-5">4.9</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white px-6 py-4 rounded-lg">
|
||||
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top Booked Mentoring Topics</h2>
|
||||
|
||||
<div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-label1 bg-primary-50 text-left font-medium">
|
||||
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
|
||||
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
|
||||
<th className="font-medium py-4 px-5 rounded-r-lg">Total Sesi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<For data={Array.from({ length: 5 })}>
|
||||
{(_, index) => (
|
||||
<tr key={index} className="shadow rounded-lg">
|
||||
<td className="py-4 px-5">{index + 1}</td>
|
||||
<td className="py-4 px-5">Mursid Al-Catraz</td>
|
||||
<td className="py-4 px-5">1000</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BackofficeWrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { useConfirmItem, useItem } from '../_hook/use-item';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
interface IModalAddItem {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleAddItem?: () => Promise<boolean>;
|
||||
currentStep?: number;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const ModalAddItem = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentStep,
|
||||
nextStep,
|
||||
resetStep,
|
||||
handleAddItem,
|
||||
}: IModalAddItem) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
disableEscapeKeyDown={true}
|
||||
>
|
||||
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||
{currentStep === 2 && (
|
||||
<StepTwo
|
||||
onClose={onClose}
|
||||
handleAddItem={handleAddItem}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepOneProps {
|
||||
nextStep: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||
const { form, onSubmit } = useItem(nextStep);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header>
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Tambah Item Gacha
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Lengkapi detail di bawah ini untuk menambahkan item gacha
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Nama Hadiah"
|
||||
name="itemName"
|
||||
type="text"
|
||||
placeholder="Masukkan Nama Hadiah"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Quantity"
|
||||
name="quantity"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Masukkan Kuantitas Item"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Foto Barang"
|
||||
type="file"
|
||||
name="foto"
|
||||
placeholder=".jpg, .jpeg, atau .png"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={nextStep}
|
||||
>
|
||||
Tambahkan Item
|
||||
</Button>
|
||||
</form>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepTwoProps {
|
||||
onClose: () => void;
|
||||
handleAddItem?: () => Promise<boolean>;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => {
|
||||
const { onConfirm, onCancel } = useConfirmItem(
|
||||
onClose,
|
||||
resetStep,
|
||||
handleAddItem,
|
||||
{
|
||||
success: 'Item ditambahkan ke gacha item',
|
||||
error: 'Item gagal ditambahkan ke gacha item',
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header className="mb-0 text-center items-center">
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Tambah Item
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin ingin
|
||||
<br /> menambahkan item ini?
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Tambahkan
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalAddItem;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
|
||||
interface IModalDeleteItem {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleDeleteItem?: () => void;
|
||||
}
|
||||
|
||||
const ModalDeleteItem = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
handleDeleteItem,
|
||||
}: IModalDeleteItem) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
closeButtonClassName="hidden"
|
||||
>
|
||||
<Modal.Header className="gap-8">
|
||||
<img
|
||||
src="/chibi-delete.webp"
|
||||
alt="Delete item?"
|
||||
width={148}
|
||||
className="self-center"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
|
||||
Delete Item
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin untuk menghapus item ini?
|
||||
</p>
|
||||
</div>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onClose}
|
||||
>
|
||||
Batal Hapus
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
handleDeleteItem && handleDeleteItem();
|
||||
}}
|
||||
>
|
||||
Hapus Item
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalDeleteItem;
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { useConfirmItem, useItem } from '../_hook/use-item';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
interface IModalEditItem {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleEditItem?: () => Promise<boolean>;
|
||||
currentStep?: number;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const ModalEditItem = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentStep,
|
||||
nextStep,
|
||||
resetStep,
|
||||
handleEditItem,
|
||||
}: IModalEditItem) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
disableEscapeKeyDown={true}
|
||||
>
|
||||
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||
{currentStep === 2 && (
|
||||
<StepTwo
|
||||
onClose={onClose}
|
||||
handleEditItem={handleEditItem}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepOneProps {
|
||||
nextStep: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||
const initialValues = {
|
||||
itemName: 'Hoodie IMPHNEN Official 2025',
|
||||
quantity: 10,
|
||||
};
|
||||
|
||||
const { form, onSubmit } = useItem(nextStep, initialValues);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header>
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Edit Item Gacha
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Silakan mengubah detail dari item yang diperlukan
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex flex-col gap-8">
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Nama Hadiah"
|
||||
type="text"
|
||||
name="itemName"
|
||||
placeholder="Masukkan Nama Hadiah"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Quantity"
|
||||
type="number"
|
||||
name="quantity"
|
||||
placeholder="Masukkan Kuantitas Item"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Foto Barang"
|
||||
type="file"
|
||||
name="foto"
|
||||
placeholder=".jpg, .jpeg, atau .png"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={nextStep}
|
||||
>
|
||||
Perbarui Item
|
||||
</Button>
|
||||
</form>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepTwoProps {
|
||||
onClose: () => void;
|
||||
handleEditItem?: () => Promise<boolean>;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const StepTwo = ({ onClose, handleEditItem, resetStep }: IStepTwoProps) => {
|
||||
const { onConfirm, onCancel } = useConfirmItem(
|
||||
onClose,
|
||||
resetStep,
|
||||
handleEditItem,
|
||||
{
|
||||
success: 'Perubahan item berhasil dilakukan',
|
||||
error: 'Perubahan item gagal dilakukan',
|
||||
}
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Modal.Header className="mb-0 text-center items-center">
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Update Item
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin dengan
|
||||
<br /> perubahan yang dilakukan?
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalEditItem;
|
||||
+5
-7
@@ -8,17 +8,16 @@ import { toast } from 'sonner';
|
||||
|
||||
export const useItem = (
|
||||
nextStep: () => void,
|
||||
initialValues?: Partial<TGachaItem>,
|
||||
onDataCapture?: (data: any) => void,
|
||||
initialValues?: TGachaItem
|
||||
) => {
|
||||
const form = useForm<any>({
|
||||
const form = useForm<TGachaItem>({
|
||||
resolver: zodResolver(gachaItemSchema),
|
||||
mode: 'all',
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
onDataCapture?.(data);
|
||||
console.log('Form data:', data);
|
||||
nextStep();
|
||||
});
|
||||
|
||||
@@ -39,9 +38,8 @@ export const useConfirmItem = (
|
||||
) => {
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
if (actionFunction) {
|
||||
await actionFunction();
|
||||
}
|
||||
// const result = await actionFunction?.();
|
||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||
toast.success(messages?.success);
|
||||
onClose();
|
||||
resetStep();
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
UsergroupAddOutlined,
|
||||
UsergroupDeleteOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||
import ModalAddItem from './_components/modal-add-item';
|
||||
import ModalEditItem from './_components/modal-edit-item';
|
||||
import ModalDeleteItem from './_components/modal-delete-item';
|
||||
import { useQueryState } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const [showModalAddItem, setShowModalAddItem] = useState(false);
|
||||
const [showModalEditItem, setShowModalEditItem] = useState(false);
|
||||
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
|
||||
|
||||
const {
|
||||
step: currentStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
resetStep,
|
||||
} = useQueryState('step', {
|
||||
defaultValue: 1,
|
||||
maxValue: 2,
|
||||
minValue: 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Dashboard</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex justify-between gap-[40px] p-8 bg-white rounded-md">
|
||||
<div className="w-full flex flex-col gap-[40px]">
|
||||
<section>
|
||||
<h2 className="text-p2 font-medium text-primary-500 mb-8">
|
||||
Summary
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||
<UsergroupAddOutlined className="text-[20px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-p1 font-semibold">1000</h3>
|
||||
<p className="text-label1 text-neutral-500">Participants</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||
<ReloadOutlined className="text-[20px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-p1 font-semibold">1000</h3>
|
||||
<p className="text-label1 text-neutral-500">
|
||||
Roll and Reroll
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||
<UserSwitchOutlined className="text-[20px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-p1 font-semibold">1000</h3>
|
||||
<p className="text-label1 text-neutral-500">Redeem</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||
<UsergroupDeleteOutlined className="text-[20px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-p1 font-semibold">1000</h3>
|
||||
<p className="text-label1 text-neutral-500">
|
||||
Inactive Users
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-p2 font-medium text-primary-500">
|
||||
Gacha Items
|
||||
</h2>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="items-end gap-3"
|
||||
onClick={() => setShowModalAddItem(true)}
|
||||
>
|
||||
<span>Tambah Item</span>
|
||||
<PlusOutlined className="text-[16px]" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 max-h-140 overflow-auto">
|
||||
{[1, 2, 3, 4, 5, 6].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="bg-white overflow-clip rounded-lg shadow-sm flex justify-between border border-neutral-100"
|
||||
>
|
||||
<div className="flex flex-col py-4 px-6 gap-[8px]">
|
||||
<div>
|
||||
<h3 className="text-p3 text-primary-500 font-medium">
|
||||
Lanyard IMPHNEN
|
||||
</h3>
|
||||
<div className="flex items-center justify-start gap-10 text-label2 text-gray-500 mt-1">
|
||||
<span>Prize {item}</span>
|
||||
<span>Quantity: 10</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-start gap-2">
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent hover:text-primary-500"
|
||||
onClick={() => setShowModalEditItem(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="text-[10px] text-red-500 p-0 font-normal hover:bg-transparent hover:text-red-700"
|
||||
onClick={() => setShowModalDeleteItem(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img src="gacha-clip.webp" alt="Lanyard IMPHNEN" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Right-side illustration */}
|
||||
<img
|
||||
src="gacha.webp"
|
||||
alt=""
|
||||
className="rounded-lg hidden xl:block xl:min-w-[436px] h-auto object-cover"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Modal Add Item */}
|
||||
<ModalAddItem
|
||||
currentStep={currentStep}
|
||||
isOpen={showModalAddItem}
|
||||
onClose={() => setShowModalAddItem(false)}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
|
||||
{/* Modal Edit Item */}
|
||||
<ModalEditItem
|
||||
currentStep={currentStep}
|
||||
isOpen={showModalEditItem}
|
||||
onClose={() => setShowModalEditItem(false)}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
|
||||
{/* Modal Delete Item */}
|
||||
<ModalDeleteItem
|
||||
isOpen={showModalDeleteItem}
|
||||
onClose={() => setShowModalDeleteItem(false)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
@@ -0,0 +1,181 @@
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
|
||||
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
|
||||
import { cn, For } from "@imphnen-frontend-service/utils";
|
||||
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
|
||||
import { ReactElement, useState } from "react"
|
||||
|
||||
const TABS = {
|
||||
MENTORING: 'Mentoring',
|
||||
PLATFORM: 'Platform'
|
||||
} as const
|
||||
type Tabs = typeof TABS[keyof typeof TABS]
|
||||
|
||||
type FeedbackStatus = 'done' | 'todo';
|
||||
|
||||
interface FeedbackType {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
rating: number
|
||||
status: FeedbackStatus
|
||||
}
|
||||
|
||||
const mockData: FeedbackType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana',
|
||||
email: 'fullname23@gmail.com',
|
||||
rating: 4.5,
|
||||
status: i % 2 === 0 ? 'done' : 'todo',
|
||||
}))
|
||||
|
||||
export default function Components(): ReactElement {
|
||||
const [activeTab, setActiveTab] = useState<Tabs>(TABS.MENTORING)
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
});
|
||||
|
||||
const columns: ColumnDef<FeedbackType>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
meta: { cellClassName: cn("w-20") },
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
header: 'Email',
|
||||
accessorKey: 'email',
|
||||
},
|
||||
{
|
||||
id: 'rating',
|
||||
header: 'Rating',
|
||||
accessorKey: 'rating',
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status;
|
||||
const statusColors: Record<FeedbackStatus, string> = {
|
||||
done: 'bg-success-200 text-success-500',
|
||||
todo: 'bg-primary-200 text-primary-500',
|
||||
};
|
||||
const statusText: Record<FeedbackStatus, string> = {
|
||||
done: 'Done',
|
||||
todo: 'To Do',
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||
>
|
||||
{statusText[status]}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
meta: { cellClassName: cn("w-72") },
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
className="flex items-center gap-2 w-max"
|
||||
>
|
||||
<SearchOutlined className="text-[16px]" /> Lihat Feedback
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||
manualPagination: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="Dimentorin.dev">
|
||||
<div className="mb-8 flex justify-between items-center">
|
||||
<h1 className="text-p1 font-semibold text-neutral-700">Feedback</h1>
|
||||
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
||||
<For data={Object.values(TABS)}>
|
||||
{(tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
variant="text"
|
||||
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{tab}
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
<div className="flex justify-between items-center gap-5 mb-2">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama mentor/mentee"
|
||||
className="pl-12 w-full max-h-full"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<Select>
|
||||
<option selected disabled>Rating</option>
|
||||
<option value="4.5">4.5</option>
|
||||
<option value="5">5</option>
|
||||
</Select>
|
||||
<Select>
|
||||
<option selected disabled>Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DataTable data={mockData} columns={columns} table={table} />
|
||||
</section>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { useItem, useConfirmItem } from '../_hook/use-item';
|
||||
|
||||
interface IModalAddItem {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleAddItem?: () => Promise<boolean>;
|
||||
currentStep?: number;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const ModalAddItem = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentStep,
|
||||
nextStep,
|
||||
resetStep,
|
||||
handleAddItem,
|
||||
}: IModalAddItem) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
disableEscapeKeyDown={true}
|
||||
>
|
||||
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||
{currentStep === 2 && (
|
||||
<StepTwo
|
||||
onClose={onClose}
|
||||
handleAddItem={handleAddItem}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepOneProps {
|
||||
nextStep: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||
const { form, onSubmit } = useItem(nextStep);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header>
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Tambah Item Roll Gacha
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Lengkapi detal di bawah ini, untuk menambahkan item gacha
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Pilih Item"
|
||||
name="itemName"
|
||||
type="text"
|
||||
placeholder="Pilih item yang dimasukkan ke roll"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Quantity"
|
||||
name="quantity"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Masukkan Kuantitas Item"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Chance Rate"
|
||||
name="chanceRate"
|
||||
type="number"
|
||||
value={0.1}
|
||||
min={0.1}
|
||||
step={0.1}
|
||||
max={1}
|
||||
placeholder="Masukkan Chance Rate (0,1 - 1)"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" size="lg" className="w-full" type="submit">
|
||||
Tambahkan Item
|
||||
</Button>
|
||||
</form>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepTwoProps {
|
||||
onClose: () => void;
|
||||
handleAddItem?: () => Promise<boolean>;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => {
|
||||
const { onConfirm, onCancel } = useConfirmItem(
|
||||
onClose,
|
||||
resetStep,
|
||||
handleAddItem,
|
||||
{
|
||||
success: 'Item ditambahkan ke roll gacha',
|
||||
error: 'Item gagal ditambahkan ke roll gacha',
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header className="mb-0 text-center items-center">
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Tambah ke Roll Gacha
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin ingin
|
||||
<br /> menambahkan item ini ke roll gacha?
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Tambahkan
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalAddItem;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
|
||||
interface IModalDeleteItem {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleDeleteItem?: () => void;
|
||||
}
|
||||
|
||||
const ModalDeleteItem = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
handleDeleteItem,
|
||||
}: IModalDeleteItem) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
closeButtonClassName="hidden"
|
||||
>
|
||||
<Modal.Header className="gap-8">
|
||||
<img
|
||||
src="/chibi-delete.webp"
|
||||
alt="Delete item?"
|
||||
width={148}
|
||||
className="self-center"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
|
||||
Delete Item
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin untuk menghapus item ini?
|
||||
</p>
|
||||
</div>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onClose}
|
||||
>
|
||||
Batal Hapus
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
handleDeleteItem && handleDeleteItem();
|
||||
}}
|
||||
>
|
||||
Hapus Item
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalDeleteItem;
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { useConfirmItem, useItem } from '../_hook/use-item';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
interface IModalUpdateItem {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleUpdateItem?: () => Promise<boolean>;
|
||||
currentStep?: number;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const ModalUpdateItem = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentStep,
|
||||
nextStep,
|
||||
resetStep,
|
||||
handleUpdateItem,
|
||||
}: IModalUpdateItem) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
disableEscapeKeyDown={true}
|
||||
>
|
||||
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||
{currentStep === 2 && (
|
||||
<StepTwo
|
||||
onClose={onClose}
|
||||
handleUpdateItem={handleUpdateItem}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepOneProps {
|
||||
nextStep: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||
const initialValues = {
|
||||
itemName: 'Hoodie IMPHNEN Official 2025',
|
||||
quantity: 10,
|
||||
chanceRate: 0.1,
|
||||
};
|
||||
|
||||
const { form, onSubmit } = useItem(nextStep, initialValues);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header>
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Update Item Roll Gacha
|
||||
</h2>
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Pilih Item"
|
||||
name="itemName"
|
||||
type="text"
|
||||
placeholder="Pilih item yang dimasukkan ke roll"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Quantity"
|
||||
name="quantity"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Masukkan Kuantitas Item"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Chance Rate"
|
||||
name="chanceRate"
|
||||
type="number"
|
||||
value={0.1}
|
||||
min={0.1}
|
||||
step={0.1}
|
||||
max={1}
|
||||
placeholder="Masukkan Chance Rate (0,1 - 1)"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={nextStep}
|
||||
>
|
||||
Perbarui Item
|
||||
</Button>
|
||||
</form>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepTwoProps {
|
||||
onClose: () => void;
|
||||
handleUpdateItem?: () => Promise<boolean>;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const StepTwo = ({ onClose, handleUpdateItem, resetStep }: IStepTwoProps) => {
|
||||
const { onConfirm, onCancel } = useConfirmItem(
|
||||
onClose,
|
||||
resetStep,
|
||||
handleUpdateItem,
|
||||
{
|
||||
success: 'Perubahan item roll berhasil dilakukan',
|
||||
error: 'Perubahan item roll gagal dilakukan',
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header className="mb-0 text-center items-center">
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Update Item
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin dengan
|
||||
<br /> perubahan yang dilakukan?
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalUpdateItem;
|
||||
+5
-7
@@ -8,17 +8,16 @@ import { toast } from 'sonner';
|
||||
|
||||
export const useItem = (
|
||||
nextStep: () => void,
|
||||
initialValues?: Partial<TGachaRollItem>,
|
||||
onDataCapture?: (data: any) => void,
|
||||
initialValues?: TGachaRollItem
|
||||
) => {
|
||||
const form = useForm<any>({
|
||||
const form = useForm<TGachaRollItem>({
|
||||
resolver: zodResolver(gachaRollItemSchema),
|
||||
mode: 'all',
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
onDataCapture?.(data);
|
||||
console.log('Form data:', data);
|
||||
nextStep();
|
||||
});
|
||||
|
||||
@@ -39,9 +38,8 @@ export const useConfirmItem = (
|
||||
) => {
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
if (actionFunction) {
|
||||
await actionFunction();
|
||||
}
|
||||
// const result = await actionFunction?.();
|
||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||
toast.success(messages?.success);
|
||||
onClose();
|
||||
resetStep();
|
||||
@@ -0,0 +1,210 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||
import {
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
useReactTable,
|
||||
RowSelectionState,
|
||||
} from '@tanstack/react-table';
|
||||
import ModalAddItem from './_components/modal-add-item';
|
||||
import ModalUpdateItem from './_components/modal-update-item';
|
||||
import ModalDeleteItem from './_components/modal-delete-item';
|
||||
import { useQueryState } from '@imphnen-frontend-service/utils';
|
||||
|
||||
interface GachaItem {
|
||||
id: number;
|
||||
name: string;
|
||||
chanceRate: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
const mockData: GachaItem[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: 'Hoodie IMPHNEN Official 2025',
|
||||
chanceRate: 0.1,
|
||||
quantity: 10,
|
||||
}));
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const [showModalAddItem, setShowModalAddItem] = useState(false);
|
||||
const [showModalUpdateItem, setShowModalUpdateItem] = useState(false);
|
||||
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
|
||||
|
||||
const {
|
||||
step: currentStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
resetStep,
|
||||
} = useQueryState('step', {
|
||||
defaultValue: 1,
|
||||
maxValue: 2,
|
||||
minValue: 1,
|
||||
});
|
||||
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
});
|
||||
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
||||
|
||||
const columns: ColumnDef<GachaItem>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'No',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'Nama Item',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Chance Rate',
|
||||
accessorKey: 'chanceRate',
|
||||
},
|
||||
{
|
||||
header: 'Quantity',
|
||||
accessorKey: 'quantity',
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
cell: () => (
|
||||
<div className="flex gap-[8px]">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowModalUpdateItem(true);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<EditOutlined /> Update
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowModalDeleteItem(true);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<DeleteOutlined /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||
manualPagination: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Gacha Roll</h1>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
<div className="flex justify-between items-center gap-8 mb-2">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama item"
|
||||
className="pl-12 w-full max-h-full"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex gap-3 text-nowrap"
|
||||
onClick={() => {
|
||||
setShowModalAddItem(true);
|
||||
}}
|
||||
>
|
||||
<PlusOutlined />
|
||||
Tambah Item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable data={mockData} columns={columns} table={table} />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<ModalAddItem
|
||||
currentStep={currentStep}
|
||||
isOpen={showModalAddItem}
|
||||
onClose={() => setShowModalAddItem(false)}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
<ModalUpdateItem
|
||||
currentStep={currentStep}
|
||||
isOpen={showModalUpdateItem}
|
||||
onClose={() => setShowModalUpdateItem(false)}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
<ModalDeleteItem
|
||||
isOpen={showModalDeleteItem}
|
||||
onClose={() => setShowModalDeleteItem(false)}
|
||||
handleDeleteItem={() => {
|
||||
console.log('Item deleted');
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { FC, ReactElement } from 'react';
|
||||
|
||||
export const HackathonDashboardPage: FC = (): ReactElement => {
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
|
||||
|
||||
<section className="grid grid-cols-5 gap-5">
|
||||
{/* Participant */}
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||
1261
|
||||
</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Participants</p>
|
||||
</div>
|
||||
{/* Team */}
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">206</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Teams</p>
|
||||
</div>
|
||||
{/* Project Submitted */}
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">0</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
|
||||
</div>
|
||||
</section>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HackathonDashboardPage;
|
||||
@@ -0,0 +1,128 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
|
||||
export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
});
|
||||
|
||||
const mockData: any[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
project_name: `Project ${i + 1}`,
|
||||
repository_url: `https://github.com/user/repo${i + 1}`,
|
||||
demo_url: `https://demo.example.com/project${i + 1}`,
|
||||
presentation_url: `https://slides.example.com/project${i + 1}`,
|
||||
}));
|
||||
|
||||
type UserStatus = 'active' | 'inactive';
|
||||
|
||||
interface SubmissionType {
|
||||
id: number;
|
||||
project_name: string;
|
||||
repository_url: string;
|
||||
demo_url: string;
|
||||
presentation_url: string;
|
||||
}
|
||||
|
||||
const columns: ColumnDef<SubmissionType>[] = [
|
||||
{
|
||||
header: 'Project Name',
|
||||
accessorKey: 'project_name',
|
||||
},
|
||||
{
|
||||
header: 'Repository URL',
|
||||
accessorKey: 'repository_url',
|
||||
},
|
||||
{
|
||||
header: 'Demo URL',
|
||||
accessorKey: 'demo_url',
|
||||
},
|
||||
{
|
||||
header: 'Presentation URL',
|
||||
accessorKey: 'presentation_url',
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
meta: { cellClassName: cn('w-72') },
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 w-max"
|
||||
onClick={() => {
|
||||
// View detail logic
|
||||
}}
|
||||
>
|
||||
<EditOutlined className="text-base" /> View & Manage
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||
manualPagination: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||
Project Submission
|
||||
</h1>
|
||||
{/* Filters and actions */}
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<input
|
||||
type="text"
|
||||
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64"
|
||||
placeholder="Search name or email"
|
||||
/>
|
||||
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||
<option value="all">All City</option>
|
||||
<option value="jakarta">Jakarta</option>
|
||||
<option value="bandung">Bandung</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<DataTable data={mockData} columns={columns} table={table} />
|
||||
</section>
|
||||
|
||||
{/* Modals extracted into shared backoffice components */}
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HackathonUsersPage;
|
||||
@@ -0,0 +1,210 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { useTeams } from '@imphnen-frontend-service/service';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
|
||||
export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
const { data: teamsData } = useTeams();
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
});
|
||||
|
||||
const mockData: TeamType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
id: `team-${i + 1}`,
|
||||
name: `Team ${i + 1} - ${i % 3 === 0 ? 'Innovators' : 'Hackers'}`,
|
||||
city: i % 2 === 0 ? 'Jakarta' : 'Bandung',
|
||||
visibility: i % 4 === 0 ? 'private' : 'public',
|
||||
member_count: Math.floor(Math.random() * 4) + 1,
|
||||
has_submission: i % 3 !== 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
leader: {
|
||||
user: {
|
||||
fullname: `Leader User ${i}`,
|
||||
email: `leader${i}@example.com`,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
interface TeamType {
|
||||
id: string;
|
||||
name: string;
|
||||
city: string;
|
||||
visibility: 'public' | 'private';
|
||||
member_count: number;
|
||||
has_submission: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
leader?: {
|
||||
user: {
|
||||
fullname: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const columns: ColumnDef<TeamType>[] = [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: 'ID',
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Team Name',
|
||||
},
|
||||
{
|
||||
accessorKey: 'city',
|
||||
header: 'City',
|
||||
},
|
||||
{
|
||||
accessorKey: 'visibility',
|
||||
header: 'Visibility',
|
||||
cell: ({ row }) => {
|
||||
const isPublic = row.original.visibility === 'public';
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'py-2 px-4 text-sm rounded-2xl text-center',
|
||||
isPublic
|
||||
? 'bg-info-200 text-info-700'
|
||||
: 'bg-gray-200 text-gray-700'
|
||||
)}
|
||||
>
|
||||
{isPublic ? 'Public' : 'Private'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'member_count',
|
||||
header: 'Members',
|
||||
},
|
||||
{
|
||||
id: 'leader',
|
||||
header: 'Leader',
|
||||
cell: ({ row }) => {
|
||||
const leader = row.original.leader?.user;
|
||||
return leader ? (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{leader.fullname}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{leader.email}</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">-</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'has_submission',
|
||||
header: 'Submitted',
|
||||
cell: ({ row }) => {
|
||||
const hasSubmission = row.original.has_submission;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'py-2 px-4 text-sm rounded-2xl text-center',
|
||||
hasSubmission
|
||||
? 'bg-success-200 text-success-700'
|
||||
: 'bg-danger-200 text-danger-700'
|
||||
)}
|
||||
>
|
||||
{hasSubmission ? 'Yes' : 'No'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: 'Last Updated',
|
||||
cell: ({ row }) => {
|
||||
return new Date(row.original.updated_at).toLocaleDateString();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Action',
|
||||
meta: { cellClassName: cn('w-48') },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 w-max"
|
||||
onClick={() => {
|
||||
// View detail logic
|
||||
}}
|
||||
>
|
||||
<EditOutlined className="text-base" /> View & Manage
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||
manualPagination: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||
Team Management
|
||||
</h1>
|
||||
{/* Filters and actions */}
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<input
|
||||
type="text"
|
||||
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64"
|
||||
placeholder="Search name or email"
|
||||
/>
|
||||
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||
<option value="all">All City</option>
|
||||
<option value="jakarta">Jakarta</option>
|
||||
<option value="bandung">Bandung</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Table */}
|
||||
<DataTable data={mockData} columns={columns} table={table} />
|
||||
</section>
|
||||
{/* Modals extracted into shared backoffice components */}
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HackathonTeamsPage;
|
||||
+43
-18
@@ -15,10 +15,10 @@ import {
|
||||
|
||||
interface UserType {
|
||||
id: string;
|
||||
avatar?: string | null;
|
||||
avatar?: string;
|
||||
fullname: string;
|
||||
bio?: string;
|
||||
location: string | null;
|
||||
location: string;
|
||||
is_active: boolean;
|
||||
skills: string[];
|
||||
created_at: string;
|
||||
@@ -37,13 +37,16 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
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: '',
|
||||
id: '', // Will be generated by backend
|
||||
fullname: '',
|
||||
bio: '',
|
||||
location: '',
|
||||
@@ -57,9 +60,10 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
}
|
||||
}, [isOpen, user]);
|
||||
|
||||
// Check if form has changes
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!formData) return false;
|
||||
if (!user) return true;
|
||||
if (!user) return true; // New user always has changes
|
||||
return (
|
||||
formData.fullname !== user.fullname ||
|
||||
formData.location !== user.location ||
|
||||
@@ -70,9 +74,10 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
);
|
||||
}, [formData, user]);
|
||||
|
||||
// Check if required fields are filled
|
||||
const isFormValid = useMemo(() => {
|
||||
if (!formData) return false;
|
||||
return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
|
||||
return formData.fullname.trim() !== '' && formData.location.trim() !== '';
|
||||
}, [formData]);
|
||||
|
||||
const canSave = hasChanges && isFormValid;
|
||||
@@ -94,22 +99,25 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
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 });
|
||||
setFormData({ ...user }); // Reset to original for edit mode
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
if (!user) return;
|
||||
if (!user) return; // Can't delete new user
|
||||
console.log('Delete user:', user.id);
|
||||
setShowDeleteConfirm(false);
|
||||
onClose();
|
||||
@@ -118,16 +126,19 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
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;
|
||||
@@ -172,6 +183,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
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}
|
||||
@@ -179,8 +191,10 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
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 ? (
|
||||
@@ -194,6 +208,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
)}
|
||||
</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"
|
||||
@@ -201,6 +216,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
<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
|
||||
@@ -243,24 +259,27 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
|
||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
onClick={() => {
|
||||
setShowAvatarMenu(false);
|
||||
handleCancel();
|
||||
}}
|
||||
>
|
||||
<CloseOutlined className="text-neutral-400 text-lg" />
|
||||
<CloseOutlined className="text-neutral-400 text-lg cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Left Column - Basic Info */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
Basic Information
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* Full Name - Required */}
|
||||
<div className="flex items-center gap-3">
|
||||
<UserOutlined className="text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
@@ -275,15 +294,13 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
}
|
||||
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() === ''
|
||||
formData.fullname.trim() === ''
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-neutral-300'
|
||||
)}
|
||||
placeholder="Enter full name"
|
||||
/>
|
||||
{(!formData.fullname ||
|
||||
formData.fullname.trim() === '') && (
|
||||
{formData.fullname.trim() === '' && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
Full name is required
|
||||
</p>
|
||||
@@ -291,6 +308,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location - Required */}
|
||||
<div className="flex items-center gap-3">
|
||||
<EnvironmentOutlined className="text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
@@ -298,14 +316,13 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
Location <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.location || ''}
|
||||
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() === ''
|
||||
formData.location.trim() === ''
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-neutral-300'
|
||||
)}
|
||||
@@ -317,8 +334,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
<option value="Medan">Medan</option>
|
||||
<option value="Yogyakarta">Yogyakarta</option>
|
||||
</select>
|
||||
{(!formData.location ||
|
||||
formData.location.trim() === '') && (
|
||||
{formData.location.trim() === '' && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
Location is required
|
||||
</p>
|
||||
@@ -326,6 +342,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Joined Date - Read Only - Only show for existing users */}
|
||||
{user && (
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarOutlined className="text-neutral-400" />
|
||||
@@ -349,6 +366,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bio Section - Optional */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
|
||||
Bio{' '}
|
||||
@@ -368,7 +386,9 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</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
|
||||
@@ -424,6 +444,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Skills Section - Optional */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
Skills & Expertise{' '}
|
||||
@@ -485,6 +506,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Details - Read Only - Only show for existing users */}
|
||||
{user && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
@@ -521,6 +543,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</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">
|
||||
@@ -531,6 +554,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
? 'Please fill required fields'
|
||||
: 'No changes made'}
|
||||
</div>
|
||||
{/* Delete Account Button - Only show for existing users */}
|
||||
{user && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
@@ -567,6 +591,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 z-60">
|
||||
<div
|
||||
@@ -0,0 +1,476 @@
|
||||
import { FC, ReactElement, useState, useMemo, useCallback } from 'react';
|
||||
import ModalUserDetail from './_components/modal-user-detail';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
EditOutlined,
|
||||
UserOutlined,
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
// Removed unused SearchOutlined icon after schema revision
|
||||
|
||||
// Define interface outside component
|
||||
interface UserType {
|
||||
id: string; // UUID
|
||||
avatar?: string;
|
||||
fullname: string;
|
||||
bio?: string;
|
||||
location: string;
|
||||
is_active: boolean; // admin can deactivate
|
||||
skills: string[]; // Frontend Developer, Backend Developer, etc.
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Move mock data outside component to prevent recreation
|
||||
const skillsOptions = [
|
||||
'Frontend Developer',
|
||||
'Backend Developer',
|
||||
'Full Stack Developer',
|
||||
'DevOps Engineer',
|
||||
'UI/UX Designer',
|
||||
'Product Manager',
|
||||
'Data Scientist',
|
||||
'Mobile Developer',
|
||||
];
|
||||
|
||||
const locations = ['Jakarta', 'Bandung', 'Surabaya', 'Medan', 'Yogyakarta'];
|
||||
const bios = [
|
||||
'Passionate developer with 5+ years experience',
|
||||
'Tech enthusiast and problem solver',
|
||||
'Building scalable solutions for modern problems',
|
||||
'Creative designer with technical background',
|
||||
'Data-driven decision maker',
|
||||
];
|
||||
|
||||
const mockData: UserType[] = Array.from({ length: 50 }, (_, i) => {
|
||||
const randomSkillsCount = Math.floor(Math.random() * 3) + 1; // 1-3 skills
|
||||
const randomSkills = skillsOptions
|
||||
.sort(() => 0.5 - Math.random())
|
||||
.slice(0, randomSkillsCount);
|
||||
|
||||
return {
|
||||
id: `24db9e4d-ca4c-46aa-ac36-8ef04bbe01${String(i).padStart(2, '0')}`,
|
||||
avatar:
|
||||
i % 4 === 0
|
||||
? `https://ui-avatars.com/api/?name=${encodeURIComponent(
|
||||
i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana'
|
||||
)}&background=random`
|
||||
: undefined,
|
||||
fullname:
|
||||
i % 3 === 0
|
||||
? 'Ahmad Wijuana'
|
||||
: i % 3 === 1
|
||||
? 'Sofia Wijuana'
|
||||
: 'Budi Santoso',
|
||||
bio: i % 4 === 0 ? bios[i % bios.length] : undefined,
|
||||
location: locations[i % locations.length],
|
||||
is_active: i % 7 !== 0, // More realistic distribution
|
||||
skills: randomSkills,
|
||||
created_at: new Date(
|
||||
Date.now() - i * 86400000 * (Math.random() * 30 + 1)
|
||||
).toISOString(), // Random within last 30-60 days
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [showNewUserModal, setShowNewUserModal] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
|
||||
const [globalFilter, setGlobalFilter] = useState('');
|
||||
|
||||
// Advanced filtering states
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [locationFilter, setLocationFilter] = useState('all');
|
||||
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
|
||||
|
||||
// Constants
|
||||
const pageSize = 10;
|
||||
|
||||
// Memoize the callback to prevent recreation
|
||||
const handleShowDetailModal = useCallback((user: UserType) => {
|
||||
setSelectedUser(user);
|
||||
setShowDetailModal(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseDetailModal = useCallback(() => {
|
||||
setShowDetailModal(false);
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
const handleShowNewUserModal = useCallback(() => {
|
||||
setShowNewUserModal(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseNewUserModal = useCallback(() => {
|
||||
setShowNewUserModal(false);
|
||||
}, []);
|
||||
|
||||
// Filter data based on current filter states
|
||||
const filteredData = useMemo(() => {
|
||||
return mockData.filter((user) => {
|
||||
// Status filter
|
||||
if (statusFilter !== 'all') {
|
||||
const isActive = statusFilter === 'active';
|
||||
if (user.is_active !== isActive) return false;
|
||||
}
|
||||
|
||||
// Location filter
|
||||
if (locationFilter !== 'all' && user.location !== locationFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skills filter
|
||||
if (skillsFilter.length > 0) {
|
||||
const hasMatchingSkill = skillsFilter.some((skill) =>
|
||||
user.skills.includes(skill)
|
||||
);
|
||||
if (!hasMatchingSkill) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [statusFilter, locationFilter, skillsFilter]);
|
||||
|
||||
// Memoize columns to prevent recreation on every render
|
||||
const columns: ColumnDef<UserType>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'fullname',
|
||||
header: 'User',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Avatar */}
|
||||
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
|
||||
{row.original.avatar ? (
|
||||
<img
|
||||
src={row.original.avatar}
|
||||
alt={row.original.fullname}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<UserOutlined className="text-neutral-500 text-lg" />
|
||||
)}
|
||||
</div>
|
||||
{/* Name only */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-neutral-900 truncate">
|
||||
{row.original.fullname}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'skills',
|
||||
header: 'Skills',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||
{row.original.skills.slice(0, 2).map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
|
||||
>
|
||||
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||
</span>
|
||||
))}
|
||||
{row.original.skills.length > 2 && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
|
||||
+{row.original.skills.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'location',
|
||||
header: 'Location',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-neutral-700">{row.original.location}</span>
|
||||
),
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'is_active',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
|
||||
)}
|
||||
>
|
||||
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
enableSorting: true,
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const aActive = rowA.original.is_active;
|
||||
const bActive = rowB.original.is_active;
|
||||
if (aActive && !bActive) return -1;
|
||||
if (!aActive && bActive) return 1;
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Joined',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-neutral-900 text-sm">
|
||||
{new Date(row.original.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
enableSorting: true,
|
||||
sortingFn: 'datetime',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
meta: { cellClassName: cn('w-48') },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||
onClick={() => handleShowDetailModal(row.original)}
|
||||
>
|
||||
<EditOutlined className="text-sm" />
|
||||
Manage
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-sm px-4 py-2"
|
||||
onClick={() => {
|
||||
// Toggle user status - implement later
|
||||
console.log(`Toggle status for ${row.original.fullname}`);
|
||||
}}
|
||||
>
|
||||
{row.original.is_active ? 'Deactivate' : 'Activate'}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[handleShowDetailModal]
|
||||
);
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||
User Management
|
||||
</h1>
|
||||
{/* Filters and actions */}
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||
{/* Left side - Search & filters */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||
<input
|
||||
type="text"
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="Search users by name or location..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="relative">
|
||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Location Filter */}
|
||||
<div className="relative">
|
||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value={locationFilter}
|
||||
onChange={(e) => setLocationFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Locations</option>
|
||||
{locations.map((location) => (
|
||||
<option key={location} value={location}>
|
||||
{location}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Skills Filter with Icon */}
|
||||
<div className="relative">
|
||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (
|
||||
e.target.value &&
|
||||
!skillsFilter.includes(e.target.value)
|
||||
) {
|
||||
setSkillsFilter((prev) => [...prev, e.target.value]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Add Skill Filter</option>
|
||||
{skillsOptions.map((skill) => (
|
||||
<option
|
||||
key={skill}
|
||||
value={skill}
|
||||
disabled={skillsFilter.includes(skill)}
|
||||
>
|
||||
{skill}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Add User Button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
onClick={handleShowNewUserModal}
|
||||
>
|
||||
<PlusOutlined className="text-sm" />
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filters display */}
|
||||
{(skillsFilter.length > 0 ||
|
||||
statusFilter !== 'all' ||
|
||||
locationFilter !== 'all') && (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||
|
||||
{/* Status filter badge */}
|
||||
{statusFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||
Status: {statusFilter}
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Location filter badge */}
|
||||
{locationFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||
Location: {locationFilter}
|
||||
<button
|
||||
onClick={() => setLocationFilter('all')}
|
||||
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Skills filter badges */}
|
||||
{skillsFilter.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
|
||||
>
|
||||
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||
<button
|
||||
onClick={() =>
|
||||
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
||||
}
|
||||
className="text-purple-600 hover:text-purple-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Clear all filters */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStatusFilter('all');
|
||||
setLocationFilter('all');
|
||||
setSkillsFilter([]);
|
||||
setGlobalFilter('');
|
||||
}}
|
||||
className="text-sm text-neutral-600"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination-aware results display */}
|
||||
{filteredData.length > 0 && (
|
||||
<div className="text-sm text-neutral-600">
|
||||
Showing {Math.min(pageSize, filteredData.length)} of{' '}
|
||||
{filteredData.length} users
|
||||
{filteredData.length > pageSize}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<DataTable data={filteredData} columns={columns} pageSize={10} />
|
||||
</section>
|
||||
|
||||
{/* Modals component */}
|
||||
<ModalUserDetail
|
||||
isOpen={showDetailModal}
|
||||
onClose={handleCloseDetailModal}
|
||||
user={selectedUser}
|
||||
/>
|
||||
|
||||
{/* New User Modal */}
|
||||
<ModalUserDetail
|
||||
isOpen={showNewUserModal}
|
||||
onClose={handleCloseNewUserModal}
|
||||
user={null} // null indicates creating new user
|
||||
/>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HackathonUsersPage;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
export const AppLayout: FC = (): ReactElement => {
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||
<BackofficeSidebar
|
||||
isOpen={mobileSidebarOpen}
|
||||
onClose={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Sticky top header */}
|
||||
<header
|
||||
className={
|
||||
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
|
||||
(mobileSidebarOpen ? 'z-0' : 'z-30')
|
||||
}
|
||||
>
|
||||
{/* Mobile menu button (shown on small screens) */}
|
||||
<button
|
||||
type="button"
|
||||
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700"
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-p3 font-semibold text-primary-700">
|
||||
IMPHNEN Backoffice
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { useItem, useConfirmItem } from '../_hook/use-item';
|
||||
|
||||
interface IModalAddPermission {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleAddItem?: () => Promise<boolean>;
|
||||
currentStep?: number;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const ModalAddPermission = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentStep,
|
||||
nextStep,
|
||||
resetStep,
|
||||
handleAddItem,
|
||||
}: IModalAddPermission) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-3 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
disableEscapeKeyDown={true}
|
||||
>
|
||||
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||
{currentStep === 2 && (
|
||||
<StepTwo
|
||||
onClose={onClose}
|
||||
handleAddItem={handleAddItem}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepOneProps {
|
||||
nextStep: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||
const { form, onSubmit } = useItem(nextStep);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header>
|
||||
<h2 className="text-p1 font-semibold text-primary-500">
|
||||
Tambah Permissions
|
||||
</h2>
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Nama Permission"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<Button variant="primary" size="lg" className="w-full" type="submit">
|
||||
Tambah Permission
|
||||
</Button>
|
||||
</form>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepTwoProps {
|
||||
onClose: () => void;
|
||||
handleAddItem?: () => Promise<boolean>;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => {
|
||||
const { onConfirm, onCancel } = useConfirmItem(
|
||||
onClose,
|
||||
resetStep,
|
||||
handleAddItem,
|
||||
{
|
||||
success: 'Data permissions berhasil ditambahkan',
|
||||
error: 'Data permissions gagal ditambahkan',
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header className="mb-7 text-center items-center">
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Tambah Permissions
|
||||
</h2>
|
||||
<p className="text-p3 text-center text-neutral-400">
|
||||
Apakah kamu yakin ingin
|
||||
<br /> menambahkan permission ini?
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex mb-0 gap-4">
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Tambahkan
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalAddPermission;
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { useConfirmItem } from '../_hook/use-item';
|
||||
|
||||
interface IModalDeletePermission {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleDelete?: () => Promise<boolean>;
|
||||
currentStep?: number;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const ModalDeletePermission = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
resetStep,
|
||||
handleDelete,
|
||||
}: IModalDeletePermission) => {
|
||||
const { onConfirm } = useConfirmItem(onClose, resetStep, handleDelete, {
|
||||
success: 'Data permissions berhasil dihapus',
|
||||
error: 'Data permissions gagal dihapus',
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
closeButtonClassName="hidden"
|
||||
>
|
||||
<Modal.Header className="gap-8">
|
||||
<img
|
||||
src="/chibi-delete.webp"
|
||||
alt="Delete?"
|
||||
width={148}
|
||||
className="self-center"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
|
||||
Delete Permissions
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin untuk menghapus permission ini? Menghapus data ini
|
||||
mungkin akan mempengaruhi fungsional sistem
|
||||
</p>
|
||||
</div>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onClose}
|
||||
>
|
||||
Batal Hapus
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full bg-danger-500 hover:bg-danger-600"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Hapus Item
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalDeletePermission;
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { useConfirmItem } from '../_hook/use-item';
|
||||
|
||||
interface IModalUpdatePermission {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleUpdate?: () => Promise<boolean>;
|
||||
currentStep?: number;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const ModalUpdatePermission = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
resetStep,
|
||||
handleUpdate,
|
||||
}: IModalUpdatePermission) => {
|
||||
const { onConfirm } = useConfirmItem(onClose, resetStep, handleUpdate, {
|
||||
success: 'Perubahan permissions berhasil dilakukan',
|
||||
error: 'Perubahan permissions gagal dilakukan',
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-3 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
disableEscapeKeyDown={true}
|
||||
>
|
||||
<Modal.Header>
|
||||
<h2 className="text-p1 font-semibold text-primary-500">
|
||||
Update Permissions
|
||||
</h2>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex flex-col gap-8">
|
||||
<InputField
|
||||
label="Name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Nama Permission"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
type="submit"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Update Permission
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalUpdatePermission;
|
||||
+10
-6
@@ -1,18 +1,23 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
// import { zodResolver } from '@hookform/resolvers/zod';
|
||||
// import {
|
||||
// gachaRollItemSchema,
|
||||
// TGachaRollItem
|
||||
// } from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const useItem = (
|
||||
nextStep: () => void,
|
||||
initialValues?: any,
|
||||
onDataCapture?: (data: any) => void,
|
||||
initialValues?: any
|
||||
) => {
|
||||
const form = useForm<any>({
|
||||
// resolver: zodResolver(),
|
||||
mode: 'all',
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
onDataCapture?.(data);
|
||||
console.log('Form data:', data);
|
||||
nextStep();
|
||||
});
|
||||
|
||||
@@ -33,9 +38,8 @@ export const useConfirmItem = (
|
||||
) => {
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
if (actionFunction) {
|
||||
await actionFunction();
|
||||
}
|
||||
// const result = await actionFunction?.();
|
||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||
toast.success(messages?.success);
|
||||
onClose();
|
||||
resetStep();
|
||||
@@ -0,0 +1,202 @@
|
||||
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||
import {
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import ModalAddPermission from './_components/modal-add-permission';
|
||||
import ModalUpdatePermission from './_components/modal-update-permission';
|
||||
import ModalDeletePermission from './_components/modal-delete-permission';
|
||||
import { useQueryState } from '@imphnen-frontend-service/utils';
|
||||
import React from 'react';
|
||||
|
||||
interface Permission {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const mockData: Permission[] = [
|
||||
{ id: 1, name: 'Read' },
|
||||
{ id: 2, name: 'Create' },
|
||||
{ id: 3, name: 'Update' },
|
||||
{ id: 4, name: 'Delete' },
|
||||
];
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const [showModalAddItem, setShowModalAddItem] = useState(false);
|
||||
const [showModalUpdateItem, setShowModalUpdateItem] = useState(false);
|
||||
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
|
||||
|
||||
const {
|
||||
step: currentStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
resetStep,
|
||||
} = useQueryState('step', {
|
||||
defaultValue: 1,
|
||||
maxValue: 2,
|
||||
minValue: 1,
|
||||
});
|
||||
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
});
|
||||
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
||||
|
||||
const columns: ColumnDef<Permission>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'No',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
cell: () => (
|
||||
<div className="flex gap-[8px]">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowModalUpdateItem(true);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<EditOutlined /> Update
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowModalDeleteItem(true);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<DeleteOutlined /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||
manualPagination: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Permissions</h1>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
<div className="flex justify-between items-center gap-8 mb-2">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama permissions"
|
||||
className="pl-12 w-full max-h-full"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex gap-3 text-nowrap"
|
||||
onClick={() => {
|
||||
setShowModalAddItem(true);
|
||||
}}
|
||||
>
|
||||
<PlusOutlined />
|
||||
Tambah Permissionss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={mockData}
|
||||
columns={columns}
|
||||
pageSize={9}
|
||||
table={table}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<ModalAddPermission
|
||||
currentStep={currentStep}
|
||||
isOpen={showModalAddItem}
|
||||
onClose={() => setShowModalAddItem(false)}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
<ModalUpdatePermission
|
||||
isOpen={showModalUpdateItem}
|
||||
onClose={() => setShowModalUpdateItem(false)}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
<ModalDeletePermission
|
||||
isOpen={showModalDeleteItem}
|
||||
onClose={() => setShowModalDeleteItem(false)}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user