Compare commits
232
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d3e2470c8 | ||
|
|
48411d3a08 | ||
|
|
426b71f341 | ||
|
|
06c69cb3c5 | ||
|
|
81999aa044 | ||
|
|
d83a6d9ec7 | ||
|
|
63dfb7299f | ||
|
|
53079fe868 | ||
|
|
2944a2b98a | ||
|
|
79f087e2fc | ||
|
|
d5b267332f | ||
|
|
8cb7b592eb | ||
|
|
b2ed098067 | ||
|
|
8749e83dad | ||
|
|
00b0997a9b | ||
|
|
05bdac6ee0 | ||
|
|
5cbad2a996 | ||
|
|
e78002ff69 | ||
|
|
b48f653a97 | ||
|
|
63da701b1b | ||
|
|
7bef667e96 | ||
|
|
82d1fae3d5 | ||
|
|
49d8419c1c | ||
|
|
4240e8eb51 | ||
|
|
86d9e759a6 | ||
|
|
6b3faf2a21 | ||
|
|
bcfac70e2a | ||
|
|
cb32146daa | ||
|
|
b82b03a8a2 | ||
|
|
0e0ca9720a | ||
|
|
964dd009cc | ||
|
|
a7ec622925 | ||
|
|
931355e64e | ||
|
|
58fbfcd0d8 | ||
|
|
063587aca9 | ||
|
|
7576b2557d | ||
|
|
95bf09c2fa | ||
|
|
0ade3c3e50 | ||
|
|
e945da4b7f | ||
|
|
61b650bb25 | ||
|
|
e61c003e07 | ||
|
|
8fff0df753 | ||
|
|
054f16053d | ||
|
|
0193245551 | ||
|
|
7f5948724b | ||
|
|
2cba806cd5 | ||
|
|
414e4d19b8 | ||
|
|
7268403ca4 | ||
|
|
651fd65ccb | ||
|
|
4e9d110bb0 | ||
|
|
c6719b19e5 | ||
|
|
53ad40f3fd | ||
|
|
8d2aaf5188 | ||
|
|
3f27e8d622 | ||
|
|
629d609792 | ||
|
|
8439c5d5e0 | ||
|
|
1afd7c014c | ||
|
|
419335d6b3 | ||
|
|
aef8d59c9f | ||
|
|
e14f6d4ef7 | ||
|
|
aa3e935fee | ||
|
|
1100d6f417 | ||
|
|
54ebcf1a1e | ||
|
|
9dabbb625d | ||
|
|
3f4461c65c | ||
|
|
f68d97188c | ||
|
|
f882668e7f | ||
|
|
dab4fa4941 | ||
|
|
bbb1bae909 | ||
|
|
3de1748f12 | ||
|
|
a610e180ba | ||
|
|
ce8cb8188c | ||
|
|
3aaf022393 | ||
|
|
ebe27a3f05 | ||
|
|
f846a5a785 | ||
|
|
708cbde596 | ||
|
|
9d37cba65f | ||
|
|
8728d0abd7 | ||
|
|
1f4f4ddaa1 | ||
|
|
4fcc249ff4 | ||
|
|
c2bed98b06 | ||
|
|
ae1864c134 | ||
|
|
3f30fbe89c | ||
|
|
951db0f28a | ||
|
|
ad59d39f55 | ||
|
|
1283689d84 | ||
|
|
7d1370d7f2 | ||
|
|
ab0b56780f | ||
|
|
1a39ac37db | ||
|
|
e5dc784301 | ||
|
|
b2de22b0ef | ||
|
|
ad20eda182 | ||
|
|
da65aa4109 | ||
|
|
1f0798752c | ||
|
|
5f28b9b406 | ||
|
|
9e8fc7c07f | ||
|
|
ca51f8aca2 | ||
|
|
c086b1371c | ||
|
|
398350795d | ||
|
|
5be9ec1edf | ||
|
|
f77967a092 | ||
|
|
fe172cc128 | ||
|
|
38ee9a9549 | ||
|
|
3ad828e759 | ||
|
|
782cce820b | ||
|
|
f5882e3cdc | ||
|
|
afaaff4a7f | ||
|
|
da8b08cd73 | ||
|
|
1dbe704230 | ||
|
|
59c1bb0366 | ||
|
|
c518784810 | ||
|
|
0267de664b | ||
|
|
134ff14946 | ||
|
|
ed3b711c89 | ||
|
|
6b7b1c4d20 | ||
|
|
b4b19e02fa | ||
|
|
e61979cbb6 | ||
|
|
0c7887268c | ||
|
|
76933c0be7 | ||
|
|
abc4c26d5d | ||
|
|
a3d35716a6 | ||
|
|
ac22bd6cab | ||
|
|
27ed5605ce | ||
|
|
2c6389a67a | ||
|
|
a4c75770ae | ||
|
|
d923237969 | ||
|
|
b2a28c6fd3 | ||
|
|
417ff3390b | ||
|
|
0c646ceb85 | ||
|
|
6bbc87479a | ||
|
|
23b6677323 | ||
|
|
45c75b3283 | ||
|
|
764eb34e47 | ||
|
|
fe4e9291fa | ||
|
|
ec9a81d7b5 | ||
|
|
e652c6ed0e | ||
|
|
d431564aac | ||
|
|
350d08a2b4 | ||
|
|
eb5e043357 | ||
|
|
d67e3e7244 | ||
|
|
a7196dd453 | ||
|
|
c83e54fae8 | ||
|
|
35a5761a7c | ||
|
|
b472fbdbbf | ||
|
|
4fcc96ac15 | ||
|
|
2dfcd3adad | ||
|
|
d79788258b | ||
|
|
427d2b69c7 | ||
|
|
7e5e4cc29b | ||
|
|
a1e89b882a | ||
|
|
a74f0181f1 | ||
|
|
a9a1e5450b | ||
|
|
5e65942060 | ||
|
|
d044d0ef50 | ||
|
|
d40a913ccc | ||
|
|
66178ccc18 | ||
|
|
8280ba2a64 | ||
|
|
8259020450 | ||
|
|
692a7a7258 | ||
|
|
1ee04ada14 | ||
|
|
193cb61b45 | ||
|
|
4777a34f23 | ||
|
|
4d3677ebd2 | ||
|
|
f0eb11010d | ||
|
|
e1f13b3872 | ||
|
|
fa30849d03 | ||
|
|
f8d592d811 | ||
|
|
c385aa1996 | ||
|
|
9a97eb3e8e | ||
|
|
70ee5300f7 | ||
|
|
35c9539c08 | ||
|
|
bc5b072ad8 | ||
|
|
66b8b064ba | ||
|
|
63643cc286 | ||
|
|
19bd44a7ca | ||
|
|
b3bdf25b5f | ||
|
|
cdd4fd4703 | ||
|
|
7d1b86acdb | ||
|
|
6e6c361eb8 | ||
|
|
62780c8889 | ||
|
|
1839f4cab0 | ||
|
|
c9f5b9e4a5 | ||
|
|
33365affa2 | ||
|
|
63db7ff6ad | ||
|
|
7f8b53edd4 | ||
|
|
eb0628ac3e | ||
|
|
cbb880a053 | ||
|
|
083a912e91 | ||
|
|
ab744b411d | ||
|
|
8947b4d05e | ||
|
|
7e3eaa6584 | ||
|
|
98a459c7d7 | ||
|
|
9d24a11c05 | ||
|
|
ee60787100 | ||
|
|
301a349776 | ||
|
|
81488ceb6b | ||
|
|
9e99fd61e7 | ||
|
|
d36c693615 | ||
|
|
20125a6f4a | ||
|
|
4bc5006c91 | ||
|
|
71769b71aa | ||
|
|
a2cf67d210 | ||
|
|
d9c4217fd3 | ||
|
|
719e36e46c | ||
|
|
bd052f4866 | ||
|
|
734e8540f2 | ||
|
|
1289555749 | ||
|
|
971fe7160f | ||
|
|
722caeec9d | ||
|
|
7404afe466 | ||
|
|
400daea60f | ||
|
|
40cd47c6be | ||
|
|
529d065a75 | ||
|
|
69d6d86342 | ||
|
|
31736cc403 | ||
|
|
1cb2443b58 | ||
|
|
9e9bfc2793 | ||
|
|
5a99f216cb | ||
|
|
efdbc65be6 | ||
|
|
f5905c0acc | ||
|
|
6348a4998b | ||
|
|
e067450fcb | ||
|
|
a281f279f8 | ||
|
|
a157e11e13 | ||
|
|
b38afd2b44 | ||
|
|
3c301ec2c4 | ||
|
|
65a6bb686c | ||
|
|
f3a19fa460 | ||
|
|
228f92e9ed | ||
|
|
dcd4ef88d6 | ||
|
|
ec13f2c693 | ||
|
|
302ee37ca1 |
@@ -0,0 +1,109 @@
|
|||||||
|
name: Nix Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['develop']
|
||||||
|
pull_request:
|
||||||
|
branches: ['develop']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
detect:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
affected: ${{ steps.affected.outputs.apps }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
- name: Detect affected apps
|
||||||
|
id: affected
|
||||||
|
run: |
|
||||||
|
AFFECTED=$(npx nx show projects --affected --base=HEAD~1 --type=app 2>/dev/null | tr '\n' ' ')
|
||||||
|
echo "Affected apps: $AFFECTED"
|
||||||
|
APPS="[]"
|
||||||
|
for app in landing backoffice gacha dimentorin hackathon infra qrcampaign; do
|
||||||
|
if echo "$AFFECTED" | grep -qw "$app"; then
|
||||||
|
APPS=$(echo "$APPS" | jq -c ". + [\"$app\"]")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "apps=$APPS" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Matrix: $APPS"
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: detect
|
||||||
|
if: needs.detect.outputs.affected != '[]'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
app: ${{ fromJson(needs.detect.outputs.affected) }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Nix
|
||||||
|
uses: DeterminateSystems/nix-installer-action@main
|
||||||
|
|
||||||
|
- name: Setup Cachix
|
||||||
|
uses: cachix/cachix-action@v15
|
||||||
|
with:
|
||||||
|
name: msdqn
|
||||||
|
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
||||||
|
|
||||||
|
- name: Build ${{ matrix.app }}
|
||||||
|
run: nix build .#${{ matrix.app }} -o result-${{ matrix.app }}
|
||||||
|
|
||||||
|
- name: Push to Cachix
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
|
||||||
|
run: cachix push msdqn result-${{ matrix.app }}
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Install Nix
|
||||||
|
uses: DeterminateSystems/nix-installer-action@main
|
||||||
|
|
||||||
|
- name: Setup SSH
|
||||||
|
env:
|
||||||
|
INFRA_DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${INFRA_DEPLOY_KEY}" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
ssh-keyscan 167.235.70.37 >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
- name: Update infra flake.lock
|
||||||
|
run: |
|
||||||
|
export GIT_SSH_COMMAND="ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes"
|
||||||
|
git clone git@github.com:IMPHNEN/imphnen-infrastructure.git /tmp/infra
|
||||||
|
cd /tmp/infra
|
||||||
|
nix flake update imphnen-frontend
|
||||||
|
if git diff --quiet flake.lock; then
|
||||||
|
echo "flake.lock unchanged, skipping"
|
||||||
|
else
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add flake.lock
|
||||||
|
git commit -m "chore: update imphnen-frontend-service to ${GITHUB_SHA::7}"
|
||||||
|
git push
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy to server
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
ssh -i ~/.ssh/deploy_key -o ConnectTimeout=15 -o StrictHostKeyChecking=accept-new root@167.235.70.37 \
|
||||||
|
'nixos-rebuild switch --flake github:IMPHNEN/imphnen-infrastructure#hetzner --refresh 2>&1 | tail -30'
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
run: rm -f ~/.ssh/deploy_key
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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
|
|
||||||
+19
-1
@@ -4,7 +4,7 @@
|
|||||||
dist
|
dist
|
||||||
tmp
|
tmp
|
||||||
out-tsc
|
out-tsc
|
||||||
|
docs/FigmaImage/
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
@@ -62,3 +62,21 @@ storybook-static
|
|||||||
# Next.js
|
# Next.js
|
||||||
.next
|
.next
|
||||||
out
|
out
|
||||||
|
.cursor/rules/nx-rules.mdc
|
||||||
|
.github/instructions/nx.instructions.md
|
||||||
|
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Nix
|
||||||
|
.direnv
|
||||||
|
result
|
||||||
|
|
||||||
|
.claude/worktrees
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Screenshots and Test Reports
|
||||||
|
**/screenshots
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# 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
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": [
|
|
||||||
"nrwl.angular-console",
|
|
||||||
"esbenp.prettier-vscode",
|
|
||||||
"dbaeumer.vscode-eslint",
|
|
||||||
"ms-playwright.playwright"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# IMPHNEN Frontend Service
|
||||||
|
|
||||||
|
Nx monorepo for IMPHNEN (Ingin Menjadi Programmer Handal Namun Enggan Ngoding) — Indonesia's largest programmer community.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Monorepo**: Nx 22.6
|
||||||
|
- **Framework**: Next.js 16 (landing), Vite + React 19 (all other apps)
|
||||||
|
- **Routing**: react-router v7 (Vite apps), Next.js App Router (landing)
|
||||||
|
- **Styling**: Tailwind CSS v4, class-variance-authority (CVA)
|
||||||
|
- **State**: Zustand, TanStack React Query
|
||||||
|
- **Forms**: react-hook-form + zod
|
||||||
|
- **Build/Deploy**: Nix flakes, Cachix binary cache, NixOS modules
|
||||||
|
- **CI/CD**: GitHub Actions with `nx affected` + matrix strategy
|
||||||
|
- **Node**: v22
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Apps
|
||||||
|
| App | Framework | Description |
|
||||||
|
|-----|-----------|-------------|
|
||||||
|
| `landing` | Next.js 16 | Public website |
|
||||||
|
| `backoffice` | Vite + React | Admin dashboard |
|
||||||
|
| `hackathon` | Vite + React | Hackathon platform |
|
||||||
|
| `dimentorin` | Vite + React | Mentoring platform |
|
||||||
|
| `gacha` | Vite + React | Merch gacha system |
|
||||||
|
| `qrcampaign` | Vite + React | QR campaign tool |
|
||||||
|
| `infra` | Vite + React | Infrastructure dashboard |
|
||||||
|
|
||||||
|
### Shared Libraries
|
||||||
|
| Lib | Purpose | Depends on |
|
||||||
|
|-----|---------|------------|
|
||||||
|
| `utils` | Pure utilities only: `cn`, `For`, `Show`, `useQueryState`, `useModalLogin`, react-query helpers, react-router file-based routing | nothing |
|
||||||
|
| `service` | Business logic: API clients, auth hooks, storage (SessionToken/SessionUser), constants (PERMISSIONS, cities) | `utils` |
|
||||||
|
| `ui` | UI components: atoms (Button, Input, Card, Dialog, Drawer, Form, Label), molecules, organisms (Navbar, Sidebar, Datatable) | `utils`, `service` |
|
||||||
|
|
||||||
|
**Dependency rule**: `ui` → `service` → `utils` (never the reverse)
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dev
|
||||||
|
nx dev <app> # Start dev server
|
||||||
|
nx build <app> # Build single app
|
||||||
|
nx run-many -t build --all # Build everything
|
||||||
|
|
||||||
|
# Test
|
||||||
|
nx test <lib> # Run unit tests (vitest)
|
||||||
|
nx e2e <app>-e2e # Run e2e tests (playwright)
|
||||||
|
nx lint <project> # Lint
|
||||||
|
|
||||||
|
# Build all affected
|
||||||
|
nx affected -t build # Only build what changed
|
||||||
|
nx affected -t test # Only test what changed
|
||||||
|
|
||||||
|
# Nix
|
||||||
|
nix build .#<app> # Build Nix package for an app
|
||||||
|
nix develop # Enter dev shell (node 22, bun, git, jq)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nix / Deployment
|
||||||
|
|
||||||
|
All Nix config is in a single `flake.nix`:
|
||||||
|
- `mkViteApp`: Builds Vite apps as Nix packages
|
||||||
|
- `mkLandingApp`: Builds the Next.js landing app
|
||||||
|
- `mkLandingModule`: NixOS module (systemd service for Next.js)
|
||||||
|
- `mkStaticAppModule`: NixOS module (nginx for static Vite apps)
|
||||||
|
- `npmDepsHash`: Must be updated when `package-lock.json` changes. Use `lib.fakeHash` to get the new hash from a failed build.
|
||||||
|
|
||||||
|
## CI/CD Pipeline (.github/workflows/nix-build.yml)
|
||||||
|
|
||||||
|
1. **detect**: Uses `nx affected` to find changed apps
|
||||||
|
2. **build**: Matrix strategy builds only affected apps with Nix, pushes to Cachix
|
||||||
|
3. **deploy**: Clones `imphnen-infrastructure`, updates `flake.lock`, pushes, then runs `clan machines update hetzner` to deploy to the Hetzner server
|
||||||
|
|
||||||
|
Required GitHub secrets: `CACHIX_AUTH_TOKEN`, `INFRA_DEPLOY_KEY` (SSH key for both GitHub and server access)
|
||||||
|
|
||||||
|
The server (167.235.70.37) pulls pre-built packages from Cachix during `nixos-rebuild`.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
- Vite apps: `VITE_API_URL`, `VITE_GITHUB_CLIENT_ID`
|
||||||
|
- Next.js (landing): `NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_GITHUB_CLIENT_ID`
|
||||||
|
- The `getBaseURL()` function in `libs/service/src/api/index.ts` handles both environments
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- Atomic design: atoms → molecules → organisms
|
||||||
|
- Components have their own folder with `component.tsx`, `index.ts`, `spec.tsx`, `stories.tsx`
|
||||||
|
- Use `cn()` from `@imphnen-frontend-service/utils` for className merging
|
||||||
|
- Use `'use client'` directive on any component using React hooks (for Next.js compatibility)
|
||||||
|
- Button variants via CVA: `primary`, `secondary`, `text`, `bordered`, `success`, `danger`
|
||||||
|
- Landing app uses `container` class — Tailwind v4 requires explicit `margin-inline: auto` (defined in globals.css)
|
||||||
@@ -4,135 +4,117 @@
|
|||||||
<img src="docs/logo.svg" alt="IMPHNEN">
|
<img src="docs/logo.svg" alt="IMPHNEN">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
This repository is a **monorepo** for all frontend services of IMPHNEN. The monorepo includes three main applications:
|
Monorepo for all frontend services of [IMPHNEN](https://imphnen.dev) (Ingin Menjadi Programmer Handal Namun Enggan Ngoding) — Indonesia's largest programmer community.
|
||||||
|
|
||||||
1. **Gacha** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Gacha Website</a>.
|
## Apps
|
||||||
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>.
|
|
||||||
|
|
||||||
## How to install
|
| App | Framework | URL |
|
||||||
|
|-----|-----------|-----|
|
||||||
|
| **Landing** | Next.js 16 | [imphnen.dev](https://imphnen.dev) |
|
||||||
|
| **Backoffice** | Vite + React | [backoffice.imphnen.dev](https://backoffice.imphnen.dev) |
|
||||||
|
| **Hackathon** | Vite + React | [hackathon.imphnen.dev](https://dimentorin.imphnen.dev) |
|
||||||
|
| **Dimentorin** | Vite + React | [dimentorin.imphnen.dev](https://dimentorin.imphnen.dev) |
|
||||||
|
| **Gacha** | Vite + React | [gacha.imphnen.dev](https://gacha.imphnen.dev) |
|
||||||
|
| **QR Campaign** | Vite + React | [qr.imphnen.dev](https://qr.imphnen.dev) |
|
||||||
|
| **Infra** | Vite + React | [infra.imphnen.dev](https://infra.imphnen.dev) |
|
||||||
|
|
||||||
|
## Shared Libraries
|
||||||
|
|
||||||
|
| Lib | Purpose |
|
||||||
|
|-----|---------|
|
||||||
|
| `utils` | Pure utilities — `cn()`, `For`, `Show`, `useQueryState`, `useModalLogin` |
|
||||||
|
| `service` | Business logic — API clients, auth hooks, storage, constants |
|
||||||
|
| `ui` | UI components — atoms, molecules, organisms (atomic design) |
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 22
|
||||||
|
- [Nix](https://nixos.org/download/) (optional, for reproducible builds)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
1. Clone this repository:
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git
|
||||||
cd imphnen-frontend-service
|
cd imphnen-frontend-service
|
||||||
```
|
|
||||||
2. Install all dependencies:
|
|
||||||
```sh
|
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
## How to run
|
Or with Nix:
|
||||||
|
|
||||||
### Setup Environment Variables
|
|
||||||
|
|
||||||
Before running the applications, you need to set up the environment variables. You can do this by copying the `.env.example` file to `.env` and modifying the values according to your needs.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd apps/{appname}
|
nix develop # enters dev shell with node 22, bun, git, jq
|
||||||
cp .env.example .env
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` in the app directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp apps/<app>/.env.example apps/<app>/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
Use the following commands to run in development mode:
|
|
||||||
|
|
||||||
- **Gacha**:
|
|
||||||
```sh
|
```sh
|
||||||
npm run gacha:dev
|
nx dev <app> # e.g. nx dev landing, nx dev backoffice
|
||||||
```
|
|
||||||
- **Backoffice**:
|
|
||||||
```sh
|
|
||||||
npm run backoffice:dev
|
|
||||||
```
|
|
||||||
- **Dimentorin**:
|
|
||||||
```sh
|
|
||||||
npm run dimentorin:dev
|
|
||||||
```
|
|
||||||
- **Landing Page**:
|
|
||||||
```sh
|
|
||||||
npm run landing:dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
Use the following commands to build the applications:
|
|
||||||
|
|
||||||
- **Gacha**:
|
|
||||||
```sh
|
```sh
|
||||||
npm run gacha:build
|
nx build <app> # build single app
|
||||||
```
|
nx run-many -t build --all # build everything
|
||||||
- **Backoffice**:
|
nx affected -t build # build only what changed
|
||||||
```sh
|
|
||||||
npm run backoffice:build
|
|
||||||
```
|
|
||||||
- **Dimentorin**:
|
|
||||||
```sh
|
|
||||||
npm run dimentorin:build
|
|
||||||
```
|
|
||||||
- **Landing Page**:
|
|
||||||
```sh
|
|
||||||
npm run landing:build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Production
|
### Nix Build
|
||||||
|
|
||||||
Use the following commands to run the applications in production mode:
|
```sh
|
||||||
|
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`.
|
||||||
|
|
||||||
|
### 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Storybook
|
### Storybook
|
||||||
|
|
||||||
This repository uses Storybook to develop, test, and document UI components in an isolated and interactive environment. Below are the commands to work with Storybook:
|
|
||||||
|
|
||||||
- **Run Storybook**
|
|
||||||
|
|
||||||
This command starts Storybook in development mode, allowing you to view and test UI components interactively.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run ui:storybook
|
nx storybook ui # run storybook for ui lib
|
||||||
|
nx build-storybook ui # build static storybook
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Run Unit Test**
|
## CI/CD
|
||||||
|
|
||||||
This command runs unit tests for the UI components to ensure they function as expected.
|
GitHub Actions pipeline (`.github/workflows/nix-build.yml`):
|
||||||
|
|
||||||
```sh
|
1. **detect** — uses `nx affected` to find changed apps
|
||||||
npm run ui:test
|
2. **build** — matrix strategy builds only affected apps with Nix, pushes to [Cachix](https://app.cachix.org/cache/msdqn)
|
||||||
```
|
3. **deploy** — updates `flake.lock` in [imphnen-infrastructure](https://github.com/IMPHNEN/imphnen-infrastructure) and deploys to the server using [clan](https://clan.lol)
|
||||||
|
|
||||||
- **Build Components**
|
## Tech Stack
|
||||||
|
|
||||||
This command generates a static build of Storybook, which can be deployed for sharing and documentation purposes.
|
- **Monorepo**: Nx 22.6
|
||||||
|
- **Frontend**: React 19, TypeScript
|
||||||
|
- **Styling**: Tailwind CSS v4, CVA
|
||||||
|
- **State**: Zustand, TanStack React Query
|
||||||
|
- **Forms**: react-hook-form + zod
|
||||||
|
- **Build**: Nix flakes, Cachix
|
||||||
|
- **CI**: GitHub Actions
|
||||||
|
|
||||||
```sh
|
## Contributing
|
||||||
npm run ui:build
|
|
||||||
```
|
|
||||||
|
|
||||||
## How to contribute
|
1. Fork and clone the repository
|
||||||
|
2. Create a branch: `git checkout -b feat/feature-name`
|
||||||
|
3. Make changes, commit, and push
|
||||||
|
4. Open a pull request to the `develop` branch
|
||||||
|
|
||||||
1. Fork the repository and clone it locally.
|
Issues and feedback welcome via [GitHub Issues](https://github.com/IMPHNEN/imphnen-frontend-service/issues).
|
||||||
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
@@ -0,0 +1,21 @@
|
|||||||
|
# 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.
|
||||||
@@ -1 +1,2 @@
|
|||||||
VITE_API_URL=
|
VITE_API_URL=
|
||||||
|
VITE_GITHUB_CLIENT_ID=
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Backoffice</title>
|
<title>Backoffice</title>
|
||||||
@@ -7,10 +7,9 @@
|
|||||||
|
|
||||||
<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>
|
<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>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -1,162 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { FC, ReactElement } from 'react';
|
|
||||||
import { Outlet } from 'react-router-dom';
|
|
||||||
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
|
||||||
|
|
||||||
export const AppLayout: FC = (): ReactElement => {
|
|
||||||
return (
|
|
||||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
|
||||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
|
||||||
<BackofficeSidebar />
|
|
||||||
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AppLayout;
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
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
@@ -1,72 +0,0 @@
|
|||||||
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
@@ -1,62 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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 } = 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"
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
label="Password"
|
|
||||||
placeholder="Masukkan Password"
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
disabled={
|
|
||||||
form.formState.isSubmitting ||
|
|
||||||
form.formState.isValidating ||
|
|
||||||
!form.formState.isValid
|
|
||||||
}
|
|
||||||
type="submit"
|
|
||||||
size="md"
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Components;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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,144 @@
|
|||||||
|
import { FC, useState, useRef, useEffect } from 'react';
|
||||||
|
import { FilterOutlined } from '@ant-design/icons';
|
||||||
|
import INDONESIAN_CITIES from '../constants/cities';
|
||||||
|
|
||||||
|
interface CityFilterSelectProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
allOptionLabel?: string;
|
||||||
|
filterIcon?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CityFilterSelect: FC<CityFilterSelectProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className = '',
|
||||||
|
placeholder = 'Search cities...',
|
||||||
|
allOptionLabel = 'All Cities',
|
||||||
|
filterIcon = true,
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const filteredCities = INDONESIAN_CITIES.filter((city) =>
|
||||||
|
city.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectCity = (city: string) => {
|
||||||
|
onChange(city);
|
||||||
|
setSearchQuery('');
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputClick = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearSelection = () => {
|
||||||
|
onChange('all');
|
||||||
|
setSearchQuery('');
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayValue = value === 'all' ? allOptionLabel : value;
|
||||||
|
const showClearButton = value !== 'all' && !isOpen;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`} ref={dropdownRef}>
|
||||||
|
<div className="relative">
|
||||||
|
{filterIcon && (
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={isOpen ? searchQuery : displayValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
if (!isOpen) setIsOpen(true);
|
||||||
|
}}
|
||||||
|
onClick={handleInputClick}
|
||||||
|
onFocus={handleInputClick}
|
||||||
|
placeholder={isOpen ? placeholder : displayValue}
|
||||||
|
className={`border border-neutral-200 rounded-lg pr-10 py-2.5 text-sm w-full focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer ${
|
||||||
|
filterIcon ? ' pl-10' : 'pl-3'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{showClearButton && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClearSelection();
|
||||||
|
}}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 text-xs cursor-pointer z-20"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute z-50 w-full mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
onClick={() => handleSelectCity('all')}
|
||||||
|
className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 border-b border-neutral-100 ${
|
||||||
|
value === 'all'
|
||||||
|
? 'bg-primary-50 text-primary-700 font-medium'
|
||||||
|
: 'text-neutral-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{allOptionLabel}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredCities.length > 0 ? (
|
||||||
|
<div className="py-1">
|
||||||
|
{filteredCities.slice(0, 100).map((city) => (
|
||||||
|
<div
|
||||||
|
key={city}
|
||||||
|
onClick={() => handleSelectCity(city)}
|
||||||
|
className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 text-sm ${
|
||||||
|
value === city
|
||||||
|
? 'bg-primary-50 text-primary-700 font-medium'
|
||||||
|
: 'text-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{city}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filteredCities.length > 100 && (
|
||||||
|
<div className="px-3 py-2 text-xs text-neutral-500 border-t border-neutral-100">
|
||||||
|
Showing first 100 results. Continue typing to refine...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : searchQuery ? (
|
||||||
|
<div className="px-3 py-2 text-neutral-500 text-sm">
|
||||||
|
No cities found matching "{searchQuery}"
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
const INDONESIAN_CITIES: string[] = [
|
||||||
|
'Aceh Selatan',
|
||||||
|
'Aceh Tenggara',
|
||||||
|
'Aceh Timur',
|
||||||
|
'Aceh Tengah',
|
||||||
|
'Aceh Barat',
|
||||||
|
'Aceh Besar',
|
||||||
|
'Pidie',
|
||||||
|
'Aceh Utara',
|
||||||
|
'Simeulue',
|
||||||
|
'Aceh Singkil',
|
||||||
|
'Bireuen',
|
||||||
|
'Aceh Barat Daya',
|
||||||
|
'Gayo Lues',
|
||||||
|
'Aceh Jaya',
|
||||||
|
'Nagan Raya',
|
||||||
|
'Aceh Tamiang',
|
||||||
|
'Bener Meriah',
|
||||||
|
'Pidie Jaya',
|
||||||
|
'Kota Banda Aceh',
|
||||||
|
'Kota Sabang',
|
||||||
|
'Kota Lhokseumawe',
|
||||||
|
'Kota Langsa',
|
||||||
|
'Kota Subulussalam',
|
||||||
|
'Tapanuli Tengah',
|
||||||
|
'Tapanuli Utara',
|
||||||
|
'Tapanuli Selatan',
|
||||||
|
'Nias',
|
||||||
|
'Langkat',
|
||||||
|
'Karo',
|
||||||
|
'Deli Serdang',
|
||||||
|
'Simalungun',
|
||||||
|
'Asahan',
|
||||||
|
'Labuhanbatu',
|
||||||
|
'Dairi',
|
||||||
|
'Toba',
|
||||||
|
'Mandailing Natal',
|
||||||
|
'Nias Selatan',
|
||||||
|
'Pakpak Bharat',
|
||||||
|
'Humbang Hasundutan',
|
||||||
|
'Samosir',
|
||||||
|
'Serdang Bedagai',
|
||||||
|
'Batu Bara',
|
||||||
|
'Padang Lawas Utara',
|
||||||
|
'Padang Lawas',
|
||||||
|
'Labuhanbatu Selatan',
|
||||||
|
'Labuhanbatu Utara',
|
||||||
|
'Nias Utara',
|
||||||
|
'Nias Barat',
|
||||||
|
'Kota Medan',
|
||||||
|
'Kota Pematangsiantar',
|
||||||
|
'Kota Sibolga',
|
||||||
|
'Kota Tanjung Balai',
|
||||||
|
'Kota Binjai',
|
||||||
|
'Kota Tebing Tinggi',
|
||||||
|
'Kota Padangsidimpuan',
|
||||||
|
'Kota Gunungsitoli',
|
||||||
|
'Pesisir Selatan',
|
||||||
|
'Solok',
|
||||||
|
'Sijunjung',
|
||||||
|
'Tanah Datar',
|
||||||
|
'Padang Pariaman',
|
||||||
|
'Agam',
|
||||||
|
'Lima Puluh Kota',
|
||||||
|
'Pasaman',
|
||||||
|
'Kepulauan Mentawai',
|
||||||
|
'Dharmasraya',
|
||||||
|
'Solok Selatan',
|
||||||
|
'Pasaman Barat',
|
||||||
|
'Kota Padang',
|
||||||
|
'Kota Solok',
|
||||||
|
'Kota Sawahlunto',
|
||||||
|
'Kota Padang Panjang',
|
||||||
|
'Kota Bukittinggi',
|
||||||
|
'Kota Payakumbuh',
|
||||||
|
'Kota Pariaman',
|
||||||
|
'Kampar',
|
||||||
|
'Indragiri Hulu',
|
||||||
|
'Bengkalis',
|
||||||
|
'Indragiri Hilir',
|
||||||
|
'Pelalawan',
|
||||||
|
'Rokan Hulu',
|
||||||
|
'Rokan Hilir',
|
||||||
|
'Siak',
|
||||||
|
'Kuantan Singingi',
|
||||||
|
'Kepulauan Meranti',
|
||||||
|
'Kota Pekanbaru',
|
||||||
|
'Kota Dumai',
|
||||||
|
'Kerinci',
|
||||||
|
'Merangin',
|
||||||
|
'Sarolangun',
|
||||||
|
'Batanghari',
|
||||||
|
'Muaro Jambi',
|
||||||
|
'Tanjung Jabung Barat',
|
||||||
|
'Tanjung Jabung Timur',
|
||||||
|
'Bungo',
|
||||||
|
'Tebo',
|
||||||
|
'Kota Jambi',
|
||||||
|
'Kota Sungai Penuh',
|
||||||
|
'Ogan Komering Ulu',
|
||||||
|
'Ogan Komering Ilir',
|
||||||
|
'Muara Enim',
|
||||||
|
'Lahat',
|
||||||
|
'Musi Rawas',
|
||||||
|
'Musi Banyuasin',
|
||||||
|
'Banyuasin',
|
||||||
|
'Ogan Komering Ulu Timur',
|
||||||
|
'Ogan Komering Ulu Selatan',
|
||||||
|
'Ogan Ilir',
|
||||||
|
'Empat Lawang',
|
||||||
|
'Penukal Abab Lematang Ilir',
|
||||||
|
'Musi Rawas Utara',
|
||||||
|
'Kota Palembang',
|
||||||
|
'Kota Pagar Alam',
|
||||||
|
'Kota Lubuk Linggau',
|
||||||
|
'Kota Prabumulih',
|
||||||
|
'Bengkulu Selatan',
|
||||||
|
'Rejang Lebong',
|
||||||
|
'Bengkulu Utara',
|
||||||
|
'Kaur',
|
||||||
|
'Seluma',
|
||||||
|
'Muko Muko',
|
||||||
|
'Lebong',
|
||||||
|
'Kepahiang',
|
||||||
|
'Bengkulu Tengah',
|
||||||
|
'Kota Bengkulu',
|
||||||
|
'Lampung Selatan',
|
||||||
|
'Lampung Tengah',
|
||||||
|
'Lampung Utara',
|
||||||
|
'Lampung Barat',
|
||||||
|
'Tulang Bawang',
|
||||||
|
'Tanggamus',
|
||||||
|
'Lampung Timur',
|
||||||
|
'Way Kanan',
|
||||||
|
'Pesawaran',
|
||||||
|
'Pringsewu',
|
||||||
|
'Mesuji',
|
||||||
|
'Tulang Bawang Barat',
|
||||||
|
'Pesisir Barat',
|
||||||
|
'Kota Bandar Lampung',
|
||||||
|
'Kota Metro',
|
||||||
|
'Bangka',
|
||||||
|
'Belitung',
|
||||||
|
'Bangka Selatan',
|
||||||
|
'Bangka Tengah',
|
||||||
|
'Bangka Barat',
|
||||||
|
'Belitung Timur',
|
||||||
|
'Kota Pangkal Pinang',
|
||||||
|
'Bintan',
|
||||||
|
'Karimun',
|
||||||
|
'Natuna',
|
||||||
|
'Lingga',
|
||||||
|
'Kepulauan Anambas',
|
||||||
|
'Kota Batam',
|
||||||
|
'Kota Tanjung Pinang',
|
||||||
|
'Kepulauan Seribu',
|
||||||
|
'Kota Jakarta Pusat',
|
||||||
|
'Kota Jakarta Utara',
|
||||||
|
'Kota Jakarta Barat',
|
||||||
|
'Kota Jakarta Selatan',
|
||||||
|
'Kota Jakarta Timur',
|
||||||
|
'Bogor',
|
||||||
|
'Sukabumi',
|
||||||
|
'Cianjur',
|
||||||
|
'Bandung',
|
||||||
|
'Garut',
|
||||||
|
'Tasikmalaya',
|
||||||
|
'Ciamis',
|
||||||
|
'Kuningan',
|
||||||
|
'Cirebon',
|
||||||
|
'Majalengka',
|
||||||
|
'Sumedang',
|
||||||
|
'Indramayu',
|
||||||
|
'Subang',
|
||||||
|
'Purwakarta',
|
||||||
|
'Karawang',
|
||||||
|
'Bekasi',
|
||||||
|
'Bandung Barat',
|
||||||
|
'Pangandaran',
|
||||||
|
'Kota Bogor',
|
||||||
|
'Kota Sukabumi',
|
||||||
|
'Kota Bandung',
|
||||||
|
'Kota Cirebon',
|
||||||
|
'Kota Bekasi',
|
||||||
|
'Kota Depok',
|
||||||
|
'Kota Cimahi',
|
||||||
|
'Kota Tasikmalaya',
|
||||||
|
'Kota Banjar',
|
||||||
|
'Cilacap',
|
||||||
|
'Banyumas',
|
||||||
|
'Purbalingga',
|
||||||
|
'Banjarnegara',
|
||||||
|
'Kebumen',
|
||||||
|
'Purworejo',
|
||||||
|
'Wonosobo',
|
||||||
|
'Magelang',
|
||||||
|
'Boyolali',
|
||||||
|
'Klaten',
|
||||||
|
'Sukoharjo',
|
||||||
|
'Wonogiri',
|
||||||
|
'Karanganyar',
|
||||||
|
'Sragen',
|
||||||
|
'Grobogan',
|
||||||
|
'Blora',
|
||||||
|
'Rembang',
|
||||||
|
'Pati',
|
||||||
|
'Kudus',
|
||||||
|
'Jepara',
|
||||||
|
'Demak',
|
||||||
|
'Semarang',
|
||||||
|
'Temanggung',
|
||||||
|
'Kendal',
|
||||||
|
'Batang',
|
||||||
|
'Pekalongan',
|
||||||
|
'Pemalang',
|
||||||
|
'Tegal',
|
||||||
|
'Brebes',
|
||||||
|
'Kota Magelang',
|
||||||
|
'Kota Surakarta',
|
||||||
|
'Kota Salatiga',
|
||||||
|
'Kota Semarang',
|
||||||
|
'Kota Pekalongan',
|
||||||
|
'Kota Tegal',
|
||||||
|
'Kulon Progo',
|
||||||
|
'Bantul',
|
||||||
|
'Gunungkidul',
|
||||||
|
'Sleman',
|
||||||
|
'Kota Yogyakarta',
|
||||||
|
'Pacitan',
|
||||||
|
'Ponorogo',
|
||||||
|
'Trenggalek',
|
||||||
|
'Tulungagung',
|
||||||
|
'Blitar',
|
||||||
|
'Kediri',
|
||||||
|
'Malang',
|
||||||
|
'Lumajang',
|
||||||
|
'Jember',
|
||||||
|
'Banyuwangi',
|
||||||
|
'Bondowoso',
|
||||||
|
'Situbondo',
|
||||||
|
'Probolinggo',
|
||||||
|
'Pasuruan',
|
||||||
|
'Sidoarjo',
|
||||||
|
'Mojokerto',
|
||||||
|
'Jombang',
|
||||||
|
'Nganjuk',
|
||||||
|
'Madiun',
|
||||||
|
'Magetan',
|
||||||
|
'Ngawi',
|
||||||
|
'Bojonegoro',
|
||||||
|
'Tuban',
|
||||||
|
'Lamongan',
|
||||||
|
'Gresik',
|
||||||
|
'Bangkalan',
|
||||||
|
'Sampang',
|
||||||
|
'Pamekasan',
|
||||||
|
'Sumenep',
|
||||||
|
'Kota Kediri',
|
||||||
|
'Kota Blitar',
|
||||||
|
'Kota Malang',
|
||||||
|
'Kota Probolinggo',
|
||||||
|
'Kota Pasuruan',
|
||||||
|
'Kota Mojokerto',
|
||||||
|
'Kota Madiun',
|
||||||
|
'Kota Surabaya',
|
||||||
|
'Kota Batu',
|
||||||
|
'Pandeglang',
|
||||||
|
'Lebak',
|
||||||
|
'Tangerang',
|
||||||
|
'Serang',
|
||||||
|
'Kota Tangerang',
|
||||||
|
'Kota Cilegon',
|
||||||
|
'Kota Serang',
|
||||||
|
'Kota Tangerang Selatan',
|
||||||
|
'Jembrana',
|
||||||
|
'Tabanan',
|
||||||
|
'Badung',
|
||||||
|
'Gianyar',
|
||||||
|
'Klungkung',
|
||||||
|
'Bangli',
|
||||||
|
'Karangasem',
|
||||||
|
'Buleleng',
|
||||||
|
'Kota Denpasar',
|
||||||
|
'Lombok Barat',
|
||||||
|
'Lombok Tengah',
|
||||||
|
'Lombok Timur',
|
||||||
|
'Sumbawa',
|
||||||
|
'Dompu',
|
||||||
|
'Bima',
|
||||||
|
'Sumbawa Barat',
|
||||||
|
'Lombok Utara',
|
||||||
|
'Kota Mataram',
|
||||||
|
'Kota Bima',
|
||||||
|
'Kupang',
|
||||||
|
'Timor Tengah Selatan',
|
||||||
|
'Timor Tengah Utara',
|
||||||
|
'Belu',
|
||||||
|
'Alor',
|
||||||
|
'Flores Timur',
|
||||||
|
'Sikka',
|
||||||
|
'Ende',
|
||||||
|
'Ngada',
|
||||||
|
'Manggarai',
|
||||||
|
'Sumba Timur',
|
||||||
|
'Sumba Barat',
|
||||||
|
'Lembata',
|
||||||
|
'Rote Ndao',
|
||||||
|
'Manggarai Barat',
|
||||||
|
'Nagekeo',
|
||||||
|
'Sumba Tengah',
|
||||||
|
'Sumba Barat Daya',
|
||||||
|
'Manggarai Timur',
|
||||||
|
'Sabu Raijua',
|
||||||
|
'Malaka',
|
||||||
|
'Kota Kupang',
|
||||||
|
'Sambas',
|
||||||
|
'Mempawah',
|
||||||
|
'Sanggau',
|
||||||
|
'Ketapang',
|
||||||
|
'Sintang',
|
||||||
|
'Kapuas Hulu',
|
||||||
|
'Bengkayang',
|
||||||
|
'Landak',
|
||||||
|
'Sekadau',
|
||||||
|
'Melawi',
|
||||||
|
'Kayong Utara',
|
||||||
|
'Kubu Raya',
|
||||||
|
'Kota Pontianak',
|
||||||
|
'Kota Singkawang',
|
||||||
|
'Kotawaringin Barat',
|
||||||
|
'Kotawaringin Timur',
|
||||||
|
'Kapuas',
|
||||||
|
'Barito Selatan',
|
||||||
|
'Barito Utara',
|
||||||
|
'Katingan',
|
||||||
|
'Seruyan',
|
||||||
|
'Sukamara',
|
||||||
|
'Lamandau',
|
||||||
|
'Gunung Mas',
|
||||||
|
'Pulang Pisau',
|
||||||
|
'Murung Raya',
|
||||||
|
'Barito Timur',
|
||||||
|
'Kota Palangkaraya',
|
||||||
|
'Tanah Laut',
|
||||||
|
'Kotabaru',
|
||||||
|
'Banjar',
|
||||||
|
'Barito Kuala',
|
||||||
|
'Tapin',
|
||||||
|
'Hulu Sungai Selatan',
|
||||||
|
'Hulu Sungai Tengah',
|
||||||
|
'Hulu Sungai Utara',
|
||||||
|
'Tabalong',
|
||||||
|
'Tanah Bumbu',
|
||||||
|
'Balangan',
|
||||||
|
'Kota Banjarmasin',
|
||||||
|
'Kota Banjarbaru',
|
||||||
|
'Paser',
|
||||||
|
'Kutai Kartanegara',
|
||||||
|
'Berau',
|
||||||
|
'Kutai Barat',
|
||||||
|
'Kutai Timur',
|
||||||
|
'Penajam Paser Utara',
|
||||||
|
'Mahakam Ulu',
|
||||||
|
'Kota Balikpapan',
|
||||||
|
'Kota Samarinda',
|
||||||
|
'Kota Bontang',
|
||||||
|
'Bulungan',
|
||||||
|
'Malinau',
|
||||||
|
'Nunukan',
|
||||||
|
'Tana Tidung',
|
||||||
|
'Kota Tarakan',
|
||||||
|
'Bolaang Mongondow',
|
||||||
|
'Minahasa',
|
||||||
|
'Kepulauan Sangihe',
|
||||||
|
'Kepulauan Talaud',
|
||||||
|
'Minahasa Selatan',
|
||||||
|
'Minahasa Utara',
|
||||||
|
'Minahasa Tenggara',
|
||||||
|
'Bolaang Mongondow Utara',
|
||||||
|
'Kepulauan Siau Tagulandang Biaro (Sitaro)',
|
||||||
|
'Bolaang Mongondow Timur',
|
||||||
|
'Bolaang Mongondow Selatan',
|
||||||
|
'Kota Manado',
|
||||||
|
'Kota Bitung',
|
||||||
|
'Kota Tomohon',
|
||||||
|
'Kota Kotamobagu',
|
||||||
|
'Banggai',
|
||||||
|
'Poso',
|
||||||
|
'Donggala',
|
||||||
|
'Toli Toli',
|
||||||
|
'Buol',
|
||||||
|
'Morowali',
|
||||||
|
'Banggai Kepulauan',
|
||||||
|
'Parigi Moutong',
|
||||||
|
'Tojo Una Una',
|
||||||
|
'Sigi',
|
||||||
|
'Banggai Laut',
|
||||||
|
'Morowali Utara',
|
||||||
|
'Kota Palu',
|
||||||
|
'Kepulauan Selayar',
|
||||||
|
'Bulukumba',
|
||||||
|
'Bantaeng',
|
||||||
|
'Jeneponto',
|
||||||
|
'Takalar',
|
||||||
|
'Gowa',
|
||||||
|
'Sinjai',
|
||||||
|
'Bone',
|
||||||
|
'Maros',
|
||||||
|
'Pangkajene Kepulauan',
|
||||||
|
'Barru',
|
||||||
|
'Soppeng',
|
||||||
|
'Wajo',
|
||||||
|
'Sidenreng Rappang',
|
||||||
|
'Pinrang',
|
||||||
|
'Enrekang',
|
||||||
|
'Luwu',
|
||||||
|
'Tana Toraja',
|
||||||
|
'Luwu Utara',
|
||||||
|
'Luwu Timur',
|
||||||
|
'Toraja Utara',
|
||||||
|
'Kota Makassar',
|
||||||
|
'Kota Pare Pare',
|
||||||
|
'Kota Palopo',
|
||||||
|
'Kolaka',
|
||||||
|
'Konawe',
|
||||||
|
'Muna',
|
||||||
|
'Buton',
|
||||||
|
'Konawe Selatan',
|
||||||
|
'Bombana',
|
||||||
|
'Wakatobi',
|
||||||
|
'Kolaka Utara',
|
||||||
|
'Konawe Utara',
|
||||||
|
'Buton Utara',
|
||||||
|
'Kolaka Timur',
|
||||||
|
'Konawe Kepulauan',
|
||||||
|
'Muna Barat',
|
||||||
|
'Buton Tengah',
|
||||||
|
'Buton Selatan',
|
||||||
|
'Kota Kendari',
|
||||||
|
'Kota Bau Bau',
|
||||||
|
'Gorontalo',
|
||||||
|
'Boalemo',
|
||||||
|
'Bone Bolango',
|
||||||
|
'Pahuwato',
|
||||||
|
'Gorontalo Utara',
|
||||||
|
'Kota Gorontalo',
|
||||||
|
'Pasangkayu (Mamuju Utara)',
|
||||||
|
'Mamuju',
|
||||||
|
'Mamasa',
|
||||||
|
'Polewali Mandar',
|
||||||
|
'Majene',
|
||||||
|
'Mamuju Tengah',
|
||||||
|
'Maluku Tengah',
|
||||||
|
'Maluku Tenggara',
|
||||||
|
'Kepulauan Tanimbar (Maluku Tenggara Barat)',
|
||||||
|
'Buru',
|
||||||
|
'Seram Bagian Timur',
|
||||||
|
'Seram Bagian Barat',
|
||||||
|
'Kepulauan Aru',
|
||||||
|
'Maluku Barat Daya',
|
||||||
|
'Buru Selatan',
|
||||||
|
'Kota Ambon',
|
||||||
|
'Kota Tual',
|
||||||
|
'Halmahera Barat',
|
||||||
|
'Halmahera Tengah',
|
||||||
|
'Halmahera Utara',
|
||||||
|
'Halmahera Selatan',
|
||||||
|
'Kepulauan Sula',
|
||||||
|
'Halmahera Timur',
|
||||||
|
'Pulau Morotai',
|
||||||
|
'Pulau Taliabu',
|
||||||
|
'Kota Ternate',
|
||||||
|
'Kota Tidore Kepulauan',
|
||||||
|
'Jayapura',
|
||||||
|
'Kepulauan Yapen',
|
||||||
|
'Biak Numfor',
|
||||||
|
'Sarmi',
|
||||||
|
'Keerom',
|
||||||
|
'Waropen',
|
||||||
|
'Supiori',
|
||||||
|
'Mamberamo Raya',
|
||||||
|
'Kota Jayapura',
|
||||||
|
'Manokwari',
|
||||||
|
'Fak Fak',
|
||||||
|
'Teluk Bintuni',
|
||||||
|
'Teluk Wondama',
|
||||||
|
'Kaimana',
|
||||||
|
'Manokwari Selatan',
|
||||||
|
'Pegunungan Arfak',
|
||||||
|
'Merauke',
|
||||||
|
'Boven Digoel',
|
||||||
|
'Mappi',
|
||||||
|
'Asmat',
|
||||||
|
'Nabire',
|
||||||
|
'Puncak Jaya',
|
||||||
|
'Paniai',
|
||||||
|
'Mimika',
|
||||||
|
'Puncak',
|
||||||
|
'Dogiyai',
|
||||||
|
'Intan Jaya',
|
||||||
|
'Deiyai',
|
||||||
|
'Jayawijaya',
|
||||||
|
'Pegunungan Bintang',
|
||||||
|
'Yahukimo',
|
||||||
|
'Tolikara',
|
||||||
|
'Mamberamo Tengah',
|
||||||
|
'Yalimo',
|
||||||
|
'Lanny Jaya',
|
||||||
|
'Nduga',
|
||||||
|
'Sorong',
|
||||||
|
'Sorong Selatan',
|
||||||
|
'Raja Ampat',
|
||||||
|
'Tambrauw',
|
||||||
|
'Maybrat',
|
||||||
|
'Kota Sorong',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default INDONESIAN_CITIES;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
@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 {
|
||||||
@@ -96,6 +97,45 @@
|
|||||||
/* ~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 {
|
||||||
@@ -130,7 +170,7 @@
|
|||||||
html {
|
html {
|
||||||
font-family: 'Bai Jamjuree', sans-serif;
|
font-family: 'Bai Jamjuree', sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,42 +1,25 @@
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client'
|
||||||
import { middleware } from './middleware';
|
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||||
import { StrictMode } from 'react';
|
import { QueryProvider } from '@imphnen-frontend-service/utils'
|
||||||
import { createBrowserRouter, RouteObject, RouterProvider } from 'react-router';
|
import { Toaster } from 'sonner'
|
||||||
import {
|
import { routeTree } from './routeTree.gen'
|
||||||
add404PageToRoutesChildren,
|
import './index.css'
|
||||||
addErrorElementToRoutes,
|
|
||||||
convertPagesToRoute,
|
|
||||||
QueryProvider,
|
|
||||||
} from '@imphnen-frontend-service/utils';
|
|
||||||
import { Toaster } from 'sonner';
|
|
||||||
import './index.css';
|
|
||||||
|
|
||||||
const files = import.meta.glob('./app/**/*(page|layout).tsx');
|
const router = createRouter({ routeTree })
|
||||||
const errorFiles = import.meta.glob('./app/**/*error.tsx');
|
|
||||||
const notFoundFiles = import.meta.glob('./app/**/*404.tsx');
|
|
||||||
const loadingFiles = import.meta.glob('./app/**/*loading.tsx');
|
|
||||||
|
|
||||||
const routes = convertPagesToRoute(files, loadingFiles) as RouteObject;
|
declare module '@tanstack/react-router' {
|
||||||
addErrorElementToRoutes(errorFiles, routes);
|
interface Register {
|
||||||
add404PageToRoutesChildren(notFoundFiles, routes);
|
router: typeof router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const rootElement = document.getElementById('root')
|
||||||
{
|
|
||||||
...routes,
|
|
||||||
loader: middleware,
|
|
||||||
shouldRevalidate: () => true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
if (!rootElement) throw new Error('Failed to find the root element')
|
||||||
|
|
||||||
if (!rootElement) throw new Error('Failed to find the root element');
|
|
||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<Toaster position="top-right" />
|
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
<Toaster position="top-right" />
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</StrictMode>
|
)
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
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('/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 '/dashboard';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,923 @@
|
|||||||
|
/* 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>()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createRootRoute, Outlet } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: () => <Outlet />,
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
|
||||||
|
|
||||||
|
type ChartProps = {
|
||||||
|
name: string
|
||||||
|
value: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData: ChartProps[] = [
|
||||||
|
{ name: "Active", value: 49, color: "#23A1EB" },
|
||||||
|
{ name: "Done", value: 24, color: "#81CBF8" },
|
||||||
|
{ name: "Canceled", value: 27, color: "#BCE1FB" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const RADIAN = Math.PI / 180;
|
||||||
|
const renderCustomizedLabel = (props: any) => {
|
||||||
|
const cx = props.cx ?? 0
|
||||||
|
const cy = props.cy ?? 0
|
||||||
|
const midAngle = props.midAngle ?? 0
|
||||||
|
const innerRadius = props.innerRadius ?? 0
|
||||||
|
const outerRadius = props.outerRadius ?? 0
|
||||||
|
const percent = props.percent ?? 0
|
||||||
|
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||||
|
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||||
|
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
|
||||||
|
{`${(percent * 100).toFixed(0)}%`}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SessionStatusChart = () => {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<PieChart width={500} height={320}>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={40}
|
||||||
|
outerRadius={100}
|
||||||
|
labelLine={false}
|
||||||
|
label={renderCustomizedLabel}
|
||||||
|
>
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
|
||||||
|
|
||||||
|
type ChartProps = {
|
||||||
|
name: string
|
||||||
|
activeUser: number
|
||||||
|
activeSession: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData: ChartProps[] = [
|
||||||
|
{ name: "2014", activeUser: 0, activeSession: 0 },
|
||||||
|
{ name: "2015", activeUser: 15, activeSession: 25 },
|
||||||
|
{ name: "2016", activeUser: 30, activeSession: 40 },
|
||||||
|
{ name: "2017", activeUser: 45, activeSession: 55 },
|
||||||
|
{ name: "2018", activeUser: 60, activeSession: 70 },
|
||||||
|
{ name: "2019", activeUser: 75, activeSession: 85 },
|
||||||
|
{ name: "2020", activeUser: 90, activeSession: 95 },
|
||||||
|
{ name: "2021", activeUser: 85, activeSession: 80 },
|
||||||
|
{ name: "2022", activeUser: 95, activeSession: 90 },
|
||||||
|
{ name: "2023", activeUser: 100, activeSession: 100 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const UserGrowthChart = () => {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<LineChart data={chartData} width={500} height={320} margin={{ left: -32 }}>
|
||||||
|
<CartesianGrid />
|
||||||
|
<XAxis dataKey="name" />
|
||||||
|
<YAxis tickCount={10} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Line dataKey="activeUser" stroke="#23A1EB" />
|
||||||
|
<Line dataKey="activeSession" stroke="#0877C1" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import {
|
||||||
|
CloseOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
ProjectOutlined,
|
||||||
|
FileImageOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { TAdminSubmissionItem } from '@imphnen-frontend-service/service';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
interface SubmissionModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
submission: TAdminSubmissionItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
submission,
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'submitted':
|
||||||
|
return 'bg-success-50 border-success-200 text-success-800';
|
||||||
|
case 'pending':
|
||||||
|
return 'bg-orange-50 border-orange-200 text-orange-800';
|
||||||
|
case 'approved':
|
||||||
|
return 'bg-blue-50 border-blue-200 text-blue-800';
|
||||||
|
case 'rejected':
|
||||||
|
return 'bg-error-50 border-error-200 text-error-800';
|
||||||
|
default:
|
||||||
|
return 'bg-neutral-50 border-neutral-200 text-neutral-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center">
|
||||||
|
<ProjectOutlined className="text-success-600 text-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-neutral-900">
|
||||||
|
{submission.project_name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
Team ID: {submission.team_id} • Submitted{' '}
|
||||||
|
{new Date(submission.submitted_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-neutral-400 hover:text-neutral-600 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<CloseOutlined className="text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 p-4 border rounded-lg',
|
||||||
|
getStatusColor(submission.status)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-3 h-3 rounded-full',
|
||||||
|
submission.status === 'submitted' && 'bg-success-500',
|
||||||
|
submission.status === 'pending' && 'bg-orange-500',
|
||||||
|
submission.status === 'approved' && 'bg-blue-500',
|
||||||
|
submission.status === 'rejected' && 'bg-error-500'
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Status:{' '}
|
||||||
|
{submission.status.charAt(0).toUpperCase() +
|
||||||
|
submission.status.slice(1)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs">Submitted by: {submission.submitted_by}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-neutral-700 mb-2">
|
||||||
|
Project Description
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-600 leading-relaxed">
|
||||||
|
{submission.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-700">
|
||||||
|
Project Links
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<LinkOutlined className="text-primary-500 mt-1" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-neutral-600 mb-1">
|
||||||
|
Repository
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={submission.repository_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
|
||||||
|
>
|
||||||
|
{submission.repository_url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submission.demo_url && (
|
||||||
|
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<LinkOutlined className="text-primary-500 mt-1" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-neutral-600 mb-1">
|
||||||
|
Live Demo
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={submission.demo_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
|
||||||
|
>
|
||||||
|
{submission.demo_url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submission.presentation_url && (
|
||||||
|
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<LinkOutlined className="text-primary-500 mt-1" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-neutral-600 mb-1">
|
||||||
|
Presentation
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={submission.presentation_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
|
||||||
|
>
|
||||||
|
{submission.presentation_url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submission.screenshots && submission.screenshots.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-700 flex items-center gap-2">
|
||||||
|
<FileImageOutlined className="text-primary-500" />
|
||||||
|
Screenshots ({submission.screenshots.length})
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{submission.screenshots.map((screenshot, index) => (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={screenshot}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block rounded-lg overflow-hidden border border-neutral-200 hover:border-primary-300 transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={screenshot}
|
||||||
|
alt={`Screenshot ${index + 1}`}
|
||||||
|
className="w-full h-40 object-cover"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-neutral-200">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-neutral-500 mb-1">Created</p>
|
||||||
|
<p className="text-sm text-neutral-900">
|
||||||
|
{new Date(submission.created_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-neutral-500 mb-1">Last Updated</p>
|
||||||
|
<p className="text-sm text-neutral-900">
|
||||||
|
{new Date(submission.updated_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-neutral-200 bg-neutral-50">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubmissionModal;
|
||||||
+501
@@ -0,0 +1,501 @@
|
|||||||
|
import { FC, useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { CityFilterSelect } from '../../../../components/city-filter-select';
|
||||||
|
import TeamBannerPlaceholder from './team-banner-placeholder';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import { TAdminTeamItem } from '@imphnen-frontend-service/service';
|
||||||
|
import {
|
||||||
|
TeamOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
SaveOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
CrownOutlined,
|
||||||
|
ExclamationOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
CameraOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
EyeInvisibleOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
type TeamType = TAdminTeamItem;
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
team: TeamType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||||
|
const [formData, setFormData] = useState<TeamType | null>(null);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [showLogoMenu, setShowLogoMenu] = useState(false);
|
||||||
|
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const bannerInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (team) {
|
||||||
|
setFormData({ ...team });
|
||||||
|
} else {
|
||||||
|
setFormData({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
city: '',
|
||||||
|
banner: null,
|
||||||
|
logo: null,
|
||||||
|
visibility: 'public',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
leader_id: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, team]);
|
||||||
|
|
||||||
|
const hasChanges = useMemo(() => {
|
||||||
|
if (!formData || !team) return !!formData;
|
||||||
|
return (
|
||||||
|
formData.name !== team.name ||
|
||||||
|
formData.description !== team.description ||
|
||||||
|
formData.city !== team.city ||
|
||||||
|
formData.visibility !== team.visibility ||
|
||||||
|
formData.logo !== team.logo ||
|
||||||
|
formData.banner !== team.banner
|
||||||
|
);
|
||||||
|
}, [formData, team]);
|
||||||
|
|
||||||
|
const isFormValid = useMemo(() => {
|
||||||
|
if (!formData) return false;
|
||||||
|
return (
|
||||||
|
formData.name.trim() !== '' &&
|
||||||
|
formData.city.trim() !== '' &&
|
||||||
|
formData.description.trim() !== ''
|
||||||
|
);
|
||||||
|
}, [formData]);
|
||||||
|
|
||||||
|
const canSave = hasChanges && isFormValid;
|
||||||
|
|
||||||
|
if (!isOpen || !formData) return null;
|
||||||
|
|
||||||
|
const handleInputChange = (field: keyof TeamType, value: string | null) => {
|
||||||
|
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
console.log('Saving team:', formData);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!team) return;
|
||||||
|
console.log('Deleting team:', team.id);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('Please select an image file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('Image size must be less than 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const logoUrl = e.target?.result as string;
|
||||||
|
handleInputChange('logo', logoUrl);
|
||||||
|
setShowLogoMenu(false);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBannerUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('Please select an image file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('Image size must be less than 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const bannerUrl = e.target?.result as string;
|
||||||
|
handleInputChange('banner', bannerUrl);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||||
|
<TeamOutlined className="text-primary-600 text-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-neutral-900">
|
||||||
|
{team ? 'Team Details' : 'Create New Team'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
{team
|
||||||
|
? 'View and manage team information'
|
||||||
|
: 'Add a new team to the hackathon'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setShowLogoMenu(false);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseOutlined className="text-neutral-400 text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={logoInputRef}
|
||||||
|
onChange={handleLogoUpload}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={bannerInputRef}
|
||||||
|
onChange={handleBannerUpload}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Team Banner{' '}
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
(3:1 aspect ratio recommended)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<TeamBannerPlaceholder
|
||||||
|
banner={formData.banner || undefined}
|
||||||
|
teamName={formData.name || 'Team Name'}
|
||||||
|
className="rounded-lg border border-neutral-200 transition-all group-hover:border-primary-300"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/40 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
bannerInputRef.current?.click();
|
||||||
|
}}
|
||||||
|
className="bg-white/90 hover:bg-white text-neutral-700 border-transparent shadow-sm gap-2"
|
||||||
|
>
|
||||||
|
<UploadOutlined className="text-sm" />
|
||||||
|
{formData.banner ? 'Change Banner' : 'Add Banner'}
|
||||||
|
</Button>
|
||||||
|
{formData.banner && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleInputChange('banner', null);
|
||||||
|
}}
|
||||||
|
className="bg-white/90 hover:bg-white text-red-600 border-transparent shadow-sm hover:text-red-700 gap-2"
|
||||||
|
>
|
||||||
|
<DeleteOutlined className="text-sm" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-12 gap-4 items-start">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||||
|
Logo
|
||||||
|
</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="w-24 h-24 rounded-full bg-neutral-100 flex items-center justify-center overflow-hidden border border-neutral-200 group-hover:border-primary-300 transition-colors">
|
||||||
|
{formData.logo ? (
|
||||||
|
<img
|
||||||
|
src={formData.logo}
|
||||||
|
alt={formData.name || 'Team Logo'}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TeamOutlined className="text-neutral-400 text-xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowLogoMenu(!showLogoMenu);
|
||||||
|
}}
|
||||||
|
className="absolute inset-0 bg-neutral-300/80 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center w-24 h-24"
|
||||||
|
>
|
||||||
|
<CameraOutlined className="text-white text-lg" />
|
||||||
|
</button>
|
||||||
|
{showLogoMenu && (
|
||||||
|
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
logoInputRef.current?.click();
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<UploadOutlined className="text-sm" />
|
||||||
|
{formData.logo ? 'Change Logo' : 'Upload Logo'}
|
||||||
|
</button>
|
||||||
|
{formData.logo && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleInputChange('logo', null);
|
||||||
|
setShowLogoMenu(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<DeleteOutlined className="text-sm" />
|
||||||
|
Remove Logo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-10 space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Team Name <span className="text-danger-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full border border-neutral-200 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||||
|
placeholder="Enter team name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Description <span className="text-danger-500">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full border border-neutral-200 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
|
||||||
|
placeholder="Enter team description"
|
||||||
|
rows={3}
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
City <span className="text-danger-500">*</span>
|
||||||
|
</label>
|
||||||
|
<CityFilterSelect
|
||||||
|
value={formData.city || 'all'}
|
||||||
|
onChange={(city) =>
|
||||||
|
handleInputChange('city', city === 'all' ? '' : city)
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Search cities..."
|
||||||
|
allOptionLabel="Select a city"
|
||||||
|
filterIcon={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Team Visibility
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="visibility"
|
||||||
|
value="public"
|
||||||
|
checked={formData.visibility === 'public'}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('visibility', e.target.value)
|
||||||
|
}
|
||||||
|
className="text-primary-600"
|
||||||
|
/>
|
||||||
|
<EyeOutlined className="text-info-600" />
|
||||||
|
<span className="text-sm">Public</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="visibility"
|
||||||
|
value="private"
|
||||||
|
checked={formData.visibility === 'private'}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('visibility', e.target.value)
|
||||||
|
}
|
||||||
|
className="text-primary-600"
|
||||||
|
/>
|
||||||
|
<EyeInvisibleOutlined className="text-neutral-600" />
|
||||||
|
<span className="text-sm">Private</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{team && (
|
||||||
|
<div className="space-y-4 border-t border-neutral-200 pt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Team Leader ID
|
||||||
|
</label>
|
||||||
|
<div className="p-3 bg-neutral-50 rounded-lg flex items-center gap-3">
|
||||||
|
<CrownOutlined className="text-yellow-600 text-lg" />
|
||||||
|
<span className="text-sm text-neutral-700 font-mono">
|
||||||
|
{team.leader_id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Created
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<CalendarOutlined className="text-neutral-500" />
|
||||||
|
<span className="text-sm text-neutral-700">
|
||||||
|
{new Date(team.created_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
|
Last Updated
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<CalendarOutlined className="text-neutral-500" />
|
||||||
|
<span className="text-sm text-neutral-700">
|
||||||
|
{new Date(team.updated_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-6 border-t border-neutral-200">
|
||||||
|
<div>
|
||||||
|
{team && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="md"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
Delete Team
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="secondary" size="md" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<SaveOutlined />
|
||||||
|
{team ? 'Save Changes' : 'Create Team'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-danger-100 flex items-center justify-center">
|
||||||
|
<ExclamationOutlined className="text-danger-600 text-xl" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900">
|
||||||
|
Delete Team
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-neutral-700 mb-6">
|
||||||
|
Are you sure you want to delete "{team?.name}"? This will
|
||||||
|
permanently remove the team and all associated data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="md"
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
Delete Team
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalTeamDetail;
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { TeamOutlined } from '@ant-design/icons';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
interface TeamBannerPlaceholderProps {
|
||||||
|
banner?: string;
|
||||||
|
teamName: string;
|
||||||
|
className?: string;
|
||||||
|
showPlaceholder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
||||||
|
banner,
|
||||||
|
teamName,
|
||||||
|
className = '',
|
||||||
|
showPlaceholder = true,
|
||||||
|
}) => {
|
||||||
|
const aspectRatioClass = 'aspect-[3/1]';
|
||||||
|
|
||||||
|
if (!banner && !showPlaceholder) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (banner) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full bg-gray-100 overflow-hidden relative',
|
||||||
|
aspectRatioClass,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={banner}
|
||||||
|
alt={`${teamName} banner`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.style.display = 'none';
|
||||||
|
const placeholder = target.nextElementSibling as HTMLElement;
|
||||||
|
if (placeholder) {
|
||||||
|
placeholder.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
|
||||||
|
'hidden'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<TeamOutlined className="text-4xl text-gray-400 mb-2" />
|
||||||
|
<p className="text-sm text-gray-500 font-medium">{teamName}</p>
|
||||||
|
<p className="text-xs text-gray-400">Team Banner</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
|
||||||
|
aspectRatioClass,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<TeamOutlined className="text-4xl text-gray-400 mb-2" />
|
||||||
|
<p className="text-sm text-gray-500 font-medium">{teamName}</p>
|
||||||
|
<p className="text-xs text-gray-400">No Banner</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeamBannerPlaceholder;
|
||||||
+626
@@ -0,0 +1,626 @@
|
|||||||
|
import { FC, useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
SaveOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
ExclamationOutlined,
|
||||||
|
CameraOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface UserType {
|
||||||
|
id: string;
|
||||||
|
avatar?: string | null;
|
||||||
|
fullname: string;
|
||||||
|
bio?: string;
|
||||||
|
location: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
skills: string[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
user: UserType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||||
|
const [formData, setFormData] = useState<UserType | null>(null);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (user) {
|
||||||
|
setFormData({ ...user });
|
||||||
|
} else {
|
||||||
|
setFormData({
|
||||||
|
id: '',
|
||||||
|
fullname: '',
|
||||||
|
bio: '',
|
||||||
|
location: '',
|
||||||
|
is_active: true,
|
||||||
|
skills: [],
|
||||||
|
avatar: undefined,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, user]);
|
||||||
|
|
||||||
|
const hasChanges = useMemo(() => {
|
||||||
|
if (!formData) return false;
|
||||||
|
if (!user) return true;
|
||||||
|
return (
|
||||||
|
formData.fullname !== user.fullname ||
|
||||||
|
formData.location !== user.location ||
|
||||||
|
formData.is_active !== user.is_active ||
|
||||||
|
formData.avatar !== user.avatar ||
|
||||||
|
JSON.stringify(formData.skills) !== JSON.stringify(user.skills) ||
|
||||||
|
formData.bio !== user.bio
|
||||||
|
);
|
||||||
|
}, [formData, user]);
|
||||||
|
|
||||||
|
const isFormValid = useMemo(() => {
|
||||||
|
if (!formData) return false;
|
||||||
|
return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
|
||||||
|
}, [formData]);
|
||||||
|
|
||||||
|
const canSave = hasChanges && isFormValid;
|
||||||
|
|
||||||
|
if (!isOpen || !formData) return null;
|
||||||
|
|
||||||
|
const handleInputChange = (
|
||||||
|
field: keyof UserType,
|
||||||
|
value: string | boolean | string[] | undefined
|
||||||
|
) => {
|
||||||
|
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkillsChange = (skills: string[]) => {
|
||||||
|
setFormData((prev) => (prev ? { ...prev, skills } : null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!formData) return;
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
console.log('Update user data:', formData);
|
||||||
|
} else {
|
||||||
|
console.log('Create new user:', formData);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (user) {
|
||||||
|
setFormData({ ...user });
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = () => {
|
||||||
|
if (!user) return;
|
||||||
|
console.log('Delete user:', user.id);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('Please select an image file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('Image size must be less than 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const avatarUrl = e.target?.result as string;
|
||||||
|
handleInputChange('avatar', avatarUrl);
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAvatar = () => {
|
||||||
|
handleInputChange('avatar', undefined);
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerFileUpload = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableSkills = [
|
||||||
|
'Frontend Developer',
|
||||||
|
'Backend Developer',
|
||||||
|
'Full Stack Developer',
|
||||||
|
'DevOps Engineer',
|
||||||
|
'UI/UX Designer',
|
||||||
|
'Product Manager',
|
||||||
|
'Data Scientist',
|
||||||
|
'Mobile Developer',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50"
|
||||||
|
onClick={(e) => {
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<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="relative group ">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-primary-300 transition-colors">
|
||||||
|
{formData.avatar ? (
|
||||||
|
<img
|
||||||
|
src={formData.avatar}
|
||||||
|
alt={formData.fullname}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserOutlined className="text-neutral-500 text-2xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAvatarMenu(!showAvatarMenu)}
|
||||||
|
className="absolute inset-0 bg-neutral-400 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CameraOutlined className="text-white text-lg" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAvatarMenu && (
|
||||||
|
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
|
||||||
|
<button
|
||||||
|
onClick={triggerFileUpload}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<UploadOutlined className="text-sm" />
|
||||||
|
{formData.avatar ? 'Change Photo' : 'Upload Photo'}
|
||||||
|
</button>
|
||||||
|
{formData.avatar && (
|
||||||
|
<button
|
||||||
|
onClick={handleRemoveAvatar}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<DeleteOutlined className="text-sm" />
|
||||||
|
Remove Photo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h2 className="text-2xl font-bold text-neutral-900">
|
||||||
|
{user ? 'Edit User Profile' : 'Create New User'}
|
||||||
|
</h2>
|
||||||
|
{user && (
|
||||||
|
<span className="px-3 py-1 bg-info-100 text-info-700 text-xs font-medium rounded-2xl">
|
||||||
|
Hover avatar to change
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-neutral-500">
|
||||||
|
{user
|
||||||
|
? `Make changes to ${
|
||||||
|
formData.fullname || 'this user'
|
||||||
|
}'s profile information`
|
||||||
|
: 'Fill in the information below to create a new user account'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
handleCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseOutlined className="text-neutral-400 text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Basic Information
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserOutlined className="text-neutral-400" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm text-neutral-500 block mb-1">
|
||||||
|
Full Name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.fullname}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('fullname', e.target.value)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none',
|
||||||
|
!formData.fullname ||
|
||||||
|
formData.fullname.trim() === ''
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-neutral-300'
|
||||||
|
)}
|
||||||
|
placeholder="Enter full name"
|
||||||
|
/>
|
||||||
|
{(!formData.fullname ||
|
||||||
|
formData.fullname.trim() === '') && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
Full name is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<EnvironmentOutlined className="text-neutral-400" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm text-neutral-500 block mb-1">
|
||||||
|
Location <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.location || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('location', e.target.value)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white',
|
||||||
|
!formData.location ||
|
||||||
|
formData.location.trim() === ''
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-neutral-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">Select location</option>
|
||||||
|
<option value="Jakarta">Jakarta</option>
|
||||||
|
<option value="Bandung">Bandung</option>
|
||||||
|
<option value="Surabaya">Surabaya</option>
|
||||||
|
<option value="Medan">Medan</option>
|
||||||
|
<option value="Yogyakarta">Yogyakarta</option>
|
||||||
|
</select>
|
||||||
|
{(!formData.location ||
|
||||||
|
formData.location.trim() === '') && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
Location is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CalendarOutlined className="text-neutral-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
Joined Date
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{new Date(formData.created_at).toLocaleDateString(
|
||||||
|
'en-US',
|
||||||
|
{
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
|
||||||
|
Bio{' '}
|
||||||
|
<span className="text-neutral-400 text-sm font-normal">
|
||||||
|
(Optional)
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<textarea
|
||||||
|
value={formData.bio || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('bio', e.target.value || undefined)
|
||||||
|
}
|
||||||
|
placeholder="Tell us about yourself..."
|
||||||
|
rows={4}
|
||||||
|
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Account Status
|
||||||
|
</h3>
|
||||||
|
<div className="flex bg-neutral-100 p-1 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => handleInputChange('is_active', true)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
|
||||||
|
formData.is_active
|
||||||
|
? 'bg-white text-success-700 shadow-sm ring-1 ring-success-200'
|
||||||
|
: 'text-neutral-600 hover:text-neutral-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
formData.is_active
|
||||||
|
? 'bg-success-500'
|
||||||
|
: 'bg-neutral-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
Active
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleInputChange('is_active', false)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
|
||||||
|
!formData.is_active
|
||||||
|
? 'bg-white text-neutral-700 shadow-sm ring-1 ring-neutral-200'
|
||||||
|
: 'text-neutral-600 hover:text-neutral-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
!formData.is_active
|
||||||
|
? 'bg-neutral-500'
|
||||||
|
: 'bg-neutral-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
Inactive
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-500 mt-2">
|
||||||
|
{formData.is_active
|
||||||
|
? 'User can access their account and participate in activities'
|
||||||
|
: 'User account is suspended and cannot access services'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Skills & Expertise{' '}
|
||||||
|
<span className="text-neutral-400 text-sm font-normal">
|
||||||
|
(Optional)
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2 min-h-10 p-3 border border-neutral-300 rounded-lg bg-neutral-50">
|
||||||
|
{formData.skills.length > 0 ? (
|
||||||
|
formData.skills.map((skill, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-2xl text-sm font-medium bg-blue-100 text-blue-800"
|
||||||
|
>
|
||||||
|
{skill}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleSkillsChange(
|
||||||
|
formData.skills.filter((_, i) => i !== index)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="text-blue-600 hover:text-blue-800 ml-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-400 text-sm">
|
||||||
|
No skills added yet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (
|
||||||
|
e.target.value &&
|
||||||
|
!formData.skills.includes(e.target.value)
|
||||||
|
) {
|
||||||
|
handleSkillsChange([
|
||||||
|
...formData.skills,
|
||||||
|
e.target.value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white"
|
||||||
|
>
|
||||||
|
<option value="">Add a skill...</option>
|
||||||
|
{availableSkills
|
||||||
|
.filter((skill) => !formData.skills.includes(skill))
|
||||||
|
.map((skill) => (
|
||||||
|
<option key={skill} value={skill}>
|
||||||
|
{skill}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Account Details
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3 bg-neutral-50 p-4 rounded-lg">
|
||||||
|
<div className="flex justify-between items-center py-1">
|
||||||
|
<span className="text-neutral-600 text-sm">
|
||||||
|
User ID
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-sm text-neutral-800">
|
||||||
|
{formData.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1">
|
||||||
|
<span className="text-neutral-600 text-sm">
|
||||||
|
Last Updated
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-neutral-800">
|
||||||
|
{new Date(formData.updated_at).toLocaleDateString(
|
||||||
|
'en-US',
|
||||||
|
{
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-neutral-200 px-8 py-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-sm text-neutral-500">
|
||||||
|
{canSave
|
||||||
|
? 'Ready to save changes'
|
||||||
|
: hasChanges
|
||||||
|
? 'Please fill required fields'
|
||||||
|
: 'No changes made'}
|
||||||
|
</div>
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="text-red-600 hover:text-red-700 text-sm font-medium transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Delete Account
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-6"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-6',
|
||||||
|
!canSave && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SaveOutlined className="text-sm" />
|
||||||
|
{user ? 'Save Changes' : 'Create User'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<div className="fixed inset-0 z-60">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<ExclamationOutlined className="text-red-600 text-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900">
|
||||||
|
Delete Account
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
This action cannot be undone
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-neutral-700 mb-6">
|
||||||
|
Are you sure you want to permanently delete{' '}
|
||||||
|
<strong>{formData.fullname}</strong>'s account? This will
|
||||||
|
remove all their data and cannot be reversed.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="px-4"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDeleteAccount}
|
||||||
|
className="px-4 bg-red-600 hover:bg-red-700 border-red-600"
|
||||||
|
>
|
||||||
|
Delete Account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalUserDetail;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Button, Input, NativeSelect as Select, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { cn } 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 GeneralSettings: FC = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">General Settings</h1>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<ToggleInput label="Mode Maintenance" />
|
||||||
|
|
||||||
|
<h2 className="text-p3 font-semibold text-neutral-700 mb-5">Platform Settings</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-x-8 gap-y-5 mb-8">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Nama Platform</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Bahasa</label>
|
||||||
|
<Select defaultValue="id" className="w-full">
|
||||||
|
<option value="id">Indonesia</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Logo Platform</label>
|
||||||
|
<Input type="file" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Favicon</label>
|
||||||
|
<Input type="file" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-p3 font-semibold text-neutral-700 mb-5">Legal Settings</h2>
|
||||||
|
<div className="grid gap-x-8 gap-y-5 mb-20">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>URL Syarat & Ketentuan</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>URL Kebijakan Privasi</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-5">
|
||||||
|
<Button type="button" variant="bordered">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="button">
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
import { Button, Textarea, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { FC } from "react"
|
||||||
|
|
||||||
|
export const NotificationSettings: FC = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">Notification Settings</h1>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-8 flex-wrap">
|
||||||
|
<ToggleInput label="Nyalakan Notifikasi Email" />
|
||||||
|
<ToggleInput label="Beritahu mentor ketika ada request" />
|
||||||
|
<ToggleInput label="Beritahu mentee untuk update sesi " />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-20">
|
||||||
|
<label className="text-neutral-800 font-medium inline-block mb-2 text-p3">
|
||||||
|
API Integrasi Notifikasi (URL)
|
||||||
|
</label>
|
||||||
|
<Textarea placeholder="Masukkan url API notifikasi yang akan digunakan" className="w-full h-40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-5">
|
||||||
|
<Button type="button" variant="bordered">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="button">
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Button, Input, NativeSelect as Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { cn } 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 PaymentSettings: FC = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">Payment</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-x-8 gap-y-5 mb-8">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Integrasi Payment Gateway</label>
|
||||||
|
<Textarea className="min-w-full w-full h-20" placeholder="Durasi dalam menit" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Mata Uang</label>
|
||||||
|
<Select defaultValue="idr">
|
||||||
|
<option value="idr">Rupiah (IDR)</option>
|
||||||
|
<option value="usd">Dollar (USD)</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Harga sesi mentoring <span className="text-neutral-600">(default)</span></label>
|
||||||
|
<Input type="text" className="min-w-full w-full" placeholder="Masukkan harga sesi mentoring" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Tarif Komisi untuk Platform</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" placeholder="Masukkan persentase komisi" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-p3 font-semibold text-neutral-700 mb-5">Invoice</h2>
|
||||||
|
<div className="mb-32">
|
||||||
|
<label className={labelClass}>Masukkan Format Invoice</label>
|
||||||
|
<Input type="file" className="min-w-full w-full" placeholder="InvoiceDimentorin.png" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-5">
|
||||||
|
<Button type="button" variant="bordered">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="button">
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import { Button, Input } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { cn } 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 SecuritySettings: FC = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">Security Settings</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-8 mb-20">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Durasi Session Timeout</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" placeholder="Durasi dalam menit" />
|
||||||
|
<p className="text-[10px] text-neutral-800">Auto logout setelah X menit tidak aktif</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Blokir Setelah Upaya Gagal</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" placeholder="x kali perobaan login" />
|
||||||
|
<p className="text-[10px] text-neutral-800">Misal: 5 kali salah login = lock akun</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-5">
|
||||||
|
<Button type="button" variant="bordered">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="button">
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
import { DeleteOutlined, UserSwitchOutlined } from "@ant-design/icons";
|
||||||
|
import { Button } 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 { useRoleList, useDeleteRole, TRolesListItem } from "@imphnen-frontend-service/service";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const UserRolesPermission: FC = () => {
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: rolesData, isLoading } = useRoleList({
|
||||||
|
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',
|
||||||
|
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: 'role',
|
||||||
|
header: 'Role',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'totalUser',
|
||||||
|
header: 'Total Permissions',
|
||||||
|
accessorKey: 'permissions_count',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
meta: { cellClassName: cn("w-96") },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-max"
|
||||||
|
>
|
||||||
|
<UserSwitchOutlined className="text-[16px]" /> Manage Permissions
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(row.original.id);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-max"
|
||||||
|
>
|
||||||
|
<DeleteOutlined className="text-[16px]" /> Delete Role
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: roles,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||||
|
manualPagination: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<h1 className="text-p2 font-semibold text-neutral-700">User Roles & Permissions</h1>
|
||||||
|
<Button type="button">
|
||||||
|
Add Role
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white shadow p-8 rounded-lg">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<DataTable data={roles} columns={columns} table={table} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+6
-10
@@ -1,23 +1,18 @@
|
|||||||
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) => {
|
||||||
console.log('Form data:', data);
|
onDataCapture?.(data);
|
||||||
nextStep();
|
nextStep();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,8 +33,9 @@ export const useConfirmItem = (
|
|||||||
) => {
|
) => {
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
// const result = await actionFunction?.();
|
if (actionFunction) {
|
||||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
await actionFunction();
|
||||||
|
}
|
||||||
toast.success(messages?.success);
|
toast.success(messages?.success);
|
||||||
onClose();
|
onClose();
|
||||||
resetStep();
|
resetStep();
|
||||||
+6
-10
@@ -1,23 +1,18 @@
|
|||||||
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) => {
|
||||||
console.log('Form data:', data);
|
onDataCapture?.(data);
|
||||||
nextStep();
|
nextStep();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,8 +33,9 @@ export const useConfirmItem = (
|
|||||||
) => {
|
) => {
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
// const result = await actionFunction?.();
|
if (actionFunction) {
|
||||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
await actionFunction();
|
||||||
|
}
|
||||||
toast.success(messages?.success);
|
toast.success(messages?.success);
|
||||||
onClose();
|
onClose();
|
||||||
resetStep();
|
resetStep();
|
||||||
+7
-5
@@ -8,16 +8,17 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
export const useItem = (
|
export const useItem = (
|
||||||
nextStep: () => void,
|
nextStep: () => void,
|
||||||
initialValues?: TGachaItem
|
initialValues?: Partial<TGachaItem>,
|
||||||
|
onDataCapture?: (data: any) => void,
|
||||||
) => {
|
) => {
|
||||||
const form = useForm<TGachaItem>({
|
const form = useForm<any>({
|
||||||
resolver: zodResolver(gachaItemSchema),
|
resolver: zodResolver(gachaItemSchema),
|
||||||
mode: 'all',
|
mode: 'all',
|
||||||
defaultValues: initialValues,
|
defaultValues: initialValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => {
|
const onSubmit = form.handleSubmit((data) => {
|
||||||
console.log('Form data:', data);
|
onDataCapture?.(data);
|
||||||
nextStep();
|
nextStep();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,8 +39,9 @@ export const useConfirmItem = (
|
|||||||
) => {
|
) => {
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
// const result = await actionFunction?.();
|
if (actionFunction) {
|
||||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
await actionFunction();
|
||||||
|
}
|
||||||
toast.success(messages?.success);
|
toast.success(messages?.success);
|
||||||
onClose();
|
onClose();
|
||||||
resetStep();
|
resetStep();
|
||||||
+7
-5
@@ -8,16 +8,17 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
export const useItem = (
|
export const useItem = (
|
||||||
nextStep: () => void,
|
nextStep: () => void,
|
||||||
initialValues?: TGachaRollItem
|
initialValues?: Partial<TGachaRollItem>,
|
||||||
|
onDataCapture?: (data: any) => void,
|
||||||
) => {
|
) => {
|
||||||
const form = useForm<TGachaRollItem>({
|
const form = useForm<any>({
|
||||||
resolver: zodResolver(gachaRollItemSchema),
|
resolver: zodResolver(gachaRollItemSchema),
|
||||||
mode: 'all',
|
mode: 'all',
|
||||||
defaultValues: initialValues,
|
defaultValues: initialValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => {
|
const onSubmit = form.handleSubmit((data) => {
|
||||||
console.log('Form data:', data);
|
onDataCapture?.(data);
|
||||||
nextStep();
|
nextStep();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,8 +39,9 @@ export const useConfirmItem = (
|
|||||||
) => {
|
) => {
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
// const result = await actionFunction?.();
|
if (actionFunction) {
|
||||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
await actionFunction();
|
||||||
|
}
|
||||||
toast.success(messages?.success);
|
toast.success(messages?.success);
|
||||||
onClose();
|
onClose();
|
||||||
resetStep();
|
resetStep();
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
|
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 { useCreateEvent } from '@imphnen-frontend-service/service'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/cms-events_/create')({
|
||||||
|
component: CmsEventsCreatePage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function CmsEventsCreatePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const createEvent = useCreateEvent()
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await createEvent.mutateAsync(data)
|
||||||
|
toast.success('Data event berhasil ditambahkan')
|
||||||
|
navigate({ to: '/cms-events' })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
toast.error('Data event gagal ditambahkan')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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">Tambah 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"
|
||||||
|
className="rounded"
|
||||||
|
{...form.register('is_online')}
|
||||||
|
/>
|
||||||
|
<label htmlFor="is_online" 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"
|
||||||
|
>
|
||||||
|
Tambah Event
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate({ to: '/cms-events' })}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
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,
|
||||||
|
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 {
|
||||||
|
useTestimonialList,
|
||||||
|
useDeleteTestimonial,
|
||||||
|
TTestimonialsListItem,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/cms-testimonials')({
|
||||||
|
component: CmsTestimonialsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function CmsTestimonialsPage() {
|
||||||
|
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: testimonialsData, isLoading } = useTestimonialList({
|
||||||
|
search,
|
||||||
|
page: pagination.pageIndex + 1,
|
||||||
|
per_page: pagination.pageSize,
|
||||||
|
});
|
||||||
|
const deleteTestimonial = useDeleteTestimonial();
|
||||||
|
|
||||||
|
const testimonials: TTestimonialsListItem[] = testimonialsData?.data ?? [];
|
||||||
|
const totalItems = testimonialsData?.meta?.total ?? testimonials.length;
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deleteTestimonial.mutateAsync(id);
|
||||||
|
toast.success('Data testimonial berhasil dihapus');
|
||||||
|
setDeleteId(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
toast.error('Data testimonial gagal dihapus');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<TTestimonialsListItem>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
table.getIsAllRowsSelected()
|
||||||
|
? true
|
||||||
|
: table.getIsSomeRowsSelected()
|
||||||
|
? 'indeterminate'
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ header: 'User', accessorKey: 'user_fullname' },
|
||||||
|
{ header: 'Role', accessorKey: 'role' },
|
||||||
|
{
|
||||||
|
header: 'Content',
|
||||||
|
accessorKey: 'content',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const content = row.original.content;
|
||||||
|
return content.length > 80
|
||||||
|
? `${content.substring(0, 80)}…`
|
||||||
|
: content;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Created At',
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
new Date(row.original.created_at).toLocaleDateString('id-ID', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate({
|
||||||
|
to: '/cms-testimonials/$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: testimonials,
|
||||||
|
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 Testimonials"
|
||||||
|
description="Kelola testimonial pengguna"
|
||||||
|
>
|
||||||
|
<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 user…"
|
||||||
|
className="pl-9"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate({ to: '/cms-testimonials/create' })}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Tambah Testimonial
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Memuat data…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={testimonials}
|
||||||
|
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 testimonial ini?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Tindakan ini tidak dapat dibatalkan. Testimonial 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
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 {
|
||||||
|
useTestimonialList,
|
||||||
|
useUpdateTestimonial,
|
||||||
|
} from '@imphnen-frontend-service/service'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/cms-testimonials_/$id')({
|
||||||
|
component: CmsTestimonialsEditPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function CmsTestimonialsEditPage() {
|
||||||
|
const { id } = Route.useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const updateTestimonial = useUpdateTestimonial()
|
||||||
|
|
||||||
|
const { data: testimonialsData, isLoading } = useTestimonialList({ search: '', per_page: 100 })
|
||||||
|
const testimonial = testimonialsData?.data?.find((t) => t.id === id)
|
||||||
|
|
||||||
|
const form = useForm<{ role: string; content: string }>({
|
||||||
|
mode: 'all',
|
||||||
|
defaultValues: { role: '', content: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (testimonial) {
|
||||||
|
form.reset({
|
||||||
|
role: testimonial.role,
|
||||||
|
content: testimonial.content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [testimonial])
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await updateTestimonial.mutateAsync({ id, data })
|
||||||
|
toast.success('Perubahan testimonial berhasil dilakukan')
|
||||||
|
navigate({ to: '/cms-testimonials' })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
toast.error('Perubahan testimonial 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-testimonials' })}
|
||||||
|
className="text-primary-500 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<ArrowLeftOutlined className="text-[20px]" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Edit Testimonial</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="Role"
|
||||||
|
name="role"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Role"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Konten Testimonial"
|
||||||
|
name="content"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Konten Testimonial"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Update Testimonial
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate({ to: '/cms-testimonials' })}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
|
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 { useCreateTestimonial } from '@imphnen-frontend-service/service'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/cms-testimonials_/create')({
|
||||||
|
component: CmsTestimonialsCreatePage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function CmsTestimonialsCreatePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const createTestimonial = useCreateTestimonial()
|
||||||
|
|
||||||
|
const form = useForm<{ role: string; content: string }>({
|
||||||
|
mode: 'all',
|
||||||
|
defaultValues: { role: '', content: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await createTestimonial.mutateAsync(data)
|
||||||
|
toast.success('Data testimonial berhasil ditambahkan')
|
||||||
|
navigate({ to: '/cms-testimonials' })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
toast.error('Data testimonial gagal ditambahkan')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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-testimonials' })}
|
||||||
|
className="text-primary-500 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<ArrowLeftOutlined className="text-[20px]" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Tambah Testimonial</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="Role"
|
||||||
|
name="role"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Role (e.g. Software Engineer)"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Konten Testimonial"
|
||||||
|
name="content"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Konten Testimonial"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Tambah Testimonial
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate({ to: '/cms-testimonials' })}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Users, UserCog, CalendarClock, Activity, CircleCheck } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { UserGrowthChart } from './_components/dashboard-dimentorin/chart/user-growth';
|
||||||
|
import { SessionStatusChart } from './_components/dashboard-dimentorin/chart/session-status';
|
||||||
|
import {
|
||||||
|
useMentorList,
|
||||||
|
useUserList,
|
||||||
|
useMySessions,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/dashboard-dimentorin')({
|
||||||
|
component: DashboardDimentorinPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
type StatCardProps = {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatCard({ icon: Icon, label, value }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-4 pt-6">
|
||||||
|
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary-100 text-primary-600">
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-2xl font-semibold leading-tight text-foreground">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardDimentorinPage() {
|
||||||
|
const { data: mentorData } = useMentorList({
|
||||||
|
per_page: 5,
|
||||||
|
sort_by: 'rating',
|
||||||
|
order: 'desc',
|
||||||
|
});
|
||||||
|
const { data: userData } = useUserList({ per_page: 1 });
|
||||||
|
const { data: sessionsData } = useMySessions();
|
||||||
|
|
||||||
|
const totalMentors = mentorData?.meta?.total ?? 0;
|
||||||
|
const totalUsers = userData?.meta?.total ?? 0;
|
||||||
|
const totalSessions = sessionsData?.total ?? 0;
|
||||||
|
const topMentors = mentorData?.data ?? [];
|
||||||
|
const activeMentors = topMentors.filter((m) => m.status === 'active').length;
|
||||||
|
const completedSessions =
|
||||||
|
sessionsData?.sessions?.filter((s) => s.status === 'completed').length ?? 0;
|
||||||
|
|
||||||
|
const topTopics = React.useMemo(() => {
|
||||||
|
const sessions = sessionsData?.sessions ?? [];
|
||||||
|
const topicCount: Record<string, number> = {};
|
||||||
|
sessions.forEach((s) => {
|
||||||
|
topicCount[s.topic] = (topicCount[s.topic] ?? 0) + 1;
|
||||||
|
});
|
||||||
|
return Object.entries(topicCount)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.slice(0, 5);
|
||||||
|
}, [sessionsData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper
|
||||||
|
title="Dimentorin Overview"
|
||||||
|
description="Ringkasan metrik platform mentoring"
|
||||||
|
>
|
||||||
|
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<StatCard icon={Users} label="Total Users" value={totalUsers} />
|
||||||
|
<StatCard icon={UserCog} label="Total Mentors" value={totalMentors} />
|
||||||
|
<StatCard
|
||||||
|
icon={CalendarClock}
|
||||||
|
label="Total Sessions"
|
||||||
|
value={totalSessions}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Activity}
|
||||||
|
label="Active Mentors"
|
||||||
|
value={activeMentors}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={CircleCheck}
|
||||||
|
label="Completed Sessions"
|
||||||
|
value={completedSessions}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 gap-4 lg:grid-cols-7">
|
||||||
|
<Card className="lg:col-span-5">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>User Growth</CardTitle>
|
||||||
|
<CardDescription>Pertumbuhan user dari waktu ke waktu</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<UserGrowthChart />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Session Status</CardTitle>
|
||||||
|
<CardDescription>Distribusi status sesi</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<SessionStatusChart />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top 5 Mentors</CardTitle>
|
||||||
|
<CardDescription>Mentor dengan rating tertinggi</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border border-neutral-200">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="w-[10%]">No.</TableHead>
|
||||||
|
<TableHead>Nama Lengkap</TableHead>
|
||||||
|
<TableHead>Avg Rating</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{topMentors.length === 0 ? (
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell
|
||||||
|
colSpan={3}
|
||||||
|
className="py-6 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
Belum ada data
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
topMentors.slice(0, 5).map((mentor, index) => (
|
||||||
|
<TableRow key={mentor.id}>
|
||||||
|
<TableCell>{index + 1}</TableCell>
|
||||||
|
<TableCell>{mentor.fullname ?? '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{mentor.rating?.toFixed(1) ?? '-'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top Booked Topics</CardTitle>
|
||||||
|
<CardDescription>Topik mentoring paling banyak dibooking</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border border-neutral-200">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="w-[10%]">No.</TableHead>
|
||||||
|
<TableHead>Topik</TableHead>
|
||||||
|
<TableHead>Total Sesi</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{topTopics.length === 0 ? (
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell
|
||||||
|
colSpan={3}
|
||||||
|
className="py-6 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
Belum ada data
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
topTopics.map(([topic, count], index) => (
|
||||||
|
<TableRow key={topic}>
|
||||||
|
<TableCell>{index + 1}</TableCell>
|
||||||
|
<TableCell>{topic}</TableCell>
|
||||||
|
<TableCell>{count}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
UsersRound,
|
||||||
|
UserMinus,
|
||||||
|
UserCog,
|
||||||
|
RefreshCcw,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
MoreHorizontal,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import {
|
||||||
|
useUserList,
|
||||||
|
useGachaItemList,
|
||||||
|
useDeleteGachaItem,
|
||||||
|
TGachaItemDto,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { DeleteConfirmDialog } from '../../components/list-helpers';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/dashboard')({
|
||||||
|
component: DashboardPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
type StatCardProps = {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatCard({ icon: Icon, label, value }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-4 pt-6">
|
||||||
|
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary-100 text-primary-600">
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-2xl font-semibold leading-tight text-foreground">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: usersData } = useUserList({ per_page: 1 });
|
||||||
|
const { data: gachaItemsData } = useGachaItemList({ per_page: 9 });
|
||||||
|
const deleteItem = useDeleteGachaItem();
|
||||||
|
|
||||||
|
const totalUsers = usersData?.meta?.total ?? 0;
|
||||||
|
const gachaItems: TGachaItemDto[] = gachaItemsData?.data ?? [];
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deleteItem.mutateAsync(id);
|
||||||
|
toast.success('Item berhasil dihapus');
|
||||||
|
setDeleteId(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
toast.error('Item gagal dihapus');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper
|
||||||
|
title="Gacha Dashboard"
|
||||||
|
description="Ringkasan statistik & daftar item gacha"
|
||||||
|
>
|
||||||
|
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
icon={UsersRound}
|
||||||
|
label="Participants"
|
||||||
|
value={totalUsers.toLocaleString('id-ID')}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={RefreshCcw}
|
||||||
|
label="Gacha Items"
|
||||||
|
value={(gachaItemsData?.meta?.total ?? 0).toLocaleString('id-ID')}
|
||||||
|
/>
|
||||||
|
<StatCard icon={UserCog} label="Redeem" value="—" />
|
||||||
|
<StatCard icon={UserMinus} label="Inactive Users" value="—" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Gacha Items</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Daftar item yang tersedia di gacha
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
onClick={() => navigate({ to: '/dashboard/create' })}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Tambah Item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{gachaItems.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Belum ada item gacha.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{gachaItems.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-start justify-between gap-3 rounded-md border border-neutral-200 p-4 transition-colors hover:border-primary-200 hover:bg-primary-50/40"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="truncate text-sm font-semibold text-primary-700">
|
||||||
|
{item.name}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-0.5 font-mono text-xs text-muted-foreground">
|
||||||
|
{item.id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="text" size="icon" aria-label="Actions">
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
navigate({
|
||||||
|
to: '/dashboard/$id',
|
||||||
|
params: { id: item.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
<span>Edit</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() => setDeleteId(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
<span>Hapus</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={!!deleteId}
|
||||||
|
onOpenChange={(o) => !o && setDeleteId(null)}
|
||||||
|
onConfirm={() => deleteId && handleDelete(deleteId)}
|
||||||
|
title="Hapus item gacha ini?"
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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 {
|
||||||
|
useGachaItemList,
|
||||||
|
useUpdateGachaItem,
|
||||||
|
} from '@imphnen-frontend-service/service'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/dashboard_/$id')({
|
||||||
|
component: DashboardEditPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function DashboardEditPage() {
|
||||||
|
const { id } = Route.useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const updateItem = useUpdateGachaItem()
|
||||||
|
|
||||||
|
const { data: gachaItemsData, isLoading } = useGachaItemList({ per_page: 100 })
|
||||||
|
const item = gachaItemsData?.data?.find((i) => i.id === id)
|
||||||
|
|
||||||
|
const form = useForm<{ itemName: string; quantity: number; foto?: FileList }>({
|
||||||
|
mode: 'all',
|
||||||
|
defaultValues: { itemName: '', quantity: 1 },
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
form.reset({ itemName: item.name, quantity: item.stock })
|
||||||
|
}
|
||||||
|
}, [item])
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await updateItem.mutateAsync({
|
||||||
|
id,
|
||||||
|
data: { name: data.itemName, stock: data.quantity },
|
||||||
|
})
|
||||||
|
toast.success('Perubahan item berhasil dilakukan')
|
||||||
|
navigate({ to: '/dashboard' })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
toast.error('Perubahan item 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: '/dashboard' })}
|
||||||
|
className="text-primary-500 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<ArrowLeftOutlined className="text-[20px]" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Edit Item Gacha</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 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 className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Perbarui Item
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate({ to: '/dashboard' })}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
|
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 { useCreateGachaItem } from '@imphnen-frontend-service/service'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/dashboard_/create')({
|
||||||
|
component: DashboardCreatePage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function DashboardCreatePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const createItem = useCreateGachaItem()
|
||||||
|
|
||||||
|
const form = useForm<{ itemName: string; quantity: number; foto?: FileList }>({
|
||||||
|
mode: 'all',
|
||||||
|
defaultValues: { itemName: '', quantity: 1 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await createItem.mutateAsync({
|
||||||
|
item_code: (data.itemName as string).toLowerCase().replace(/\s+/g, '-'),
|
||||||
|
name: data.itemName,
|
||||||
|
description: '',
|
||||||
|
rarity: 'common',
|
||||||
|
type_: 'physical',
|
||||||
|
category: 'merchandise',
|
||||||
|
value: 0,
|
||||||
|
weight: 1,
|
||||||
|
stock: data.quantity ?? 1,
|
||||||
|
is_limited: false,
|
||||||
|
})
|
||||||
|
toast.success('Item ditambahkan ke gacha item')
|
||||||
|
navigate({ to: '/dashboard' })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
toast.error('Item gagal ditambahkan ke gacha item')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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: '/dashboard' })}
|
||||||
|
className="text-primary-500 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<ArrowLeftOutlined className="text-[20px]" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Tambah Item Gacha</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 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 className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Tambahkan Item
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate({ to: '/dashboard' })}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Search, MessageSquare } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} 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 { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service';
|
||||||
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
|
export const Route = createFileRoute(
|
||||||
|
'/_authenticated/feedback-review-dimentorin'
|
||||||
|
)({
|
||||||
|
component: FeedbackReviewDimentorinPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function FeedbackReviewDimentorinPage() {
|
||||||
|
const [activeTab, setActiveTab] = React.useState<'mentoring' | 'platform'>(
|
||||||
|
'mentoring'
|
||||||
|
);
|
||||||
|
const [ratingFilter, setRatingFilter] = React.useState<string>('all');
|
||||||
|
const [statusFilter, setStatusFilter] = React.useState<string>('all');
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
|
React.useState<RowSelectionState>({});
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: sessionsData, isLoading } = useMySessions(
|
||||||
|
activeTab === 'mentoring' ? { status: 'completed' } : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSessions: TSessionListItem[] =
|
||||||
|
activeTab === 'mentoring' ? (sessionsData?.sessions ?? []) : [];
|
||||||
|
|
||||||
|
const sessions = React.useMemo(() => {
|
||||||
|
return allSessions.filter((s) => {
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
const hasRating = !!s.rating;
|
||||||
|
if (statusFilter === 'done' && !hasRating) return false;
|
||||||
|
if (statusFilter === 'todo' && hasRating) return false;
|
||||||
|
}
|
||||||
|
if (ratingFilter !== 'all' && String(s.rating ?? '') !== ratingFilter)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [allSessions, statusFilter, ratingFilter]);
|
||||||
|
|
||||||
|
const totalItems =
|
||||||
|
activeTab === 'mentoring' ? (sessionsData?.total ?? sessions.length) : 0;
|
||||||
|
|
||||||
|
const columns: ColumnDef<TSessionListItem>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
meta: { cellClassName: cn('w-10') },
|
||||||
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
accessorKey: 'mentee_fullname',
|
||||||
|
cell: ({ row }) => <span>{row.original.mentee_fullname ?? '-'}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'email',
|
||||||
|
header: 'Email',
|
||||||
|
accessorKey: 'mentee_email',
|
||||||
|
cell: ({ row }) => <span>{row.original.mentee_email ?? '-'}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rating',
|
||||||
|
header: 'Rating',
|
||||||
|
accessorKey: 'rating',
|
||||||
|
cell: ({ row }) => <span>{row.original.rating ?? '-'}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const hasRating = !!row.original.rating;
|
||||||
|
return (
|
||||||
|
<Badge variant={hasRating ? 'success' : 'info'}>
|
||||||
|
{hasRating ? 'Done' : 'To Do'}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: () => (
|
||||||
|
<Button variant="secondary" size="sm">
|
||||||
|
<MessageSquare className="size-3.5" />
|
||||||
|
Lihat Feedback
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: sessions,
|
||||||
|
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="Feedback & Review"
|
||||||
|
description="Review feedback dari mentoring & platform"
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setActiveTab(v as 'mentoring' | 'platform');
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="mentoring">Mentoring</TabsTrigger>
|
||||||
|
<TabsTrigger value="platform">Platform</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<div className="relative w-full sm:w-72">
|
||||||
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="pl-9"
|
||||||
|
placeholder="Cari nama mentor/mentee…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={ratingFilter} onValueChange={setRatingFilter}>
|
||||||
|
<SelectTrigger className="w-28">
|
||||||
|
<SelectValue placeholder="Rating" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Semua Rating</SelectItem>
|
||||||
|
<SelectItem value="4.5">4.5</SelectItem>
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-28">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Semua Status</SelectItem>
|
||||||
|
<SelectItem value="done">Done</SelectItem>
|
||||||
|
<SelectItem value="todo">To Do</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="mentoring" className="mt-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Memuat data…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={sessions}
|
||||||
|
columns={columns}
|
||||||
|
table={table}
|
||||||
|
manualPagination
|
||||||
|
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||||
|
currentPage={pagination.pageIndex + 1}
|
||||||
|
onPageChange={(p) =>
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="platform" className="mt-4">
|
||||||
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Platform feedback belum tersedia
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Input,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
BackofficeWrapper,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
useReactTable,
|
||||||
|
RowSelectionState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
useGachaItemList,
|
||||||
|
useDeleteGachaItem,
|
||||||
|
TGachaItemDto,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
DeleteConfirmDialog,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/gacha-roll')({
|
||||||
|
component: GachaRollPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function GachaRollPage() {
|
||||||
|
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: itemsData, isLoading } = useGachaItemList({
|
||||||
|
search,
|
||||||
|
page: pagination.pageIndex + 1,
|
||||||
|
per_page: pagination.pageSize,
|
||||||
|
});
|
||||||
|
const deleteItem = useDeleteGachaItem();
|
||||||
|
|
||||||
|
const items: TGachaItemDto[] = itemsData?.data ?? [];
|
||||||
|
const totalItems = itemsData?.meta?.total ?? items.length;
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deleteItem.mutateAsync(id);
|
||||||
|
toast.success('Item berhasil dihapus');
|
||||||
|
setDeleteId(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
toast.error('Item gagal dihapus');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<TGachaItemDto>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
|
},
|
||||||
|
{ header: 'No', accessorKey: 'id' },
|
||||||
|
{ header: 'Nama Item', accessorKey: 'name' },
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate({
|
||||||
|
to: '/gacha-roll/$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: items,
|
||||||
|
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="Gacha Roll"
|
||||||
|
description="Kelola item hadiah gacha"
|
||||||
|
>
|
||||||
|
<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 item…"
|
||||||
|
className="pl-9"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate({ to: '/gacha-roll/create' })}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Tambah Item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Memuat data…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={items}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={!!deleteId}
|
||||||
|
onOpenChange={(o) => !o && setDeleteId(null)}
|
||||||
|
onConfirm={() => deleteId && handleDelete(deleteId)}
|
||||||
|
title="Hapus item gacha ini?"
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
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 {
|
||||||
|
useGachaItemList,
|
||||||
|
useUpdateGachaItem,
|
||||||
|
} from '@imphnen-frontend-service/service'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/gacha-roll_/$id')({
|
||||||
|
component: GachaRollEditPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function GachaRollEditPage() {
|
||||||
|
const { id } = Route.useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const updateItem = useUpdateGachaItem()
|
||||||
|
|
||||||
|
const { data: itemsData, isLoading } = useGachaItemList({ per_page: 100 })
|
||||||
|
const item = itemsData?.data?.find((i) => i.id === id)
|
||||||
|
|
||||||
|
const form = useForm<{ itemName: string; quantity: number; chanceRate: number }>({
|
||||||
|
mode: 'all',
|
||||||
|
defaultValues: { itemName: '', quantity: 1, chanceRate: 0.1 },
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
form.reset({
|
||||||
|
itemName: item.name,
|
||||||
|
quantity: item.stock,
|
||||||
|
chanceRate: item.weight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [item])
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await updateItem.mutateAsync({
|
||||||
|
id,
|
||||||
|
data: {
|
||||||
|
name: data.itemName,
|
||||||
|
weight: data.chanceRate,
|
||||||
|
stock: data.quantity,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
toast.success('Perubahan item roll berhasil dilakukan')
|
||||||
|
navigate({ to: '/gacha-roll' })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
toast.error('Perubahan item roll 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: '/gacha-roll' })}
|
||||||
|
className="text-primary-500 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<ArrowLeftOutlined className="text-[20px]" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Edit Item Roll Gacha</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="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"
|
||||||
|
min={0.1}
|
||||||
|
step={0.1}
|
||||||
|
max={1}
|
||||||
|
placeholder="Masukkan Chance Rate (0,1 - 1)"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Perbarui Item
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate({ to: '/gacha-roll' })}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
|
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 { useCreateGachaItem } from '@imphnen-frontend-service/service'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/gacha-roll_/create')({
|
||||||
|
component: GachaRollCreatePage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function GachaRollCreatePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const createItem = useCreateGachaItem()
|
||||||
|
|
||||||
|
const form = useForm<{ itemName: string; quantity: number; chanceRate: number }>({
|
||||||
|
mode: 'all',
|
||||||
|
defaultValues: { itemName: '', quantity: 1, chanceRate: 0.1 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await createItem.mutateAsync({
|
||||||
|
item_code: (data.itemName as string).toLowerCase().replace(/\s+/g, '-'),
|
||||||
|
name: data.itemName,
|
||||||
|
description: '',
|
||||||
|
rarity: 'common',
|
||||||
|
type_: 'physical',
|
||||||
|
category: 'merchandise',
|
||||||
|
value: 0,
|
||||||
|
weight: data.chanceRate ?? 1,
|
||||||
|
stock: data.quantity ?? 1,
|
||||||
|
is_limited: false,
|
||||||
|
})
|
||||||
|
toast.success('Item ditambahkan ke roll gacha')
|
||||||
|
navigate({ to: '/gacha-roll' })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
toast.error('Item gagal ditambahkan ke roll gacha')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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: '/gacha-roll' })}
|
||||||
|
className="text-primary-500 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<ArrowLeftOutlined className="text-[20px]" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Tambah Item Roll Gacha</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="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"
|
||||||
|
min={0.1}
|
||||||
|
step={0.1}
|
||||||
|
max={1}
|
||||||
|
placeholder="Masukkan Chance Rate (0,1 - 1)"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Tambahkan Item
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate({ to: '/gacha-roll' })}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { UsersRound, UserCog, ClipboardCheck } from 'lucide-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import {
|
||||||
|
getAdminUsers,
|
||||||
|
getAdminTeams,
|
||||||
|
getAdminSubmissions,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/hackathon-dashboard')({
|
||||||
|
component: HackathonDashboardPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
type StatCardProps = {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatCard({ icon: Icon, label, value }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-4 pt-6">
|
||||||
|
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary-100 text-primary-600">
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-2xl font-semibold leading-tight text-foreground">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HackathonDashboardPage() {
|
||||||
|
const { data: usersData } = useQuery({
|
||||||
|
queryKey: ['admin-users-count'],
|
||||||
|
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
|
||||||
|
});
|
||||||
|
const { data: teamsData } = useQuery({
|
||||||
|
queryKey: ['admin-teams-count'],
|
||||||
|
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
|
||||||
|
});
|
||||||
|
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="Hackathon Dashboard"
|
||||||
|
description="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||||
|
>
|
||||||
|
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
icon={UserCog}
|
||||||
|
label="Total Participants"
|
||||||
|
value={totalParticipants}
|
||||||
|
/>
|
||||||
|
<StatCard icon={UsersRound} label="Total Teams" value={totalTeams} />
|
||||||
|
<StatCard
|
||||||
|
icon={ClipboardCheck}
|
||||||
|
label="Total Project Submitted"
|
||||||
|
value={totalSubmissions}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Search, Eye } from 'lucide-react';
|
||||||
|
import SubmissionModal from './_components/hackathon-submissions/submission-modal';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Input,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getAdminSubmissions,
|
||||||
|
TAdminSubmissionItem,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
|
type SubmissionType = TAdminSubmissionItem;
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/hackathon-submissions')({
|
||||||
|
component: HackathonSubmissionsPage,
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
page: Number(search.page) || 1,
|
||||||
|
search: (search.search as string) || '',
|
||||||
|
per_page: Number(search.per_page) || 10,
|
||||||
|
status: (search.status as string) || 'all',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function HackathonSubmissionsPage() {
|
||||||
|
const searchParams = Route.useSearch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const currentPage = Math.max(1, searchParams.page);
|
||||||
|
const searchQuery = searchParams.search || '';
|
||||||
|
const perPage = searchParams.per_page || 10;
|
||||||
|
const statusFilter = searchParams.status || 'all';
|
||||||
|
|
||||||
|
const [showSubmissionModal, setShowSubmissionModal] = React.useState(false);
|
||||||
|
const [selectedSubmission, setSelectedSubmission] =
|
||||||
|
React.useState<SubmissionType | null>(null);
|
||||||
|
const [globalFilter, setGlobalFilter] = React.useState(searchQuery);
|
||||||
|
|
||||||
|
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,
|
||||||
|
gcTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalData = submissionsResponse?.meta?.total_data || 0;
|
||||||
|
const totalPages = submissionsResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
|
const handlePageChange = React.useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
|
navigate({
|
||||||
|
search: {
|
||||||
|
page: newPage,
|
||||||
|
per_page: perPage !== 10 ? perPage : undefined,
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
},
|
||||||
|
[navigate, perPage, searchQuery, statusFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||||
|
navigate({ search: { page: totalPages } as any });
|
||||||
|
}
|
||||||
|
}, [currentPage, totalPages, navigate, isLoading]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setGlobalFilter(searchQuery);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const handleSearch = React.useCallback(() => {
|
||||||
|
navigate({
|
||||||
|
search: {
|
||||||
|
page: 1,
|
||||||
|
per_page: perPage !== 10 ? perPage : undefined,
|
||||||
|
search: globalFilter.trim() || undefined,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
}, [globalFilter, navigate, perPage, statusFilter]);
|
||||||
|
|
||||||
|
const filteredData = React.useMemo<SubmissionType[]>(() => {
|
||||||
|
return (
|
||||||
|
((submissionsResponse?.data as any)?.data as SubmissionType[]) ??
|
||||||
|
(submissionsResponse?.data as SubmissionType[]) ??
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}, [submissionsResponse]);
|
||||||
|
|
||||||
|
const statusVariants: Record<string, 'success' | 'warning' | 'secondary'> = {
|
||||||
|
submitted: 'success',
|
||||||
|
pending: 'warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<SubmissionType>[] = React.useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'project_name',
|
||||||
|
header: 'Project Name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{row.original.project_name}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'team_id',
|
||||||
|
header: 'Team ID',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
{row.original.team_id}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge
|
||||||
|
variant={statusVariants[row.original.status] ?? 'secondary'}
|
||||||
|
className="capitalize"
|
||||||
|
>
|
||||||
|
{row.original.status}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'submitted_at',
|
||||||
|
header: 'Submitted',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{new Date(row.original.submitted_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSubmission(row.original);
|
||||||
|
setShowSubmissionModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="size-3.5" />
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper
|
||||||
|
title="Project Submissions"
|
||||||
|
description="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
className="pl-9"
|
||||||
|
placeholder="Cari nama project…"
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
Memuat submissions…
|
||||||
|
</div>
|
||||||
|
) : filteredData.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-3 text-xs text-muted-foreground">
|
||||||
|
Menampilkan {filteredData.length} dari {totalData} submissions
|
||||||
|
(page {currentPage} / {totalPages})
|
||||||
|
{isFetching && (
|
||||||
|
<span className="ml-2 text-primary-500">Updating…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
data={filteredData}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={perPage}
|
||||||
|
manualPagination
|
||||||
|
pageCount={totalPages}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
Tidak ada submissions.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{selectedSubmission && (
|
||||||
|
<SubmissionModal
|
||||||
|
isOpen={showSubmissionModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSubmissionModal(false);
|
||||||
|
setSelectedSubmission(null);
|
||||||
|
}}
|
||||||
|
submission={selectedSubmission}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Search, Plus, Users as TeamIcon, Pencil, X } from 'lucide-react';
|
||||||
|
import ModalTeamDetail from './_components/hackathon-teams/modal-team-detail-new';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Input,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getAdminTeams,
|
||||||
|
TAdminTeamItem,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
|
type TeamType = TAdminTeamItem;
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/hackathon-teams')({
|
||||||
|
component: HackathonTeamsPage,
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
page: Number(search.page) || 1,
|
||||||
|
search: (search.search as string) || '',
|
||||||
|
per_page: Number(search.per_page) || 10,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function HackathonTeamsPage() {
|
||||||
|
const searchParams = Route.useSearch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const currentPage = Math.max(1, searchParams.page);
|
||||||
|
const searchQuery = searchParams.search || '';
|
||||||
|
const perPage = searchParams.per_page || 10;
|
||||||
|
|
||||||
|
const [showDetailModal, setShowDetailModal] = React.useState(false);
|
||||||
|
const [showNewTeamModal, setShowNewTeamModal] = React.useState(false);
|
||||||
|
const [selectedTeam, setSelectedTeam] = React.useState<TeamType | null>(null);
|
||||||
|
const [globalFilter, setGlobalFilter] = React.useState(searchQuery);
|
||||||
|
const [visibilityFilter, setVisibilityFilter] = React.useState('all');
|
||||||
|
const [cityFilter, setCityFilter] = React.useState('all');
|
||||||
|
|
||||||
|
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,
|
||||||
|
gcTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalData = teamsResponse?.meta?.total_data || 0;
|
||||||
|
const totalPages = teamsResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
|
const handlePageChange = React.useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
|
navigate({
|
||||||
|
search: {
|
||||||
|
page: newPage,
|
||||||
|
per_page: perPage !== 10 ? perPage : undefined,
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
},
|
||||||
|
[navigate, perPage, searchQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||||
|
navigate({ search: { page: totalPages } as any });
|
||||||
|
}
|
||||||
|
}, [currentPage, totalPages, navigate, isLoading]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setGlobalFilter(searchQuery);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const handleSearch = React.useCallback(() => {
|
||||||
|
navigate({
|
||||||
|
search: {
|
||||||
|
page: 1,
|
||||||
|
per_page: perPage !== 10 ? perPage : undefined,
|
||||||
|
search: globalFilter.trim() || undefined,
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
}, [globalFilter, navigate, perPage]);
|
||||||
|
|
||||||
|
const filteredData = React.useMemo<TeamType[]>(() => {
|
||||||
|
return (
|
||||||
|
((teamsResponse?.data as any)?.data as TeamType[]) ??
|
||||||
|
(teamsResponse?.data as TeamType[]) ??
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}, [teamsResponse]);
|
||||||
|
|
||||||
|
const handleShowDetailModal = React.useCallback((team: TeamType) => {
|
||||||
|
setSelectedTeam(team);
|
||||||
|
setShowDetailModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const columns: ColumnDef<TeamType>[] = React.useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Team',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src={row.original.logo ?? undefined} alt={row.original.name} />
|
||||||
|
<AvatarFallback>
|
||||||
|
<TeamIcon className="size-4 text-muted-foreground" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
className="truncate font-medium text-foreground"
|
||||||
|
title={row.original.name}
|
||||||
|
>
|
||||||
|
{row.original.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'city',
|
||||||
|
header: 'City',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-foreground">{row.original.city}</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'visibility',
|
||||||
|
header: 'Visibility',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
row.original.visibility === 'public' ? 'success' : 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.original.visibility === 'public' ? 'Public' : 'Private'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leader',
|
||||||
|
header: 'Leader ID',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="font-mono text-xs text-muted-foreground">
|
||||||
|
{row.original.leader_id}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: 'Created',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{new Date(row.original.created_at).toLocaleDateString('en-UK', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleShowDetailModal(row.original)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[handleShowDetailModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasActiveFilters = visibilityFilter !== 'all' || cityFilter !== 'all';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper
|
||||||
|
title="Hackathon Teams"
|
||||||
|
description="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
className="pl-9"
|
||||||
|
placeholder="Cari nama atau kota…"
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowNewTeamModal(true)} size="md">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add Team
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Active filters:
|
||||||
|
</span>
|
||||||
|
{visibilityFilter !== 'all' && (
|
||||||
|
<Badge variant="info" className="gap-1">
|
||||||
|
Visibility: {visibilityFilter}
|
||||||
|
<button onClick={() => setVisibilityFilter('all')}>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{cityFilter !== 'all' && (
|
||||||
|
<Badge variant="success" className="gap-1">
|
||||||
|
City: {cityFilter}
|
||||||
|
<button onClick={() => setCityFilter('all')}>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setVisibilityFilter('all');
|
||||||
|
setCityFilter('all');
|
||||||
|
setGlobalFilter('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||||
|
Memuat data teams…
|
||||||
|
</div>
|
||||||
|
) : filteredData.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Menampilkan {filteredData.length} dari {totalData} teams (page{' '}
|
||||||
|
{currentPage} / {totalPages})
|
||||||
|
{isFetching && (
|
||||||
|
<span className="ml-2 text-primary-500">Updating…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
data={filteredData}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={perPage}
|
||||||
|
manualPagination
|
||||||
|
pageCount={totalPages}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
Tidak ada team. Coba ubah filter pencarian.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ModalTeamDetail
|
||||||
|
isOpen={showDetailModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedTeam(null);
|
||||||
|
}}
|
||||||
|
team={selectedTeam}
|
||||||
|
/>
|
||||||
|
<ModalTeamDetail
|
||||||
|
isOpen={showNewTeamModal}
|
||||||
|
onClose={() => setShowNewTeamModal(false)}
|
||||||
|
team={null}
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Search, Plus, User, Pencil, X } from 'lucide-react';
|
||||||
|
import ModalUserDetail from './_components/hackathon-users/modal-user-detail';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Input,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getAdminUsers,
|
||||||
|
TAdminUserItem,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
|
type UserType = TAdminUserItem;
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/hackathon-users')({
|
||||||
|
component: HackathonUsersPage,
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
page: Number(search.page) || 1,
|
||||||
|
search: (search.search as string) || '',
|
||||||
|
per_page: Number(search.per_page) || 10,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function HackathonUsersPage() {
|
||||||
|
const searchParams = Route.useSearch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const currentPage = Math.max(1, searchParams.page);
|
||||||
|
const searchQuery = searchParams.search || '';
|
||||||
|
const perPage = searchParams.per_page || 10;
|
||||||
|
|
||||||
|
const [showDetailModal, setShowDetailModal] = React.useState(false);
|
||||||
|
const [showNewUserModal, setShowNewUserModal] = React.useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = React.useState<UserType | null>(null);
|
||||||
|
const [globalFilter, setGlobalFilter] = React.useState(searchQuery);
|
||||||
|
const [statusFilter, setStatusFilter] = React.useState('all');
|
||||||
|
const [skillsFilter, setSkillsFilter] = React.useState<string[]>([]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: usersResponse,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['admin-users', currentPage, perPage, statusFilter, searchQuery],
|
||||||
|
queryFn: () =>
|
||||||
|
getAdminUsers({
|
||||||
|
page: currentPage,
|
||||||
|
per_page: perPage,
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
}),
|
||||||
|
staleTime: 30000,
|
||||||
|
gcTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalData = usersResponse?.meta?.total_data || 0;
|
||||||
|
const totalPages = usersResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
|
const handlePageChange = React.useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
|
navigate({
|
||||||
|
search: {
|
||||||
|
page: newPage,
|
||||||
|
per_page: perPage !== 10 ? perPage : undefined,
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
},
|
||||||
|
[navigate, perPage, searchQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||||
|
navigate({ search: { page: totalPages } as any });
|
||||||
|
}
|
||||||
|
}, [currentPage, totalPages, navigate, isLoading]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setGlobalFilter(searchQuery);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const handleSearch = React.useCallback(() => {
|
||||||
|
navigate({
|
||||||
|
search: {
|
||||||
|
page: 1,
|
||||||
|
per_page: perPage !== 10 ? perPage : undefined,
|
||||||
|
search: globalFilter.trim() || undefined,
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
}, [globalFilter, navigate, perPage]);
|
||||||
|
|
||||||
|
const handleShowDetailModal = React.useCallback((user: UserType) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setShowDetailModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredData = React.useMemo(() => {
|
||||||
|
const usersData: UserType[] =
|
||||||
|
((usersResponse?.data as any)?.data as UserType[]) ??
|
||||||
|
(usersResponse?.data as UserType[]) ??
|
||||||
|
[];
|
||||||
|
return usersData.filter((user) => {
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
const isActive = statusFilter === 'active';
|
||||||
|
if (user.is_active !== isActive) return false;
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const columns: ColumnDef<UserType>[] = React.useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'fullname',
|
||||||
|
header: 'User',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="size-9">
|
||||||
|
<AvatarImage src={row.original.avatar ?? undefined} alt={row.original.fullname} />
|
||||||
|
<AvatarFallback>
|
||||||
|
<User className="size-4 text-muted-foreground" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium text-foreground">
|
||||||
|
{row.original.fullname}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'skills',
|
||||||
|
header: 'Skills',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const skills = row.original.skills || [];
|
||||||
|
if (skills.length === 0) {
|
||||||
|
return <span className="text-muted-foreground">-</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{skills.slice(0, 2).map((skill, index) => (
|
||||||
|
<Badge key={index} variant="success">
|
||||||
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{skills.length > 2 && (
|
||||||
|
<Badge variant="secondary">+{skills.length - 2}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'location',
|
||||||
|
header: 'Location',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-foreground">{row.original.location}</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'is_active',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'size-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-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: (rowA, rowB) => {
|
||||||
|
const a = rowA.original.is_active;
|
||||||
|
const b = rowB.original.is_active;
|
||||||
|
if (a && !b) return -1;
|
||||||
|
if (!a && b) return 1;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: 'Joined',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{new Date(row.original.created_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleShowDetailModal(row.original)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[handleShowDetailModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasActiveFilters = statusFilter !== 'all' || skillsFilter.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper
|
||||||
|
title="Hackathon Users"
|
||||||
|
description="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
className="pl-9"
|
||||||
|
placeholder="Cari nama atau lokasi…"
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowNewUserModal(true)} size="md">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Active filters:
|
||||||
|
</span>
|
||||||
|
{statusFilter !== 'all' && (
|
||||||
|
<Badge variant="info" className="gap-1">
|
||||||
|
Status: {statusFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
aria-label="Clear status filter"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{skillsFilter.map((skill) => (
|
||||||
|
<Badge key={skill} variant="secondary" className="gap-1">
|
||||||
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
||||||
|
}
|
||||||
|
aria-label={`Clear ${skill} filter`}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter('all');
|
||||||
|
setSkillsFilter([]);
|
||||||
|
setGlobalFilter('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||||
|
<span>Memuat data users…</span>
|
||||||
|
</div>
|
||||||
|
) : filteredData.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Menampilkan {filteredData.length} dari {totalData} users (page{' '}
|
||||||
|
{currentPage} / {totalPages})
|
||||||
|
{isFetching && (
|
||||||
|
<span className="ml-2 text-primary-500">Updating…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
data={filteredData}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={perPage}
|
||||||
|
manualPagination
|
||||||
|
pageCount={totalPages}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
Tidak ada user. Coba ubah filter pencarian.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ModalUserDetail
|
||||||
|
isOpen={showDetailModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
}}
|
||||||
|
user={selectedUser}
|
||||||
|
/>
|
||||||
|
<ModalUserDetail
|
||||||
|
isOpen={showNewUserModal}
|
||||||
|
onClose={() => setShowNewUserModal(false)}
|
||||||
|
user={null}
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
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 {
|
||||||
|
usePermissionList,
|
||||||
|
useDeletePermission,
|
||||||
|
TPermissionItem,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
DeleteConfirmDialog,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/permissions')({
|
||||||
|
component: PermissionsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function PermissionsPage() {
|
||||||
|
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: permissionsData, isLoading } = usePermissionList({
|
||||||
|
search,
|
||||||
|
page: pagination.pageIndex + 1,
|
||||||
|
per_page: pagination.pageSize,
|
||||||
|
});
|
||||||
|
const deletePermission = useDeletePermission();
|
||||||
|
|
||||||
|
const permissions: TPermissionItem[] = permissionsData?.data ?? [];
|
||||||
|
const totalItems = permissionsData?.meta?.total ?? permissions.length;
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deletePermission.mutateAsync(id);
|
||||||
|
toast.success('Data permission berhasil dihapus');
|
||||||
|
setDeleteId(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
toast.error('Data permission gagal dihapus');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<TPermissionItem>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
|
},
|
||||||
|
{ header: 'No', accessorKey: 'id' },
|
||||||
|
{ header: 'Name', accessorKey: 'name' },
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate({
|
||||||
|
to: '/permissions/$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: permissions,
|
||||||
|
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="Permissions"
|
||||||
|
description="Kelola hak akses sistem"
|
||||||
|
>
|
||||||
|
<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 permission…"
|
||||||
|
className="pl-9"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate({ to: '/permissions/create' })}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Tambah Permission
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Memuat data…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={permissions}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={!!deleteId}
|
||||||
|
onOpenChange={(o) => !o && setDeleteId(null)}
|
||||||
|
onConfirm={() => deleteId && handleDelete(deleteId)}
|
||||||
|
title="Hapus permission ini?"
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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 {
|
||||||
|
usePermissionList,
|
||||||
|
useUpdatePermission,
|
||||||
|
} from '@imphnen-frontend-service/service'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/permissions_/$id')({
|
||||||
|
component: PermissionsEditPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PermissionsEditPage() {
|
||||||
|
const { id } = Route.useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const updatePermission = useUpdatePermission()
|
||||||
|
|
||||||
|
const { data: permissionsData, isLoading } = usePermissionList({ search: '', per_page: 100 })
|
||||||
|
const permission = permissionsData?.data?.find((p) => p.id === id)
|
||||||
|
|
||||||
|
const form = useForm<{ name: string }>({
|
||||||
|
mode: 'all',
|
||||||
|
defaultValues: { name: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (permission) {
|
||||||
|
form.reset({ name: permission.name })
|
||||||
|
}
|
||||||
|
}, [permission])
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await updatePermission.mutateAsync({ id, data })
|
||||||
|
toast.success('Perubahan permissions berhasil dilakukan')
|
||||||
|
navigate({ to: '/permissions' })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
toast.error('Perubahan permissions 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: '/permissions' })}
|
||||||
|
className="text-primary-500 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<ArrowLeftOutlined className="text-[20px]" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Edit Permission</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="Name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nama Permission"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Update Permission
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate({ to: '/permissions' })}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
|
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 { useCreatePermission } from '@imphnen-frontend-service/service'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/permissions_/create')({
|
||||||
|
component: PermissionsCreatePage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PermissionsCreatePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const createPermission = useCreatePermission()
|
||||||
|
|
||||||
|
const form = useForm<{ name: string }>({
|
||||||
|
mode: 'all',
|
||||||
|
defaultValues: { name: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await createPermission.mutateAsync(data)
|
||||||
|
toast.success('Data permissions berhasil ditambahkan')
|
||||||
|
navigate({ to: '/permissions' })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
toast.error('Data permissions gagal ditambahkan')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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: '/permissions' })}
|
||||||
|
className="text-primary-500 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<ArrowLeftOutlined className="text-[20px]" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Tambah Permission</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="Name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nama Permission"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Tambah Permission
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate({ to: '/permissions' })}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Filter as FilterIcon, Search, ClipboardCheck } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Input,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} 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 ModalProcessDelivery from './_components/prizes/modal-process-item';
|
||||||
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
|
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',
|
||||||
|
];
|
||||||
|
|
||||||
|
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 Route = createFileRoute('/_authenticated/prizes')({
|
||||||
|
component: PrizesPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function PrizesPage() {
|
||||||
|
const [showModalProcessDelivery, setShowModalProcessDelivery] =
|
||||||
|
React.useState(false);
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
|
React.useState<RowSelectionState>({});
|
||||||
|
const [showFilter, setShowFilter] = React.useState(false);
|
||||||
|
|
||||||
|
const deliveryOptions = [
|
||||||
|
{ id: 'undelivered', value: 'undelivered', label: 'Undelivered' },
|
||||||
|
{ id: 'delivered', value: 'delivered', label: 'Delivered' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const orderValidVariants: Record<
|
||||||
|
OrderValid,
|
||||||
|
'success' | 'destructive' | 'warning'
|
||||||
|
> = {
|
||||||
|
valid: 'success',
|
||||||
|
invalid: 'destructive',
|
||||||
|
unchecked: 'warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderValidText: Record<OrderValid, string> = {
|
||||||
|
valid: 'Valid',
|
||||||
|
invalid: 'Invalid',
|
||||||
|
unchecked: 'Unchecked',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusVariants: Record<Status, 'success' | 'destructive'> = {
|
||||||
|
delivered: 'success',
|
||||||
|
undelivered: 'destructive',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusText: Record<Status, string> = {
|
||||||
|
delivered: 'Delivered',
|
||||||
|
undelivered: 'Undelivered',
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<Prize>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
|
},
|
||||||
|
{ header: 'No', accessorKey: 'id' },
|
||||||
|
{ header: 'Nama Lengkap', accessorKey: 'name' },
|
||||||
|
{
|
||||||
|
header: 'Order Valid?',
|
||||||
|
accessorKey: 'orderValid',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant={orderValidVariants[row.original.orderValid]}>
|
||||||
|
{orderValidText[row.original.orderValid]}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ header: 'Items', accessorKey: 'items' },
|
||||||
|
{ header: 'Alamat Pengiriman', accessorKey: 'address' },
|
||||||
|
{
|
||||||
|
header: 'Status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant={statusVariants[row.original.status]}>
|
||||||
|
{statusText[row.original.status]}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: () => (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowModalProcessDelivery(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClipboardCheck className="size-3.5" />
|
||||||
|
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 (
|
||||||
|
<BackofficeWrapper
|
||||||
|
title="Data Pengiriman Hadiah"
|
||||||
|
description="Proses pengiriman hadiah ke pemenang"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
className="pl-9"
|
||||||
|
placeholder="Cari nama atau nomor order…"
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
options={deliveryOptions}
|
||||||
|
title="Status"
|
||||||
|
onClose={() => setShowFilter(false)}
|
||||||
|
onFilterChange={(value) => {
|
||||||
|
console.log('Selected filter:', value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ModalProcessDelivery
|
||||||
|
isOpen={showModalProcessDelivery}
|
||||||
|
onClose={() => setShowModalProcessDelivery(false)}
|
||||||
|
handleProcessDelivery={() => {
|
||||||
|
console.log('Action proses pengiriman');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Search, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} 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 { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
useRoadmapList,
|
||||||
|
useDeleteRoadmap,
|
||||||
|
TRoadmapListItem,
|
||||||
|
TRoadmapStatus,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
DeleteConfirmDialog,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/roadmap-dimentorin')({
|
||||||
|
component: RoadmapDimentorinPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RoadmapDimentorinPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [search, setSearch] = React.useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = React.useState('all');
|
||||||
|
const [deletingId, setDeletingId] = React.useState<string | null>(null);
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
|
React.useState<RowSelectionState>({});
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: roadmapData, isLoading } = useRoadmapList();
|
||||||
|
const deleteRoadmap = useDeleteRoadmap();
|
||||||
|
|
||||||
|
const allItems: TRoadmapListItem[] = roadmapData ?? [];
|
||||||
|
const filteredItems = allItems.filter((item) => {
|
||||||
|
const matchSearch =
|
||||||
|
!search || item.title.toLowerCase().includes(search.toLowerCase());
|
||||||
|
const matchStatus = statusFilter === 'all' || item.status === statusFilter;
|
||||||
|
return matchSearch && matchStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deleteRoadmap.mutateAsync(id);
|
||||||
|
toast.success('Roadmap berhasil dihapus');
|
||||||
|
setDeletingId(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
toast.error('Gagal menghapus roadmap');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusVariants: Record<
|
||||||
|
TRoadmapStatus,
|
||||||
|
'warning' | 'info' | 'success'
|
||||||
|
> = {
|
||||||
|
upcoming: 'warning',
|
||||||
|
in_progress: 'info',
|
||||||
|
completed: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusText: Record<TRoadmapStatus, string> = {
|
||||||
|
upcoming: 'Upcoming',
|
||||||
|
in_progress: 'In Progress',
|
||||||
|
completed: 'Completed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<TRoadmapListItem>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
meta: { cellClassName: cn('w-10') },
|
||||||
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
|
},
|
||||||
|
{ id: 'title', header: 'Title', accessorKey: 'title' },
|
||||||
|
{
|
||||||
|
id: 'description',
|
||||||
|
header: 'Description',
|
||||||
|
accessorKey: 'description',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="line-clamp-2 max-w-md">{row.original.description}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant={statusVariants[row.original.status] ?? 'secondary'}>
|
||||||
|
{statusText[row.original.status] ?? row.original.status}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ id: 'votes', header: 'Votes', accessorKey: 'votes' },
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate({
|
||||||
|
to: '/roadmap-dimentorin/$id',
|
||||||
|
params: { id: row.original.id },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeletingId(row.original.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: filteredItems,
|
||||||
|
columns,
|
||||||
|
state: { pagination, rowSelection },
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(filteredItems.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper
|
||||||
|
title="Content & Roadmap"
|
||||||
|
description="Kelola AI roadmap dimentorin"
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex flex-1 flex-col gap-2 sm:flex-row">
|
||||||
|
<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
|
||||||
|
className="pl-9"
|
||||||
|
placeholder="Cari judul roadmap…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Semua Status</SelectItem>
|
||||||
|
<SelectItem value="upcoming">Upcoming</SelectItem>
|
||||||
|
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
onClick={() => navigate({ to: '/roadmap-dimentorin/create' })}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Buat Roadmap
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Memuat data…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable data={filteredItems} columns={columns} table={table} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={!!deletingId}
|
||||||
|
onOpenChange={(o) => !o && setDeletingId(null)}
|
||||||
|
onConfirm={() => deletingId && handleDelete(deletingId)}
|
||||||
|
title="Hapus roadmap ini?"
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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 { useRoadmapList, useUpdateRoadmap, TRoadmapStatus } from '@imphnen-frontend-service/service'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/roadmap-dimentorin_/$id')({
|
||||||
|
component: RoadmapEditPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RoadmapEditPage() {
|
||||||
|
const { id } = Route.useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const updateRoadmap = useUpdateRoadmap()
|
||||||
|
|
||||||
|
const { data: roadmapData, isLoading } = useRoadmapList()
|
||||||
|
const roadmap = roadmapData?.find((r) => r.id === id)
|
||||||
|
|
||||||
|
const form = useForm<{
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
status: TRoadmapStatus
|
||||||
|
}>({
|
||||||
|
mode: 'all',
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
status: 'upcoming',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (roadmap) {
|
||||||
|
form.reset({
|
||||||
|
title: roadmap.title,
|
||||||
|
description: roadmap.description,
|
||||||
|
status: roadmap.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [roadmap])
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await updateRoadmap.mutateAsync({ id, data })
|
||||||
|
toast.success('Roadmap berhasil diperbarui')
|
||||||
|
navigate({ to: '/roadmap-dimentorin' })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
toast.error('Gagal memperbarui roadmap')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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: '/roadmap-dimentorin' })}
|
||||||
|
className="text-primary-500 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<ArrowLeftOutlined className="text-[20px]" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Edit Roadmap</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="Title"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan judul roadmap"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Description"
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan deskripsi roadmap"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-p3 font-medium text-neutral-800">Status</label>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-p3 focus:border-primary-500 focus:outline-none"
|
||||||
|
{...form.register('status')}
|
||||||
|
>
|
||||||
|
<option value="upcoming">Upcoming</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Update Roadmap
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate({ to: '/roadmap-dimentorin' })}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
|
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 { useCreateRoadmap, TRoadmapStatus } from '@imphnen-frontend-service/service'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/roadmap-dimentorin_/create')({
|
||||||
|
component: RoadmapCreatePage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RoadmapCreatePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const createRoadmap = useCreateRoadmap()
|
||||||
|
|
||||||
|
const form = useForm<{
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
status: TRoadmapStatus
|
||||||
|
}>({
|
||||||
|
mode: 'all',
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
status: 'upcoming',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await createRoadmap.mutateAsync(data)
|
||||||
|
toast.success('Roadmap berhasil ditambahkan')
|
||||||
|
navigate({ to: '/roadmap-dimentorin' })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
toast.error('Gagal menambahkan roadmap')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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: '/roadmap-dimentorin' })}
|
||||||
|
className="text-primary-500 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<ArrowLeftOutlined className="text-[20px]" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Buat Roadmap</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="Title"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan judul roadmap"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Description"
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan deskripsi roadmap"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-p3 font-medium text-neutral-800">Status</label>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-p3 focus:border-primary-500 focus:outline-none"
|
||||||
|
{...form.register('status')}
|
||||||
|
>
|
||||||
|
<option value="upcoming">Upcoming</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Buat Roadmap
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate({ to: '/roadmap-dimentorin' })}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
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 {
|
||||||
|
useRoleList,
|
||||||
|
useDeleteRole,
|
||||||
|
TRolesListItem,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
DeleteConfirmDialog,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/roles')({
|
||||||
|
component: RolesPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RolesPage() {
|
||||||
|
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: rolesData, isLoading } = useRoleList({
|
||||||
|
search,
|
||||||
|
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('Data role berhasil dihapus');
|
||||||
|
setDeleteId(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
toast.error('Data role gagal dihapus');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<TRolesListItem>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
|
},
|
||||||
|
{ header: 'ID', accessorKey: 'id' },
|
||||||
|
{ header: 'Roles Name', accessorKey: 'name' },
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate({ to: '/roles/$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: roles,
|
||||||
|
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="Roles" description="Kelola role dan akses">
|
||||||
|
<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 role…"
|
||||||
|
className="pl-9"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate({ to: '/roles/create' })}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Tambah Role
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Memuat data…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={roles}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={!!deleteId}
|
||||||
|
onOpenChange={(o) => !o && setDeleteId(null)}
|
||||||
|
onConfirm={() => deleteId && handleDelete(deleteId)}
|
||||||
|
title="Hapus role ini?"
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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 {
|
||||||
|
useRoleList,
|
||||||
|
useUpdateRole,
|
||||||
|
} from '@imphnen-frontend-service/service'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/roles_/$id')({
|
||||||
|
component: RolesEditPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RolesEditPage() {
|
||||||
|
const { id } = Route.useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const updateRole = useUpdateRole()
|
||||||
|
|
||||||
|
const { data: rolesData, isLoading } = useRoleList({ search: '', per_page: 100 })
|
||||||
|
const role = rolesData?.data?.find((r) => r.id === id)
|
||||||
|
|
||||||
|
const form = useForm<{ name: string }>({
|
||||||
|
mode: 'all',
|
||||||
|
defaultValues: { name: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (role) {
|
||||||
|
form.reset({ name: role.name })
|
||||||
|
}
|
||||||
|
}, [role])
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await updateRole.mutateAsync({ id, data })
|
||||||
|
toast.success('Perubahan role berhasil dilakukan')
|
||||||
|
navigate({ to: '/roles' })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
toast.error('Perubahan role 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: '/roles' })}
|
||||||
|
className="text-primary-500 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<ArrowLeftOutlined className="text-[20px]" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Edit Role</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 Role"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Nama Role"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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-auto">
|
||||||
|
{['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>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Update Role
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate({ to: '/roles' })}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
|
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 { useCreateRole } from '@imphnen-frontend-service/service'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/roles_/create')({
|
||||||
|
component: RolesCreatePage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RolesCreatePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const createRole = useCreateRole()
|
||||||
|
|
||||||
|
const form = useForm<{ name: string }>({
|
||||||
|
mode: 'all',
|
||||||
|
defaultValues: { name: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await createRole.mutateAsync(data)
|
||||||
|
toast.success('Data role berhasil ditambahkan')
|
||||||
|
navigate({ to: '/roles' })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
toast.error('Data role gagal ditambahkan')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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: '/roles' })}
|
||||||
|
className="text-primary-500 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<ArrowLeftOutlined className="text-[20px]" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Tambah Role</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 Role"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Nama Role"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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-auto">
|
||||||
|
{[
|
||||||
|
'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>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Tambah Role
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate({ to: '/roles' })}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Search, Eye } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} 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 {
|
||||||
|
useMySessions,
|
||||||
|
TSessionListItem,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/session-dimentorin')({
|
||||||
|
component: SessionDimentorinPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function SessionDimentorinPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [statusFilter, setStatusFilter] = React.useState('all');
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
|
React.useState<RowSelectionState>({});
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: sessionsData, isLoading } = useMySessions(
|
||||||
|
statusFilter !== 'all' ? { status: statusFilter } : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessions: TSessionListItem[] = sessionsData?.sessions ?? [];
|
||||||
|
const totalItems = sessionsData?.total ?? sessions.length;
|
||||||
|
|
||||||
|
const statusVariants: Record<
|
||||||
|
string,
|
||||||
|
'warning' | 'info' | 'success' | 'destructive' | 'secondary'
|
||||||
|
> = {
|
||||||
|
pending: 'warning',
|
||||||
|
confirmed: 'info',
|
||||||
|
ongoing: 'warning',
|
||||||
|
completed: 'success',
|
||||||
|
cancelled: 'destructive',
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<TSessionListItem>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
meta: { cellClassName: cn('w-10') },
|
||||||
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
|
},
|
||||||
|
{ id: 'id', header: 'ID Sesi', accessorKey: 'id' },
|
||||||
|
{ id: 'mentorId', header: 'Nama Mentor', accessorKey: 'mentor_id' },
|
||||||
|
{
|
||||||
|
id: 'menteeName',
|
||||||
|
header: 'Nama Mentee',
|
||||||
|
accessorKey: 'mentee_fullname',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'datetime',
|
||||||
|
header: 'Waktu',
|
||||||
|
accessorKey: 'scheduled_at',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span>
|
||||||
|
{new Date(row.original.scheduled_at).toLocaleString('id-ID')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge
|
||||||
|
variant={statusVariants[row.original.status] ?? 'secondary'}
|
||||||
|
className="capitalize"
|
||||||
|
>
|
||||||
|
{row.original.status}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate({
|
||||||
|
to: '/session-dimentorin/$id',
|
||||||
|
params: { id: row.original.id },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="size-3.5" />
|
||||||
|
Detail
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: sessions,
|
||||||
|
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="Session Management"
|
||||||
|
description="Kelola sesi mentoring aktif"
|
||||||
|
>
|
||||||
|
<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 className="pl-9" placeholder="Cari nama lengkap…" />
|
||||||
|
</div>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Semua Status</SelectItem>
|
||||||
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
<SelectItem value="confirmed">Confirmed</SelectItem>
|
||||||
|
<SelectItem value="ongoing">On Going</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Memuat data…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={sessions}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||||
|
import { Input, NativeSelect as Select, Textarea } from '@imphnen-frontend-service/ui/atoms'
|
||||||
|
import { cn, For } from '@imphnen-frontend-service/utils'
|
||||||
|
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service'
|
||||||
|
|
||||||
|
const TOPICS = [
|
||||||
|
{ id: 2, icon: '\uD83C\uDFE2', name: 'Industry Insight' },
|
||||||
|
{ id: 4, icon: '\uD83D\uDDA5\uFE0F', 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]')
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/session-dimentorin_/$id')({
|
||||||
|
component: SessionDetailPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function SessionDetailPage() {
|
||||||
|
const { id } = Route.useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { data: sessionsData, isLoading } = useMySessions()
|
||||||
|
const session: TSessionListItem | undefined = sessionsData?.sessions?.find((s) => s.id === id)
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
pending: 'bg-warning-200 text-warning-700',
|
||||||
|
confirmed: 'bg-primary-200 text-primary-700',
|
||||||
|
ongoing: 'bg-warning-200 text-warning-700',
|
||||||
|
completed: 'bg-success-200 text-success-500',
|
||||||
|
cancelled: 'bg-danger-200 text-danger-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
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="w-full">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate({ to: '/session-dimentorin' })}
|
||||||
|
className="text-primary-500 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<ArrowLeftOutlined className="text-[20px]" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Detail Sesi</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
|
||||||
|
Detail Sesi
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<h3 className="text-p2 font-semibold text-primary-500 mb-4">Mentor</h3>
|
||||||
|
<div>
|
||||||
|
<p className="text-p3 font-semibold mb-2.5">{session?.mentor_id ?? "-"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-5 flex items-center gap-12">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-p2 font-semibold text-primary-500 mb-4">Mentee</h3>
|
||||||
|
<div>
|
||||||
|
<p className="text-p3 font-semibold mb-2.5">{session?.mentee_fullname ?? "-"}</p>
|
||||||
|
<p className="text-neutral-600">{session?.mentee_email ?? "-"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-p2 font-semibold text-neutral-700 mb-4">Status</h3>
|
||||||
|
<div className={`py-2 px-6 rounded-md text-center capitalize font-semibold ${statusColors[session?.status ?? ``] ?? `bg-neutral-200 text-neutral-700`}`}>
|
||||||
|
{session?.status ?? '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-p3 font-medium mb-2.5">Topics</h3>
|
||||||
|
<div className="p-5 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-2.5 mb-8">
|
||||||
|
{session?.topic ? (
|
||||||
|
<div className="px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow font-medium">
|
||||||
|
<span>{session.topic}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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"
|
||||||
|
value={session?.scheduled_at ? new Date(session.scheduled_at).toISOString().split('T')[0] : ''}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Waktu</label>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
className="min-w-full w-full"
|
||||||
|
value={session?.scheduled_at ? new Date(session.scheduled_at).toTimeString().slice(0, 5) : ''}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative md:col-span-full">
|
||||||
|
<label className={labelClass}>Tipe Sesi</label>
|
||||||
|
<Select className="min-w-full w-full" value={session?.session_type ?? "online"} disabled>
|
||||||
|
<option value="online">Online</option>
|
||||||
|
<option value="offline">Offline</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-full">
|
||||||
|
<label className={labelClass}>Durasi (menit)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="min-w-full w-full"
|
||||||
|
value={session?.duration_minutes ?? ''}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { GeneralSettings } from './_components/settings-dimentorin/general';
|
||||||
|
import { UserRolesPermission } from './_components/settings-dimentorin/user-roles-permission';
|
||||||
|
import { NotificationSettings } from './_components/settings-dimentorin/notification';
|
||||||
|
import { SecuritySettings } from './_components/settings-dimentorin/security';
|
||||||
|
import { PaymentSettings } from './_components/settings-dimentorin/payment';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/settings-dimentorin')({
|
||||||
|
component: SettingsDimentorinPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function SettingsDimentorinPage(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper
|
||||||
|
title="Dimentorin Settings"
|
||||||
|
description="Konfigurasi platform Dimentorin.dev"
|
||||||
|
>
|
||||||
|
<Tabs defaultValue="general" className="gap-6">
|
||||||
|
<TabsList className="flex-wrap">
|
||||||
|
<TabsTrigger value="general">General</TabsTrigger>
|
||||||
|
<TabsTrigger value="roles">Roles & Permissions</TabsTrigger>
|
||||||
|
<TabsTrigger value="notification">Notification</TabsTrigger>
|
||||||
|
<TabsTrigger value="security">Security</TabsTrigger>
|
||||||
|
<TabsTrigger value="payment">Payment</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="general">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<GeneralSettings />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="roles">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<UserRolesPermission />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="notification">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<NotificationSettings />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="security">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<SecuritySettings />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="payment">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<PaymentSettings />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user