Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5cfed24ca | ||
|
|
f018da21c1 |
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npx nx run-many:*)",
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(npm view:*)",
|
||||||
|
"Bash(npx nx build landing)",
|
||||||
|
"Bash(npm uninstall:*)",
|
||||||
|
"Bash(dir:*)",
|
||||||
|
"Bash(ren page.tsx page-original.tsx)",
|
||||||
|
"Bash(ren:*)",
|
||||||
|
"Bash(npx supabase:*)",
|
||||||
|
"Bash(libs/service/src/types/supabase.ts)",
|
||||||
|
"Bash(npx nx build:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(git push)",
|
||||||
|
"Bash(findstr:*)",
|
||||||
|
"Bash(ls:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
if [[ ! -d "/Users/ms/Development/personal/imphnen-frontend-service" ]]; then
|
||||||
|
echo "Cannot find source directory; Did you move it?"
|
||||||
|
echo "(Looking for "/Users/ms/Development/personal/imphnen-frontend-service")"
|
||||||
|
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# rebuild the cache forcefully
|
||||||
|
_nix_direnv_force_reload=1 direnv exec "/Users/ms/Development/personal/imphnen-frontend-service" true
|
||||||
|
|
||||||
|
# Update the mtime for .envrc.
|
||||||
|
# This will cause direnv to reload again - but without re-building.
|
||||||
|
touch "/Users/ms/Development/personal/imphnen-frontend-service/.envrc"
|
||||||
|
|
||||||
|
# Also update the timestamp of whatever profile_rc we have.
|
||||||
|
# This makes sure that we know we are up to date.
|
||||||
|
touch -r "/Users/ms/Development/personal/imphnen-frontend-service/.envrc" "/Users/ms/Development/personal/imphnen-frontend-service/.direnv"/*.rc
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/8hx8jwa24q6rkzhca9iqy5ckk9rbphi1-source
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/jizz7ld5r2w11y03wrmnvwsskc45krj9-source
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/y4p9szcab9xd7v1c7k9ghr86hb6myyg4-nix-shell-env
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Nx
|
||||||
|
.nx
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
.editorconfig
|
||||||
|
.prettierrc
|
||||||
|
.eslintrc
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Nix Build & Deploy
|
name: Nix Build & Cache
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -7,44 +7,8 @@ on:
|
|||||||
branches: ['develop']
|
branches: ['develop']
|
||||||
|
|
||||||
jobs:
|
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:
|
build:
|
||||||
needs: detect
|
|
||||||
if: needs.detect.outputs.affected != '[]'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
app: ${{ fromJson(needs.detect.outputs.affected) }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -57,53 +21,20 @@ jobs:
|
|||||||
name: msdqn
|
name: msdqn
|
||||||
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
||||||
|
|
||||||
- name: Build ${{ matrix.app }}
|
- name: Build all packages
|
||||||
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: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
nix build .#landing -o result-landing
|
||||||
echo "${INFRA_DEPLOY_KEY}" > ~/.ssh/deploy_key
|
nix build .#backoffice -o result-backoffice
|
||||||
chmod 600 ~/.ssh/deploy_key
|
nix build .#gacha -o result-gacha
|
||||||
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
nix build .#dimentorin -o result-dimentorin
|
||||||
ssh-keyscan 167.235.70.37 >> ~/.ssh/known_hosts 2>/dev/null
|
nix build .#hackathon -o result-hackathon
|
||||||
|
nix build .#infra -o result-infra
|
||||||
|
|
||||||
- name: Update infra flake.lock
|
- name: Show build outputs
|
||||||
run: |
|
run: |
|
||||||
export GIT_SSH_COMMAND="ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes"
|
echo "Landing: $(readlink result-landing)"
|
||||||
git clone git@github.com:IMPHNEN/imphnen-infrastructure.git /tmp/infra
|
echo "Backoffice: $(readlink result-backoffice)"
|
||||||
cd /tmp/infra
|
echo "Gacha: $(readlink result-gacha)"
|
||||||
nix flake update imphnen-frontend
|
echo "Dimentorin: $(readlink result-dimentorin)"
|
||||||
if git diff --quiet flake.lock; then
|
echo "Hackathon: $(readlink result-hackathon)"
|
||||||
echo "flake.lock unchanged, skipping"
|
echo "Infra: $(readlink result-infra)"
|
||||||
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
|
dist
|
||||||
tmp
|
tmp
|
||||||
out-tsc
|
out-tsc
|
||||||
docs/FigmaImage/
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
@@ -66,17 +66,3 @@ out
|
|||||||
.github/instructions/nx.instructions.md
|
.github/instructions/nx.instructions.md
|
||||||
|
|
||||||
.env.local
|
.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
|
<!-- nx configuration end-->
|
||||||
- **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)
|
|
||||||
|
|||||||
@@ -4,117 +4,148 @@
|
|||||||
<img src="docs/logo.svg" alt="IMPHNEN">
|
<img src="docs/logo.svg" alt="IMPHNEN">
|
||||||
</p>
|
</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>.
|
||||||
|
5. **QR Campaign** - Application for QR Campaign Management.
|
||||||
|
|
||||||
| App | Framework | URL |
|
## How to install
|
||||||
|-----|-----------|-----|
|
|
||||||
| **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) |
|
|
||||||
|
|
||||||
## 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 |
|
## How to run
|
||||||
|-----|---------|
|
|
||||||
| `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) |
|
|
||||||
|
|
||||||
## Getting Started
|
### Setup Environment Variables
|
||||||
|
|
||||||
### Prerequisites
|
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.
|
||||||
|
|
||||||
- Node.js 22
|
|
||||||
- [Nix](https://nixos.org/download/) (optional, for reproducible builds)
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git
|
cd apps/{appname}
|
||||||
cd imphnen-frontend-service
|
cp .env.example .env
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
```sh
|
Use the following commands to run in development mode:
|
||||||
nx dev <app> # e.g. nx dev landing, nx dev backoffice
|
|
||||||
```
|
- **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
|
||||||
|
```
|
||||||
|
- **QR Campaign**:
|
||||||
|
```sh
|
||||||
|
npm run qrcampaign:dev
|
||||||
|
```
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
```sh
|
Use the following commands to build the applications:
|
||||||
nx build <app> # build single app
|
|
||||||
nx run-many -t build --all # build everything
|
|
||||||
nx affected -t build # build only what changed
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
||||||
|
```
|
||||||
|
- **QR Campaign**:
|
||||||
|
```sh
|
||||||
|
npm run qrcampaign:build
|
||||||
|
```
|
||||||
|
|
||||||
```sh
|
### Production
|
||||||
nix build .#<app> # e.g. nix build .#landing, .#dimentorin
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
- **Gacha**:
|
||||||
|
```sh
|
||||||
```sh
|
npm run gacha:prod
|
||||||
nx test <project> # unit tests (vitest)
|
```
|
||||||
nx e2e <app>-e2e # e2e tests (playwright)
|
- **Backoffice**:
|
||||||
nx lint <project> # eslint
|
```sh
|
||||||
```
|
npm run backoffice:prod
|
||||||
|
```
|
||||||
|
- **Dimentorin**:
|
||||||
|
```sh
|
||||||
|
npm run dimentorin:prod
|
||||||
|
```
|
||||||
|
- **Landing Page**:
|
||||||
|
```sh
|
||||||
|
npm run landing:prod
|
||||||
|
```
|
||||||
|
- **QR Campaign**:
|
||||||
|
```sh
|
||||||
|
npm run qrcampaign:prod
|
||||||
|
```
|
||||||
|
|
||||||
### Storybook
|
### Storybook
|
||||||
|
|
||||||
```sh
|
This repository uses Storybook to develop, test, and document UI components in an isolated and interactive environment. Below are the commands to work with Storybook:
|
||||||
nx storybook ui # run storybook for ui lib
|
|
||||||
nx build-storybook ui # build static 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
|
```sh
|
||||||
2. **build** — matrix strategy builds only affected apps with Nix, pushes to [Cachix](https://app.cachix.org/cache/msdqn)
|
npm run ui:storybook
|
||||||
3. **deploy** — updates `flake.lock` in [imphnen-infrastructure](https://github.com/IMPHNEN/imphnen-infrastructure) and deploys to the server using [clan](https://clan.lol)
|
```
|
||||||
|
|
||||||
## Tech Stack
|
- **Run Unit Test**
|
||||||
|
|
||||||
- **Monorepo**: Nx 22.6
|
This command runs unit tests for the UI components to ensure they function as expected.
|
||||||
- **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
|
|
||||||
|
|
||||||
## Contributing
|
```sh
|
||||||
|
npm run ui:test
|
||||||
|
```
|
||||||
|
|
||||||
1. Fork and clone the repository
|
- **Build Components**
|
||||||
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
|
|
||||||
|
|
||||||
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_API_URL=
|
||||||
VITE_GITHUB_CLIENT_ID=
|
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
|
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
|
||||||
|
<link rel="stylesheet" href="/src/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
|
||||||
|
import { PieLabelProps } from "recharts/types/polar/Pie"
|
||||||
|
|
||||||
type ChartProps = {
|
type ChartProps = {
|
||||||
name: string
|
name: string
|
||||||
@@ -13,20 +14,14 @@ const chartData: ChartProps[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const RADIAN = Math.PI / 180;
|
const RADIAN = Math.PI / 180;
|
||||||
const renderCustomizedLabel = (props: any) => {
|
const renderCustomizedLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }: PieLabelProps) => {
|
||||||
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 radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
const x = cx + radius * Math.cos(-(midAngle ?? 0) * RADIAN);
|
||||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
const y = cy + radius * Math.sin(-(midAngle ?? 0) * RADIAN);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
|
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
|
||||||
{`${(percent * 100).toFixed(0)}%`}
|
{`${((percent ?? 1) * 100).toFixed(0)}%`}
|
||||||
</text>
|
</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 = (
|
export const useItem = (
|
||||||
nextStep: () => void,
|
nextStep: () => void,
|
||||||
initialValues?: Partial<TGachaItem>,
|
initialValues?: TGachaItem
|
||||||
onDataCapture?: (data: any) => void,
|
|
||||||
) => {
|
) => {
|
||||||
const form = useForm<any>({
|
const form = useForm<TGachaItem>({
|
||||||
resolver: zodResolver(gachaItemSchema),
|
resolver: zodResolver(gachaItemSchema),
|
||||||
mode: 'all',
|
mode: 'all',
|
||||||
defaultValues: initialValues,
|
defaultValues: initialValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => {
|
const onSubmit = form.handleSubmit((data) => {
|
||||||
onDataCapture?.(data);
|
console.log('Form data:', data);
|
||||||
nextStep();
|
nextStep();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,9 +38,8 @@ export const useConfirmItem = (
|
|||||||
) => {
|
) => {
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
if (actionFunction) {
|
// const result = await actionFunction?.();
|
||||||
await actionFunction();
|
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||||
}
|
|
||||||
toast.success(messages?.success);
|
toast.success(messages?.success);
|
||||||
onClose();
|
onClose();
|
||||||
resetStep();
|
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 = (
|
export const useItem = (
|
||||||
nextStep: () => void,
|
nextStep: () => void,
|
||||||
initialValues?: Partial<TGachaRollItem>,
|
initialValues?: TGachaRollItem
|
||||||
onDataCapture?: (data: any) => void,
|
|
||||||
) => {
|
) => {
|
||||||
const form = useForm<any>({
|
const form = useForm<TGachaRollItem>({
|
||||||
resolver: zodResolver(gachaRollItemSchema),
|
resolver: zodResolver(gachaRollItemSchema),
|
||||||
mode: 'all',
|
mode: 'all',
|
||||||
defaultValues: initialValues,
|
defaultValues: initialValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => {
|
const onSubmit = form.handleSubmit((data) => {
|
||||||
onDataCapture?.(data);
|
console.log('Form data:', data);
|
||||||
nextStep();
|
nextStep();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,9 +38,8 @@ export const useConfirmItem = (
|
|||||||
) => {
|
) => {
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
if (actionFunction) {
|
// const result = await actionFunction?.();
|
||||||
await actionFunction();
|
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||||
}
|
|
||||||
toast.success(messages?.success);
|
toast.success(messages?.success);
|
||||||
onClose();
|
onClose();
|
||||||
resetStep();
|
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,64 @@
|
|||||||
|
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { FC, ReactElement } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getAdminUsers,
|
||||||
|
getAdminTeams,
|
||||||
|
getAdminSubmissions,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
|
export const HackathonDashboardPage: FC = (): ReactElement => {
|
||||||
|
// Fetch total participants
|
||||||
|
const { data: usersData } = useQuery({
|
||||||
|
queryKey: ['admin-users-count'],
|
||||||
|
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch total teams
|
||||||
|
const { data: teamsData } = useQuery({
|
||||||
|
queryKey: ['admin-teams-count'],
|
||||||
|
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch total submissions
|
||||||
|
const { data: submissionsData } = useQuery({
|
||||||
|
queryKey: ['admin-submissions-count'],
|
||||||
|
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalParticipants = usersData?.meta?.total_data ?? '??';
|
||||||
|
const totalTeams = teamsData?.meta?.total_data ?? '??';
|
||||||
|
const totalSubmissions = submissionsData?.meta?.total_data ?? '??';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-5 gap-5">
|
||||||
|
{/* Participant */}
|
||||||
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||||
|
{totalParticipants}
|
||||||
|
</h3>
|
||||||
|
<p className="text-neutral-400 text-p3">Total Participants</p>
|
||||||
|
</div>
|
||||||
|
{/* Team */}
|
||||||
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||||
|
{totalTeams}
|
||||||
|
</h3>
|
||||||
|
<p className="text-neutral-400 text-p3">Total Teams</p>
|
||||||
|
</div>
|
||||||
|
{/* Project Submitted */}
|
||||||
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||||
|
{totalSubmissions}
|
||||||
|
</h3>
|
||||||
|
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonDashboardPage;
|
||||||
+19
-3
@@ -40,7 +40,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center">
|
||||||
@@ -68,8 +68,9 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Submission Status */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 p-4 border rounded-lg',
|
'flex items-center gap-3 p-4 border rounded-lg',
|
||||||
@@ -95,6 +96,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Project Description */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-neutral-700 mb-2">
|
<h3 className="text-sm font-medium text-neutral-700 mb-2">
|
||||||
Project Description
|
Project Description
|
||||||
@@ -104,11 +106,13 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Project Links */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-medium text-neutral-700">
|
<h3 className="text-sm font-medium text-neutral-700">
|
||||||
Project Links
|
Project Links
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{/* Repository URL */}
|
||||||
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||||
<LinkOutlined className="text-primary-500 mt-1" />
|
<LinkOutlined className="text-primary-500 mt-1" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -126,6 +130,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Demo URL */}
|
||||||
{submission.demo_url && (
|
{submission.demo_url && (
|
||||||
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||||
<LinkOutlined className="text-primary-500 mt-1" />
|
<LinkOutlined className="text-primary-500 mt-1" />
|
||||||
@@ -145,6 +150,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Presentation URL */}
|
||||||
{submission.presentation_url && (
|
{submission.presentation_url && (
|
||||||
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||||
<LinkOutlined className="text-primary-500 mt-1" />
|
<LinkOutlined className="text-primary-500 mt-1" />
|
||||||
@@ -165,6 +171,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Screenshots */}
|
||||||
{submission.screenshots && submission.screenshots.length > 0 && (
|
{submission.screenshots && submission.screenshots.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-medium text-neutral-700 flex items-center gap-2">
|
<h3 className="text-sm font-medium text-neutral-700 flex items-center gap-2">
|
||||||
@@ -191,6 +198,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-neutral-200">
|
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-neutral-200">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-neutral-500 mb-1">Created</p>
|
<p className="text-xs text-neutral-500 mb-1">Created</p>
|
||||||
@@ -219,11 +227,19 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-neutral-200 bg-neutral-50">
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-neutral-200 bg-neutral-50">
|
||||||
<Button variant="secondary" onClick={onClose}>
|
<Button variant="secondary" onClick={onClose}>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* <Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
console.log('Edit submission:', submission.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit Status
|
||||||
|
</Button> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import {
|
||||||
|
FC,
|
||||||
|
ReactElement,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
} from 'react';
|
||||||
|
import SubmissionModal from './_components/submission-modal';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
FilterOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getAdminSubmissions,
|
||||||
|
TAdminSubmissionItem,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
type SubmissionType = TAdminSubmissionItem;
|
||||||
|
|
||||||
|
export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const currentPage = Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(searchParams.get('page') || '1', 10)
|
||||||
|
);
|
||||||
|
const searchQuery = searchParams.get('search') || '';
|
||||||
|
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
|
||||||
|
const statusFilter = searchParams.get('status') || 'all';
|
||||||
|
|
||||||
|
const [showSubmissionModal, setShowSubmissionModal] = useState(false);
|
||||||
|
const [selectedSubmission, setSelectedSubmission] =
|
||||||
|
useState<SubmissionType | null>(null);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
||||||
|
|
||||||
|
// Fetch submissions from API
|
||||||
|
const {
|
||||||
|
data: submissionsResponse,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
'admin-submissions',
|
||||||
|
currentPage,
|
||||||
|
perPage,
|
||||||
|
statusFilter,
|
||||||
|
searchQuery,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
getAdminSubmissions({
|
||||||
|
page: currentPage,
|
||||||
|
per_page: perPage,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
}),
|
||||||
|
staleTime: 30000, // 30 seconds cache
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalData = submissionsResponse?.meta?.total_data || 0;
|
||||||
|
const totalPages = submissionsResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
|
// Handle page change
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', newPage.toString());
|
||||||
|
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||||
|
if (searchQuery) params.set('search', searchQuery);
|
||||||
|
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||||
|
setSearchParams(params);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
},
|
||||||
|
[setSearchParams, perPage, searchQuery, statusFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate page number
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||||
|
setSearchParams({ page: totalPages.toString() });
|
||||||
|
}
|
||||||
|
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
||||||
|
|
||||||
|
// Sync globalFilter with URL
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalFilter(searchQuery);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Handle search
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', '1');
|
||||||
|
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||||
|
if (globalFilter.trim()) {
|
||||||
|
params.set('search', globalFilter.trim());
|
||||||
|
}
|
||||||
|
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||||
|
setSearchParams(params);
|
||||||
|
}, [globalFilter, setSearchParams, perPage, statusFilter]);
|
||||||
|
|
||||||
|
const handleSearchKeyPress = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSearch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle per page change
|
||||||
|
const handlePerPageChange = useCallback(
|
||||||
|
(newPerPage: number) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', '1');
|
||||||
|
params.set('per_page', newPerPage.toString());
|
||||||
|
if (searchQuery) params.set('search', searchQuery);
|
||||||
|
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||||
|
setSearchParams(params);
|
||||||
|
},
|
||||||
|
[setSearchParams, searchQuery, statusFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle status filter change
|
||||||
|
// const handleStatusFilterChange = useCallback(
|
||||||
|
// (newStatus: string) => {
|
||||||
|
// const params = new URLSearchParams();
|
||||||
|
// params.set('page', '1');
|
||||||
|
// if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||||
|
// if (searchQuery) params.set('search', searchQuery);
|
||||||
|
// if (newStatus !== 'all') params.set('status', newStatus);
|
||||||
|
// setSearchParams(params);
|
||||||
|
// },
|
||||||
|
// [setSearchParams, perPage, searchQuery]
|
||||||
|
// );
|
||||||
|
|
||||||
|
// Handle modal
|
||||||
|
const handleShowSubmissionModal = useCallback(
|
||||||
|
(submission: SubmissionType) => {
|
||||||
|
setSelectedSubmission(submission);
|
||||||
|
setShowSubmissionModal(true);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCloseSubmissionModal = useCallback(() => {
|
||||||
|
setShowSubmissionModal(false);
|
||||||
|
setSelectedSubmission(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get submissions data
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
return submissionsResponse?.data || [];
|
||||||
|
}, [submissionsResponse]);
|
||||||
|
|
||||||
|
// Memoize columns
|
||||||
|
const columns: ColumnDef<SubmissionType>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'project_name',
|
||||||
|
header: 'Project Name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium text-neutral-900">
|
||||||
|
{row.original.project_name}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'team_id',
|
||||||
|
header: 'Team ID',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-neutral-700 font-mono">
|
||||||
|
{row.original.team_id}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
|
||||||
|
status === 'submitted'
|
||||||
|
? 'bg-success-100 text-success-800'
|
||||||
|
: status === 'pending'
|
||||||
|
? 'bg-orange-100 text-orange-800'
|
||||||
|
: 'bg-neutral-100 text-neutral-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'submitted_at',
|
||||||
|
header: 'Submitted',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-900 text-sm">
|
||||||
|
{new Date(row.original.submitted_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
meta: { cellClassName: cn('w-48') },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||||
|
onClick={() => handleShowSubmissionModal(row.original)}
|
||||||
|
>
|
||||||
|
<EyeOutlined className="text-sm" />
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[handleShowSubmissionModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
|
Project Submissions
|
||||||
|
</h1>
|
||||||
|
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
||||||
|
placeholder="Search by project name..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
onKeyPress={handleSearchKeyPress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per Page Dropdown */}
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={perPage}
|
||||||
|
onChange={(e) =>
|
||||||
|
handlePerPageChange(parseInt(e.target.value, 10))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value={10}>10 / page</option>
|
||||||
|
<option value={20}>20 / page</option>
|
||||||
|
<option value={50}>50 / page</option>
|
||||||
|
<option value={100}>100 / page</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
{/* <div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => handleStatusFilterChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="submitted">Submitted</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="approved">Approved</option>
|
||||||
|
<option value="rejected">Rejected</option>
|
||||||
|
</select>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filters */}
|
||||||
|
{/* {statusFilter !== 'all' && (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||||
|
Status: {statusFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusFilterChange('all')}
|
||||||
|
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
handleStatusFilterChange('all');
|
||||||
|
setGlobalFilter('');
|
||||||
|
}}
|
||||||
|
className="text-sm text-neutral-600"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{/* Loading & results */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||||
|
<span className="ml-3 text-neutral-600">
|
||||||
|
Loading submissions...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : filteredData.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-neutral-600">
|
||||||
|
Showing {filteredData.length} of {totalData} submissions (Page{' '}
|
||||||
|
{currentPage} of {totalPages})
|
||||||
|
{isFetching && (
|
||||||
|
<span className="ml-2 text-primary-500">(Updating...)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
data={filteredData}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={perPage}
|
||||||
|
manualPagination={true}
|
||||||
|
pageCount={totalPages}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-neutral-500">
|
||||||
|
No submissions found. Try adjusting your filters.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Submission Modal */}
|
||||||
|
{selectedSubmission && (
|
||||||
|
<SubmissionModal
|
||||||
|
isOpen={showSubmissionModal}
|
||||||
|
onClose={handleCloseSubmissionModal}
|
||||||
|
submission={selectedSubmission}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonSubmissionsPage;
|
||||||
+13
@@ -33,6 +33,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||||
const bannerInputRef = useRef<HTMLInputElement>(null);
|
const bannerInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Initialize form data when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
if (team) {
|
if (team) {
|
||||||
@@ -54,6 +55,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
}
|
}
|
||||||
}, [isOpen, team]);
|
}, [isOpen, team]);
|
||||||
|
|
||||||
|
// Check if form has changes
|
||||||
const hasChanges = useMemo(() => {
|
const hasChanges = useMemo(() => {
|
||||||
if (!formData || !team) return !!formData;
|
if (!formData || !team) return !!formData;
|
||||||
return (
|
return (
|
||||||
@@ -66,6 +68,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
);
|
);
|
||||||
}, [formData, team]);
|
}, [formData, team]);
|
||||||
|
|
||||||
|
// Check if required fields are filled
|
||||||
const isFormValid = useMemo(() => {
|
const isFormValid = useMemo(() => {
|
||||||
if (!formData) return false;
|
if (!formData) return false;
|
||||||
return (
|
return (
|
||||||
@@ -141,6 +144,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||||
@@ -168,6 +172,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
<div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}>
|
<div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -184,6 +189,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Banner Section */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-neutral-700">
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
Team Banner{' '}
|
Team Banner{' '}
|
||||||
@@ -228,6 +234,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Logo & Name */}
|
||||||
<div className="grid grid-cols-12 gap-4 items-start">
|
<div className="grid grid-cols-12 gap-4 items-start">
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||||
@@ -298,6 +305,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-neutral-700">
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
Description <span className="text-danger-500">*</span>
|
Description <span className="text-danger-500">*</span>
|
||||||
@@ -311,6 +319,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* City */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-neutral-700">
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
City <span className="text-danger-500">*</span>
|
City <span className="text-danger-500">*</span>
|
||||||
@@ -327,6 +336,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Visibility */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-neutral-700">
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
Team Visibility
|
Team Visibility
|
||||||
@@ -363,6 +373,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Team Details */}
|
||||||
{team && (
|
{team && (
|
||||||
<div className="space-y-4 border-t border-neutral-200 pt-4">
|
<div className="space-y-4 border-t border-neutral-200 pt-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -418,6 +429,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between p-6 border-t border-neutral-200">
|
<div className="flex items-center justify-between p-6 border-t border-neutral-200">
|
||||||
<div>
|
<div>
|
||||||
{team && (
|
{team && (
|
||||||
@@ -451,6 +463,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
{showDeleteConfirm && (
|
{showDeleteConfirm && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4">
|
||||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||||
+5
-2
@@ -15,7 +15,7 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
|||||||
className = '',
|
className = '',
|
||||||
showPlaceholder = true,
|
showPlaceholder = true,
|
||||||
}) => {
|
}) => {
|
||||||
const aspectRatioClass = 'aspect-[3/1]';
|
const aspectRatioClass = 'aspect-[3/1]'; // 3:1 aspect ratio
|
||||||
|
|
||||||
if (!banner && !showPlaceholder) {
|
if (!banner && !showPlaceholder) {
|
||||||
return null;
|
return null;
|
||||||
@@ -35,6 +35,7 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
|||||||
alt={`${teamName} banner`}
|
alt={`${teamName} banner`}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
|
// Fallback to placeholder if image fails to load
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
target.style.display = 'none';
|
target.style.display = 'none';
|
||||||
const placeholder = target.nextElementSibling as HTMLElement;
|
const placeholder = target.nextElementSibling as HTMLElement;
|
||||||
@@ -43,10 +44,11 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/* Fallback placeholder (hidden by default, shown on image error) */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
|
'absolute inset-0 bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
|
||||||
'hidden'
|
'hidden' // Hidden by default
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -59,6 +61,7 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No banner - show placeholder
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
import {
|
||||||
|
FC,
|
||||||
|
ReactElement,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
} from 'react';
|
||||||
|
import ModalTeamDetail from './_components/modal-team-detail-new';
|
||||||
|
import { CityFilterSelect } from '../../../components/city-filter-select';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
EditOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
FilterOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getAdminTeams,
|
||||||
|
TAdminTeamItem,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
type TeamType = TAdminTeamItem;
|
||||||
|
|
||||||
|
export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const currentPage = Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(searchParams.get('page') || '1', 10)
|
||||||
|
);
|
||||||
|
const searchQuery = searchParams.get('search') || '';
|
||||||
|
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
|
||||||
|
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||||
|
const [showNewTeamModal, setShowNewTeamModal] = useState(false);
|
||||||
|
const [selectedTeam, setSelectedTeam] = useState<TeamType | null>(null);
|
||||||
|
useState<TeamType | null>(null);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
||||||
|
|
||||||
|
// Advanced filtering states
|
||||||
|
const [visibilityFilter, setVisibilityFilter] = useState('all');
|
||||||
|
const [cityFilter, setCityFilter] = useState('all');
|
||||||
|
|
||||||
|
// Fetch teams from API
|
||||||
|
const {
|
||||||
|
data: teamsResponse,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
'admin-teams',
|
||||||
|
currentPage,
|
||||||
|
perPage,
|
||||||
|
cityFilter,
|
||||||
|
visibilityFilter,
|
||||||
|
searchQuery,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
getAdminTeams({
|
||||||
|
page: currentPage,
|
||||||
|
per_page: perPage,
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
}),
|
||||||
|
staleTime: 30000, // 30 seconds cache
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalData = teamsResponse?.meta?.total_data || 0;
|
||||||
|
const totalPages = teamsResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
|
// Handle page change - update URL query params
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', newPage.toString());
|
||||||
|
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||||
|
if (searchQuery) params.set('search', searchQuery);
|
||||||
|
setSearchParams(params);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
},
|
||||||
|
[setSearchParams, perPage, searchQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate page number doesn't exceed total pages
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||||
|
setSearchParams({ page: totalPages.toString() });
|
||||||
|
}
|
||||||
|
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
||||||
|
|
||||||
|
// Sync globalFilter with URL search param on mount
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalFilter(searchQuery);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Handle search teams
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', '1');
|
||||||
|
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||||
|
if (globalFilter.trim()) {
|
||||||
|
params.set('search', globalFilter.trim());
|
||||||
|
}
|
||||||
|
setSearchParams(params);
|
||||||
|
}, [globalFilter, setSearchParams, perPage]);
|
||||||
|
|
||||||
|
// Handle Enter key press in search input
|
||||||
|
const handleSearchKeyPress = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSearch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle per page change
|
||||||
|
const handlePerPageChange = useCallback(
|
||||||
|
(newPerPage: number) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', '1');
|
||||||
|
params.set('per_page', newPerPage.toString());
|
||||||
|
if (searchQuery) params.set('search', searchQuery);
|
||||||
|
setSearchParams(params);
|
||||||
|
},
|
||||||
|
[setSearchParams, searchQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize the callback to prevent recreation
|
||||||
|
const handleShowDetailModal = useCallback((team: TeamType) => {
|
||||||
|
setSelectedTeam(team);
|
||||||
|
setShowDetailModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseDetailModal = useCallback(() => {
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedTeam(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShowNewTeamModal = useCallback(() => {
|
||||||
|
setShowNewTeamModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseNewTeamModal = useCallback(() => {
|
||||||
|
setShowNewTeamModal(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get teams data from API response
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
return teamsResponse?.data || [];
|
||||||
|
}, [teamsResponse]);
|
||||||
|
|
||||||
|
// Memoize columns to prevent recreation on every render
|
||||||
|
const columns: ColumnDef<TeamType>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Team',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const team = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Team Logo */}
|
||||||
|
<div className="w-10 h-10 rounded-full bg-neutral-100 flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
|
{team.logo ? (
|
||||||
|
<img
|
||||||
|
src={team.logo}
|
||||||
|
alt={team.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TeamOutlined className="text-neutral-400 text-lg" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Team Name */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
className="font-medium text-neutral-900 truncate max-w-sm"
|
||||||
|
title={team.name}
|
||||||
|
>
|
||||||
|
{team.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'city',
|
||||||
|
header: 'City',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-700">{row.original.city}</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'visibility',
|
||||||
|
header: 'Visibility',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isPublic = row.original.visibility === 'public';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
|
||||||
|
isPublic
|
||||||
|
? 'bg-success-100 text-success-800'
|
||||||
|
: 'bg-neutral-100 text-neutral-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPublic ? 'Public' : 'Private'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leader',
|
||||||
|
header: 'Leader ID',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="text-sm text-neutral-700 font-mono">
|
||||||
|
{row.original.leader_id}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: 'Created',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-900 text-sm">
|
||||||
|
{new Date(row.original.created_at).toLocaleDateString('en-UK', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
meta: { cellClassName: cn('w-48') },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||||
|
onClick={() => handleShowDetailModal(row.original)}
|
||||||
|
>
|
||||||
|
<EditOutlined className="text-sm" />
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[handleShowDetailModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
|
Team Management
|
||||||
|
</h1>
|
||||||
|
{/* Filters and actions */}
|
||||||
|
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||||
|
{/* Left side - Search & filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
||||||
|
placeholder="Search teams by name or city..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
onKeyPress={handleSearchKeyPress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per Page Dropdown */}
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={perPage}
|
||||||
|
onChange={(e) =>
|
||||||
|
handlePerPageChange(parseInt(e.target.value, 10))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value={10}>10 / page</option>
|
||||||
|
<option value={20}>20 / page</option>
|
||||||
|
<option value={50}>50 / page</option>
|
||||||
|
<option value={100}>100 / page</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visibility Filter */}
|
||||||
|
{/* <div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={visibilityFilter}
|
||||||
|
onChange={(e) => setVisibilityFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Visibility</option>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
<option value="private">Private</option>
|
||||||
|
</select>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* City Filter */}
|
||||||
|
{/* <CityFilterSelect
|
||||||
|
value={cityFilter}
|
||||||
|
onChange={setCityFilter}
|
||||||
|
className="w-full sm:w-44"
|
||||||
|
placeholder="Search cities..."
|
||||||
|
allOptionLabel="All Cities"
|
||||||
|
/> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Add Team Button */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="flex items-center gap-2 px-4 py-2"
|
||||||
|
onClick={handleShowNewTeamModal}
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-sm" />
|
||||||
|
Add Team
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filters display */}
|
||||||
|
{(visibilityFilter !== 'all' || cityFilter !== 'all') && (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||||
|
|
||||||
|
{/* Visibility filter badge */}
|
||||||
|
{visibilityFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||||
|
Visibility: {visibilityFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibilityFilter('all')}
|
||||||
|
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* City filter badge */}
|
||||||
|
{cityFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||||
|
City: {cityFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setCityFilter('all')}
|
||||||
|
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Clear all filters */}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setVisibilityFilter('all');
|
||||||
|
setCityFilter('all');
|
||||||
|
setGlobalFilter('');
|
||||||
|
}}
|
||||||
|
className="text-sm text-neutral-600"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading & results display */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||||
|
<span className="ml-3 text-neutral-600">Loading teams...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredData.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-neutral-600">
|
||||||
|
Showing {filteredData.length} of {totalData} teams (Page{' '}
|
||||||
|
{currentPage} of {totalPages})
|
||||||
|
{isFetching && (
|
||||||
|
<span className="ml-2 text-primary-500">(Updating...)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
data={filteredData}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={perPage}
|
||||||
|
manualPagination={true}
|
||||||
|
pageCount={totalPages}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-neutral-500">
|
||||||
|
No teams found. Try adjusting your filters.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Modals component */}
|
||||||
|
<ModalTeamDetail
|
||||||
|
isOpen={showDetailModal}
|
||||||
|
onClose={handleCloseDetailModal}
|
||||||
|
team={selectedTeam}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* New Team Modal */}
|
||||||
|
<ModalTeamDetail
|
||||||
|
isOpen={showNewTeamModal}
|
||||||
|
onClose={handleCloseNewTeamModal}
|
||||||
|
team={null} // null indicates creating new team
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonTeamsPage;
|
||||||
+33
-4
@@ -37,13 +37,16 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
|
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Initialize form data when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
if (user) {
|
if (user) {
|
||||||
|
// Edit existing user
|
||||||
setFormData({ ...user });
|
setFormData({ ...user });
|
||||||
} else {
|
} else {
|
||||||
|
// Create new user
|
||||||
setFormData({
|
setFormData({
|
||||||
id: '',
|
id: '', // Will be generated by backend
|
||||||
fullname: '',
|
fullname: '',
|
||||||
bio: '',
|
bio: '',
|
||||||
location: '',
|
location: '',
|
||||||
@@ -57,9 +60,10 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
}
|
}
|
||||||
}, [isOpen, user]);
|
}, [isOpen, user]);
|
||||||
|
|
||||||
|
// Check if form has changes
|
||||||
const hasChanges = useMemo(() => {
|
const hasChanges = useMemo(() => {
|
||||||
if (!formData) return false;
|
if (!formData) return false;
|
||||||
if (!user) return true;
|
if (!user) return true; // New user always has changes
|
||||||
return (
|
return (
|
||||||
formData.fullname !== user.fullname ||
|
formData.fullname !== user.fullname ||
|
||||||
formData.location !== user.location ||
|
formData.location !== user.location ||
|
||||||
@@ -70,6 +74,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
);
|
);
|
||||||
}, [formData, user]);
|
}, [formData, user]);
|
||||||
|
|
||||||
|
// Check if required fields are filled
|
||||||
const isFormValid = useMemo(() => {
|
const isFormValid = useMemo(() => {
|
||||||
if (!formData) return false;
|
if (!formData) return false;
|
||||||
return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
|
return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
|
||||||
@@ -94,22 +99,25 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
if (!formData) return;
|
if (!formData) return;
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
|
// Update existing user
|
||||||
console.log('Update user data:', formData);
|
console.log('Update user data:', formData);
|
||||||
} else {
|
} else {
|
||||||
|
// Create new user
|
||||||
console.log('Create new user:', formData);
|
console.log('Create new user:', formData);
|
||||||
}
|
}
|
||||||
|
// Here you would typically make an API call to save the data
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setFormData({ ...user });
|
setFormData({ ...user }); // Reset to original for edit mode
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAccount = () => {
|
const handleDeleteAccount = () => {
|
||||||
if (!user) return;
|
if (!user) return; // Can't delete new user
|
||||||
console.log('Delete user:', user.id);
|
console.log('Delete user:', user.id);
|
||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -118,16 +126,19 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
|
// Validate file type
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
alert('Please select an image file');
|
alert('Please select an image file');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate file size (max 5MB)
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
alert('Image size must be less than 5MB');
|
alert('Image size must be less than 5MB');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create preview URL
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
const avatarUrl = e.target?.result as string;
|
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"
|
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
{/* Hidden File Input */}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -179,8 +191,10 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
accept="image/*"
|
accept="image/*"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
{/* Header */}
|
||||||
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
|
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Interactive User Avatar */}
|
||||||
<div className="relative group ">
|
<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">
|
<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 ? (
|
{formData.avatar ? (
|
||||||
@@ -194,6 +208,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar Hover Overlay */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAvatarMenu(!showAvatarMenu)}
|
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"
|
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" />
|
<CameraOutlined className="text-white text-lg" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Avatar Menu Dropdown */}
|
||||||
{showAvatarMenu && (
|
{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">
|
<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
|
<button
|
||||||
@@ -253,14 +269,17 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
|
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* Left Column - Basic Info */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
Basic Information
|
Basic Information
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Full Name - Required */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<UserOutlined className="text-neutral-400" />
|
<UserOutlined className="text-neutral-400" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -291,6 +310,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Location - Required */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<EnvironmentOutlined className="text-neutral-400" />
|
<EnvironmentOutlined className="text-neutral-400" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -326,6 +346,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Joined Date - Read Only - Only show for existing users */}
|
||||||
{user && (
|
{user && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<CalendarOutlined className="text-neutral-400" />
|
<CalendarOutlined className="text-neutral-400" />
|
||||||
@@ -349,6 +370,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bio Section - Optional */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
|
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
|
||||||
Bio{' '}
|
Bio{' '}
|
||||||
@@ -368,7 +390,9 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Skills & Status */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Account Status - Enhanced Tab Design */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
Account Status
|
Account Status
|
||||||
@@ -424,6 +448,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Skills Section - Optional */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
Skills & Expertise{' '}
|
Skills & Expertise{' '}
|
||||||
@@ -485,6 +510,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Account Details - Read Only - Only show for existing users */}
|
||||||
{user && (
|
{user && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
@@ -521,6 +547,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
<div className="border-t border-neutral-200 px-8 py-6">
|
<div className="border-t border-neutral-200 px-8 py-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -531,6 +558,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
? 'Please fill required fields'
|
? 'Please fill required fields'
|
||||||
: 'No changes made'}
|
: 'No changes made'}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Delete Account Button - Only show for existing users */}
|
||||||
{user && (
|
{user && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
@@ -567,6 +595,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
{showDeleteConfirm && (
|
{showDeleteConfirm && (
|
||||||
<div className="fixed inset-0 z-60">
|
<div className="fixed inset-0 z-60">
|
||||||
<div
|
<div
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
import {
|
||||||
|
FC,
|
||||||
|
ReactElement,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
} from 'react';
|
||||||
|
import ModalUserDetail from './_components/modal-user-detail';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
EditOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
FilterOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { CityFilterSelect } from '../../../components/city-filter-select';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getAdminUsers,
|
||||||
|
TAdminUserItem,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
type UserType = TAdminUserItem;
|
||||||
|
|
||||||
|
// Skills options for filter
|
||||||
|
const skillsOptions = [
|
||||||
|
'Frontend Developer',
|
||||||
|
'Backend Developer',
|
||||||
|
'Full Stack Developer',
|
||||||
|
'DevOps Engineer',
|
||||||
|
'UI/UX Designer',
|
||||||
|
'Product Manager',
|
||||||
|
'Data Scientist',
|
||||||
|
'Mobile Developer',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const HackathonUsersPage: FC = (): ReactElement => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const currentPage = Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(searchParams.get('page') || '1', 10)
|
||||||
|
);
|
||||||
|
const searchQuery = searchParams.get('search') || '';
|
||||||
|
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
|
||||||
|
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||||
|
const [showNewUserModal, setShowNewUserModal] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
||||||
|
|
||||||
|
// Advanced filtering states
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [cityFilter, setCityFilter] = useState('all');
|
||||||
|
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Fetch users from API
|
||||||
|
const {
|
||||||
|
data: usersResponse,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
'admin-users',
|
||||||
|
currentPage,
|
||||||
|
perPage,
|
||||||
|
cityFilter,
|
||||||
|
statusFilter,
|
||||||
|
searchQuery,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
getAdminUsers({
|
||||||
|
page: currentPage,
|
||||||
|
per_page: perPage,
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
}),
|
||||||
|
staleTime: 30000, // 30 seconds cache
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalData = usersResponse?.meta?.total_data || 0;
|
||||||
|
const totalPages = usersResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
|
// Handle page change - update URL query params
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', newPage.toString());
|
||||||
|
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||||
|
if (searchQuery) params.set('search', searchQuery);
|
||||||
|
setSearchParams(params);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
},
|
||||||
|
[setSearchParams, perPage, searchQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate page number doesn't exceed total pages
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||||
|
setSearchParams({ page: totalPages.toString() });
|
||||||
|
}
|
||||||
|
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
||||||
|
|
||||||
|
// Sync globalFilter with URL search param on mount
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalFilter(searchQuery);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Handle search users
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', '1');
|
||||||
|
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||||
|
if (globalFilter.trim()) {
|
||||||
|
params.set('search', globalFilter.trim());
|
||||||
|
}
|
||||||
|
setSearchParams(params);
|
||||||
|
}, [globalFilter, setSearchParams, perPage]);
|
||||||
|
|
||||||
|
// Handle Enter key press in search input
|
||||||
|
const handleSearchKeyPress = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSearch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle per page change
|
||||||
|
const handlePerPageChange = useCallback(
|
||||||
|
(newPerPage: number) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', '1');
|
||||||
|
params.set('per_page', newPerPage.toString());
|
||||||
|
if (searchQuery) params.set('search', searchQuery);
|
||||||
|
setSearchParams(params);
|
||||||
|
},
|
||||||
|
[setSearchParams, searchQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize the callback to prevent recreation
|
||||||
|
const handleShowDetailModal = useCallback((user: UserType) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setShowDetailModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseDetailModal = useCallback(() => {
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShowNewUserModal = useCallback(() => {
|
||||||
|
setShowNewUserModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseNewUserModal = useCallback(() => {
|
||||||
|
setShowNewUserModal(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter data based on current filter states
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
const usersData = usersResponse?.data || [];
|
||||||
|
return usersData.filter((user: UserType) => {
|
||||||
|
// Status filter
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
const isActive = statusFilter === 'active';
|
||||||
|
if (user.is_active !== isActive) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills filter
|
||||||
|
if (skillsFilter.length > 0) {
|
||||||
|
const userSkills = user.skills || [];
|
||||||
|
const hasMatchingSkill = skillsFilter.some((skill) =>
|
||||||
|
userSkills.includes(skill)
|
||||||
|
);
|
||||||
|
if (!hasMatchingSkill) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [usersResponse, statusFilter, skillsFilter]);
|
||||||
|
|
||||||
|
// Memoize columns to prevent recreation on every render
|
||||||
|
const columns: ColumnDef<UserType>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'fullname',
|
||||||
|
header: 'User',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
|
||||||
|
{row.original.avatar ? (
|
||||||
|
<img
|
||||||
|
src={row.original.avatar}
|
||||||
|
alt={row.original.fullname}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserOutlined className="text-neutral-500 text-lg" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Name only */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-neutral-900 truncate">
|
||||||
|
{row.original.fullname}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'skills',
|
||||||
|
header: 'Skills',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const skills = row.original.skills || [];
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||||
|
{skills.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{skills.slice(0, 2).map((skill, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
|
||||||
|
>
|
||||||
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{skills.length > 2 && (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
|
||||||
|
+{skills.length - 2}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-400">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'location',
|
||||||
|
header: 'Location',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-700">{row.original.location}</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'is_active',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium',
|
||||||
|
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: (rowA, rowB) => {
|
||||||
|
const aActive = rowA.original.is_active;
|
||||||
|
const bActive = rowB.original.is_active;
|
||||||
|
if (aActive && !bActive) return -1;
|
||||||
|
if (!aActive && bActive) return 1;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: 'Joined',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-900 text-sm">
|
||||||
|
{new Date(row.original.created_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
meta: { cellClassName: cn('w-48') },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||||
|
onClick={() => handleShowDetailModal(row.original)}
|
||||||
|
>
|
||||||
|
<EditOutlined className="text-sm" />
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
{/* <Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="text-sm px-4 py-2"
|
||||||
|
onClick={() => {
|
||||||
|
// Toggle user status - implement later
|
||||||
|
console.log(`Toggle status for ${row.original.fullname}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.original.is_active ? 'Deactivate' : 'Activate'}
|
||||||
|
</Button> */}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[handleShowDetailModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
|
User Management
|
||||||
|
</h1>
|
||||||
|
{/* Filters and actions */}
|
||||||
|
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||||
|
{/* Left side - Search & filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
||||||
|
placeholder="Search users by name or location..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
onKeyPress={handleSearchKeyPress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per Page Dropdown */}
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={perPage}
|
||||||
|
onChange={(e) =>
|
||||||
|
handlePerPageChange(parseInt(e.target.value, 10))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value={10}>10 / page</option>
|
||||||
|
<option value={20}>20 / page</option>
|
||||||
|
<option value={50}>50 / page</option>
|
||||||
|
<option value={100}>100 / page</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
{/* <div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* City Filter */}
|
||||||
|
{/* <CityFilterSelect
|
||||||
|
value={cityFilter}
|
||||||
|
onChange={setCityFilter}
|
||||||
|
className="w-full sm:w-44"
|
||||||
|
placeholder="Search cities..."
|
||||||
|
allOptionLabel="All Cities"
|
||||||
|
/>
|
||||||
|
{cityFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||||
|
Location: {cityFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setCityFilter('all')}
|
||||||
|
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{/* Skills Filter with Icon */}
|
||||||
|
{/* <div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (
|
||||||
|
e.target.value &&
|
||||||
|
!skillsFilter.includes(e.target.value)
|
||||||
|
) {
|
||||||
|
setSkillsFilter((prev) => [...prev, e.target.value]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Add Skill Filter</option>
|
||||||
|
{skillsOptions.map((skill) => (
|
||||||
|
<option
|
||||||
|
key={skill}
|
||||||
|
value={skill}
|
||||||
|
disabled={skillsFilter.includes(skill)}
|
||||||
|
>
|
||||||
|
{skill}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Add User Button */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="flex items-center gap-2 px-4 py-2"
|
||||||
|
onClick={handleShowNewUserModal}
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-sm" />
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filters display */}
|
||||||
|
{(skillsFilter.length > 0 ||
|
||||||
|
statusFilter !== 'all' ||
|
||||||
|
cityFilter !== 'all') && (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||||
|
|
||||||
|
{/* Status filter badge */}
|
||||||
|
{statusFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||||
|
Status: {statusFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location filter badge */}
|
||||||
|
{cityFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||||
|
City: {cityFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setCityFilter('all')}
|
||||||
|
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skills filter badges */}
|
||||||
|
{skillsFilter.map((skill) => (
|
||||||
|
<span
|
||||||
|
key={skill}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
|
||||||
|
>
|
||||||
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
||||||
|
}
|
||||||
|
className="text-purple-600 hover:text-purple-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Clear all filters */}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter('all');
|
||||||
|
setCityFilter('all');
|
||||||
|
setSkillsFilter([]);
|
||||||
|
setGlobalFilter('');
|
||||||
|
}}
|
||||||
|
className="text-sm text-neutral-600"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading & results display */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||||
|
<span className="ml-3 text-neutral-600">Loading users...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredData.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-neutral-600">
|
||||||
|
Showing {filteredData.length} of {totalData} users (Page{' '}
|
||||||
|
{currentPage} of {totalPages})
|
||||||
|
{isFetching && (
|
||||||
|
<span className="ml-2 text-primary-500">(Updating...)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
data={filteredData}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={perPage}
|
||||||
|
manualPagination={true}
|
||||||
|
pageCount={totalPages}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-neutral-500">
|
||||||
|
No users found. Try adjusting your filters.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Modals component */}
|
||||||
|
<ModalUserDetail
|
||||||
|
isOpen={showDetailModal}
|
||||||
|
onClose={handleCloseDetailModal}
|
||||||
|
user={selectedUser}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* New User Modal */}
|
||||||
|
<ModalUserDetail
|
||||||
|
isOpen={showNewUserModal}
|
||||||
|
onClose={handleCloseNewUserModal}
|
||||||
|
user={null} // null indicates creating new user
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonUsersPage;
|
||||||
@@ -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 cursor-pointer"
|
||||||
|
onClick={() => setMobileSidebarOpen(true)}
|
||||||
|
aria-label="Open sidebar"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h1 className="text-p3 font-semibold text-primary-700">
|
||||||
|
IMPHNEN Backoffice
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Outlet />
|
||||||
|
</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 { useForm } from 'react-hook-form';
|
||||||
|
// import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
// import {
|
||||||
|
// gachaRollItemSchema,
|
||||||
|
// TGachaRollItem
|
||||||
|
// } from '@imphnen-frontend-service/service';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export const useItem = (
|
export const useItem = (
|
||||||
nextStep: () => void,
|
nextStep: () => void,
|
||||||
initialValues?: any,
|
initialValues?: any
|
||||||
onDataCapture?: (data: any) => void,
|
|
||||||
) => {
|
) => {
|
||||||
const form = useForm<any>({
|
const form = useForm<any>({
|
||||||
|
// resolver: zodResolver(),
|
||||||
mode: 'all',
|
mode: 'all',
|
||||||
defaultValues: initialValues,
|
defaultValues: initialValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => {
|
const onSubmit = form.handleSubmit((data) => {
|
||||||
onDataCapture?.(data);
|
console.log('Form data:', data);
|
||||||
nextStep();
|
nextStep();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,9 +38,8 @@ export const useConfirmItem = (
|
|||||||
) => {
|
) => {
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
if (actionFunction) {
|
// const result = await actionFunction?.();
|
||||||
await actionFunction();
|
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||||
}
|
|
||||||
toast.success(messages?.success);
|
toast.success(messages?.success);
|
||||||
onClose();
|
onClose();
|
||||||
resetStep();
|
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;
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||||
|
import {
|
||||||
|
FilterOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
AuditOutlined,
|
||||||
|
} 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 ModalProcessDelivery from './_components/modal-process-item';
|
||||||
|
|
||||||
|
type OrderValid = 'valid' | 'invalid' | 'unchecked';
|
||||||
|
type Status = 'undelivered' | 'delivered';
|
||||||
|
|
||||||
|
interface Prize {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
orderValid: OrderValid;
|
||||||
|
items: string;
|
||||||
|
address: string;
|
||||||
|
status: Status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
'Sertifikat + Laminating',
|
||||||
|
'Lanyard + ID Card',
|
||||||
|
'Pin',
|
||||||
|
'Sticker Isi 3',
|
||||||
|
'Sticker Isi 5',
|
||||||
|
'Gelang Karet',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock data for transactions
|
||||||
|
const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: 'Nama Lengkap',
|
||||||
|
orderValid: (i % 3 === 0
|
||||||
|
? 'invalid'
|
||||||
|
: i % 5 === 0
|
||||||
|
? 'unchecked'
|
||||||
|
: 'valid') as OrderValid,
|
||||||
|
items: items[i % items.length],
|
||||||
|
address: 'Jl. Pantai Cibaduyut Indah',
|
||||||
|
status: (i % 3 === 0 ? 'undelivered' : 'delivered') as Status,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
const [showModalProcessDelivery, setShowModalProcessDelivery] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
||||||
|
const [showFilter, setShowFilter] = useState(false);
|
||||||
|
|
||||||
|
const deliveryOptions = [
|
||||||
|
{ id: 'option1', value: 'undelivered', label: 'Undelivered' },
|
||||||
|
{ id: 'option1', value: 'delivered', label: 'Delivered' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns: ColumnDef<Prize>[] = [
|
||||||
|
{
|
||||||
|
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: 'Order Valid?',
|
||||||
|
accessorKey: 'orderValid',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.orderValid;
|
||||||
|
const statusColors: Record<OrderValid, string> = {
|
||||||
|
valid: 'bg-success-200 text-success-500',
|
||||||
|
invalid: 'bg-danger-200 text-danger-500',
|
||||||
|
unchecked: 'bg-warning-200 text-warning-900',
|
||||||
|
};
|
||||||
|
const statusText: Record<OrderValid, string> = {
|
||||||
|
valid: 'Valid',
|
||||||
|
invalid: 'Invalid',
|
||||||
|
unchecked: 'Unchecked',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||||
|
>
|
||||||
|
{statusText[status]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Items',
|
||||||
|
accessorKey: 'items',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Alamat Pengiriman',
|
||||||
|
accessorKey: 'address',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status;
|
||||||
|
const statusColors: Record<Status, string> = {
|
||||||
|
delivered: 'bg-success-200 text-success-500',
|
||||||
|
undelivered: 'bg-danger-200 text-danger-500',
|
||||||
|
};
|
||||||
|
const statusText: Record<Status, string> = {
|
||||||
|
delivered: 'Delivered',
|
||||||
|
undelivered: 'Undelivered',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||||
|
>
|
||||||
|
{statusText[status]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowModalProcessDelivery(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full"
|
||||||
|
>
|
||||||
|
<AuditOutlined className="text-[16px]" /> Process
|
||||||
|
</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 Pengiriman Hadiah</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, nomor order Shopee"
|
||||||
|
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"
|
||||||
|
onClick={() => setShowFilter(!showFilter)}
|
||||||
|
>
|
||||||
|
<FilterOutlined />
|
||||||
|
Filters
|
||||||
|
</Button>
|
||||||
|
{showFilter && (
|
||||||
|
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
||||||
|
<Filter
|
||||||
|
options={deliveryOptions}
|
||||||
|
onClose={() => setShowFilter(false)}
|
||||||
|
onFilterChange={(value) => {
|
||||||
|
console.log('Selected filter:', value);
|
||||||
|
// Filter logic di sini
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Modal Process Delivery */}
|
||||||
|
<ModalProcessDelivery
|
||||||
|
isOpen={showModalProcessDelivery}
|
||||||
|
onClose={() => setShowModalProcessDelivery(false)}
|
||||||
|
handleProcessDelivery={() => {
|
||||||
|
console.log('Action ketika user menekan tombol Proses Pengiriman');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
import { ArrowRightOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { Accordion, Modal, ModalProps } from "@imphnen-frontend-service/ui/molecules";
|
||||||
|
import { cn, For } from "@imphnen-frontend-service/utils";
|
||||||
|
import { FC } from "react";
|
||||||
|
|
||||||
|
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
|
||||||
|
|
||||||
|
export const ModalCreateRoadmap: FC<Omit<ModalProps, 'children'>> = ({ isOpen, onClose }) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
className="xl:max-w-[64rem] bg-white px-10 py-9"
|
||||||
|
closeButtonClassName="hidden"
|
||||||
|
>
|
||||||
|
<div className="overflow-y-auto max-h-[80vh]">
|
||||||
|
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
|
||||||
|
Create Roadmaps
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-5 gap-6 mb-12">
|
||||||
|
<div className="col-span-3">
|
||||||
|
<label className={labelClass}>Prompt</label>
|
||||||
|
<Textarea
|
||||||
|
className="min-w-full w-full h-[calc(100%-2rem)]"
|
||||||
|
placeholder="Create a learning roadmap for (subject) at the (level) level. Include topics, estimated duration, and logical progression."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-8">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Roadmap Name</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" placeholder="Nama Roadmap" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Roadmap Name</label>
|
||||||
|
<Select className="w-full">
|
||||||
|
<option value="pemula">Pemula</option>
|
||||||
|
<option value="menengah">Menengah</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Model</label>
|
||||||
|
<Select className="w-full">
|
||||||
|
<option value="gpt-3.5-turbo">GPT 3.5 Turbo</option>
|
||||||
|
<option value="gpt-4">GPT 4</option>
|
||||||
|
<option value="gpt-4-32k">GPT 4 32k</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-full">
|
||||||
|
<Button>
|
||||||
|
Generate Roadmap
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-neutral-600 text-p2 font-semibold mb-8">
|
||||||
|
Roadmap Preview
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<For data={Array.from({ length: 3 })}>
|
||||||
|
{(_, index) => (
|
||||||
|
<Accordion
|
||||||
|
key={index}
|
||||||
|
title={`Day ${index + 1}`}
|
||||||
|
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit. In convallis tincidunt nisl, id consequat mi malesuada vel. Nulla facilisi. Nam in turpis ligula."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-12">
|
||||||
|
<Button type="button" className="flex items-center gap-5">
|
||||||
|
Submit Roadmap
|
||||||
|
<ArrowRightOutlined />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { DeleteOutlined, EditOutlined, PlusOutlined, 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 } from "@imphnen-frontend-service/utils";
|
||||||
|
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ModalCreateRoadmap } from "./_components/modal/create-roadmap";
|
||||||
|
|
||||||
|
type LearningStatus = 'active' | 'inactive'
|
||||||
|
|
||||||
|
type RoadmapType = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
learningLevel: string
|
||||||
|
status: LearningStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: RoadmapType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: i === 0 ? 'Ahmad Wijuana' : 'Anna Wiguana',
|
||||||
|
learningLevel: ['Pemula', 'Menengah'][Math.floor(Math.random() * 2)],
|
||||||
|
status: i % 2 === 0 ? 'active' : 'inactive',
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default function Components(): React.ReactElement {
|
||||||
|
const [openCreateModal, setOpenCreateModal] = useState(false)
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: ColumnDef<RoadmapType>[] = [
|
||||||
|
{
|
||||||
|
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: 'id',
|
||||||
|
header: 'No',
|
||||||
|
accessorKey: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
header: 'Nama Roadmap',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'learningLevel',
|
||||||
|
header: 'Tingkat Belajar',
|
||||||
|
accessorKey: 'learningLevel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status;
|
||||||
|
const statusColors: Record<LearningStatus, string> = {
|
||||||
|
inactive: 'bg-danger-200 text-danger-700',
|
||||||
|
active: 'bg-success-200 text-success-500',
|
||||||
|
};
|
||||||
|
const statusText: Record<LearningStatus, string> = {
|
||||||
|
inactive: 'Inactive',
|
||||||
|
active: 'Active',
|
||||||
|
};
|
||||||
|
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 }) => (
|
||||||
|
<div className="flex gap-[8px]">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<EditOutlined /> Action
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
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 (
|
||||||
|
<BackofficeWrapper title="Dimentorin.dev">
|
||||||
|
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Content & Roadmap</h1>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||||
|
<div className="flex items-center justify-between mb-9">
|
||||||
|
<h2 className="text-p2 font-semibold text-neutral-600">AI Roadmaps</h2>
|
||||||
|
<Button type="button" variant="primary" className="flex items-center gap-2" onClick={() => setOpenCreateModal(true)}>
|
||||||
|
<PlusOutlined /> Buat Roadmap
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center gap-5 mb-2">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Cari berdasarkan nama roadmap"
|
||||||
|
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>Tingkat Belajar</option>
|
||||||
|
<option value="pemula">Pemula</option>
|
||||||
|
<option value="menengah">Menengah</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ModalCreateRoadmap isOpen={openCreateModal} onClose={() => setOpenCreateModal(false)} />
|
||||||
|
</BackofficeWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
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 IModalAddRole {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleAdd?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalAddRole = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
currentStep,
|
||||||
|
nextStep,
|
||||||
|
resetStep,
|
||||||
|
handleAdd,
|
||||||
|
}: IModalAddRole) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-0 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<StepTwo
|
||||||
|
onClose={onClose}
|
||||||
|
handleAdd={handleAdd}
|
||||||
|
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 Roles
|
||||||
|
</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="Nama Role"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Nama Role"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 items-start overflow-auto">
|
||||||
|
<span className="text-p3 font-medium text-neutral-800 sticky left-0">
|
||||||
|
Permissions
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-x-6 overflow-x-scroll">
|
||||||
|
{[
|
||||||
|
'Gacha Items',
|
||||||
|
'Gacha Roll',
|
||||||
|
'Roll',
|
||||||
|
'Users',
|
||||||
|
'Gacha Claim',
|
||||||
|
].map((title) => (
|
||||||
|
<div
|
||||||
|
key={title}
|
||||||
|
className="flex flex-col gap-4 select-none text-label2 font-medium text-neutral-900 "
|
||||||
|
>
|
||||||
|
<span className="text-nowrap text-label1">{title}</span>
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`${title}-all`}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor={`${title}-all`} className="text-nowrap">
|
||||||
|
Check All
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<hr className="border-blue-200" />
|
||||||
|
<div className="flex flex-col items-start gap-4 mb-4">
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input type="checkbox" id={`${title}-read`} />
|
||||||
|
<label htmlFor={`${title}-read`}>Read</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input type="checkbox" id={`${title}-create`} />
|
||||||
|
<label htmlFor={`${title}-create`}>Create</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input type="checkbox" id={`${title}-update`} />
|
||||||
|
<label htmlFor={`${title}-update`}>Update</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input type="checkbox" id={`${title}-delete`} />
|
||||||
|
<label htmlFor={`${title}-delete`}>Delete</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="primary" size="lg" className="w-full" type="submit">
|
||||||
|
Tambah Role
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepTwoProps {
|
||||||
|
onClose: () => void;
|
||||||
|
handleAdd?: () => Promise<boolean>;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepTwo = ({ onClose, handleAdd, resetStep }: IStepTwoProps) => {
|
||||||
|
const { onConfirm, onCancel } = useConfirmItem(
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleAdd,
|
||||||
|
{
|
||||||
|
success: 'Data role berhasil ditambahkan',
|
||||||
|
error: 'Data role gagal ditambahkan',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header className="mb-10 text-center items-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Tambah Roles
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-center text-neutral-400">
|
||||||
|
Apakah kamu yakin ingin
|
||||||
|
<br /> menambahkan role 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 ModalAddRole;
|
||||||
@@ -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 roles berhasil dihapus',
|
||||||
|
error: 'Data roles 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 Roles
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin untuk menghapus role 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 Role
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalDeletePermission;
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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 roles berhasil dilakukan',
|
||||||
|
error: 'Perubahan roles gagal dilakukan',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-0 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
<Modal.Header>
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Update Roles
|
||||||
|
</h2>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<InputField
|
||||||
|
label="Nama Role"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Nama Role"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 items-start overflow-auto">
|
||||||
|
<span className="text-p3 font-medium text-neutral-800 sticky left-0">
|
||||||
|
Permissions
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-x-6 overflow-x-scroll">
|
||||||
|
{['Gacha Items', 'Gacha Roll', 'Roll', 'Users', 'Gacha Claim'].map(
|
||||||
|
(title) => (
|
||||||
|
<div
|
||||||
|
key={title}
|
||||||
|
className="flex flex-col gap-4 select-none text-label2 font-medium text-neutral-900 "
|
||||||
|
>
|
||||||
|
<span className="text-nowrap text-label1">{title}</span>
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`${title}-all`}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor={`${title}-all`} className="text-nowrap">
|
||||||
|
Check All
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<hr className="border-blue-200" />
|
||||||
|
<div className="flex flex-col items-start gap-4 mb-4">
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input type="checkbox" id={`${title}-read`} />
|
||||||
|
<label htmlFor={`${title}-read`}>Read</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input type="checkbox" id={`${title}-create`} />
|
||||||
|
<label htmlFor={`${title}-create`}>Create</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input type="checkbox" id={`${title}-update`} />
|
||||||
|
<label htmlFor={`${title}-update`}>Update</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input type="checkbox" id={`${title}-delete`} />
|
||||||
|
<label htmlFor={`${title}-delete`}>Delete</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Update Role
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalUpdatePermission;
|
||||||
+10
-6
@@ -1,18 +1,23 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
// import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
// import {
|
||||||
|
// gachaRollItemSchema,
|
||||||
|
// TGachaRollItem
|
||||||
|
// } from '@imphnen-frontend-service/service';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export const useItem = (
|
export const useItem = (
|
||||||
nextStep: () => void,
|
nextStep: () => void,
|
||||||
initialValues?: any,
|
initialValues?: any
|
||||||
onDataCapture?: (data: any) => void,
|
|
||||||
) => {
|
) => {
|
||||||
const form = useForm<any>({
|
const form = useForm<any>({
|
||||||
|
// resolver: zodResolver(),
|
||||||
mode: 'all',
|
mode: 'all',
|
||||||
defaultValues: initialValues,
|
defaultValues: initialValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => {
|
const onSubmit = form.handleSubmit((data) => {
|
||||||
onDataCapture?.(data);
|
console.log('Form data:', data);
|
||||||
nextStep();
|
nextStep();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,9 +38,8 @@ export const useConfirmItem = (
|
|||||||
) => {
|
) => {
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
if (actionFunction) {
|
// const result = await actionFunction?.();
|
||||||
await actionFunction();
|
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||||
}
|
|
||||||
toast.success(messages?.success);
|
toast.success(messages?.success);
|
||||||
onClose();
|
onClose();
|
||||||
resetStep();
|
resetStep();
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
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 ModalAddRole from './_components/modal-add-role';
|
||||||
|
import ModalUpdateRole from './_components/modal-update-role';
|
||||||
|
import ModalDeleteRole from './_components/modal-delete-role';
|
||||||
|
import { useQueryState } from '@imphnen-frontend-service/utils';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface Role {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: Role[] = [
|
||||||
|
{ id: 1, name: 'Admin' },
|
||||||
|
{ id: 2, name: 'Admin Pembayaran' },
|
||||||
|
{ id: 3, name: 'Staff' },
|
||||||
|
{ id: 4, name: 'Staff Aktivasi User' },
|
||||||
|
{ id: 5, name: 'User' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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<Role>[] = [
|
||||||
|
{
|
||||||
|
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: 'ID',
|
||||||
|
accessorKey: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Roles 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">Roles</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 roles"
|
||||||
|
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 Role
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
data={mockData}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={9}
|
||||||
|
table={table}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<ModalAddRole
|
||||||
|
currentStep={currentStep}
|
||||||
|
isOpen={showModalAddItem}
|
||||||
|
onClose={() => setShowModalAddItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
<ModalUpdateRole
|
||||||
|
isOpen={showModalUpdateItem}
|
||||||
|
onClose={() => setShowModalUpdateItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
<ModalDeleteRole
|
||||||
|
isOpen={showModalDeleteItem}
|
||||||
|
onClose={() => setShowModalDeleteItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { Modal } from "@imphnen-frontend-service/ui/molecules";
|
||||||
|
import { cn, For } from "@imphnen-frontend-service/utils";
|
||||||
|
import { FC } from "react";
|
||||||
|
|
||||||
|
const TOPICS = [
|
||||||
|
{ id: 2, icon: '🏢', name: 'Industry Insight' },
|
||||||
|
{ id: 4, icon: '🖥️', name: 'Basic IT' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const placeholder = `Hi [Nama Mentor], Saya [Nama Kamu] & saya berharap dapat memiliki sesi mentoring dengan Anda.
|
||||||
|
|
||||||
|
Saat ini, saya tertarik untuk mengejar __. Tujuan saya untuk sesi ini adalah __.
|
||||||
|
|
||||||
|
Saya ingin tahu secara khusus tentang ___.
|
||||||
|
1.Pertanyaan Anda
|
||||||
|
2. ...
|
||||||
|
3. ...`
|
||||||
|
|
||||||
|
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModalDetailSession: FC<ModalProps> = ({ open, setOpen }) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
className="xl:max-w-[64rem] bg-white px-10 py-9"
|
||||||
|
closeButtonClassName="hidden"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
|
||||||
|
Detail Sesi
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-9 px-9 py-7 border rounded-md gap-12 mb-10">
|
||||||
|
<div className="col-span-4 flex items-center gap-x-12">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-p2 font-semibold text-primary-500 mb-4">Mentor</h2>
|
||||||
|
<div>
|
||||||
|
<p className="text-p3 font-semibold mb-2.5">Muhammad Firdaus Oi Oi Oi, S.H., M.H.</p>
|
||||||
|
<p className="text-neutral-600">UI Designer at Oray orayan Studios</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Icon icon="ph:arrow-right" className="text-9xl text-primary-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-5 flex items-center gap-12">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-p2 font-semibold text-primary-500 mb-4">Mentee</h2>
|
||||||
|
<div>
|
||||||
|
<p className="text-p3 font-semibold mb-2.5">Muhammad Firdaus Oi Oi Oi, S.H., M.H.</p>
|
||||||
|
<p className="text-neutral-600">UI Designer at Oray orayan Studios</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-p2 font-semibold text-neutral-700 mb-4">Status</h2>
|
||||||
|
<div className="py-2 px-6 rounded-md text-center bg-success-200 text-success-500 font-semibold">
|
||||||
|
Finished
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-p3 font-medium mb-2.5">Topics</h1>
|
||||||
|
<div className="p-5 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-2.5 mb-8">
|
||||||
|
<For data={TOPICS}>
|
||||||
|
{(item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow font-medium"
|
||||||
|
>
|
||||||
|
<span>{item.icon} </span>
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2.5 md:grid-cols-2 md:gap-5">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Tanggal</label>
|
||||||
|
<Input type="date" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Waktu</label>
|
||||||
|
<Input type="time" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="relative md:col-span-full">
|
||||||
|
<label className={labelClass}>Lokasi</label>
|
||||||
|
<Select className="min-w-full w-full">
|
||||||
|
<option value="online">Online</option>
|
||||||
|
<option value="offline">Offline</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-full">
|
||||||
|
<label className={labelClass}>Pertanyaan Untuk Senpai</label>
|
||||||
|
<Textarea
|
||||||
|
className="min-w-full w-full h-40"
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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 } from "@imphnen-frontend-service/utils";
|
||||||
|
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
|
||||||
|
import { ReactElement, useState } from "react";
|
||||||
|
import { ModalDetailSession } from "./_components/modal/detail";
|
||||||
|
|
||||||
|
type SessionStatus = 'ongoing' | 'finished';
|
||||||
|
|
||||||
|
interface SessionType {
|
||||||
|
id: string
|
||||||
|
mentorName: string
|
||||||
|
menteeName: string
|
||||||
|
datetime: number
|
||||||
|
status: SessionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: SessionType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: `DS-${i + 1}`,
|
||||||
|
mentorName: 'Ahmad Wijuana',
|
||||||
|
menteeName: 'Sofia Wijuana',
|
||||||
|
datetime: new Date().getTime(),
|
||||||
|
status: i % 2 === 0 ? 'ongoing' : 'finished',
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default function Components(): ReactElement {
|
||||||
|
const [openDetail, setOpenDetail] = useState(false);
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: ColumnDef<SessionType>[] = [
|
||||||
|
{
|
||||||
|
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: 'id',
|
||||||
|
header: 'ID Sesi',
|
||||||
|
accessorKey: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mentorName',
|
||||||
|
header: 'Nama Mentor',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'menteeName',
|
||||||
|
header: 'Nama Mentee',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'datetime',
|
||||||
|
header: 'Waktu',
|
||||||
|
accessorKey: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status;
|
||||||
|
const statusColors: Record<SessionStatus, string> = {
|
||||||
|
ongoing: 'bg-warning-200 text-warning-700',
|
||||||
|
finished: 'bg-success-200 text-success-500',
|
||||||
|
};
|
||||||
|
const statusText: Record<SessionStatus, string> = {
|
||||||
|
ongoing: 'On Going',
|
||||||
|
finished: 'Finished',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||||
|
>
|
||||||
|
{statusText[status]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
meta: { cellClassName: cn("w-52") },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpenDetail(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-max"
|
||||||
|
>
|
||||||
|
<SearchOutlined className="text-[16px]" /> Cek Detail
|
||||||
|
</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">
|
||||||
|
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Session Management</h1>
|
||||||
|
|
||||||
|
<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 lengkap"
|
||||||
|
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="finished">Finished</option>
|
||||||
|
<option value="ongoing">On Going</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ModalDetailSession open={openDetail} setOpen={setOpenDetail} />
|
||||||
|
</BackofficeWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { Button, Input, NativeSelect as Select, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
|
import { Button, Input, Select, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
|
||||||
import { cn } from "@imphnen-frontend-service/utils"
|
import { cn } from "@imphnen-frontend-service/utils"
|
||||||
import { FC } from "react"
|
import { FC } from "react"
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { Button, Input, NativeSelect as Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
|
import { Button, Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
|
||||||
import { cn } from "@imphnen-frontend-service/utils"
|
import { cn } from "@imphnen-frontend-service/utils"
|
||||||
import { FC } from "react"
|
import { FC } from "react"
|
||||||
|
|
||||||
+21
-34
@@ -4,8 +4,18 @@ import { DataTable } from "@imphnen-frontend-service/ui/organisms";
|
|||||||
import { cn } from "@imphnen-frontend-service/utils";
|
import { cn } from "@imphnen-frontend-service/utils";
|
||||||
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
|
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
|
||||||
import { FC, useState } from "react";
|
import { FC, useState } from "react";
|
||||||
import { useRoleList, useDeleteRole, TRolesListItem } from "@imphnen-frontend-service/service";
|
|
||||||
import { toast } from "sonner";
|
type UserRolesPermissionType = {
|
||||||
|
id: number
|
||||||
|
role: string
|
||||||
|
totalUser: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: UserRolesPermissionType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
role: ['Admin', 'Super Admin', 'Mentee', 'Mentor'][Math.floor(Math.random() * 4)],
|
||||||
|
totalUser: 10
|
||||||
|
}))
|
||||||
|
|
||||||
export const UserRolesPermission: FC = () => {
|
export const UserRolesPermission: FC = () => {
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
@@ -14,25 +24,7 @@ export const UserRolesPermission: FC = () => {
|
|||||||
pageSize: 9,
|
pageSize: 9,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: rolesData, isLoading } = useRoleList({
|
const columns: ColumnDef<UserRolesPermissionType>[] = [
|
||||||
page: pagination.pageIndex + 1,
|
|
||||||
per_page: pagination.pageSize,
|
|
||||||
});
|
|
||||||
const deleteRole = useDeleteRole();
|
|
||||||
|
|
||||||
const roles: TRolesListItem[] = rolesData?.data ?? [];
|
|
||||||
const totalItems = rolesData?.meta?.total ?? roles.length;
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await deleteRole.mutateAsync(id);
|
|
||||||
toast.success('Role berhasil dihapus');
|
|
||||||
} catch {
|
|
||||||
toast.error('Role gagal dihapus');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: ColumnDef<TRolesListItem>[] = [
|
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
meta: { cellClassName: cn("w-20") },
|
meta: { cellClassName: cn("w-20") },
|
||||||
@@ -56,12 +48,12 @@ export const UserRolesPermission: FC = () => {
|
|||||||
{
|
{
|
||||||
id: 'role',
|
id: 'role',
|
||||||
header: 'Role',
|
header: 'Role',
|
||||||
accessorKey: 'name',
|
accessorKey: 'role',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'totalUser',
|
id: 'totalUser',
|
||||||
header: 'Total Permissions',
|
header: 'Total User',
|
||||||
accessorKey: 'permissions_count',
|
accessorKey: 'totalUser',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
@@ -83,7 +75,6 @@ export const UserRolesPermission: FC = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDelete(row.original.id);
|
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 w-max"
|
className="flex items-center gap-2 w-max"
|
||||||
>
|
>
|
||||||
@@ -95,7 +86,7 @@ export const UserRolesPermission: FC = () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: roles,
|
data: mockData,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: {
|
||||||
pagination,
|
pagination,
|
||||||
@@ -106,8 +97,8 @@ export const UserRolesPermission: FC = () => {
|
|||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -115,16 +106,12 @@ export const UserRolesPermission: FC = () => {
|
|||||||
<div className="mb-8 flex items-center justify-between">
|
<div className="mb-8 flex items-center justify-between">
|
||||||
<h1 className="text-p2 font-semibold text-neutral-700">User Roles & Permissions</h1>
|
<h1 className="text-p2 font-semibold text-neutral-700">User Roles & Permissions</h1>
|
||||||
<Button type="button">
|
<Button type="button">
|
||||||
Add Role
|
Add Rols
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white shadow p-8 rounded-lg">
|
<div className="bg-white shadow p-8 rounded-lg">
|
||||||
{isLoading ? (
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
|
||||||
) : (
|
|
||||||
<DataTable data={roles} columns={columns} table={table} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { BackofficeWrapper } from "@imphnen-frontend-service/ui/organisms"
|
||||||
|
import { cn, For } from "@imphnen-frontend-service/utils"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { GeneralSettings } from "./_components/general"
|
||||||
|
import { UserRolesPermission } from "./_components/user-roles-permission"
|
||||||
|
import { NotificationSettings } from "./_components/notification"
|
||||||
|
import { SecuritySettings } from "./_components/security"
|
||||||
|
import { PaymentSettings } from "./_components/payment"
|
||||||
|
|
||||||
|
const TABS = {
|
||||||
|
general: "General Settings",
|
||||||
|
userRolePermissions: "User Roles & Permissions",
|
||||||
|
notification: "Notification Settings",
|
||||||
|
security: "Security",
|
||||||
|
payment: "Payment",
|
||||||
|
} as const
|
||||||
|
type Tabs = typeof TABS[keyof typeof TABS]
|
||||||
|
|
||||||
|
export default function Components(): React.ReactElement {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tabs>(TABS.general)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="Dimentorin.dev">
|
||||||
|
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Settings</h1>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-x-8">
|
||||||
|
<div className="w-64 bg-white p-2.5 shadow space-y-2 rounded-md">
|
||||||
|
<For data={Object.values(TABS)}>
|
||||||
|
{(tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-3 w-full text-left font-medium rounded-md text-neutral-400 cursor-pointer select-none hover:bg-primary-100",
|
||||||
|
activeTab === tab && "bg-primary-500 text-white hover:bg-primary-600"
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white px-8 py-6 shadow space-y-2 rounded-md flex-1">
|
||||||
|
{activeTab === TABS.general && <GeneralSettings />}
|
||||||
|
{activeTab === TABS.userRolePermissions && <UserRolesPermission />}
|
||||||
|
{activeTab === TABS.notification && <NotificationSettings />}
|
||||||
|
{activeTab === TABS.security && <SecuritySettings />}
|
||||||
|
{activeTab === TABS.payment && <PaymentSettings />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||||
|
import {
|
||||||
|
FilterOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
AuditOutlined,
|
||||||
|
} 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 ModalValidate from './_components/modal-validate';
|
||||||
|
|
||||||
|
type TransactionStatus = 'valid' | 'invalid' | 'unchecked';
|
||||||
|
|
||||||
|
interface Transaction {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
transactionNumber: string;
|
||||||
|
status: TransactionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock data for transactions
|
||||||
|
const mockTransactions: Transaction[] = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
||||||
|
transactionNumber: '25D2133Y9AFYBD',
|
||||||
|
status: (i % 3 === 0
|
||||||
|
? 'invalid'
|
||||||
|
: i % 5 === 0
|
||||||
|
? 'unchecked'
|
||||||
|
: 'valid') as TransactionStatus,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
const [showModalValidate, setShowModalValidate] = useState(false);
|
||||||
|
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
||||||
|
const [showFilter, setShowFilter] = useState(false);
|
||||||
|
|
||||||
|
const validationOptions = [
|
||||||
|
{ id: 'option1', value: 'unchecked', label: 'Unchecked' },
|
||||||
|
{ id: 'option2', value: 'valid', label: 'Valid' },
|
||||||
|
{ id: 'option3', value: 'invalid', label: 'Invalid' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns: ColumnDef<Transaction>[] = [
|
||||||
|
{
|
||||||
|
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: 'Nomor Transaksi',
|
||||||
|
accessorKey: 'transactionNumber',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Order Valid?',
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status;
|
||||||
|
const statusColors: Record<TransactionStatus, string> = {
|
||||||
|
valid: 'bg-success-200 text-success-500',
|
||||||
|
invalid: 'bg-danger-200 text-danger-500',
|
||||||
|
unchecked: 'bg-warning-200 text-warning-900',
|
||||||
|
};
|
||||||
|
const statusText: Record<TransactionStatus, string> = {
|
||||||
|
valid: 'Valid',
|
||||||
|
invalid: 'Invalid',
|
||||||
|
unchecked: 'Unchecked',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||||
|
>
|
||||||
|
{statusText[status]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowModalValidate(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full"
|
||||||
|
>
|
||||||
|
<AuditOutlined className="text-[16px]" /> Update
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockTransactions,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockTransactions.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">Validasi Transaksi</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, nomor order Shopee"
|
||||||
|
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"
|
||||||
|
onClick={() => setShowFilter(!showFilter)}
|
||||||
|
>
|
||||||
|
<FilterOutlined />
|
||||||
|
Filters
|
||||||
|
</Button>
|
||||||
|
{showFilter && (
|
||||||
|
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
||||||
|
<Filter
|
||||||
|
options={validationOptions}
|
||||||
|
onClose={() => setShowFilter(false)}
|
||||||
|
onFilterChange={(value) => {
|
||||||
|
console.log('Selected filter:', value);
|
||||||
|
// Filter logic di sini
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable data={mockTransactions} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<ModalValidate
|
||||||
|
isOpen={showModalValidate}
|
||||||
|
onClose={() => setShowModalValidate(false)}
|
||||||
|
handleValid={() => {
|
||||||
|
console.log('Action ketika user klik Valid');
|
||||||
|
}}
|
||||||
|
handleInvalid={() => {
|
||||||
|
console.log('Action ketika user klik Tidak Valid');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { Modal } from "@imphnen-frontend-service/ui/molecules";
|
||||||
|
import { FC } from "react";
|
||||||
|
|
||||||
|
type ModalDeleteProps = {
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
hadnleDelete?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModalDelete: FC<ModalDeleteProps> = ({ open, setOpen, hadnleDelete }) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
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">
|
||||||
|
Hapus Akun
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin untuk menghapus akun ini?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full border-danger-500 text-danger-500 hover:bg-danger-50 hover:text-danger-600 hover:border-danger-600"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => hadnleDelete?.()}
|
||||||
|
>
|
||||||
|
Hapus Akun
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
import { Button, Input } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { cn } from "@imphnen-frontend-service/utils";
|
||||||
|
import { FC, useState } from "react";
|
||||||
|
import { ModalDelete } from "../delete";
|
||||||
|
import { ModalProps } from "../type";
|
||||||
|
import { ModalSuspendOrBan } from "../suspend-or-ban";
|
||||||
|
|
||||||
|
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
|
||||||
|
|
||||||
|
export const AccountProfile: FC<ModalProps> = ({ setOpen }) => {
|
||||||
|
const [openDelete, setOpenDelete] = useState(false)
|
||||||
|
const [openSuspendOrBan, setOpenSuspendOrBan] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-x-8 mb-8">
|
||||||
|
<div className="size-[100px] rounded-full overflow-hidden">
|
||||||
|
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button type="button" size="sm" variant="bordered" className="mb-4">
|
||||||
|
Upload Foto
|
||||||
|
</Button>
|
||||||
|
<p className="text-neutral-400 font-medium">
|
||||||
|
Setidaknya rekomendasi ukuran 240x240 px. <br />
|
||||||
|
.jpg, .jpeg, .png diperbolehkan
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="mb-7 text-p2 font-semibold">Informasi Pribadi</h1>
|
||||||
|
<div className="grid grid-cols-2 gap-8">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Nama Depan</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" placeholder="Nama Depan" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Nama Belakang</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" placeholder="Nama Belakang" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Email</label>
|
||||||
|
<Input type="email" className="min-w-full w-full" placeholder="Email" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Nomor Telepon</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" placeholder="+62 81234567890" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-7 text-p2 font-semibold">Status Akun</h1>
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="bordered"
|
||||||
|
className="border-danger-500 text-danger-500"
|
||||||
|
onClick={() => setOpenSuspendOrBan(true)}
|
||||||
|
>
|
||||||
|
Suspend/Ban
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="danger" onClick={() => setOpenDelete(true)}>
|
||||||
|
Delete Akun
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-x-5">
|
||||||
|
<Button type="button" variant="bordered" onClick={() => setOpen(false)}>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="button" disabled>
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalDelete open={openDelete} setOpen={setOpenDelete} />
|
||||||
|
<ModalSuspendOrBan open={openSuspendOrBan} setOpen={setOpenSuspendOrBan} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
import { SearchOutlined } from "@ant-design/icons"
|
||||||
|
import { Input, Select } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { DataTable } from "@imphnen-frontend-service/ui/organisms"
|
||||||
|
import { cn } from "@imphnen-frontend-service/utils"
|
||||||
|
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table"
|
||||||
|
import { FC, useState } from "react"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
|
||||||
|
interface ActivityLogType {
|
||||||
|
id: number
|
||||||
|
date: string
|
||||||
|
menu: string
|
||||||
|
activity: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: ActivityLogType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
menu: 'Mentoring',
|
||||||
|
activity: 'Mentoring Session',
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const ActivityLog: FC = () => {
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ColumnDef<ActivityLogType>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
meta: { cellClassName: cn("w-16") },
|
||||||
|
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: 'date',
|
||||||
|
header: 'Waktu',
|
||||||
|
accessorKey: 'date',
|
||||||
|
cell: (info) => dayjs(info.row.original.date).format('DD MMMM YYYY, HH:mm WIB'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'menu',
|
||||||
|
header: 'Menu',
|
||||||
|
accessorKey: 'menu',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'activity',
|
||||||
|
header: 'Activity',
|
||||||
|
accessorKey: 'activity',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="p-8 shadow rounded-lg">
|
||||||
|
<div className="flex justify-between items-center gap-5 mb-9">
|
||||||
|
<div className="relative w-1/2">
|
||||||
|
<Input
|
||||||
|
placeholder="Cari aktivitas"
|
||||||
|
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="w-1/4">
|
||||||
|
<Input type="date" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
<Select>
|
||||||
|
<option selected disabled>Menu</option>
|
||||||
|
<option value="profle">Profile</option>
|
||||||
|
<option value="profle-2">Profile</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
import { Icon } from "@iconify/react"
|
||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { For } from "@imphnen-frontend-service/utils"
|
||||||
|
import { FC } from "react"
|
||||||
|
|
||||||
|
const SOCIAL_LINKS = [
|
||||||
|
{ icon: <Icon icon="mdi:linkedin" className="text-2xl" />, url: "https://linkedin.com", label: "LinkedIn" },
|
||||||
|
{ icon: <Icon icon="mdi:github" className="text-2xl" />, url: "https://github.com", label: "Github" },
|
||||||
|
{ icon: <Icon icon="mingcute:meta-line" className="text-2xl" />, url: "https://facebook.com", label: "Facebook (Meta)" },
|
||||||
|
{ icon: <Icon icon="mdi:stack-overflow" className="text-2xl" />, url: "https://stackoverflow.com", label: "Stack Overflow" }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DetailProfile: FC = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="shadow rounded-lg p-8 mb-7">
|
||||||
|
<div className="flex justify-between items-start mb-6">
|
||||||
|
<div className="flex items-center gap-x-6">
|
||||||
|
<div className="size-[54px] rounded-full overflow-hidden">
|
||||||
|
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-p2 font-semibold text-neutral-800">Muhammad Firdaus Oiwobo</h1>
|
||||||
|
<p className="text-p3 font-medium text-neutral-600">Mentor</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="text" className="bg-primary-100">
|
||||||
|
Actively Seeking Job
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<For data={SOCIAL_LINKS}>
|
||||||
|
{({ icon, url, label }) => (
|
||||||
|
<a key={url} href={url} target="_blank" rel="noreferrer">
|
||||||
|
<Button type="button" size="sm" className="flex items-center gap-x-2">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-7">
|
||||||
|
<div className="text-pretty px-8 py-10 rounded-lg shadow h-max">
|
||||||
|
<h1 className="text-p2 font-semibold text-neutral-800 mb-6">Description</h1>
|
||||||
|
<p className="text-p3 font-medium text-neutral-600">
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-8 py-10 rounded-lg shadow h-max">
|
||||||
|
<h1 className="text-p2 font-semibold text-neutral-800 mb-6">Personal Informations</h1>
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="flex items-center gap-x-5">
|
||||||
|
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
|
||||||
|
<Icon icon="ic:outline-mail" className="text-3xl" />
|
||||||
|
</div>
|
||||||
|
<div className="text-p3">
|
||||||
|
<p className="text-neutral-800 font-semibold">rzalaxib23@gmail.com</p>
|
||||||
|
<p className="text-neutral-600 font-medium">Email Address</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-x-5">
|
||||||
|
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
|
||||||
|
<Icon icon="cil:phone" className="text-3xl" />
|
||||||
|
</div>
|
||||||
|
<div className="text-p3">
|
||||||
|
<p className="text-neutral-800 font-semibold">+62 888 8888 8888</p>
|
||||||
|
<p className="text-neutral-600 font-medium">Phone Number</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-x-5">
|
||||||
|
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
|
||||||
|
<Icon icon="ion:location-outline" className="text-3xl" />
|
||||||
|
</div>
|
||||||
|
<div className="text-p3">
|
||||||
|
<p className="text-neutral-800 font-semibold">Jl. Mergosari, Kec. Suryakencana, Banjaran</p>
|
||||||
|
<p className="text-neutral-600 font-medium">Location</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { Modal } from "@imphnen-frontend-service/ui/molecules"
|
||||||
|
import { cn, For, Show } from "@imphnen-frontend-service/utils"
|
||||||
|
import { FC, useEffect, useState } from "react"
|
||||||
|
import { AccountProfile } from "./account-profile"
|
||||||
|
import { DetailProfile } from "./detail-profile"
|
||||||
|
import { ActivityLog } from "./activity-log"
|
||||||
|
import { ModalDetailUserProps } from "./type"
|
||||||
|
|
||||||
|
const TABS = {
|
||||||
|
account: 'account profile',
|
||||||
|
detail: 'detail profile',
|
||||||
|
activity: 'activity logs',
|
||||||
|
} as const
|
||||||
|
type TabType = typeof TABS[keyof typeof TABS]
|
||||||
|
|
||||||
|
export const ModalDetailUser: FC<ModalDetailUserProps> = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
}) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>(TABS.account)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setActiveTab(TABS.account)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
className="xl:max-w-[84rem] bg-white px-10 py-9"
|
||||||
|
closeButtonClassName="hidden"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
|
||||||
|
Detail - Muhammad Firdaus Oiwobo
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md w-max mb-8">
|
||||||
|
<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>
|
||||||
|
<Show condition={activeTab === TABS.account}>
|
||||||
|
<AccountProfile open={open} setOpen={setOpen} />
|
||||||
|
</Show>
|
||||||
|
<Show condition={activeTab === TABS.detail}>
|
||||||
|
<DetailProfile />
|
||||||
|
</Show>
|
||||||
|
<Show condition={activeTab === TABS.activity}>
|
||||||
|
<ActivityLog />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
import { ModalProps } from "../type";
|
||||||
|
|
||||||
|
export interface ModalDetailUserProps extends ModalProps {
|
||||||
|
userId?: number | null
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { ModalProps } from "../type";
|
||||||
|
import { Modal } from "@imphnen-frontend-service/ui/molecules";
|
||||||
|
import { cn } from "@imphnen-frontend-service/utils";
|
||||||
|
import { Button, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
|
||||||
|
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
|
||||||
|
|
||||||
|
export const ModalSuspendOrBan: FC<ModalProps> = ({ open, setOpen }) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
className="bg-white px-10 py-9 xl:max-w-[32rem]"
|
||||||
|
closeButtonClassName="hidden"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
|
||||||
|
Suspend/Ban
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Suspend/Ban</label>
|
||||||
|
<Select defaultValue="suspend" className="w-full">
|
||||||
|
<option value="suspend">Suspend</option>
|
||||||
|
<option value="banned">Banned</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Alasan</label>
|
||||||
|
<Textarea placeholder="Masukkan alasan suspend/ban" className="w-full h-40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="button" className="w-full">
|
||||||
|
Selesai
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
export interface ModalProps {
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
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';
|
||||||
|
import { ModalDetailUser } from './_components/modal/detail';
|
||||||
|
|
||||||
|
type UserStatus = 'active' | 'inactive';
|
||||||
|
|
||||||
|
interface UserType {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
rating: number;
|
||||||
|
status: UserStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: UserType[] = 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 ? 'active' : 'inactive',
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default function Components(): ReactElement {
|
||||||
|
const TABS = ['mentor', 'mentee'] as const;
|
||||||
|
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor');
|
||||||
|
const [showDetail, setShowDetail] = useState(false);
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: ColumnDef<UserType>[] = [
|
||||||
|
{
|
||||||
|
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<UserStatus, string> = {
|
||||||
|
active: 'bg-success-200 text-success-500',
|
||||||
|
inactive: 'bg-danger-200 text-danger-500',
|
||||||
|
};
|
||||||
|
const statusText: Record<UserStatus, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
inactive: 'Inactive',
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
setSelectedUserId(row.original.id);
|
||||||
|
setShowDetail(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-max"
|
||||||
|
>
|
||||||
|
<SearchOutlined className="text-[16px]" /> Lihat Detail & Action
|
||||||
|
</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 mb-8">
|
||||||
|
User Management
|
||||||
|
</h1>
|
||||||
|
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
||||||
|
<For data={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 lengkap"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<ModalDetailUser
|
||||||
|
open={showDetail}
|
||||||
|
setOpen={setShowDetail}
|
||||||
|
userId={selectedUserId}
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-2
@@ -5,7 +5,7 @@ import {
|
|||||||
useBackofficeLogin,
|
useBackofficeLogin,
|
||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export const useLogin = () => {
|
export const useLogin = () => {
|
||||||
@@ -25,7 +25,7 @@ export const useLogin = () => {
|
|||||||
try {
|
try {
|
||||||
await loginMutation.mutateAsync(data);
|
await loginMutation.mutateAsync(data);
|
||||||
toast.success('Login berhasil!');
|
toast.success('Login berhasil!');
|
||||||
navigate({ to: '/hackathon-dashboard' });
|
navigate('/hackathon-dashboard');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Backoffice Login] Error:', error);
|
console.error('[Backoffice Login] Error:', error);
|
||||||
toast.error((error as Error).message || 'Login gagal');
|
toast.error((error as Error).message || 'Login gagal');
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { FC, ReactElement } from 'react';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { useLogin } from './_hooks/use-login';
|
||||||
|
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
const { form, onSubmit, isLoading } = useLogin();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
|
<div className="bg-white border border-primary-200 shadow-lg p-[60px] text-center flex flex-col justify-items-stretch gap-8 rounded-2xl">
|
||||||
|
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
|
||||||
|
<h1 className="text-primary-500 text-p1 font-semibold">
|
||||||
|
Welcome to IMPHNEN Backoffice
|
||||||
|
</h1>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Email"
|
||||||
|
placeholder="Masukkan Email"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Password"
|
||||||
|
placeholder="Masukkan Password"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
form.formState.isValidating ||
|
||||||
|
!form.formState.isValid
|
||||||
|
}
|
||||||
|
type="submit"
|
||||||
|
size="md"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Loading...' : 'Login'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { FC, ReactElement } from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const AppLayout: FC = (): ReactElement => {
|
||||||
|
return (
|
||||||
|
<main className="bg-primary-50 min-h-screen">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppLayout;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1>
|
||||||
|
<h2 className="text-3xl font-semibold text-gray-900 dark:text-gray-300 mb-4">
|
||||||
|
Page Not Found
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||||
|
The page you are looking for doesn't exist or has been moved.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/hackathon-dashboard"
|
||||||
|
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
Go Back Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function ErrorPage() {
|
||||||
|
const error = useRouteError();
|
||||||
|
let errorMessage: string;
|
||||||
|
|
||||||
|
if (isRouteErrorResponse(error)) {
|
||||||
|
errorMessage = error.statusText;
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
} else if (typeof error === 'string') {
|
||||||
|
errorMessage = error;
|
||||||
|
} else {
|
||||||
|
console.error(error);
|
||||||
|
errorMessage = 'Unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-6xl font-bold text-red-600 mb-4">Oops!</h1>
|
||||||
|
<p className="text-xl text-gray-700 mb-2">
|
||||||
|
Sorry, an unexpected error has occurred.
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 italic">{errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,10 +24,12 @@ export const CityFilterSelect: FC<CityFilterSelectProps> = ({
|
|||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Filter cities based on search query
|
||||||
const filteredCities = INDONESIAN_CITIES.filter((city) =>
|
const filteredCities = INDONESIAN_CITIES.filter((city) =>
|
||||||
city.toLowerCase().includes(searchQuery.toLowerCase())
|
city.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
@@ -100,6 +102,7 @@ export const CityFilterSelect: FC<CityFilterSelectProps> = ({
|
|||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="absolute z-50 w-full mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
<div className="absolute z-50 w-full mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||||
|
{/* All Cities Option */}
|
||||||
<div
|
<div
|
||||||
onClick={() => handleSelectCity('all')}
|
onClick={() => handleSelectCity('all')}
|
||||||
className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 border-b border-neutral-100 ${
|
className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 border-b border-neutral-100 ${
|
||||||
@@ -111,6 +114,7 @@ export const CityFilterSelect: FC<CityFilterSelectProps> = ({
|
|||||||
{allOptionLabel}
|
{allOptionLabel}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filtered Cities */}
|
||||||
{filteredCities.length > 0 ? (
|
{filteredCities.length > 0 ? (
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{filteredCities.slice(0, 100).map((city) => (
|
{filteredCities.slice(0, 100).map((city) => (
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import type { Row, Table } from '@tanstack/react-table';
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
Checkbox,
|
|
||||||
} from '@imphnen-frontend-service/ui/atoms';
|
|
||||||
|
|
||||||
/** Renders a header checkbox that toggles all rows (with indeterminate support). */
|
|
||||||
export function SelectAllCheckbox<T>({ table }: { table: Table<T> }) {
|
|
||||||
return (
|
|
||||||
<Checkbox
|
|
||||||
checked={
|
|
||||||
table.getIsAllRowsSelected()
|
|
||||||
? true
|
|
||||||
: table.getIsSomeRowsSelected()
|
|
||||||
? 'indeterminate'
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
|
|
||||||
}
|
|
||||||
aria-label="Select all"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Per-row checkbox. */
|
|
||||||
export function RowSelectCheckbox<T>({ row }: { row: Row<T> }) {
|
|
||||||
return (
|
|
||||||
<Checkbox
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
|
||||||
aria-label="Select row"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeleteConfirmProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DeleteConfirmDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onConfirm,
|
|
||||||
title = 'Hapus data ini?',
|
|
||||||
description = 'Tindakan ini tidak dapat dibatalkan. Data akan dihapus permanen.',
|
|
||||||
}: DeleteConfirmProps) {
|
|
||||||
return (
|
|
||||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Batal</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Hapus
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { FC, ReactElement, useState } from 'react';
|
|
||||||
import { useLocation, useNavigate } from '@tanstack/react-router';
|
|
||||||
import {
|
|
||||||
LayoutDashboard,
|
|
||||||
Users,
|
|
||||||
UsersRound,
|
|
||||||
ClipboardCheck,
|
|
||||||
BookOpen,
|
|
||||||
MessageSquare,
|
|
||||||
MessageCircle,
|
|
||||||
Calendar,
|
|
||||||
CalendarClock,
|
|
||||||
Settings,
|
|
||||||
BarChart3,
|
|
||||||
RefreshCcw,
|
|
||||||
Inbox,
|
|
||||||
UserCog,
|
|
||||||
UserPlus,
|
|
||||||
ShieldCheck,
|
|
||||||
KeyRound,
|
|
||||||
User,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarHeader,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
SidebarMenuSub,
|
|
||||||
SidebarMenuSubButton,
|
|
||||||
SidebarMenuSubItem,
|
|
||||||
} from '@imphnen-frontend-service/ui/atoms';
|
|
||||||
import { cn } from '@imphnen-frontend-service/utils';
|
|
||||||
|
|
||||||
type MenuLink = {
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MenuGroup = {
|
|
||||||
label: string;
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
children: MenuLink[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type MenuItem = MenuLink | MenuGroup;
|
|
||||||
|
|
||||||
const MENUS: MenuItem[] = [
|
|
||||||
{
|
|
||||||
label: 'Hackathon',
|
|
||||||
icon: BarChart3,
|
|
||||||
children: [
|
|
||||||
{ label: 'Dashboard', href: '/hackathon-dashboard', icon: LayoutDashboard },
|
|
||||||
{ label: 'Users', href: '/hackathon-users', icon: Users },
|
|
||||||
{ label: 'Teams', href: '/hackathon-teams', icon: UsersRound },
|
|
||||||
{ label: 'Submissions', href: '/hackathon-submissions', icon: ClipboardCheck },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Dimentorin',
|
|
||||||
icon: BookOpen,
|
|
||||||
children: [
|
|
||||||
{ label: 'Dashboard', href: '/dashboard-dimentorin', icon: LayoutDashboard },
|
|
||||||
{ label: 'Users', href: '/users-dimentorin', icon: UserCog },
|
|
||||||
{ label: 'Session', href: '/session-dimentorin', icon: CalendarClock },
|
|
||||||
{ label: 'Content & Roadmap', href: '/roadmap-dimentorin', icon: BookOpen },
|
|
||||||
{ label: 'Feedback & Review', href: '/feedback-review-dimentorin', icon: MessageSquare },
|
|
||||||
{ label: 'Settings', href: '/settings-dimentorin', icon: Settings },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Gacha',
|
|
||||||
icon: RefreshCcw,
|
|
||||||
children: [
|
|
||||||
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
|
||||||
{ label: 'Gacha Roll', href: '/gacha-roll', icon: RefreshCcw },
|
|
||||||
{ label: 'Validasi Transaksi', href: '/transactions', icon: ClipboardCheck },
|
|
||||||
{ label: 'Data Pengiriman', href: '/prizes', icon: Inbox },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'CMS',
|
|
||||||
icon: BookOpen,
|
|
||||||
children: [
|
|
||||||
{ label: 'Events', href: '/cms-events', icon: Calendar },
|
|
||||||
{ label: 'Testimonials', href: '/cms-testimonials', icon: MessageCircle },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const FLAT_MENUS: MenuLink[] = [
|
|
||||||
{ label: 'Permissions', href: '/permissions', icon: ShieldCheck },
|
|
||||||
{ label: 'Roles', href: '/roles', icon: KeyRound },
|
|
||||||
{ label: 'Data Akun', href: '/accounts', icon: User },
|
|
||||||
];
|
|
||||||
|
|
||||||
const isMenuGroup = (item: MenuItem): item is MenuGroup =>
|
|
||||||
(item as MenuGroup).children !== undefined;
|
|
||||||
|
|
||||||
export const BackofficeSidebar: FC = (): ReactElement => {
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
|
||||||
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return location.pathname.includes(path);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() => {
|
|
||||||
const initial: Record<string, boolean> = {};
|
|
||||||
MENUS.forEach((menu) => {
|
|
||||||
if (
|
|
||||||
isMenuGroup(menu) &&
|
|
||||||
menu.children.some((child) => isActive(child.href))
|
|
||||||
) {
|
|
||||||
initial[menu.label] = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return initial;
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleGroup = (label: string) =>
|
|
||||||
setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sidebar collapsible="offcanvas" variant="inset">
|
|
||||||
<SidebarHeader>
|
|
||||||
<div className="flex items-center justify-center px-2 py-3">
|
|
||||||
<img
|
|
||||||
src="/logos/simple.svg"
|
|
||||||
alt="IMPHNEN Logo"
|
|
||||||
className="h-10 w-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SidebarHeader>
|
|
||||||
<SidebarContent>
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
{MENUS.map((menu) => {
|
|
||||||
const GroupIcon = menu.icon;
|
|
||||||
const open = !!openGroups[menu.label];
|
|
||||||
const groupHasActive =
|
|
||||||
isMenuGroup(menu) &&
|
|
||||||
menu.children.some((c) => isActive(c.href));
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem key={menu.label}>
|
|
||||||
<SidebarMenuButton
|
|
||||||
onClick={() => toggleGroup(menu.label)}
|
|
||||||
isActive={groupHasActive && !open}
|
|
||||||
className="justify-between"
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<GroupIcon className="size-4" />
|
|
||||||
<span>{menu.label}</span>
|
|
||||||
</span>
|
|
||||||
{open ? (
|
|
||||||
<ChevronDown className="size-3.5 opacity-60" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="size-3.5 opacity-60" />
|
|
||||||
)}
|
|
||||||
</SidebarMenuButton>
|
|
||||||
{isMenuGroup(menu) && open && (
|
|
||||||
<SidebarMenuSub>
|
|
||||||
{menu.children.map((child) => {
|
|
||||||
const ChildIcon = child.icon;
|
|
||||||
return (
|
|
||||||
<SidebarMenuSubItem key={child.href}>
|
|
||||||
<SidebarMenuSubButton
|
|
||||||
asChild
|
|
||||||
isActive={isActive(child.href)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
navigate({ to: child.href })
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
'w-full text-left',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ChildIcon className="size-4" />
|
|
||||||
<span>{child.label}</span>
|
|
||||||
</button>
|
|
||||||
</SidebarMenuSubButton>
|
|
||||||
</SidebarMenuSubItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SidebarMenuSub>
|
|
||||||
)}
|
|
||||||
</SidebarMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel>System</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
{FLAT_MENUS.map((menu) => {
|
|
||||||
const Icon = menu.icon;
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem key={menu.href}>
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={isActive(menu.href)}
|
|
||||||
onClick={() => navigate({ to: menu.href })}
|
|
||||||
>
|
|
||||||
<Icon className="size-4" />
|
|
||||||
<span>{menu.label}</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
</SidebarContent>
|
|
||||||
</Sidebar>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import 'tw-animate-css';
|
|
||||||
@source "../../../libs/ui/**/*.{ts,tsx}";
|
@source "../../../libs/ui/**/*.{ts,tsx}";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
@@ -97,45 +96,6 @@
|
|||||||
/* ~10px */
|
/* ~10px */
|
||||||
--text-label3: 0.677rem;
|
--text-label3: 0.677rem;
|
||||||
/* ~8px */
|
/* ~8px */
|
||||||
|
|
||||||
/* shadcn/ui semantic tokens — aliased to existing palette (keep colors consistent) */
|
|
||||||
--color-background: #ffffff;
|
|
||||||
--color-foreground: #3d3d3d;
|
|
||||||
--color-card: #ffffff;
|
|
||||||
--color-card-foreground: #3d3d3d;
|
|
||||||
--color-popover: #ffffff;
|
|
||||||
--color-popover-foreground: #3d3d3d;
|
|
||||||
--color-primary: #23a1eb;
|
|
||||||
--color-primary-foreground: #ffffff;
|
|
||||||
--color-secondary: #e7e7e7;
|
|
||||||
--color-secondary-foreground: #3d3d3d;
|
|
||||||
--color-muted: #f6f6f6;
|
|
||||||
--color-muted-foreground: #6d6d6d;
|
|
||||||
--color-accent: #e1f0fd;
|
|
||||||
--color-accent-foreground: #085f9c;
|
|
||||||
--color-destructive: #ff5242;
|
|
||||||
--color-destructive-foreground: #ffffff;
|
|
||||||
--color-border: #d1d1d1;
|
|
||||||
--color-input: #d1d1d1;
|
|
||||||
--color-ring: #3eb0f2;
|
|
||||||
|
|
||||||
--color-sidebar: #ffffff;
|
|
||||||
--color-sidebar-foreground: #4f4f4f;
|
|
||||||
--color-sidebar-primary: #23a1eb;
|
|
||||||
--color-sidebar-primary-foreground: #ffffff;
|
|
||||||
--color-sidebar-accent: #f0f8ff;
|
|
||||||
--color-sidebar-accent-foreground: #085f9c;
|
|
||||||
--color-sidebar-border: #e7e7e7;
|
|
||||||
--color-sidebar-ring: #3eb0f2;
|
|
||||||
|
|
||||||
--color-chart-1: #23a1eb;
|
|
||||||
--color-chart-2: #35ba43;
|
|
||||||
--color-chart-3: #04acf3;
|
|
||||||
--color-chart-4: #ffed27;
|
|
||||||
--color-chart-5: #ff5242;
|
|
||||||
|
|
||||||
--radius: 8px;
|
|
||||||
--font-sans: 'Bai Jamjuree', sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@@ -1,25 +1,42 @@
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client';
|
||||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
import { middleware } from './middleware';
|
||||||
import { QueryProvider } from '@imphnen-frontend-service/utils'
|
import { StrictMode } from 'react';
|
||||||
import { Toaster } from 'sonner'
|
import { createBrowserRouter, RouteObject, RouterProvider } from 'react-router';
|
||||||
import { routeTree } from './routeTree.gen'
|
import {
|
||||||
import './index.css'
|
add404PageToRoutesChildren,
|
||||||
|
addErrorElementToRoutes,
|
||||||
|
convertPagesToRoute,
|
||||||
|
QueryProvider,
|
||||||
|
} from '@imphnen-frontend-service/utils';
|
||||||
|
import { Toaster } from 'sonner';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
const router = createRouter({ routeTree })
|
const files = import.meta.glob('./app/**/*(page|layout).tsx');
|
||||||
|
const errorFiles = import.meta.glob('./app/**/*error.tsx');
|
||||||
|
const notFoundFiles = import.meta.glob('./app/**/*404.tsx');
|
||||||
|
const loadingFiles = import.meta.glob('./app/**/*loading.tsx');
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
const routes = convertPagesToRoute(files, loadingFiles) as RouteObject;
|
||||||
interface Register {
|
addErrorElementToRoutes(errorFiles, routes);
|
||||||
router: typeof router
|
add404PageToRoutesChildren(notFoundFiles, routes);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootElement = document.getElementById('root')
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
...routes,
|
||||||
|
loader: middleware,
|
||||||
|
shouldRevalidate: () => true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
if (!rootElement) throw new Error('Failed to find the root element')
|
const rootElement = document.getElementById('root');
|
||||||
|
|
||||||
|
if (!rootElement) throw new Error('Failed to find the root element');
|
||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<QueryProvider>
|
<StrictMode>
|
||||||
<RouterProvider router={router} />
|
<QueryProvider>
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
</QueryProvider>
|
<RouterProvider router={router} />
|
||||||
)
|
</QueryProvider>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import {
|
||||||
|
PERMISSIONS,
|
||||||
|
SessionToken,
|
||||||
|
SessionUser,
|
||||||
|
} from '@imphnen-frontend-service/utils';
|
||||||
|
import { LoaderFunctionArgs, redirect } from 'react-router';
|
||||||
|
|
||||||
|
const mappingPublicRoutes = [
|
||||||
|
'/auth/login',
|
||||||
|
'/auth/forgot',
|
||||||
|
'/auth/new-password',
|
||||||
|
];
|
||||||
|
|
||||||
|
const mappingRoutePermissions = [
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/users',
|
||||||
|
permissions: [PERMISSIONS.USERS.READ_LIST],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/users/create',
|
||||||
|
permissions: [PERMISSIONS.USERS.CREATE],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/users/update',
|
||||||
|
permissions: [PERMISSIONS.USERS.UPDATE],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/users/detail',
|
||||||
|
permissions: [PERMISSIONS.USERS.READ_DETAIL],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/roles',
|
||||||
|
permissions: [PERMISSIONS.ROLES.READ_LIST],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/roles/create',
|
||||||
|
permissions: [PERMISSIONS.ROLES.CREATE],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/roles/update',
|
||||||
|
permissions: [PERMISSIONS.ROLES.UPDATE],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/roles/detail',
|
||||||
|
permissions: [PERMISSIONS.ROLES.READ_DETAIL],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/permissions',
|
||||||
|
permissions: [PERMISSIONS.PERMISSIONS.READ_LIST],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/permissions/create',
|
||||||
|
permissions: [PERMISSIONS.PERMISSIONS.CREATE],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/permissions/update',
|
||||||
|
permissions: [PERMISSIONS.PERMISSIONS.UPDATE],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/permissions/detail',
|
||||||
|
permissions: [PERMISSIONS.PERMISSIONS.READ_DETAIL],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
//TODO : Fix this later
|
||||||
|
// const redirectToFirstAccessibleRoute = (userPermissions: string[]) => {
|
||||||
|
// const fallback = mappingRoutePermissions.find((route) =>
|
||||||
|
// route.permissions.some((perm) => userPermissions.includes(perm))
|
||||||
|
// );
|
||||||
|
// return redirect(fallback?.path ?? '/auth/login');
|
||||||
|
// };
|
||||||
|
|
||||||
|
export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const pathname = url.pathname;
|
||||||
|
const session = SessionUser.get();
|
||||||
|
const session_token = SessionToken.get();
|
||||||
|
const token = session_token?.token?.access_token;
|
||||||
|
const userPermissions =
|
||||||
|
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||||
|
|
||||||
|
if (mappingPublicRoutes.includes(pathname)) {
|
||||||
|
if (token) return redirect('/hackathon-dashboard');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) return redirect('/auth/login');
|
||||||
|
|
||||||
|
const matchedRoute = mappingRoutePermissions.find(
|
||||||
|
(route) => route.path === pathname
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchedRoute) {
|
||||||
|
const hasPermission =
|
||||||
|
!matchedRoute.permissions ||
|
||||||
|
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
return '/hackathon-dashboard';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -1,923 +0,0 @@
|
|||||||
/* eslint-disable */
|
|
||||||
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
// noinspection JSUnusedGlobalSymbols
|
|
||||||
|
|
||||||
// This file was automatically generated by TanStack Router.
|
|
||||||
// You should NOT make any changes in this file as it will be overwritten.
|
|
||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
|
||||||
import { Route as PublicRouteImport } from './routes/_public'
|
|
||||||
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
|
||||||
import { Route as AuthenticatedUsersDimentorinRouteImport } from './routes/_authenticated/users-dimentorin'
|
|
||||||
import { Route as AuthenticatedTransactionsRouteImport } from './routes/_authenticated/transactions'
|
|
||||||
import { Route as AuthenticatedSettingsDimentorinRouteImport } from './routes/_authenticated/settings-dimentorin'
|
|
||||||
import { Route as AuthenticatedSessionDimentorinRouteImport } from './routes/_authenticated/session-dimentorin'
|
|
||||||
import { Route as AuthenticatedRolesRouteImport } from './routes/_authenticated/roles'
|
|
||||||
import { Route as AuthenticatedRoadmapDimentorinRouteImport } from './routes/_authenticated/roadmap-dimentorin'
|
|
||||||
import { Route as AuthenticatedPrizesRouteImport } from './routes/_authenticated/prizes'
|
|
||||||
import { Route as AuthenticatedPermissionsRouteImport } from './routes/_authenticated/permissions'
|
|
||||||
import { Route as AuthenticatedHackathonUsersRouteImport } from './routes/_authenticated/hackathon-users'
|
|
||||||
import { Route as AuthenticatedHackathonTeamsRouteImport } from './routes/_authenticated/hackathon-teams'
|
|
||||||
import { Route as AuthenticatedHackathonSubmissionsRouteImport } from './routes/_authenticated/hackathon-submissions'
|
|
||||||
import { Route as AuthenticatedHackathonDashboardRouteImport } from './routes/_authenticated/hackathon-dashboard'
|
|
||||||
import { Route as AuthenticatedGachaRollRouteImport } from './routes/_authenticated/gacha-roll'
|
|
||||||
import { Route as AuthenticatedFeedbackReviewDimentorinRouteImport } from './routes/_authenticated/feedback-review-dimentorin'
|
|
||||||
import { Route as AuthenticatedDashboardDimentorinRouteImport } from './routes/_authenticated/dashboard-dimentorin'
|
|
||||||
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
|
|
||||||
import { Route as AuthenticatedCmsTestimonialsRouteImport } from './routes/_authenticated/cms-testimonials'
|
|
||||||
import { Route as AuthenticatedCmsEventsRouteImport } from './routes/_authenticated/cms-events'
|
|
||||||
import { Route as AuthenticatedAccountsRouteImport } from './routes/_authenticated/accounts'
|
|
||||||
import { Route as PublicAuthLoginRouteImport } from './routes/_public/auth/login'
|
|
||||||
import { Route as AuthenticatedUsersDimentorinIdRouteImport } from './routes/_authenticated/users-dimentorin_/$id'
|
|
||||||
import { Route as AuthenticatedSessionDimentorinIdRouteImport } from './routes/_authenticated/session-dimentorin_/$id'
|
|
||||||
import { Route as AuthenticatedRolesCreateRouteImport } from './routes/_authenticated/roles_/create'
|
|
||||||
import { Route as AuthenticatedRolesIdRouteImport } from './routes/_authenticated/roles_/$id'
|
|
||||||
import { Route as AuthenticatedRoadmapDimentorinCreateRouteImport } from './routes/_authenticated/roadmap-dimentorin_/create'
|
|
||||||
import { Route as AuthenticatedRoadmapDimentorinIdRouteImport } from './routes/_authenticated/roadmap-dimentorin_/$id'
|
|
||||||
import { Route as AuthenticatedPermissionsCreateRouteImport } from './routes/_authenticated/permissions_/create'
|
|
||||||
import { Route as AuthenticatedPermissionsIdRouteImport } from './routes/_authenticated/permissions_/$id'
|
|
||||||
import { Route as AuthenticatedGachaRollCreateRouteImport } from './routes/_authenticated/gacha-roll_/create'
|
|
||||||
import { Route as AuthenticatedGachaRollIdRouteImport } from './routes/_authenticated/gacha-roll_/$id'
|
|
||||||
import { Route as AuthenticatedDashboardCreateRouteImport } from './routes/_authenticated/dashboard_/create'
|
|
||||||
import { Route as AuthenticatedDashboardIdRouteImport } from './routes/_authenticated/dashboard_/$id'
|
|
||||||
import { Route as AuthenticatedCmsTestimonialsCreateRouteImport } from './routes/_authenticated/cms-testimonials_/create'
|
|
||||||
import { Route as AuthenticatedCmsTestimonialsIdRouteImport } from './routes/_authenticated/cms-testimonials_/$id'
|
|
||||||
import { Route as AuthenticatedCmsEventsCreateRouteImport } from './routes/_authenticated/cms-events_/create'
|
|
||||||
import { Route as AuthenticatedCmsEventsIdRouteImport } from './routes/_authenticated/cms-events_/$id'
|
|
||||||
import { Route as AuthenticatedAccountsIdRouteImport } from './routes/_authenticated/accounts_/$id'
|
|
||||||
|
|
||||||
const PublicRoute = PublicRouteImport.update({
|
|
||||||
id: '/_public',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedRoute = AuthenticatedRouteImport.update({
|
|
||||||
id: '/_authenticated',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
|
||||||
id: '/',
|
|
||||||
path: '/',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedUsersDimentorinRoute =
|
|
||||||
AuthenticatedUsersDimentorinRouteImport.update({
|
|
||||||
id: '/users-dimentorin',
|
|
||||||
path: '/users-dimentorin',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedTransactionsRoute =
|
|
||||||
AuthenticatedTransactionsRouteImport.update({
|
|
||||||
id: '/transactions',
|
|
||||||
path: '/transactions',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedSettingsDimentorinRoute =
|
|
||||||
AuthenticatedSettingsDimentorinRouteImport.update({
|
|
||||||
id: '/settings-dimentorin',
|
|
||||||
path: '/settings-dimentorin',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedSessionDimentorinRoute =
|
|
||||||
AuthenticatedSessionDimentorinRouteImport.update({
|
|
||||||
id: '/session-dimentorin',
|
|
||||||
path: '/session-dimentorin',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedRolesRoute = AuthenticatedRolesRouteImport.update({
|
|
||||||
id: '/roles',
|
|
||||||
path: '/roles',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedRoadmapDimentorinRoute =
|
|
||||||
AuthenticatedRoadmapDimentorinRouteImport.update({
|
|
||||||
id: '/roadmap-dimentorin',
|
|
||||||
path: '/roadmap-dimentorin',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedPrizesRoute = AuthenticatedPrizesRouteImport.update({
|
|
||||||
id: '/prizes',
|
|
||||||
path: '/prizes',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedPermissionsRoute =
|
|
||||||
AuthenticatedPermissionsRouteImport.update({
|
|
||||||
id: '/permissions',
|
|
||||||
path: '/permissions',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedHackathonUsersRoute =
|
|
||||||
AuthenticatedHackathonUsersRouteImport.update({
|
|
||||||
id: '/hackathon-users',
|
|
||||||
path: '/hackathon-users',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedHackathonTeamsRoute =
|
|
||||||
AuthenticatedHackathonTeamsRouteImport.update({
|
|
||||||
id: '/hackathon-teams',
|
|
||||||
path: '/hackathon-teams',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedHackathonSubmissionsRoute =
|
|
||||||
AuthenticatedHackathonSubmissionsRouteImport.update({
|
|
||||||
id: '/hackathon-submissions',
|
|
||||||
path: '/hackathon-submissions',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedHackathonDashboardRoute =
|
|
||||||
AuthenticatedHackathonDashboardRouteImport.update({
|
|
||||||
id: '/hackathon-dashboard',
|
|
||||||
path: '/hackathon-dashboard',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedGachaRollRoute = AuthenticatedGachaRollRouteImport.update({
|
|
||||||
id: '/gacha-roll',
|
|
||||||
path: '/gacha-roll',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedFeedbackReviewDimentorinRoute =
|
|
||||||
AuthenticatedFeedbackReviewDimentorinRouteImport.update({
|
|
||||||
id: '/feedback-review-dimentorin',
|
|
||||||
path: '/feedback-review-dimentorin',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedDashboardDimentorinRoute =
|
|
||||||
AuthenticatedDashboardDimentorinRouteImport.update({
|
|
||||||
id: '/dashboard-dimentorin',
|
|
||||||
path: '/dashboard-dimentorin',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedDashboardRoute = AuthenticatedDashboardRouteImport.update({
|
|
||||||
id: '/dashboard',
|
|
||||||
path: '/dashboard',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedCmsTestimonialsRoute =
|
|
||||||
AuthenticatedCmsTestimonialsRouteImport.update({
|
|
||||||
id: '/cms-testimonials',
|
|
||||||
path: '/cms-testimonials',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedCmsEventsRoute = AuthenticatedCmsEventsRouteImport.update({
|
|
||||||
id: '/cms-events',
|
|
||||||
path: '/cms-events',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedAccountsRoute = AuthenticatedAccountsRouteImport.update({
|
|
||||||
id: '/accounts',
|
|
||||||
path: '/accounts',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const PublicAuthLoginRoute = PublicAuthLoginRouteImport.update({
|
|
||||||
id: '/auth/login',
|
|
||||||
path: '/auth/login',
|
|
||||||
getParentRoute: () => PublicRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedUsersDimentorinIdRoute =
|
|
||||||
AuthenticatedUsersDimentorinIdRouteImport.update({
|
|
||||||
id: '/users-dimentorin_/$id',
|
|
||||||
path: '/users-dimentorin/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedSessionDimentorinIdRoute =
|
|
||||||
AuthenticatedSessionDimentorinIdRouteImport.update({
|
|
||||||
id: '/session-dimentorin_/$id',
|
|
||||||
path: '/session-dimentorin/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedRolesCreateRoute =
|
|
||||||
AuthenticatedRolesCreateRouteImport.update({
|
|
||||||
id: '/roles_/create',
|
|
||||||
path: '/roles/create',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedRolesIdRoute = AuthenticatedRolesIdRouteImport.update({
|
|
||||||
id: '/roles_/$id',
|
|
||||||
path: '/roles/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedRoadmapDimentorinCreateRoute =
|
|
||||||
AuthenticatedRoadmapDimentorinCreateRouteImport.update({
|
|
||||||
id: '/roadmap-dimentorin_/create',
|
|
||||||
path: '/roadmap-dimentorin/create',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedRoadmapDimentorinIdRoute =
|
|
||||||
AuthenticatedRoadmapDimentorinIdRouteImport.update({
|
|
||||||
id: '/roadmap-dimentorin_/$id',
|
|
||||||
path: '/roadmap-dimentorin/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedPermissionsCreateRoute =
|
|
||||||
AuthenticatedPermissionsCreateRouteImport.update({
|
|
||||||
id: '/permissions_/create',
|
|
||||||
path: '/permissions/create',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedPermissionsIdRoute =
|
|
||||||
AuthenticatedPermissionsIdRouteImport.update({
|
|
||||||
id: '/permissions_/$id',
|
|
||||||
path: '/permissions/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedGachaRollCreateRoute =
|
|
||||||
AuthenticatedGachaRollCreateRouteImport.update({
|
|
||||||
id: '/gacha-roll_/create',
|
|
||||||
path: '/gacha-roll/create',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedGachaRollIdRoute =
|
|
||||||
AuthenticatedGachaRollIdRouteImport.update({
|
|
||||||
id: '/gacha-roll_/$id',
|
|
||||||
path: '/gacha-roll/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedDashboardCreateRoute =
|
|
||||||
AuthenticatedDashboardCreateRouteImport.update({
|
|
||||||
id: '/dashboard_/create',
|
|
||||||
path: '/dashboard/create',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedDashboardIdRoute =
|
|
||||||
AuthenticatedDashboardIdRouteImport.update({
|
|
||||||
id: '/dashboard_/$id',
|
|
||||||
path: '/dashboard/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedCmsTestimonialsCreateRoute =
|
|
||||||
AuthenticatedCmsTestimonialsCreateRouteImport.update({
|
|
||||||
id: '/cms-testimonials_/create',
|
|
||||||
path: '/cms-testimonials/create',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedCmsTestimonialsIdRoute =
|
|
||||||
AuthenticatedCmsTestimonialsIdRouteImport.update({
|
|
||||||
id: '/cms-testimonials_/$id',
|
|
||||||
path: '/cms-testimonials/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedCmsEventsCreateRoute =
|
|
||||||
AuthenticatedCmsEventsCreateRouteImport.update({
|
|
||||||
id: '/cms-events_/create',
|
|
||||||
path: '/cms-events/create',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedCmsEventsIdRoute =
|
|
||||||
AuthenticatedCmsEventsIdRouteImport.update({
|
|
||||||
id: '/cms-events_/$id',
|
|
||||||
path: '/cms-events/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedAccountsIdRoute = AuthenticatedAccountsIdRouteImport.update({
|
|
||||||
id: '/accounts_/$id',
|
|
||||||
path: '/accounts/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
|
||||||
'/': typeof IndexRoute
|
|
||||||
'/accounts': typeof AuthenticatedAccountsRoute
|
|
||||||
'/cms-events': typeof AuthenticatedCmsEventsRoute
|
|
||||||
'/cms-testimonials': typeof AuthenticatedCmsTestimonialsRoute
|
|
||||||
'/dashboard': typeof AuthenticatedDashboardRoute
|
|
||||||
'/dashboard-dimentorin': typeof AuthenticatedDashboardDimentorinRoute
|
|
||||||
'/feedback-review-dimentorin': typeof AuthenticatedFeedbackReviewDimentorinRoute
|
|
||||||
'/gacha-roll': typeof AuthenticatedGachaRollRoute
|
|
||||||
'/hackathon-dashboard': typeof AuthenticatedHackathonDashboardRoute
|
|
||||||
'/hackathon-submissions': typeof AuthenticatedHackathonSubmissionsRoute
|
|
||||||
'/hackathon-teams': typeof AuthenticatedHackathonTeamsRoute
|
|
||||||
'/hackathon-users': typeof AuthenticatedHackathonUsersRoute
|
|
||||||
'/permissions': typeof AuthenticatedPermissionsRoute
|
|
||||||
'/prizes': typeof AuthenticatedPrizesRoute
|
|
||||||
'/roadmap-dimentorin': typeof AuthenticatedRoadmapDimentorinRoute
|
|
||||||
'/roles': typeof AuthenticatedRolesRoute
|
|
||||||
'/session-dimentorin': typeof AuthenticatedSessionDimentorinRoute
|
|
||||||
'/settings-dimentorin': typeof AuthenticatedSettingsDimentorinRoute
|
|
||||||
'/transactions': typeof AuthenticatedTransactionsRoute
|
|
||||||
'/users-dimentorin': typeof AuthenticatedUsersDimentorinRoute
|
|
||||||
'/accounts/$id': typeof AuthenticatedAccountsIdRoute
|
|
||||||
'/cms-events/$id': typeof AuthenticatedCmsEventsIdRoute
|
|
||||||
'/cms-events/create': typeof AuthenticatedCmsEventsCreateRoute
|
|
||||||
'/cms-testimonials/$id': typeof AuthenticatedCmsTestimonialsIdRoute
|
|
||||||
'/cms-testimonials/create': typeof AuthenticatedCmsTestimonialsCreateRoute
|
|
||||||
'/dashboard/$id': typeof AuthenticatedDashboardIdRoute
|
|
||||||
'/dashboard/create': typeof AuthenticatedDashboardCreateRoute
|
|
||||||
'/gacha-roll/$id': typeof AuthenticatedGachaRollIdRoute
|
|
||||||
'/gacha-roll/create': typeof AuthenticatedGachaRollCreateRoute
|
|
||||||
'/permissions/$id': typeof AuthenticatedPermissionsIdRoute
|
|
||||||
'/permissions/create': typeof AuthenticatedPermissionsCreateRoute
|
|
||||||
'/roadmap-dimentorin/$id': typeof AuthenticatedRoadmapDimentorinIdRoute
|
|
||||||
'/roadmap-dimentorin/create': typeof AuthenticatedRoadmapDimentorinCreateRoute
|
|
||||||
'/roles/$id': typeof AuthenticatedRolesIdRoute
|
|
||||||
'/roles/create': typeof AuthenticatedRolesCreateRoute
|
|
||||||
'/session-dimentorin/$id': typeof AuthenticatedSessionDimentorinIdRoute
|
|
||||||
'/users-dimentorin/$id': typeof AuthenticatedUsersDimentorinIdRoute
|
|
||||||
'/auth/login': typeof PublicAuthLoginRoute
|
|
||||||
}
|
|
||||||
export interface FileRoutesByTo {
|
|
||||||
'/': typeof IndexRoute
|
|
||||||
'/accounts': typeof AuthenticatedAccountsRoute
|
|
||||||
'/cms-events': typeof AuthenticatedCmsEventsRoute
|
|
||||||
'/cms-testimonials': typeof AuthenticatedCmsTestimonialsRoute
|
|
||||||
'/dashboard': typeof AuthenticatedDashboardRoute
|
|
||||||
'/dashboard-dimentorin': typeof AuthenticatedDashboardDimentorinRoute
|
|
||||||
'/feedback-review-dimentorin': typeof AuthenticatedFeedbackReviewDimentorinRoute
|
|
||||||
'/gacha-roll': typeof AuthenticatedGachaRollRoute
|
|
||||||
'/hackathon-dashboard': typeof AuthenticatedHackathonDashboardRoute
|
|
||||||
'/hackathon-submissions': typeof AuthenticatedHackathonSubmissionsRoute
|
|
||||||
'/hackathon-teams': typeof AuthenticatedHackathonTeamsRoute
|
|
||||||
'/hackathon-users': typeof AuthenticatedHackathonUsersRoute
|
|
||||||
'/permissions': typeof AuthenticatedPermissionsRoute
|
|
||||||
'/prizes': typeof AuthenticatedPrizesRoute
|
|
||||||
'/roadmap-dimentorin': typeof AuthenticatedRoadmapDimentorinRoute
|
|
||||||
'/roles': typeof AuthenticatedRolesRoute
|
|
||||||
'/session-dimentorin': typeof AuthenticatedSessionDimentorinRoute
|
|
||||||
'/settings-dimentorin': typeof AuthenticatedSettingsDimentorinRoute
|
|
||||||
'/transactions': typeof AuthenticatedTransactionsRoute
|
|
||||||
'/users-dimentorin': typeof AuthenticatedUsersDimentorinRoute
|
|
||||||
'/accounts/$id': typeof AuthenticatedAccountsIdRoute
|
|
||||||
'/cms-events/$id': typeof AuthenticatedCmsEventsIdRoute
|
|
||||||
'/cms-events/create': typeof AuthenticatedCmsEventsCreateRoute
|
|
||||||
'/cms-testimonials/$id': typeof AuthenticatedCmsTestimonialsIdRoute
|
|
||||||
'/cms-testimonials/create': typeof AuthenticatedCmsTestimonialsCreateRoute
|
|
||||||
'/dashboard/$id': typeof AuthenticatedDashboardIdRoute
|
|
||||||
'/dashboard/create': typeof AuthenticatedDashboardCreateRoute
|
|
||||||
'/gacha-roll/$id': typeof AuthenticatedGachaRollIdRoute
|
|
||||||
'/gacha-roll/create': typeof AuthenticatedGachaRollCreateRoute
|
|
||||||
'/permissions/$id': typeof AuthenticatedPermissionsIdRoute
|
|
||||||
'/permissions/create': typeof AuthenticatedPermissionsCreateRoute
|
|
||||||
'/roadmap-dimentorin/$id': typeof AuthenticatedRoadmapDimentorinIdRoute
|
|
||||||
'/roadmap-dimentorin/create': typeof AuthenticatedRoadmapDimentorinCreateRoute
|
|
||||||
'/roles/$id': typeof AuthenticatedRolesIdRoute
|
|
||||||
'/roles/create': typeof AuthenticatedRolesCreateRoute
|
|
||||||
'/session-dimentorin/$id': typeof AuthenticatedSessionDimentorinIdRoute
|
|
||||||
'/users-dimentorin/$id': typeof AuthenticatedUsersDimentorinIdRoute
|
|
||||||
'/auth/login': typeof PublicAuthLoginRoute
|
|
||||||
}
|
|
||||||
export interface FileRoutesById {
|
|
||||||
__root__: typeof rootRouteImport
|
|
||||||
'/': typeof IndexRoute
|
|
||||||
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
|
||||||
'/_public': typeof PublicRouteWithChildren
|
|
||||||
'/_authenticated/accounts': typeof AuthenticatedAccountsRoute
|
|
||||||
'/_authenticated/cms-events': typeof AuthenticatedCmsEventsRoute
|
|
||||||
'/_authenticated/cms-testimonials': typeof AuthenticatedCmsTestimonialsRoute
|
|
||||||
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
|
|
||||||
'/_authenticated/dashboard-dimentorin': typeof AuthenticatedDashboardDimentorinRoute
|
|
||||||
'/_authenticated/feedback-review-dimentorin': typeof AuthenticatedFeedbackReviewDimentorinRoute
|
|
||||||
'/_authenticated/gacha-roll': typeof AuthenticatedGachaRollRoute
|
|
||||||
'/_authenticated/hackathon-dashboard': typeof AuthenticatedHackathonDashboardRoute
|
|
||||||
'/_authenticated/hackathon-submissions': typeof AuthenticatedHackathonSubmissionsRoute
|
|
||||||
'/_authenticated/hackathon-teams': typeof AuthenticatedHackathonTeamsRoute
|
|
||||||
'/_authenticated/hackathon-users': typeof AuthenticatedHackathonUsersRoute
|
|
||||||
'/_authenticated/permissions': typeof AuthenticatedPermissionsRoute
|
|
||||||
'/_authenticated/prizes': typeof AuthenticatedPrizesRoute
|
|
||||||
'/_authenticated/roadmap-dimentorin': typeof AuthenticatedRoadmapDimentorinRoute
|
|
||||||
'/_authenticated/roles': typeof AuthenticatedRolesRoute
|
|
||||||
'/_authenticated/session-dimentorin': typeof AuthenticatedSessionDimentorinRoute
|
|
||||||
'/_authenticated/settings-dimentorin': typeof AuthenticatedSettingsDimentorinRoute
|
|
||||||
'/_authenticated/transactions': typeof AuthenticatedTransactionsRoute
|
|
||||||
'/_authenticated/users-dimentorin': typeof AuthenticatedUsersDimentorinRoute
|
|
||||||
'/_authenticated/accounts_/$id': typeof AuthenticatedAccountsIdRoute
|
|
||||||
'/_authenticated/cms-events_/$id': typeof AuthenticatedCmsEventsIdRoute
|
|
||||||
'/_authenticated/cms-events_/create': typeof AuthenticatedCmsEventsCreateRoute
|
|
||||||
'/_authenticated/cms-testimonials_/$id': typeof AuthenticatedCmsTestimonialsIdRoute
|
|
||||||
'/_authenticated/cms-testimonials_/create': typeof AuthenticatedCmsTestimonialsCreateRoute
|
|
||||||
'/_authenticated/dashboard_/$id': typeof AuthenticatedDashboardIdRoute
|
|
||||||
'/_authenticated/dashboard_/create': typeof AuthenticatedDashboardCreateRoute
|
|
||||||
'/_authenticated/gacha-roll_/$id': typeof AuthenticatedGachaRollIdRoute
|
|
||||||
'/_authenticated/gacha-roll_/create': typeof AuthenticatedGachaRollCreateRoute
|
|
||||||
'/_authenticated/permissions_/$id': typeof AuthenticatedPermissionsIdRoute
|
|
||||||
'/_authenticated/permissions_/create': typeof AuthenticatedPermissionsCreateRoute
|
|
||||||
'/_authenticated/roadmap-dimentorin_/$id': typeof AuthenticatedRoadmapDimentorinIdRoute
|
|
||||||
'/_authenticated/roadmap-dimentorin_/create': typeof AuthenticatedRoadmapDimentorinCreateRoute
|
|
||||||
'/_authenticated/roles_/$id': typeof AuthenticatedRolesIdRoute
|
|
||||||
'/_authenticated/roles_/create': typeof AuthenticatedRolesCreateRoute
|
|
||||||
'/_authenticated/session-dimentorin_/$id': typeof AuthenticatedSessionDimentorinIdRoute
|
|
||||||
'/_authenticated/users-dimentorin_/$id': typeof AuthenticatedUsersDimentorinIdRoute
|
|
||||||
'/_public/auth/login': typeof PublicAuthLoginRoute
|
|
||||||
}
|
|
||||||
export interface FileRouteTypes {
|
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
|
||||||
fullPaths:
|
|
||||||
| '/'
|
|
||||||
| '/accounts'
|
|
||||||
| '/cms-events'
|
|
||||||
| '/cms-testimonials'
|
|
||||||
| '/dashboard'
|
|
||||||
| '/dashboard-dimentorin'
|
|
||||||
| '/feedback-review-dimentorin'
|
|
||||||
| '/gacha-roll'
|
|
||||||
| '/hackathon-dashboard'
|
|
||||||
| '/hackathon-submissions'
|
|
||||||
| '/hackathon-teams'
|
|
||||||
| '/hackathon-users'
|
|
||||||
| '/permissions'
|
|
||||||
| '/prizes'
|
|
||||||
| '/roadmap-dimentorin'
|
|
||||||
| '/roles'
|
|
||||||
| '/session-dimentorin'
|
|
||||||
| '/settings-dimentorin'
|
|
||||||
| '/transactions'
|
|
||||||
| '/users-dimentorin'
|
|
||||||
| '/accounts/$id'
|
|
||||||
| '/cms-events/$id'
|
|
||||||
| '/cms-events/create'
|
|
||||||
| '/cms-testimonials/$id'
|
|
||||||
| '/cms-testimonials/create'
|
|
||||||
| '/dashboard/$id'
|
|
||||||
| '/dashboard/create'
|
|
||||||
| '/gacha-roll/$id'
|
|
||||||
| '/gacha-roll/create'
|
|
||||||
| '/permissions/$id'
|
|
||||||
| '/permissions/create'
|
|
||||||
| '/roadmap-dimentorin/$id'
|
|
||||||
| '/roadmap-dimentorin/create'
|
|
||||||
| '/roles/$id'
|
|
||||||
| '/roles/create'
|
|
||||||
| '/session-dimentorin/$id'
|
|
||||||
| '/users-dimentorin/$id'
|
|
||||||
| '/auth/login'
|
|
||||||
fileRoutesByTo: FileRoutesByTo
|
|
||||||
to:
|
|
||||||
| '/'
|
|
||||||
| '/accounts'
|
|
||||||
| '/cms-events'
|
|
||||||
| '/cms-testimonials'
|
|
||||||
| '/dashboard'
|
|
||||||
| '/dashboard-dimentorin'
|
|
||||||
| '/feedback-review-dimentorin'
|
|
||||||
| '/gacha-roll'
|
|
||||||
| '/hackathon-dashboard'
|
|
||||||
| '/hackathon-submissions'
|
|
||||||
| '/hackathon-teams'
|
|
||||||
| '/hackathon-users'
|
|
||||||
| '/permissions'
|
|
||||||
| '/prizes'
|
|
||||||
| '/roadmap-dimentorin'
|
|
||||||
| '/roles'
|
|
||||||
| '/session-dimentorin'
|
|
||||||
| '/settings-dimentorin'
|
|
||||||
| '/transactions'
|
|
||||||
| '/users-dimentorin'
|
|
||||||
| '/accounts/$id'
|
|
||||||
| '/cms-events/$id'
|
|
||||||
| '/cms-events/create'
|
|
||||||
| '/cms-testimonials/$id'
|
|
||||||
| '/cms-testimonials/create'
|
|
||||||
| '/dashboard/$id'
|
|
||||||
| '/dashboard/create'
|
|
||||||
| '/gacha-roll/$id'
|
|
||||||
| '/gacha-roll/create'
|
|
||||||
| '/permissions/$id'
|
|
||||||
| '/permissions/create'
|
|
||||||
| '/roadmap-dimentorin/$id'
|
|
||||||
| '/roadmap-dimentorin/create'
|
|
||||||
| '/roles/$id'
|
|
||||||
| '/roles/create'
|
|
||||||
| '/session-dimentorin/$id'
|
|
||||||
| '/users-dimentorin/$id'
|
|
||||||
| '/auth/login'
|
|
||||||
id:
|
|
||||||
| '__root__'
|
|
||||||
| '/'
|
|
||||||
| '/_authenticated'
|
|
||||||
| '/_public'
|
|
||||||
| '/_authenticated/accounts'
|
|
||||||
| '/_authenticated/cms-events'
|
|
||||||
| '/_authenticated/cms-testimonials'
|
|
||||||
| '/_authenticated/dashboard'
|
|
||||||
| '/_authenticated/dashboard-dimentorin'
|
|
||||||
| '/_authenticated/feedback-review-dimentorin'
|
|
||||||
| '/_authenticated/gacha-roll'
|
|
||||||
| '/_authenticated/hackathon-dashboard'
|
|
||||||
| '/_authenticated/hackathon-submissions'
|
|
||||||
| '/_authenticated/hackathon-teams'
|
|
||||||
| '/_authenticated/hackathon-users'
|
|
||||||
| '/_authenticated/permissions'
|
|
||||||
| '/_authenticated/prizes'
|
|
||||||
| '/_authenticated/roadmap-dimentorin'
|
|
||||||
| '/_authenticated/roles'
|
|
||||||
| '/_authenticated/session-dimentorin'
|
|
||||||
| '/_authenticated/settings-dimentorin'
|
|
||||||
| '/_authenticated/transactions'
|
|
||||||
| '/_authenticated/users-dimentorin'
|
|
||||||
| '/_authenticated/accounts_/$id'
|
|
||||||
| '/_authenticated/cms-events_/$id'
|
|
||||||
| '/_authenticated/cms-events_/create'
|
|
||||||
| '/_authenticated/cms-testimonials_/$id'
|
|
||||||
| '/_authenticated/cms-testimonials_/create'
|
|
||||||
| '/_authenticated/dashboard_/$id'
|
|
||||||
| '/_authenticated/dashboard_/create'
|
|
||||||
| '/_authenticated/gacha-roll_/$id'
|
|
||||||
| '/_authenticated/gacha-roll_/create'
|
|
||||||
| '/_authenticated/permissions_/$id'
|
|
||||||
| '/_authenticated/permissions_/create'
|
|
||||||
| '/_authenticated/roadmap-dimentorin_/$id'
|
|
||||||
| '/_authenticated/roadmap-dimentorin_/create'
|
|
||||||
| '/_authenticated/roles_/$id'
|
|
||||||
| '/_authenticated/roles_/create'
|
|
||||||
| '/_authenticated/session-dimentorin_/$id'
|
|
||||||
| '/_authenticated/users-dimentorin_/$id'
|
|
||||||
| '/_public/auth/login'
|
|
||||||
fileRoutesById: FileRoutesById
|
|
||||||
}
|
|
||||||
export interface RootRouteChildren {
|
|
||||||
IndexRoute: typeof IndexRoute
|
|
||||||
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
|
|
||||||
PublicRoute: typeof PublicRouteWithChildren
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
|
||||||
interface FileRoutesByPath {
|
|
||||||
'/_public': {
|
|
||||||
id: '/_public'
|
|
||||||
path: ''
|
|
||||||
fullPath: '/'
|
|
||||||
preLoaderRoute: typeof PublicRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/_authenticated': {
|
|
||||||
id: '/_authenticated'
|
|
||||||
path: ''
|
|
||||||
fullPath: '/'
|
|
||||||
preLoaderRoute: typeof AuthenticatedRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/': {
|
|
||||||
id: '/'
|
|
||||||
path: '/'
|
|
||||||
fullPath: '/'
|
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/_authenticated/users-dimentorin': {
|
|
||||||
id: '/_authenticated/users-dimentorin'
|
|
||||||
path: '/users-dimentorin'
|
|
||||||
fullPath: '/users-dimentorin'
|
|
||||||
preLoaderRoute: typeof AuthenticatedUsersDimentorinRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/transactions': {
|
|
||||||
id: '/_authenticated/transactions'
|
|
||||||
path: '/transactions'
|
|
||||||
fullPath: '/transactions'
|
|
||||||
preLoaderRoute: typeof AuthenticatedTransactionsRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/settings-dimentorin': {
|
|
||||||
id: '/_authenticated/settings-dimentorin'
|
|
||||||
path: '/settings-dimentorin'
|
|
||||||
fullPath: '/settings-dimentorin'
|
|
||||||
preLoaderRoute: typeof AuthenticatedSettingsDimentorinRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/session-dimentorin': {
|
|
||||||
id: '/_authenticated/session-dimentorin'
|
|
||||||
path: '/session-dimentorin'
|
|
||||||
fullPath: '/session-dimentorin'
|
|
||||||
preLoaderRoute: typeof AuthenticatedSessionDimentorinRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/roles': {
|
|
||||||
id: '/_authenticated/roles'
|
|
||||||
path: '/roles'
|
|
||||||
fullPath: '/roles'
|
|
||||||
preLoaderRoute: typeof AuthenticatedRolesRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/roadmap-dimentorin': {
|
|
||||||
id: '/_authenticated/roadmap-dimentorin'
|
|
||||||
path: '/roadmap-dimentorin'
|
|
||||||
fullPath: '/roadmap-dimentorin'
|
|
||||||
preLoaderRoute: typeof AuthenticatedRoadmapDimentorinRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/prizes': {
|
|
||||||
id: '/_authenticated/prizes'
|
|
||||||
path: '/prizes'
|
|
||||||
fullPath: '/prizes'
|
|
||||||
preLoaderRoute: typeof AuthenticatedPrizesRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/permissions': {
|
|
||||||
id: '/_authenticated/permissions'
|
|
||||||
path: '/permissions'
|
|
||||||
fullPath: '/permissions'
|
|
||||||
preLoaderRoute: typeof AuthenticatedPermissionsRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/hackathon-users': {
|
|
||||||
id: '/_authenticated/hackathon-users'
|
|
||||||
path: '/hackathon-users'
|
|
||||||
fullPath: '/hackathon-users'
|
|
||||||
preLoaderRoute: typeof AuthenticatedHackathonUsersRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/hackathon-teams': {
|
|
||||||
id: '/_authenticated/hackathon-teams'
|
|
||||||
path: '/hackathon-teams'
|
|
||||||
fullPath: '/hackathon-teams'
|
|
||||||
preLoaderRoute: typeof AuthenticatedHackathonTeamsRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/hackathon-submissions': {
|
|
||||||
id: '/_authenticated/hackathon-submissions'
|
|
||||||
path: '/hackathon-submissions'
|
|
||||||
fullPath: '/hackathon-submissions'
|
|
||||||
preLoaderRoute: typeof AuthenticatedHackathonSubmissionsRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/hackathon-dashboard': {
|
|
||||||
id: '/_authenticated/hackathon-dashboard'
|
|
||||||
path: '/hackathon-dashboard'
|
|
||||||
fullPath: '/hackathon-dashboard'
|
|
||||||
preLoaderRoute: typeof AuthenticatedHackathonDashboardRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/gacha-roll': {
|
|
||||||
id: '/_authenticated/gacha-roll'
|
|
||||||
path: '/gacha-roll'
|
|
||||||
fullPath: '/gacha-roll'
|
|
||||||
preLoaderRoute: typeof AuthenticatedGachaRollRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/feedback-review-dimentorin': {
|
|
||||||
id: '/_authenticated/feedback-review-dimentorin'
|
|
||||||
path: '/feedback-review-dimentorin'
|
|
||||||
fullPath: '/feedback-review-dimentorin'
|
|
||||||
preLoaderRoute: typeof AuthenticatedFeedbackReviewDimentorinRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/dashboard-dimentorin': {
|
|
||||||
id: '/_authenticated/dashboard-dimentorin'
|
|
||||||
path: '/dashboard-dimentorin'
|
|
||||||
fullPath: '/dashboard-dimentorin'
|
|
||||||
preLoaderRoute: typeof AuthenticatedDashboardDimentorinRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/dashboard': {
|
|
||||||
id: '/_authenticated/dashboard'
|
|
||||||
path: '/dashboard'
|
|
||||||
fullPath: '/dashboard'
|
|
||||||
preLoaderRoute: typeof AuthenticatedDashboardRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/cms-testimonials': {
|
|
||||||
id: '/_authenticated/cms-testimonials'
|
|
||||||
path: '/cms-testimonials'
|
|
||||||
fullPath: '/cms-testimonials'
|
|
||||||
preLoaderRoute: typeof AuthenticatedCmsTestimonialsRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/cms-events': {
|
|
||||||
id: '/_authenticated/cms-events'
|
|
||||||
path: '/cms-events'
|
|
||||||
fullPath: '/cms-events'
|
|
||||||
preLoaderRoute: typeof AuthenticatedCmsEventsRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/accounts': {
|
|
||||||
id: '/_authenticated/accounts'
|
|
||||||
path: '/accounts'
|
|
||||||
fullPath: '/accounts'
|
|
||||||
preLoaderRoute: typeof AuthenticatedAccountsRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_public/auth/login': {
|
|
||||||
id: '/_public/auth/login'
|
|
||||||
path: '/auth/login'
|
|
||||||
fullPath: '/auth/login'
|
|
||||||
preLoaderRoute: typeof PublicAuthLoginRouteImport
|
|
||||||
parentRoute: typeof PublicRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/users-dimentorin_/$id': {
|
|
||||||
id: '/_authenticated/users-dimentorin_/$id'
|
|
||||||
path: '/users-dimentorin/$id'
|
|
||||||
fullPath: '/users-dimentorin/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedUsersDimentorinIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/session-dimentorin_/$id': {
|
|
||||||
id: '/_authenticated/session-dimentorin_/$id'
|
|
||||||
path: '/session-dimentorin/$id'
|
|
||||||
fullPath: '/session-dimentorin/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedSessionDimentorinIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/roles_/create': {
|
|
||||||
id: '/_authenticated/roles_/create'
|
|
||||||
path: '/roles/create'
|
|
||||||
fullPath: '/roles/create'
|
|
||||||
preLoaderRoute: typeof AuthenticatedRolesCreateRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/roles_/$id': {
|
|
||||||
id: '/_authenticated/roles_/$id'
|
|
||||||
path: '/roles/$id'
|
|
||||||
fullPath: '/roles/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedRolesIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/roadmap-dimentorin_/create': {
|
|
||||||
id: '/_authenticated/roadmap-dimentorin_/create'
|
|
||||||
path: '/roadmap-dimentorin/create'
|
|
||||||
fullPath: '/roadmap-dimentorin/create'
|
|
||||||
preLoaderRoute: typeof AuthenticatedRoadmapDimentorinCreateRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/roadmap-dimentorin_/$id': {
|
|
||||||
id: '/_authenticated/roadmap-dimentorin_/$id'
|
|
||||||
path: '/roadmap-dimentorin/$id'
|
|
||||||
fullPath: '/roadmap-dimentorin/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedRoadmapDimentorinIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/permissions_/create': {
|
|
||||||
id: '/_authenticated/permissions_/create'
|
|
||||||
path: '/permissions/create'
|
|
||||||
fullPath: '/permissions/create'
|
|
||||||
preLoaderRoute: typeof AuthenticatedPermissionsCreateRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/permissions_/$id': {
|
|
||||||
id: '/_authenticated/permissions_/$id'
|
|
||||||
path: '/permissions/$id'
|
|
||||||
fullPath: '/permissions/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedPermissionsIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/gacha-roll_/create': {
|
|
||||||
id: '/_authenticated/gacha-roll_/create'
|
|
||||||
path: '/gacha-roll/create'
|
|
||||||
fullPath: '/gacha-roll/create'
|
|
||||||
preLoaderRoute: typeof AuthenticatedGachaRollCreateRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/gacha-roll_/$id': {
|
|
||||||
id: '/_authenticated/gacha-roll_/$id'
|
|
||||||
path: '/gacha-roll/$id'
|
|
||||||
fullPath: '/gacha-roll/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedGachaRollIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/dashboard_/create': {
|
|
||||||
id: '/_authenticated/dashboard_/create'
|
|
||||||
path: '/dashboard/create'
|
|
||||||
fullPath: '/dashboard/create'
|
|
||||||
preLoaderRoute: typeof AuthenticatedDashboardCreateRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/dashboard_/$id': {
|
|
||||||
id: '/_authenticated/dashboard_/$id'
|
|
||||||
path: '/dashboard/$id'
|
|
||||||
fullPath: '/dashboard/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedDashboardIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/cms-testimonials_/create': {
|
|
||||||
id: '/_authenticated/cms-testimonials_/create'
|
|
||||||
path: '/cms-testimonials/create'
|
|
||||||
fullPath: '/cms-testimonials/create'
|
|
||||||
preLoaderRoute: typeof AuthenticatedCmsTestimonialsCreateRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/cms-testimonials_/$id': {
|
|
||||||
id: '/_authenticated/cms-testimonials_/$id'
|
|
||||||
path: '/cms-testimonials/$id'
|
|
||||||
fullPath: '/cms-testimonials/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedCmsTestimonialsIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/cms-events_/create': {
|
|
||||||
id: '/_authenticated/cms-events_/create'
|
|
||||||
path: '/cms-events/create'
|
|
||||||
fullPath: '/cms-events/create'
|
|
||||||
preLoaderRoute: typeof AuthenticatedCmsEventsCreateRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/cms-events_/$id': {
|
|
||||||
id: '/_authenticated/cms-events_/$id'
|
|
||||||
path: '/cms-events/$id'
|
|
||||||
fullPath: '/cms-events/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedCmsEventsIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/accounts_/$id': {
|
|
||||||
id: '/_authenticated/accounts_/$id'
|
|
||||||
path: '/accounts/$id'
|
|
||||||
fullPath: '/accounts/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedAccountsIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthenticatedRouteChildren {
|
|
||||||
AuthenticatedAccountsRoute: typeof AuthenticatedAccountsRoute
|
|
||||||
AuthenticatedCmsEventsRoute: typeof AuthenticatedCmsEventsRoute
|
|
||||||
AuthenticatedCmsTestimonialsRoute: typeof AuthenticatedCmsTestimonialsRoute
|
|
||||||
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
|
|
||||||
AuthenticatedDashboardDimentorinRoute: typeof AuthenticatedDashboardDimentorinRoute
|
|
||||||
AuthenticatedFeedbackReviewDimentorinRoute: typeof AuthenticatedFeedbackReviewDimentorinRoute
|
|
||||||
AuthenticatedGachaRollRoute: typeof AuthenticatedGachaRollRoute
|
|
||||||
AuthenticatedHackathonDashboardRoute: typeof AuthenticatedHackathonDashboardRoute
|
|
||||||
AuthenticatedHackathonSubmissionsRoute: typeof AuthenticatedHackathonSubmissionsRoute
|
|
||||||
AuthenticatedHackathonTeamsRoute: typeof AuthenticatedHackathonTeamsRoute
|
|
||||||
AuthenticatedHackathonUsersRoute: typeof AuthenticatedHackathonUsersRoute
|
|
||||||
AuthenticatedPermissionsRoute: typeof AuthenticatedPermissionsRoute
|
|
||||||
AuthenticatedPrizesRoute: typeof AuthenticatedPrizesRoute
|
|
||||||
AuthenticatedRoadmapDimentorinRoute: typeof AuthenticatedRoadmapDimentorinRoute
|
|
||||||
AuthenticatedRolesRoute: typeof AuthenticatedRolesRoute
|
|
||||||
AuthenticatedSessionDimentorinRoute: typeof AuthenticatedSessionDimentorinRoute
|
|
||||||
AuthenticatedSettingsDimentorinRoute: typeof AuthenticatedSettingsDimentorinRoute
|
|
||||||
AuthenticatedTransactionsRoute: typeof AuthenticatedTransactionsRoute
|
|
||||||
AuthenticatedUsersDimentorinRoute: typeof AuthenticatedUsersDimentorinRoute
|
|
||||||
AuthenticatedAccountsIdRoute: typeof AuthenticatedAccountsIdRoute
|
|
||||||
AuthenticatedCmsEventsIdRoute: typeof AuthenticatedCmsEventsIdRoute
|
|
||||||
AuthenticatedCmsEventsCreateRoute: typeof AuthenticatedCmsEventsCreateRoute
|
|
||||||
AuthenticatedCmsTestimonialsIdRoute: typeof AuthenticatedCmsTestimonialsIdRoute
|
|
||||||
AuthenticatedCmsTestimonialsCreateRoute: typeof AuthenticatedCmsTestimonialsCreateRoute
|
|
||||||
AuthenticatedDashboardIdRoute: typeof AuthenticatedDashboardIdRoute
|
|
||||||
AuthenticatedDashboardCreateRoute: typeof AuthenticatedDashboardCreateRoute
|
|
||||||
AuthenticatedGachaRollIdRoute: typeof AuthenticatedGachaRollIdRoute
|
|
||||||
AuthenticatedGachaRollCreateRoute: typeof AuthenticatedGachaRollCreateRoute
|
|
||||||
AuthenticatedPermissionsIdRoute: typeof AuthenticatedPermissionsIdRoute
|
|
||||||
AuthenticatedPermissionsCreateRoute: typeof AuthenticatedPermissionsCreateRoute
|
|
||||||
AuthenticatedRoadmapDimentorinIdRoute: typeof AuthenticatedRoadmapDimentorinIdRoute
|
|
||||||
AuthenticatedRoadmapDimentorinCreateRoute: typeof AuthenticatedRoadmapDimentorinCreateRoute
|
|
||||||
AuthenticatedRolesIdRoute: typeof AuthenticatedRolesIdRoute
|
|
||||||
AuthenticatedRolesCreateRoute: typeof AuthenticatedRolesCreateRoute
|
|
||||||
AuthenticatedSessionDimentorinIdRoute: typeof AuthenticatedSessionDimentorinIdRoute
|
|
||||||
AuthenticatedUsersDimentorinIdRoute: typeof AuthenticatedUsersDimentorinIdRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
|
||||||
AuthenticatedAccountsRoute: AuthenticatedAccountsRoute,
|
|
||||||
AuthenticatedCmsEventsRoute: AuthenticatedCmsEventsRoute,
|
|
||||||
AuthenticatedCmsTestimonialsRoute: AuthenticatedCmsTestimonialsRoute,
|
|
||||||
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
|
|
||||||
AuthenticatedDashboardDimentorinRoute: AuthenticatedDashboardDimentorinRoute,
|
|
||||||
AuthenticatedFeedbackReviewDimentorinRoute:
|
|
||||||
AuthenticatedFeedbackReviewDimentorinRoute,
|
|
||||||
AuthenticatedGachaRollRoute: AuthenticatedGachaRollRoute,
|
|
||||||
AuthenticatedHackathonDashboardRoute: AuthenticatedHackathonDashboardRoute,
|
|
||||||
AuthenticatedHackathonSubmissionsRoute:
|
|
||||||
AuthenticatedHackathonSubmissionsRoute,
|
|
||||||
AuthenticatedHackathonTeamsRoute: AuthenticatedHackathonTeamsRoute,
|
|
||||||
AuthenticatedHackathonUsersRoute: AuthenticatedHackathonUsersRoute,
|
|
||||||
AuthenticatedPermissionsRoute: AuthenticatedPermissionsRoute,
|
|
||||||
AuthenticatedPrizesRoute: AuthenticatedPrizesRoute,
|
|
||||||
AuthenticatedRoadmapDimentorinRoute: AuthenticatedRoadmapDimentorinRoute,
|
|
||||||
AuthenticatedRolesRoute: AuthenticatedRolesRoute,
|
|
||||||
AuthenticatedSessionDimentorinRoute: AuthenticatedSessionDimentorinRoute,
|
|
||||||
AuthenticatedSettingsDimentorinRoute: AuthenticatedSettingsDimentorinRoute,
|
|
||||||
AuthenticatedTransactionsRoute: AuthenticatedTransactionsRoute,
|
|
||||||
AuthenticatedUsersDimentorinRoute: AuthenticatedUsersDimentorinRoute,
|
|
||||||
AuthenticatedAccountsIdRoute: AuthenticatedAccountsIdRoute,
|
|
||||||
AuthenticatedCmsEventsIdRoute: AuthenticatedCmsEventsIdRoute,
|
|
||||||
AuthenticatedCmsEventsCreateRoute: AuthenticatedCmsEventsCreateRoute,
|
|
||||||
AuthenticatedCmsTestimonialsIdRoute: AuthenticatedCmsTestimonialsIdRoute,
|
|
||||||
AuthenticatedCmsTestimonialsCreateRoute:
|
|
||||||
AuthenticatedCmsTestimonialsCreateRoute,
|
|
||||||
AuthenticatedDashboardIdRoute: AuthenticatedDashboardIdRoute,
|
|
||||||
AuthenticatedDashboardCreateRoute: AuthenticatedDashboardCreateRoute,
|
|
||||||
AuthenticatedGachaRollIdRoute: AuthenticatedGachaRollIdRoute,
|
|
||||||
AuthenticatedGachaRollCreateRoute: AuthenticatedGachaRollCreateRoute,
|
|
||||||
AuthenticatedPermissionsIdRoute: AuthenticatedPermissionsIdRoute,
|
|
||||||
AuthenticatedPermissionsCreateRoute: AuthenticatedPermissionsCreateRoute,
|
|
||||||
AuthenticatedRoadmapDimentorinIdRoute: AuthenticatedRoadmapDimentorinIdRoute,
|
|
||||||
AuthenticatedRoadmapDimentorinCreateRoute:
|
|
||||||
AuthenticatedRoadmapDimentorinCreateRoute,
|
|
||||||
AuthenticatedRolesIdRoute: AuthenticatedRolesIdRoute,
|
|
||||||
AuthenticatedRolesCreateRoute: AuthenticatedRolesCreateRoute,
|
|
||||||
AuthenticatedSessionDimentorinIdRoute: AuthenticatedSessionDimentorinIdRoute,
|
|
||||||
AuthenticatedUsersDimentorinIdRoute: AuthenticatedUsersDimentorinIdRoute,
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
|
||||||
AuthenticatedRouteChildren,
|
|
||||||
)
|
|
||||||
|
|
||||||
interface PublicRouteChildren {
|
|
||||||
PublicAuthLoginRoute: typeof PublicAuthLoginRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
const PublicRouteChildren: PublicRouteChildren = {
|
|
||||||
PublicAuthLoginRoute: PublicAuthLoginRoute,
|
|
||||||
}
|
|
||||||
|
|
||||||
const PublicRouteWithChildren =
|
|
||||||
PublicRoute._addFileChildren(PublicRouteChildren)
|
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
|
||||||
IndexRoute: IndexRoute,
|
|
||||||
AuthenticatedRoute: AuthenticatedRouteWithChildren,
|
|
||||||
PublicRoute: PublicRouteWithChildren,
|
|
||||||
}
|
|
||||||
export const routeTree = rootRouteImport
|
|
||||||
._addFileChildren(rootRouteChildren)
|
|
||||||
._addFileTypes<FileRouteTypes>()
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createRootRoute, Outlet } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
|
||||||
component: () => <Outlet />,
|
|
||||||
})
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
|
|
||||||
import { SessionToken } from '@imphnen-frontend-service/service';
|
|
||||||
import {
|
|
||||||
SidebarInset,
|
|
||||||
SidebarProvider,
|
|
||||||
} from '@imphnen-frontend-service/ui/atoms';
|
|
||||||
import { BackofficeSidebar } from '../components/sidebar';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated')({
|
|
||||||
beforeLoad: () => {
|
|
||||||
const session = SessionToken.get();
|
|
||||||
if (!session?.token?.access_token) {
|
|
||||||
throw redirect({ to: '/auth/login' });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
component: AuthenticatedLayout,
|
|
||||||
});
|
|
||||||
|
|
||||||
function AuthenticatedLayout() {
|
|
||||||
return (
|
|
||||||
<SidebarProvider>
|
|
||||||
<BackofficeSidebar />
|
|
||||||
<SidebarInset>
|
|
||||||
<Outlet />
|
|
||||||
</SidebarInset>
|
|
||||||
</SidebarProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export const useItem = (
|
|
||||||
nextStep: () => void,
|
|
||||||
initialValues?: any,
|
|
||||||
onDataCapture?: (data: any) => void,
|
|
||||||
) => {
|
|
||||||
const form = useForm<any>({
|
|
||||||
mode: 'all',
|
|
||||||
defaultValues: initialValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => {
|
|
||||||
onDataCapture?.(data);
|
|
||||||
nextStep();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
form,
|
|
||||||
onSubmit,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useConfirmItem = (
|
|
||||||
onClose: () => void,
|
|
||||||
resetStep: () => void,
|
|
||||||
actionFunction?: () => Promise<boolean>,
|
|
||||||
messages?: {
|
|
||||||
success?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
const onConfirm = async () => {
|
|
||||||
try {
|
|
||||||
if (actionFunction) {
|
|
||||||
await actionFunction();
|
|
||||||
}
|
|
||||||
toast.success(messages?.success);
|
|
||||||
onClose();
|
|
||||||
resetStep();
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
toast.error(messages?.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCancel = () => {
|
|
||||||
onClose();
|
|
||||||
resetStep();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export const useItem = (
|
|
||||||
nextStep: () => void,
|
|
||||||
initialValues?: any,
|
|
||||||
onDataCapture?: (data: any) => void,
|
|
||||||
) => {
|
|
||||||
const form = useForm<any>({
|
|
||||||
mode: 'all',
|
|
||||||
defaultValues: initialValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => {
|
|
||||||
onDataCapture?.(data);
|
|
||||||
nextStep();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
form,
|
|
||||||
onSubmit,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useConfirmItem = (
|
|
||||||
onClose: () => void,
|
|
||||||
resetStep: () => void,
|
|
||||||
actionFunction?: () => Promise<boolean>,
|
|
||||||
messages?: {
|
|
||||||
success?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
const onConfirm = async () => {
|
|
||||||
try {
|
|
||||||
if (actionFunction) {
|
|
||||||
await actionFunction();
|
|
||||||
}
|
|
||||||
toast.success(messages?.success);
|
|
||||||
onClose();
|
|
||||||
resetStep();
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
toast.error(messages?.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCancel = () => {
|
|
||||||
onClose();
|
|
||||||
resetStep();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
||||||
import * as React from 'react';
|
|
||||||
import { Filter as FilterIcon, Search, Pencil } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
Checkbox,
|
|
||||||
Input,
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
Badge,
|
|
||||||
} from '@imphnen-frontend-service/ui/atoms';
|
|
||||||
import {
|
|
||||||
DataTable,
|
|
||||||
Filter,
|
|
||||||
BackofficeWrapper,
|
|
||||||
} from '@imphnen-frontend-service/ui/organisms';
|
|
||||||
import {
|
|
||||||
ColumnDef,
|
|
||||||
getCoreRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
PaginationState,
|
|
||||||
useReactTable,
|
|
||||||
RowSelectionState,
|
|
||||||
} from '@tanstack/react-table';
|
|
||||||
import {
|
|
||||||
useUserList,
|
|
||||||
TUsersListItem,
|
|
||||||
} from '@imphnen-frontend-service/service';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/accounts')({
|
|
||||||
component: AccountsPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
function AccountsPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [search, setSearch] = React.useState('');
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: 10,
|
|
||||||
});
|
|
||||||
const [rowSelection, setRowSelection] =
|
|
||||||
React.useState<RowSelectionState>({});
|
|
||||||
const [showFilter, setShowFilter] = React.useState(false);
|
|
||||||
|
|
||||||
const { data: usersData, isLoading } = useUserList({
|
|
||||||
search,
|
|
||||||
page: pagination.pageIndex + 1,
|
|
||||||
per_page: pagination.pageSize,
|
|
||||||
});
|
|
||||||
|
|
||||||
const users: TUsersListItem[] = usersData?.data ?? [];
|
|
||||||
const totalItems = usersData?.meta?.total ?? users.length;
|
|
||||||
|
|
||||||
const columns: ColumnDef<TUsersListItem>[] = [
|
|
||||||
{
|
|
||||||
id: 'select',
|
|
||||||
header: ({ table }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={
|
|
||||||
table.getIsAllRowsSelected()
|
|
||||||
? true
|
|
||||||
: table.getIsSomeRowsSelected()
|
|
||||||
? 'indeterminate'
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
|
|
||||||
}
|
|
||||||
aria-label="Select all"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
|
||||||
aria-label="Select row"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ header: 'No', accessorKey: 'id' },
|
|
||||||
{ header: 'Nama Lengkap', accessorKey: 'fullname' },
|
|
||||||
{ header: 'Email', accessorKey: 'email' },
|
|
||||||
{ header: 'Role', accessorKey: 'role' },
|
|
||||||
{
|
|
||||||
header: 'Status',
|
|
||||||
accessorKey: 'is_active',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge variant={row.original.is_active ? 'success' : 'destructive'}>
|
|
||||||
{row.original.is_active ? 'Aktif' : 'Tidak Aktif'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Action',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate({ to: '/accounts/$id', params: { id: row.original.id } });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="size-3.5" /> Edit
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: users,
|
|
||||||
columns,
|
|
||||||
state: { pagination, rowSelection },
|
|
||||||
enableRowSelection: true,
|
|
||||||
onRowSelectionChange: setRowSelection,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
|
||||||
manualPagination: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BackofficeWrapper
|
|
||||||
title="Data Akun"
|
|
||||||
description="Kelola akun pengguna yang terdaftar"
|
|
||||||
>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="relative w-full sm:max-w-sm">
|
|
||||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Cari nama lengkap atau email…"
|
|
||||||
className="pl-9"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Popover open={showFilter} onOpenChange={setShowFilter}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="secondary" size="md">
|
|
||||||
<FilterIcon className="size-4" />
|
|
||||||
Filters
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="end" className="w-auto p-0">
|
|
||||||
<Filter
|
|
||||||
onClose={() => setShowFilter(false)}
|
|
||||||
options={[
|
|
||||||
{ id: 'all', value: 'all', label: 'Semua' },
|
|
||||||
{ id: 'active', value: 'active', label: 'Aktif' },
|
|
||||||
{ id: 'inactive', value: 'inactive', label: 'Tidak Aktif' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
|
||||||
Memuat data…
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DataTable
|
|
||||||
data={users}
|
|
||||||
columns={columns}
|
|
||||||
table={table}
|
|
||||||
manualPagination
|
|
||||||
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
|
||||||
currentPage={pagination.pageIndex + 1}
|
|
||||||
onPageChange={(p) =>
|
|
||||||
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</BackofficeWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
||||||
import * as React from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@imphnen-frontend-service/ui/atoms';
|
|
||||||
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
|
||||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
|
||||||
import {
|
|
||||||
useUserList,
|
|
||||||
useUpdateUserById,
|
|
||||||
} from '@imphnen-frontend-service/service';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/accounts_/$id')({
|
|
||||||
component: AccountsEditPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
function AccountsEditPage() {
|
|
||||||
const { id } = Route.useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const updateUser = useUpdateUserById();
|
|
||||||
|
|
||||||
const { data: usersData, isLoading } = useUserList({
|
|
||||||
search: '',
|
|
||||||
per_page: 100,
|
|
||||||
});
|
|
||||||
const user = usersData?.data?.find((u) => u.id === id);
|
|
||||||
|
|
||||||
const [fullName, setFullName] = React.useState('');
|
|
||||||
const [email, setEmail] = React.useState('');
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
setFullName(user.fullname);
|
|
||||||
setEmail(user.email);
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
try {
|
|
||||||
await updateUser.mutateAsync({
|
|
||||||
id,
|
|
||||||
data: { fullname: fullName, email },
|
|
||||||
});
|
|
||||||
toast.success('Data akun berhasil diperbarui');
|
|
||||||
navigate({ to: '/accounts' });
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
toast.error('Data akun gagal diperbarui');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BackofficeWrapper title="Edit Data Akun">
|
|
||||||
<div className="mx-auto w-full max-w-2xl">
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigate({ to: '/accounts' })}
|
|
||||||
className="mb-4 -ml-2"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
Kembali
|
|
||||||
</Button>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Edit Data Akun</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
|
||||||
Memuat data…
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<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="md"
|
|
||||||
/>
|
|
||||||
<InputField
|
|
||||||
label="Email"
|
|
||||||
type="text"
|
|
||||||
placeholder="Masukkan email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="md"
|
|
||||||
onClick={() => navigate({ to: '/accounts' })}
|
|
||||||
>
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={updateUser.isPending || isLoading}
|
|
||||||
>
|
|
||||||
{updateUser.isPending ? 'Menyimpan…' : 'Perbarui Data'}
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</BackofficeWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
||||||
import * as React from 'react';
|
|
||||||
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
Checkbox,
|
|
||||||
Input,
|
|
||||||
} from '@imphnen-frontend-service/ui/atoms';
|
|
||||||
import {
|
|
||||||
DataTable,
|
|
||||||
BackofficeWrapper,
|
|
||||||
} from '@imphnen-frontend-service/ui/organisms';
|
|
||||||
import {
|
|
||||||
ColumnDef,
|
|
||||||
getCoreRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
PaginationState,
|
|
||||||
RowSelectionState,
|
|
||||||
useReactTable,
|
|
||||||
} from '@tanstack/react-table';
|
|
||||||
import {
|
|
||||||
useEventList,
|
|
||||||
useDeleteEvent,
|
|
||||||
TEventsListItem,
|
|
||||||
} from '@imphnen-frontend-service/service';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/cms-events')({
|
|
||||||
component: CmsEventsPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
function CmsEventsPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [search, setSearch] = React.useState('');
|
|
||||||
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: 10,
|
|
||||||
});
|
|
||||||
const [rowSelection, setRowSelection] =
|
|
||||||
React.useState<RowSelectionState>({});
|
|
||||||
|
|
||||||
const { data: eventsData, isLoading } = useEventList({
|
|
||||||
search,
|
|
||||||
page: pagination.pageIndex + 1,
|
|
||||||
per_page: pagination.pageSize,
|
|
||||||
});
|
|
||||||
const deleteEvent = useDeleteEvent();
|
|
||||||
|
|
||||||
const events: TEventsListItem[] = eventsData?.data ?? [];
|
|
||||||
const totalItems = eventsData?.meta?.total ?? events.length;
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await deleteEvent.mutateAsync(id);
|
|
||||||
toast.success('Data event berhasil dihapus');
|
|
||||||
setDeleteId(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
toast.error('Data event gagal dihapus');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: ColumnDef<TEventsListItem>[] = [
|
|
||||||
{
|
|
||||||
id: 'select',
|
|
||||||
header: ({ table }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={
|
|
||||||
table.getIsAllRowsSelected()
|
|
||||||
? true
|
|
||||||
: table.getIsSomeRowsSelected()
|
|
||||||
? 'indeterminate'
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
|
|
||||||
}
|
|
||||||
aria-label="Select all"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
|
||||||
aria-label="Select row"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ header: 'Name', accessorKey: 'name' },
|
|
||||||
{
|
|
||||||
header: 'Location',
|
|
||||||
accessorKey: 'location',
|
|
||||||
cell: ({ row }) => row.original.location || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Price',
|
|
||||||
accessorKey: 'price',
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.price === 0
|
|
||||||
? 'Free'
|
|
||||||
: `Rp ${row.original.price.toLocaleString('id-ID')}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Start Date',
|
|
||||||
accessorKey: 'start_date',
|
|
||||||
cell: ({ row }) =>
|
|
||||||
new Date(row.original.start_date).toLocaleDateString('id-ID', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Online',
|
|
||||||
accessorKey: 'is_online',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge variant={row.original.is_online ? 'success' : 'secondary'}>
|
|
||||||
{row.original.is_online ? 'Online' : 'Offline'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Action',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate({
|
|
||||||
to: '/cms-events/$id',
|
|
||||||
params: { id: row.original.id },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="size-3.5" />
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setDeleteId(row.original.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: events,
|
|
||||||
columns,
|
|
||||||
state: { pagination, rowSelection },
|
|
||||||
enableRowSelection: true,
|
|
||||||
onRowSelectionChange: setRowSelection,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
|
||||||
manualPagination: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BackofficeWrapper title="CMS Events" description="Kelola event komunitas">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="relative w-full sm:max-w-sm">
|
|
||||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Cari nama event…"
|
|
||||||
className="pl-9"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate({ to: '/cms-events/create' })}
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
Tambah Event
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
|
||||||
Memuat data…
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DataTable
|
|
||||||
data={events}
|
|
||||||
columns={columns}
|
|
||||||
table={table}
|
|
||||||
manualPagination
|
|
||||||
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
|
||||||
currentPage={pagination.pageIndex + 1}
|
|
||||||
onPageChange={(p) =>
|
|
||||||
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<AlertDialog
|
|
||||||
open={!!deleteId}
|
|
||||||
onOpenChange={(o) => !o && setDeleteId(null)}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Hapus event ini?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Tindakan ini tidak dapat dibatalkan. Event akan dihapus permanen.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Batal</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => deleteId && handleDelete(deleteId)}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Hapus
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</BackofficeWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { useForm } from 'react-hook-form'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
|
||||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms'
|
|
||||||
import {
|
|
||||||
useEventList,
|
|
||||||
useUpdateEvent,
|
|
||||||
} from '@imphnen-frontend-service/service'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/cms-events_/$id')({
|
|
||||||
component: CmsEventsEditPage,
|
|
||||||
})
|
|
||||||
|
|
||||||
function CmsEventsEditPage() {
|
|
||||||
const { id } = Route.useParams()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const updateEvent = useUpdateEvent()
|
|
||||||
|
|
||||||
const { data: eventsData, isLoading } = useEventList({ search: '', per_page: 100 })
|
|
||||||
const event = eventsData?.data?.find((e) => e.id === id)
|
|
||||||
|
|
||||||
const form = useForm<{
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
detail_link: string
|
|
||||||
location: string
|
|
||||||
price: number
|
|
||||||
start_date: string
|
|
||||||
end_date: string
|
|
||||||
is_online: boolean
|
|
||||||
}>({
|
|
||||||
mode: 'all',
|
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
detail_link: '',
|
|
||||||
location: '',
|
|
||||||
price: 0,
|
|
||||||
start_date: '',
|
|
||||||
end_date: '',
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (event) {
|
|
||||||
form.reset({
|
|
||||||
name: event.name,
|
|
||||||
description: event.description,
|
|
||||||
detail_link: event.detail_link,
|
|
||||||
location: event.location,
|
|
||||||
price: event.price,
|
|
||||||
start_date: event.start_date,
|
|
||||||
end_date: event.end_date,
|
|
||||||
is_online: event.is_online,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [event])
|
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit(async (data) => {
|
|
||||||
try {
|
|
||||||
await updateEvent.mutateAsync({ id, data })
|
|
||||||
toast.success('Perubahan event berhasil dilakukan')
|
|
||||||
navigate({ to: '/cms-events' })
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error)
|
|
||||||
toast.error('Perubahan event gagal dilakukan')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<main className="w-full px-[48px] py-[40px]">
|
|
||||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
|
||||||
<div className="max-w-2xl mx-auto w-full">
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate({ to: '/cms-events' })}
|
|
||||||
className="text-primary-500 hover:text-primary-600"
|
|
||||||
>
|
|
||||||
<ArrowLeftOutlined className="text-[20px]" />
|
|
||||||
</button>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Edit Event</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
||||||
<form onSubmit={onSubmit} className="flex flex-col gap-6">
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
label="Nama Event"
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
placeholder="Masukkan Nama Event"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
label="Deskripsi"
|
|
||||||
name="description"
|
|
||||||
type="text"
|
|
||||||
placeholder="Masukkan Deskripsi Event"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
label="Link Detail"
|
|
||||||
name="detail_link"
|
|
||||||
type="text"
|
|
||||||
placeholder="Masukkan Link Detail"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
label="Lokasi"
|
|
||||||
name="location"
|
|
||||||
type="text"
|
|
||||||
placeholder="Masukkan Lokasi"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
label="Harga"
|
|
||||||
name="price"
|
|
||||||
type="number"
|
|
||||||
placeholder="Masukkan Harga"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
label="Tanggal Mulai"
|
|
||||||
name="start_date"
|
|
||||||
type="date"
|
|
||||||
placeholder="Pilih Tanggal Mulai"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
label="Tanggal Selesai"
|
|
||||||
name="end_date"
|
|
||||||
type="date"
|
|
||||||
placeholder="Pilih Tanggal Selesai"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="is_online_update"
|
|
||||||
className="rounded"
|
|
||||||
{...form.register('is_online')}
|
|
||||||
/>
|
|
||||||
<label htmlFor="is_online_update" className="text-p3 font-medium text-neutral-800">
|
|
||||||
Event Online
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Update Event
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="bordered"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => navigate({ to: '/cms-events' })}
|
|
||||||
>
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user