Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a84e2fc31a | ||
|
|
6e1c561387 | ||
|
|
b39e918b3c | ||
|
|
0a217341a5 | ||
|
|
f1f72402b2 | ||
|
|
3f1b2a2bd7 | ||
|
|
57b27c6784 | ||
|
|
f191ffc4b1 | ||
|
|
31e4ffae92 | ||
|
|
c9f7ce24ed | ||
|
|
d21adebf73 | ||
|
|
744d39ea75 | ||
|
|
da11ba5894 | ||
|
|
6d29c81dad | ||
|
|
4a7135dd17 | ||
|
|
33d750b221 | ||
|
|
1f9aa2e6b3 | ||
|
|
7722c98c84 | ||
|
|
0914361635 | ||
|
|
74aabd1eba | ||
|
|
d1fc7a8fd5 | ||
|
|
009cabfc1c | ||
|
|
a7f7521761 | ||
|
|
54b7a2f0eb | ||
|
|
ef042f2f40 | ||
|
|
fd35cb9525 | ||
|
|
18d11fd1c3 | ||
|
|
c82d9bcc70 | ||
|
|
a4dd793bb7 | ||
|
|
bba3c89b23 | ||
|
|
8b6aa21f82 | ||
|
|
0fffe2d403 | ||
|
|
919fac811d | ||
|
|
4316dcf8b3 | ||
|
|
b9b71c4c25 | ||
|
|
121faf89c3 | ||
|
|
07913efb16 | ||
|
|
4b2733511c | ||
|
|
9f3d00a0db | ||
|
|
7ac571c019 | ||
|
|
a8c958400d | ||
|
|
760ab67ae0 | ||
|
|
c4cf9e4e6c | ||
|
|
a800b1e293 | ||
|
|
da0e599b9a | ||
|
|
a02a259f4e | ||
|
|
ff99564b05 | ||
|
|
13ab089afc | ||
|
|
378e258520 | ||
|
|
88468081b6 | ||
|
|
aba462e462 | ||
|
|
55fd1fa443 | ||
|
|
07a1eb8dbc | ||
|
|
aad04b6f94 | ||
|
|
d4a91abb63 | ||
|
|
7fd5528c0d | ||
|
|
39a57b4fed | ||
|
|
7b490702b9 | ||
|
|
f31aad8570 | ||
|
|
acb7f4aef9 | ||
|
|
c69614531b | ||
|
|
c82426282c | ||
|
|
45295953aa | ||
|
|
0561555276 |
@@ -0,0 +1,14 @@
|
|||||||
|
# API Endpoint
|
||||||
|
VITE_API_URL=
|
||||||
|
|
||||||
|
# Payload CMS Secret Key (minimum 32 characters)
|
||||||
|
CMS_SECRET=your-very-long-secret-key
|
||||||
|
|
||||||
|
# PostgreSQL Connection String
|
||||||
|
CMS_POSTGRES_URL=postgres://user:password@host:port/database
|
||||||
|
|
||||||
|
# S3 Storage Configuration
|
||||||
|
CMS_STORAGE_ENDPOINT=https://your-s3-endpoint.com
|
||||||
|
CMS_STORAGE_ACCESS_KEY_ID=your-access-key-id
|
||||||
|
CMS_STORAGE_SECRET_ACCESS_KEY=your-secret-access-key
|
||||||
|
CMS_STORAGE_REGION=ap-southeast-1
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
name: Nix Build & Deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ['develop']
|
|
||||||
pull_request:
|
|
||||||
branches: ['develop']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
detect:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
affected: ${{ steps.affected.outputs.apps }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci --legacy-peer-deps
|
|
||||||
|
|
||||||
- name: Detect affected apps
|
|
||||||
id: affected
|
|
||||||
run: |
|
|
||||||
AFFECTED=$(npx nx show projects --affected --base=HEAD~1 --type=app 2>/dev/null | tr '\n' ' ')
|
|
||||||
echo "Affected apps: $AFFECTED"
|
|
||||||
APPS="[]"
|
|
||||||
for app in landing backoffice gacha dimentorin hackathon infra qrcampaign; do
|
|
||||||
if echo "$AFFECTED" | grep -qw "$app"; then
|
|
||||||
APPS=$(echo "$APPS" | jq -c ". + [\"$app\"]")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "apps=$APPS" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Matrix: $APPS"
|
|
||||||
|
|
||||||
build:
|
|
||||||
needs: detect
|
|
||||||
if: needs.detect.outputs.affected != '[]'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
app: ${{ fromJson(needs.detect.outputs.affected) }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Nix
|
|
||||||
uses: DeterminateSystems/nix-installer-action@main
|
|
||||||
|
|
||||||
- name: Setup Cachix
|
|
||||||
uses: cachix/cachix-action@v15
|
|
||||||
with:
|
|
||||||
name: msdqn
|
|
||||||
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
|
||||||
|
|
||||||
- name: Build ${{ matrix.app }}
|
|
||||||
run: nix build .#${{ matrix.app }} -o result-${{ matrix.app }}
|
|
||||||
|
|
||||||
- name: Push to Cachix
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
|
|
||||||
run: cachix push msdqn result-${{ matrix.app }}
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
needs: build
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Install Nix
|
|
||||||
uses: DeterminateSystems/nix-installer-action@main
|
|
||||||
|
|
||||||
- name: Setup SSH
|
|
||||||
env:
|
|
||||||
INFRA_DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "${INFRA_DEPLOY_KEY}" > ~/.ssh/deploy_key
|
|
||||||
chmod 600 ~/.ssh/deploy_key
|
|
||||||
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
ssh-keyscan 167.235.70.37 >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
|
|
||||||
- name: Update infra flake.lock
|
|
||||||
run: |
|
|
||||||
export GIT_SSH_COMMAND="ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes"
|
|
||||||
git clone git@github.com:IMPHNEN/imphnen-infrastructure.git /tmp/infra
|
|
||||||
cd /tmp/infra
|
|
||||||
nix flake update imphnen-frontend
|
|
||||||
if git diff --quiet flake.lock; then
|
|
||||||
echo "flake.lock unchanged, skipping"
|
|
||||||
else
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add flake.lock
|
|
||||||
git commit -m "chore: update imphnen-frontend-service to ${GITHUB_SHA::7}"
|
|
||||||
git push
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Deploy to server
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
ssh -i ~/.ssh/deploy_key -o ConnectTimeout=15 -o StrictHostKeyChecking=accept-new root@167.235.70.37 \
|
|
||||||
'nixos-rebuild switch --flake github:IMPHNEN/imphnen-infrastructure#hetzner --refresh 2>&1 | tail -30'
|
|
||||||
|
|
||||||
- name: Cleanup
|
|
||||||
if: always()
|
|
||||||
run: rm -f ~/.ssh/deploy_key
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Test and Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['develop']
|
||||||
|
pull_request:
|
||||||
|
branches: ['develop']
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: 22.14.0
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use Node.js ${{ env.NODE_VERSION }}
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set env vars
|
||||||
|
run: |
|
||||||
|
echo "CMS_SECRET=${{ secrets.CMS_SECRET }}" >> $GITHUB_ENV
|
||||||
|
echo "CMS_STORAGE_REGION=${{ secrets.CMS_STORAGE_REGION }}" >> $GITHUB_ENV
|
||||||
|
echo "CMS_STORAGE_ENDPOINT=${{ secrets.CMS_STORAGE_ENDPOINT }}" >> $GITHUB_ENV
|
||||||
|
echo "CMS_STORAGE_ACCESS_KEY_ID=${{ secrets.CMS_STORAGE_ACCESS_KEY_ID }}" >> $GITHUB_ENV
|
||||||
|
echo "CMS_STORAGE_SECRET_ACCESS_KEY=${{ secrets.CMS_STORAGE_SECRET_ACCESS_KEY }}" >> $GITHUB_ENV
|
||||||
|
echo "CMS_POSTGRES_URL=${{ secrets.CMS_POSTGRES_URL }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Nx Build
|
||||||
|
run: npx nx run-many --target=build --all
|
||||||
+1
-19
@@ -4,7 +4,7 @@
|
|||||||
dist
|
dist
|
||||||
tmp
|
tmp
|
||||||
out-tsc
|
out-tsc
|
||||||
docs/FigmaImage/
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
@@ -62,21 +62,3 @@ 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
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# path to a directory with all packages
|
||||||
|
storage: ../tmp/local-registry/storage
|
||||||
|
|
||||||
|
# a list of other known repositories we can talk to
|
||||||
|
uplinks:
|
||||||
|
npmjs:
|
||||||
|
url: https://registry.npmjs.org/
|
||||||
|
maxage: 60m
|
||||||
|
|
||||||
|
packages:
|
||||||
|
'**':
|
||||||
|
# give all users (including non-authenticated users) full access
|
||||||
|
# because it is a local registry
|
||||||
|
access: $all
|
||||||
|
publish: $all
|
||||||
|
unpublish: $all
|
||||||
|
|
||||||
|
# if package is not available locally, proxy requests to npm registry
|
||||||
|
proxy: npmjs
|
||||||
|
|
||||||
|
# log settings
|
||||||
|
log:
|
||||||
|
type: stdout
|
||||||
|
format: pretty
|
||||||
|
level: warn
|
||||||
|
|
||||||
|
publish:
|
||||||
|
allow_offline: true # set offline to true to allow publish offline
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"nrwl.angular-console",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"ms-playwright.playwright"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# 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,117 +4,114 @@
|
|||||||
<img src="docs/logo.svg" alt="IMPHNEN">
|
<img src="docs/logo.svg" alt="IMPHNEN">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Monorepo for all frontend services of [IMPHNEN](https://imphnen.dev) (Ingin Menjadi Programmer Handal Namun Enggan Ngoding) — Indonesia's largest programmer community.
|
This repository is a **monorepo** for all frontend services of IMPHNEN. The monorepo includes three main applications:
|
||||||
|
|
||||||
## Apps
|
1. **Gacha** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">gacha website</a>.
|
||||||
|
2. **Backoffice** - Application for internal management.
|
||||||
|
3. **Dimentorin** - Application for mentoring services.
|
||||||
|
|
||||||
| App | Framework | URL |
|
## How to install
|
||||||
|-----|-----------|-----|
|
|
||||||
| **Landing** | Next.js 16 | [imphnen.dev](https://imphnen.dev) |
|
|
||||||
| **Backoffice** | Vite + React | [backoffice.imphnen.dev](https://backoffice.imphnen.dev) |
|
|
||||||
| **Hackathon** | Vite + React | [hackathon.imphnen.dev](https://dimentorin.imphnen.dev) |
|
|
||||||
| **Dimentorin** | Vite + React | [dimentorin.imphnen.dev](https://dimentorin.imphnen.dev) |
|
|
||||||
| **Gacha** | Vite + React | [gacha.imphnen.dev](https://gacha.imphnen.dev) |
|
|
||||||
| **QR Campaign** | Vite + React | [qr.imphnen.dev](https://qr.imphnen.dev) |
|
|
||||||
| **Infra** | Vite + React | [infra.imphnen.dev](https://infra.imphnen.dev) |
|
|
||||||
|
|
||||||
## Shared Libraries
|
1. Clone this repository:
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git
|
||||||
|
cd imphnen-frontend-service
|
||||||
|
```
|
||||||
|
2. Install all dependencies:
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
| Lib | Purpose |
|
## How to run
|
||||||
|-----|---------|
|
|
||||||
| `utils` | Pure utilities — `cn()`, `For`, `Show`, `useQueryState`, `useModalLogin` |
|
|
||||||
| `service` | Business logic — API clients, auth hooks, storage, constants |
|
|
||||||
| `ui` | UI components — atoms, molecules, organisms (atomic design) |
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js 22
|
|
||||||
- [Nix](https://nixos.org/download/) (optional, for reproducible builds)
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git
|
|
||||||
cd imphnen-frontend-service
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
Or with Nix:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
nix develop # enters dev shell with node 22, bun, git, jq
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
Copy `.env.example` to `.env` in the app directory:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cp apps/<app>/.env.example apps/<app>/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
```sh
|
Use the following commands to run in development mode:
|
||||||
nx dev <app> # e.g. nx dev landing, nx dev backoffice
|
|
||||||
```
|
- **Gacha**:
|
||||||
|
```sh
|
||||||
|
npm run gacha:dev
|
||||||
|
```
|
||||||
|
- **Backoffice**:
|
||||||
|
```sh
|
||||||
|
npm run backoffice:dev
|
||||||
|
```
|
||||||
|
- **Dimentorin**:
|
||||||
|
```sh
|
||||||
|
npm run dimentorin:dev
|
||||||
|
```
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
```sh
|
Use the following commands to build the applications:
|
||||||
nx build <app> # build single app
|
|
||||||
nx run-many -t build --all # build everything
|
|
||||||
nx affected -t build # build only what changed
|
|
||||||
```
|
|
||||||
|
|
||||||
### Nix Build
|
- **Gacha**:
|
||||||
|
```sh
|
||||||
|
npm run gacha:build
|
||||||
|
```
|
||||||
|
- **Backoffice**:
|
||||||
|
```sh
|
||||||
|
npm run backoffice:build
|
||||||
|
```
|
||||||
|
- **Dimentorin**:
|
||||||
|
```sh
|
||||||
|
npm run dimentorin:build
|
||||||
|
```
|
||||||
|
|
||||||
```sh
|
### Production
|
||||||
nix build .#<app> # e.g. nix build .#landing, .#dimentorin
|
|
||||||
```
|
|
||||||
|
|
||||||
All Nix config lives in `flake.nix`. When `package-lock.json` changes, update `npmDepsHash` using `lib.fakeHash`.
|
Use the following commands to run the applications in production mode:
|
||||||
|
|
||||||
### Testing
|
- **Gacha**:
|
||||||
|
```sh
|
||||||
```sh
|
npm run gacha:prod
|
||||||
nx test <project> # unit tests (vitest)
|
```
|
||||||
nx e2e <app>-e2e # e2e tests (playwright)
|
- **Backoffice**:
|
||||||
nx lint <project> # eslint
|
```sh
|
||||||
```
|
npm run backoffice:prod
|
||||||
|
```
|
||||||
|
- **Dimentorin**:
|
||||||
|
```sh
|
||||||
|
npm run dimentorin:prod
|
||||||
|
```
|
||||||
|
|
||||||
### Storybook
|
### Storybook
|
||||||
|
|
||||||
```sh
|
This repository uses Storybook to develop, test, and document UI components in an isolated and interactive environment. Below are the commands to work with Storybook:
|
||||||
nx storybook ui # run storybook for ui lib
|
|
||||||
nx build-storybook ui # build static storybook
|
|
||||||
```
|
|
||||||
|
|
||||||
## CI/CD
|
- **Run Storybook**
|
||||||
|
|
||||||
GitHub Actions pipeline (`.github/workflows/nix-build.yml`):
|
This command starts Storybook in development mode, allowing you to view and test UI components interactively.
|
||||||
|
|
||||||
1. **detect** — uses `nx affected` to find changed apps
|
```sh
|
||||||
2. **build** — matrix strategy builds only affected apps with Nix, pushes to [Cachix](https://app.cachix.org/cache/msdqn)
|
npm run ui:storybook
|
||||||
3. **deploy** — updates `flake.lock` in [imphnen-infrastructure](https://github.com/IMPHNEN/imphnen-infrastructure) and deploys to the server using [clan](https://clan.lol)
|
```
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Monorepo**: Nx 22.6
|
- **Run Unit Test**
|
||||||
- **Frontend**: React 19, TypeScript
|
|
||||||
- **Styling**: Tailwind CSS v4, CVA
|
|
||||||
- **State**: Zustand, TanStack React Query
|
|
||||||
- **Forms**: react-hook-form + zod
|
|
||||||
- **Build**: Nix flakes, Cachix
|
|
||||||
- **CI**: GitHub Actions
|
|
||||||
|
|
||||||
## Contributing
|
This command runs unit tests for the UI components to ensure they function as expected.
|
||||||
|
|
||||||
1. Fork and clone the repository
|
```sh
|
||||||
2. Create a branch: `git checkout -b feat/feature-name`
|
npm run ui:test
|
||||||
3. Make changes, commit, and push
|
```
|
||||||
4. Open a pull request to the `develop` branch
|
|
||||||
|
|
||||||
Issues and feedback welcome via [GitHub Issues](https://github.com/IMPHNEN/imphnen-frontend-service/issues).
|
- **Build Components**
|
||||||
|
|
||||||
|
This command generates a static build of Storybook, which can be deployed for sharing and documentation purposes.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run ui:build
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to contribute
|
||||||
|
|
||||||
|
1. Fork the repository and clone it locally.
|
||||||
|
2. Create a new branch for a new feature or fix:
|
||||||
|
```sh
|
||||||
|
git checkout -b feat/feature-name
|
||||||
|
```
|
||||||
|
3. Make changes, commit, and push to your forked repository.
|
||||||
|
4. Create a pull request to this repository `develop` branch.
|
||||||
|
|
||||||
|
If you encounter any issues or problems, feel free to create a new Issue.
|
||||||
|
|||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
## Supported Versions
|
|
||||||
|
|
||||||
Use this section to tell people about which versions of your project are
|
|
||||||
currently being supported with security updates.
|
|
||||||
|
|
||||||
| Version | Supported |
|
|
||||||
| ------- | ------------------ |
|
|
||||||
| 5.1.x | :white_check_mark: |
|
|
||||||
| 5.0.x | :x: |
|
|
||||||
| 4.0.x | :white_check_mark: |
|
|
||||||
| < 4.0 | :x: |
|
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
|
||||||
|
|
||||||
Use this section to tell people how to report a vulnerability.
|
|
||||||
|
|
||||||
Tell them where to go, how often they can expect to get an update on a
|
|
||||||
reported vulnerability, what to expect if the vulnerability is accepted or
|
|
||||||
declined, etc.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
VITE_API_URL=
|
|
||||||
VITE_GITHUB_CLIENT_ID=
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-theme="light">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Backoffice</title>
|
<title>Backoffice</title>
|
||||||
@@ -7,9 +7,10 @@
|
|||||||
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
|
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
|
||||||
|
<link rel="stylesheet" href="/src/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"><div style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#f9fafb"><div style="width:40px;height:40px;border:3px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:spin .8s linear infinite"></div></div><style>@keyframes spin{to{transform:rotate(360deg)}}</style></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import {
|
||||||
|
authLoginSchema,
|
||||||
|
TLoginRequest,
|
||||||
|
usePostLogin,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export const useLogin = () => {
|
||||||
|
const postLogin = usePostLogin();
|
||||||
|
const form = useForm<TLoginRequest>({
|
||||||
|
resolver: zodResolver(authLoginSchema),
|
||||||
|
mode: 'all',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit((data) => {
|
||||||
|
postLogin.mutate(data, {
|
||||||
|
onSuccess: () => toast.success("Login sukses"),
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
onSubmit,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface IModalEditAccount {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleEditAccount?: () => void;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalEditAccount = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
currentStep,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
resetStep,
|
||||||
|
handleEditAccount,
|
||||||
|
}: IModalEditAccount) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<StepTwo
|
||||||
|
onClose={onClose}
|
||||||
|
handleEditAccount={handleEditAccount}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepOneProps {
|
||||||
|
nextStep: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||||
|
const [fullName, setFullName] = useState('Ahmad Wiyana');
|
||||||
|
const [email, setEmail] = useState('fullname23@gmail.com');
|
||||||
|
const [phoneNumber, setPhoneNumber] = useState('081904423804');
|
||||||
|
const [address, setAddress] = useState('Jl. Pantai Cibaduyut Indah');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header>
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Edit Data Akun
|
||||||
|
</h2>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<InputField
|
||||||
|
label="Nama Lengkap"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Nama Lengkap"
|
||||||
|
value={fullName}
|
||||||
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Email"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Nomor Telepon"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Nomor Telepon"
|
||||||
|
value={phoneNumber}
|
||||||
|
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Alamat"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Alamat"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={nextStep}
|
||||||
|
>
|
||||||
|
Perbarui Data
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepTwoProps {
|
||||||
|
onClose: () => void;
|
||||||
|
handleEditAccount?: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepTwo = ({ onClose, handleEditAccount, resetStep }: IStepTwoProps) => (
|
||||||
|
<>
|
||||||
|
<Modal.Header className="mb-0 text-center items-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Update Data
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin dengan
|
||||||
|
<br /> perubahan yang dilakukan?
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
handleEditAccount && handleEditAccount();
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ModalEditAccount;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||||
|
import {
|
||||||
|
FilterOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
useReactTable,
|
||||||
|
RowSelectionState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import ModalEditAccount from './_components/modal-edit-account';
|
||||||
|
import { useQueryState } from '../../hook/use-query-state';
|
||||||
|
|
||||||
|
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)} />
|
||||||
|
</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;
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { useConfirmItem, useItem } from '../_hook/use-item';
|
||||||
|
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
|
interface IModalAddItem {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleAddItem?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalAddItem = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
currentStep,
|
||||||
|
nextStep,
|
||||||
|
resetStep,
|
||||||
|
handleAddItem,
|
||||||
|
}: IModalAddItem) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<StepTwo
|
||||||
|
onClose={onClose}
|
||||||
|
handleAddItem={handleAddItem}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepOneProps {
|
||||||
|
nextStep: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||||
|
const { form, onSubmit } = useItem(nextStep);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header>
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Tambah Item Gacha
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Lengkapi detail di bawah ini untuk menambahkan item gacha
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Nama Hadiah"
|
||||||
|
name="itemName"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Nama Hadiah"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Quantity"
|
||||||
|
name="quantity"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="Masukkan Kuantitas Item"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Foto Barang"
|
||||||
|
type="file"
|
||||||
|
name="foto"
|
||||||
|
placeholder=".jpg, .jpeg, atau .png"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={nextStep}
|
||||||
|
>
|
||||||
|
Tambahkan Item
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepTwoProps {
|
||||||
|
onClose: () => void;
|
||||||
|
handleAddItem?: () => Promise<boolean>;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => {
|
||||||
|
const { onConfirm, onCancel } = useConfirmItem(
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleAddItem,
|
||||||
|
{
|
||||||
|
success: 'Item ditambahkan ke gacha item',
|
||||||
|
error: 'Item gagal ditambahkan ke gacha item',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header className="mb-0 text-center items-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Tambah Item
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin ingin
|
||||||
|
<br /> menambahkan item ini?
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Tambahkan
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalAddItem;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
|
||||||
|
interface IModalDeleteItem {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleDeleteItem?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalDeleteItem = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
handleDeleteItem,
|
||||||
|
}: IModalDeleteItem) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
closeButtonClassName="hidden"
|
||||||
|
>
|
||||||
|
<Modal.Header className="gap-8">
|
||||||
|
<img
|
||||||
|
src="/chibi-delete.webp"
|
||||||
|
alt="Delete item?"
|
||||||
|
width={148}
|
||||||
|
className="self-center"
|
||||||
|
/>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
|
||||||
|
Delete Item
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin untuk menghapus item ini?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Batal Hapus
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteItem && handleDeleteItem();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hapus Item
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalDeleteItem;
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { useConfirmItem, useItem } from '../_hook/use-item';
|
||||||
|
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
|
interface IModalEditItem {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleEditItem?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalEditItem = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
currentStep,
|
||||||
|
nextStep,
|
||||||
|
resetStep,
|
||||||
|
handleEditItem,
|
||||||
|
}: IModalEditItem) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<StepTwo
|
||||||
|
onClose={onClose}
|
||||||
|
handleEditItem={handleEditItem}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepOneProps {
|
||||||
|
nextStep: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||||
|
const initialValues = {
|
||||||
|
itemName: 'Hoodie IMPHNEN Official 2025',
|
||||||
|
quantity: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { form, onSubmit } = useItem(nextStep, initialValues);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header>
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Edit Item Gacha
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Silakan mengubah detail dari item yang diperlukan
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex flex-col gap-8">
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Nama Hadiah"
|
||||||
|
type="text"
|
||||||
|
name="itemName"
|
||||||
|
placeholder="Masukkan Nama Hadiah"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Quantity"
|
||||||
|
type="number"
|
||||||
|
name="quantity"
|
||||||
|
placeholder="Masukkan Kuantitas Item"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Foto Barang"
|
||||||
|
type="file"
|
||||||
|
name="foto"
|
||||||
|
placeholder=".jpg, .jpeg, atau .png"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={nextStep}
|
||||||
|
>
|
||||||
|
Perbarui Item
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepTwoProps {
|
||||||
|
onClose: () => void;
|
||||||
|
handleEditItem?: () => Promise<boolean>;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepTwo = ({ onClose, handleEditItem, resetStep }: IStepTwoProps) => {
|
||||||
|
const { onConfirm, onCancel } = useConfirmItem(
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleEditItem,
|
||||||
|
{
|
||||||
|
success: 'Perubahan item berhasil dilakukan',
|
||||||
|
error: 'Perubahan item gagal dilakukan',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header className="mb-0 text-center items-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Update Item
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin dengan
|
||||||
|
<br /> perubahan yang dilakukan?
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalEditItem;
|
||||||
+5
-7
@@ -8,17 +8,16 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
export const useItem = (
|
export const useItem = (
|
||||||
nextStep: () => void,
|
nextStep: () => void,
|
||||||
initialValues?: Partial<TGachaItem>,
|
initialValues?: TGachaItem
|
||||||
onDataCapture?: (data: any) => void,
|
|
||||||
) => {
|
) => {
|
||||||
const form = useForm<any>({
|
const form = useForm<TGachaItem>({
|
||||||
resolver: zodResolver(gachaItemSchema),
|
resolver: zodResolver(gachaItemSchema),
|
||||||
mode: 'all',
|
mode: 'all',
|
||||||
defaultValues: initialValues,
|
defaultValues: initialValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => {
|
const onSubmit = form.handleSubmit((data) => {
|
||||||
onDataCapture?.(data);
|
console.log('Form data:', data);
|
||||||
nextStep();
|
nextStep();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,9 +38,8 @@ export const useConfirmItem = (
|
|||||||
) => {
|
) => {
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
if (actionFunction) {
|
// const result = await actionFunction?.();
|
||||||
await actionFunction();
|
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||||
}
|
|
||||||
toast.success(messages?.success);
|
toast.success(messages?.success);
|
||||||
onClose();
|
onClose();
|
||||||
resetStep();
|
resetStep();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
UsergroupAddOutlined,
|
||||||
|
UsergroupDeleteOutlined,
|
||||||
|
UserSwitchOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||||
|
import ModalAddItem from './_components/modal-add-item';
|
||||||
|
import ModalEditItem from './_components/modal-edit-item';
|
||||||
|
import ModalDeleteItem from './_components/modal-delete-item';
|
||||||
|
import { useQueryState } from '../../hook/use-query-state';
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
const [showModalAddItem, setShowModalAddItem] = useState(false);
|
||||||
|
const [showModalEditItem, setShowModalEditItem] = useState(false);
|
||||||
|
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
step: currentStep,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
resetStep,
|
||||||
|
} = useQueryState('step', {
|
||||||
|
defaultValue: 1,
|
||||||
|
maxValue: 2,
|
||||||
|
minValue: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||||
|
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||||
|
<h1 className="text-p2 font-semibold">Dashboard</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex justify-between gap-[40px] p-8 bg-white rounded-md">
|
||||||
|
<div className="w-full flex flex-col gap-[40px]">
|
||||||
|
<section>
|
||||||
|
<h2 className="text-p2 font-medium text-primary-500 mb-8">
|
||||||
|
Summary
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||||
|
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||||
|
<UsergroupAddOutlined className="text-[20px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-p1 font-semibold">1000</h3>
|
||||||
|
<p className="text-label1 text-neutral-500">Participants</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||||
|
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||||
|
<ReloadOutlined className="text-[20px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-p1 font-semibold">1000</h3>
|
||||||
|
<p className="text-label1 text-neutral-500">
|
||||||
|
Roll and Reroll
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||||
|
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||||
|
<UserSwitchOutlined className="text-[20px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-p1 font-semibold">1000</h3>
|
||||||
|
<p className="text-label1 text-neutral-500">Redeem</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||||
|
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||||
|
<UsergroupDeleteOutlined className="text-[20px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-p1 font-semibold">1000</h3>
|
||||||
|
<p className="text-label1 text-neutral-500">
|
||||||
|
Inactive Users
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-8">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-p2 font-medium text-primary-500">
|
||||||
|
Gacha Items
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="items-end gap-3"
|
||||||
|
onClick={() => setShowModalAddItem(true)}
|
||||||
|
>
|
||||||
|
<span>Tambah Item</span>
|
||||||
|
<PlusOutlined className="text-[16px]" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 max-h-140 overflow-auto">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((item) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className="bg-white overflow-clip rounded-lg shadow-sm flex justify-between border border-neutral-100"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col py-4 px-6 gap-[8px]">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-p3 text-primary-500 font-medium">
|
||||||
|
Lanyard IMPHNEN
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center justify-start gap-10 text-label2 text-gray-500 mt-1">
|
||||||
|
<span>Prize {item}</span>
|
||||||
|
<span>Quantity: 10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-start gap-2">
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent hover:text-primary-500"
|
||||||
|
onClick={() => setShowModalEditItem(true)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
className="text-[10px] text-red-500 p-0 font-normal hover:bg-transparent hover:text-red-700"
|
||||||
|
onClick={() => setShowModalDeleteItem(true)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img src="gacha-clip.webp" alt="Lanyard IMPHNEN" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right-side illustration */}
|
||||||
|
<img
|
||||||
|
src="gacha.webp"
|
||||||
|
alt=""
|
||||||
|
className="rounded-lg hidden xl:block xl:min-w-[436px] h-auto object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Modal Add Item */}
|
||||||
|
<ModalAddItem
|
||||||
|
currentStep={currentStep}
|
||||||
|
isOpen={showModalAddItem}
|
||||||
|
onClose={() => setShowModalAddItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Edit Item */}
|
||||||
|
<ModalEditItem
|
||||||
|
currentStep={currentStep}
|
||||||
|
isOpen={showModalEditItem}
|
||||||
|
onClose={() => setShowModalEditItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Delete Item */}
|
||||||
|
<ModalDeleteItem
|
||||||
|
isOpen={showModalDeleteItem}
|
||||||
|
onClose={() => setShowModalDeleteItem(false)}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { useItem, useConfirmItem } from '../_hook/use-item';
|
||||||
|
|
||||||
|
interface IModalAddItem {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleAddItem?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalAddItem = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
currentStep,
|
||||||
|
nextStep,
|
||||||
|
resetStep,
|
||||||
|
handleAddItem,
|
||||||
|
}: IModalAddItem) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<StepTwo
|
||||||
|
onClose={onClose}
|
||||||
|
handleAddItem={handleAddItem}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepOneProps {
|
||||||
|
nextStep: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||||
|
const { form, onSubmit } = useItem(nextStep);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header>
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Tambah Item Roll Gacha
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Lengkapi detal di bawah ini, untuk menambahkan item gacha
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Pilih Item"
|
||||||
|
name="itemName"
|
||||||
|
type="text"
|
||||||
|
placeholder="Pilih item yang dimasukkan ke roll"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Quantity"
|
||||||
|
name="quantity"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="Masukkan Kuantitas Item"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Chance Rate"
|
||||||
|
name="chanceRate"
|
||||||
|
type="number"
|
||||||
|
value={0.1}
|
||||||
|
min={0.1}
|
||||||
|
step={0.1}
|
||||||
|
max={1}
|
||||||
|
placeholder="Masukkan Chance Rate (0,1 - 1)"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="primary" size="lg" className="w-full" type="submit">
|
||||||
|
Tambahkan Item
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepTwoProps {
|
||||||
|
onClose: () => void;
|
||||||
|
handleAddItem?: () => Promise<boolean>;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => {
|
||||||
|
const { onConfirm, onCancel } = useConfirmItem(
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleAddItem,
|
||||||
|
{
|
||||||
|
success: 'Item ditambahkan ke roll gacha',
|
||||||
|
error: 'Item gagal ditambahkan ke roll gacha',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header className="mb-0 text-center items-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Tambah ke Roll Gacha
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin ingin
|
||||||
|
<br /> menambahkan item ini ke roll gacha?
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Tambahkan
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalAddItem;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
|
||||||
|
interface IModalDeleteItem {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleDeleteItem?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalDeleteItem = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
handleDeleteItem,
|
||||||
|
}: IModalDeleteItem) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
closeButtonClassName="hidden"
|
||||||
|
>
|
||||||
|
<Modal.Header className="gap-8">
|
||||||
|
<img
|
||||||
|
src="/chibi-delete.webp"
|
||||||
|
alt="Delete item?"
|
||||||
|
width={148}
|
||||||
|
className="self-center"
|
||||||
|
/>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
|
||||||
|
Delete Item
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin untuk menghapus item ini?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Batal Hapus
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteItem && handleDeleteItem();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hapus Item
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalDeleteItem;
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { useConfirmItem, useItem } from '../_hook/use-item';
|
||||||
|
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
|
interface IModalUpdateItem {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleUpdateItem?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalUpdateItem = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
currentStep,
|
||||||
|
nextStep,
|
||||||
|
resetStep,
|
||||||
|
handleUpdateItem,
|
||||||
|
}: IModalUpdateItem) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<StepTwo
|
||||||
|
onClose={onClose}
|
||||||
|
handleUpdateItem={handleUpdateItem}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepOneProps {
|
||||||
|
nextStep: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||||
|
const initialValues = {
|
||||||
|
itemName: 'Hoodie IMPHNEN Official 2025',
|
||||||
|
quantity: 10,
|
||||||
|
chanceRate: 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { form, onSubmit } = useItem(nextStep, initialValues);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header>
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Update Item Roll Gacha
|
||||||
|
</h2>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Pilih Item"
|
||||||
|
name="itemName"
|
||||||
|
type="text"
|
||||||
|
placeholder="Pilih item yang dimasukkan ke roll"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Quantity"
|
||||||
|
name="quantity"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="Masukkan Kuantitas Item"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Chance Rate"
|
||||||
|
name="chanceRate"
|
||||||
|
type="number"
|
||||||
|
value={0.1}
|
||||||
|
min={0.1}
|
||||||
|
step={0.1}
|
||||||
|
max={1}
|
||||||
|
placeholder="Masukkan Chance Rate (0,1 - 1)"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={nextStep}
|
||||||
|
>
|
||||||
|
Perbarui Item
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepTwoProps {
|
||||||
|
onClose: () => void;
|
||||||
|
handleUpdateItem?: () => Promise<boolean>;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepTwo = ({ onClose, handleUpdateItem, resetStep }: IStepTwoProps) => {
|
||||||
|
const { onConfirm, onCancel } = useConfirmItem(
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleUpdateItem,
|
||||||
|
{
|
||||||
|
success: 'Perubahan item roll berhasil dilakukan',
|
||||||
|
error: 'Perubahan item roll gagal dilakukan',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header className="mb-0 text-center items-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Update Item
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin dengan
|
||||||
|
<br /> perubahan yang dilakukan?
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalUpdateItem;
|
||||||
+5
-7
@@ -8,17 +8,16 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
export const useItem = (
|
export const useItem = (
|
||||||
nextStep: () => void,
|
nextStep: () => void,
|
||||||
initialValues?: Partial<TGachaRollItem>,
|
initialValues?: TGachaRollItem
|
||||||
onDataCapture?: (data: any) => void,
|
|
||||||
) => {
|
) => {
|
||||||
const form = useForm<any>({
|
const form = useForm<TGachaRollItem>({
|
||||||
resolver: zodResolver(gachaRollItemSchema),
|
resolver: zodResolver(gachaRollItemSchema),
|
||||||
mode: 'all',
|
mode: 'all',
|
||||||
defaultValues: initialValues,
|
defaultValues: initialValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => {
|
const onSubmit = form.handleSubmit((data) => {
|
||||||
onDataCapture?.(data);
|
console.log('Form data:', data);
|
||||||
nextStep();
|
nextStep();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,9 +38,8 @@ export const useConfirmItem = (
|
|||||||
) => {
|
) => {
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
if (actionFunction) {
|
// const result = await actionFunction?.();
|
||||||
await actionFunction();
|
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||||
}
|
|
||||||
toast.success(messages?.success);
|
toast.success(messages?.success);
|
||||||
onClose();
|
onClose();
|
||||||
resetStep();
|
resetStep();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
useReactTable,
|
||||||
|
RowSelectionState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import ModalAddItem from './_components/modal-add-item';
|
||||||
|
import ModalUpdateItem from './_components/modal-update-item';
|
||||||
|
import ModalDeleteItem from './_components/modal-delete-item';
|
||||||
|
import { useQueryState } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
interface GachaItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
chanceRate: number;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: GachaItem[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: 'Hoodie IMPHNEN Official 2025',
|
||||||
|
chanceRate: 0.1,
|
||||||
|
quantity: 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
const [showModalAddItem, setShowModalAddItem] = useState(false);
|
||||||
|
const [showModalUpdateItem, setShowModalUpdateItem] = useState(false);
|
||||||
|
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
step: currentStep,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
resetStep,
|
||||||
|
} = useQueryState('step', {
|
||||||
|
defaultValue: 1,
|
||||||
|
maxValue: 2,
|
||||||
|
minValue: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
||||||
|
|
||||||
|
const columns: ColumnDef<GachaItem>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'No',
|
||||||
|
accessorKey: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Nama Item',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Chance Rate',
|
||||||
|
accessorKey: 'chanceRate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Quantity',
|
||||||
|
accessorKey: 'quantity',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: () => (
|
||||||
|
<div className="flex gap-[8px]">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowModalUpdateItem(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<EditOutlined /> Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowModalDeleteItem(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<DeleteOutlined /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||||
|
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||||
|
<h1 className="text-p2 font-semibold">Gacha Roll</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||||
|
<div className="flex justify-between items-center gap-8 mb-2">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Cari berdasarkan nama item"
|
||||||
|
className="pl-12 w-full max-h-full"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||||
|
<SearchOutlined />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="flex gap-3 text-nowrap"
|
||||||
|
onClick={() => {
|
||||||
|
setShowModalAddItem(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
Tambah Item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<ModalAddItem
|
||||||
|
currentStep={currentStep}
|
||||||
|
isOpen={showModalAddItem}
|
||||||
|
onClose={() => setShowModalAddItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
<ModalUpdateItem
|
||||||
|
currentStep={currentStep}
|
||||||
|
isOpen={showModalUpdateItem}
|
||||||
|
onClose={() => setShowModalUpdateItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
<ModalDeleteItem
|
||||||
|
isOpen={showModalDeleteItem}
|
||||||
|
onClose={() => setShowModalDeleteItem(false)}
|
||||||
|
handleDeleteItem={() => {
|
||||||
|
console.log('Item deleted');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { FC, ReactElement } from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const AppLayout: FC = (): ReactElement => {
|
||||||
|
return (
|
||||||
|
<main className="bg-primary-50 min-h-screen">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppLayout;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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 type="submit" size="md" className="w-full">
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { useItem, useConfirmItem } from '../_hook/use-item';
|
||||||
|
|
||||||
|
interface IModalAddPermission {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleAddItem?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalAddPermission = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
currentStep,
|
||||||
|
nextStep,
|
||||||
|
resetStep,
|
||||||
|
handleAddItem,
|
||||||
|
}: IModalAddPermission) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-3 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<StepTwo
|
||||||
|
onClose={onClose}
|
||||||
|
handleAddItem={handleAddItem}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepOneProps {
|
||||||
|
nextStep: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||||
|
const { form, onSubmit } = useItem(nextStep);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header>
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500">
|
||||||
|
Tambah Permissions
|
||||||
|
</h2>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nama Permission"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button variant="primary" size="lg" className="w-full" type="submit">
|
||||||
|
Tambah Permission
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepTwoProps {
|
||||||
|
onClose: () => void;
|
||||||
|
handleAddItem?: () => Promise<boolean>;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => {
|
||||||
|
const { onConfirm, onCancel } = useConfirmItem(
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleAddItem,
|
||||||
|
{
|
||||||
|
success: 'Data permissions berhasil ditambahkan',
|
||||||
|
error: 'Data permissions gagal ditambahkan',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header className="mb-7 text-center items-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Tambah Permissions
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-center text-neutral-400">
|
||||||
|
Apakah kamu yakin ingin
|
||||||
|
<br /> menambahkan permission ini?
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex mb-0 gap-4">
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Tambahkan
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalAddPermission;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { useConfirmItem } from '../_hook/use-item';
|
||||||
|
|
||||||
|
interface IModalDeletePermission {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleDelete?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalDeletePermission = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleDelete,
|
||||||
|
}: IModalDeletePermission) => {
|
||||||
|
const { onConfirm } = useConfirmItem(onClose, resetStep, handleDelete, {
|
||||||
|
success: 'Data permissions berhasil dihapus',
|
||||||
|
error: 'Data permissions gagal dihapus',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
closeButtonClassName="hidden"
|
||||||
|
>
|
||||||
|
<Modal.Header className="gap-8">
|
||||||
|
<img
|
||||||
|
src="/chibi-delete.webp"
|
||||||
|
alt="Delete?"
|
||||||
|
width={148}
|
||||||
|
className="self-center"
|
||||||
|
/>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
|
||||||
|
Delete Permissions
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin untuk menghapus permission ini? Menghapus data ini
|
||||||
|
mungkin akan mempengaruhi fungsional sistem
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Batal Hapus
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full bg-danger-500 hover:bg-danger-600"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Hapus Item
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalDeletePermission;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { useConfirmItem } from '../_hook/use-item';
|
||||||
|
|
||||||
|
interface IModalUpdatePermission {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleUpdate?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalUpdatePermission = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleUpdate,
|
||||||
|
}: IModalUpdatePermission) => {
|
||||||
|
const { onConfirm } = useConfirmItem(onClose, resetStep, handleUpdate, {
|
||||||
|
success: 'Perubahan permissions berhasil dilakukan',
|
||||||
|
error: 'Perubahan permissions gagal dilakukan',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-3 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
<Modal.Header>
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500">
|
||||||
|
Update Permissions
|
||||||
|
</h2>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex flex-col gap-8">
|
||||||
|
<InputField
|
||||||
|
label="Name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nama Permission"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Update Permission
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalUpdatePermission;
|
||||||
+10
-6
@@ -1,18 +1,23 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
// import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
// import {
|
||||||
|
// gachaRollItemSchema,
|
||||||
|
// TGachaRollItem
|
||||||
|
// } from '@imphnen-frontend-service/service';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export const useItem = (
|
export const useItem = (
|
||||||
nextStep: () => void,
|
nextStep: () => void,
|
||||||
initialValues?: any,
|
initialValues?: any
|
||||||
onDataCapture?: (data: any) => void,
|
|
||||||
) => {
|
) => {
|
||||||
const form = useForm<any>({
|
const form = useForm<any>({
|
||||||
|
// resolver: zodResolver(),
|
||||||
mode: 'all',
|
mode: 'all',
|
||||||
defaultValues: initialValues,
|
defaultValues: initialValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => {
|
const onSubmit = form.handleSubmit((data) => {
|
||||||
onDataCapture?.(data);
|
console.log('Form data:', data);
|
||||||
nextStep();
|
nextStep();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,9 +38,8 @@ export const useConfirmItem = (
|
|||||||
) => {
|
) => {
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
if (actionFunction) {
|
// const result = await actionFunction?.();
|
||||||
await actionFunction();
|
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||||
}
|
|
||||||
toast.success(messages?.success);
|
toast.success(messages?.success);
|
||||||
onClose();
|
onClose();
|
||||||
resetStep();
|
resetStep();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import ModalAddPermission from './_components/modal-add-permission';
|
||||||
|
import ModalUpdatePermission from './_components/modal-update-permission';
|
||||||
|
import ModalDeletePermission from './_components/modal-delete-permission';
|
||||||
|
import { useQueryState } from '@imphnen-frontend-service/utils';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface Permission {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: Permission[] = [
|
||||||
|
{ id: 1, name: 'Read' },
|
||||||
|
{ id: 2, name: 'Create' },
|
||||||
|
{ id: 3, name: 'Update' },
|
||||||
|
{ id: 4, name: 'Delete' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
const [showModalAddItem, setShowModalAddItem] = useState(false);
|
||||||
|
const [showModalUpdateItem, setShowModalUpdateItem] = useState(false);
|
||||||
|
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
step: currentStep,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
resetStep,
|
||||||
|
} = useQueryState('step', {
|
||||||
|
defaultValue: 1,
|
||||||
|
maxValue: 2,
|
||||||
|
minValue: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
||||||
|
|
||||||
|
const columns: ColumnDef<Permission>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'No',
|
||||||
|
accessorKey: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: () => (
|
||||||
|
<div className="flex gap-[8px]">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowModalUpdateItem(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<EditOutlined /> Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowModalDeleteItem(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<DeleteOutlined /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||||
|
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||||
|
<h1 className="text-p2 font-semibold">Permissions</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||||
|
<div className="flex justify-between items-center gap-8 mb-2">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Cari berdasarkan nama permissions"
|
||||||
|
className="pl-12 w-full max-h-full"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||||
|
<SearchOutlined />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="flex gap-3 text-nowrap"
|
||||||
|
onClick={() => {
|
||||||
|
setShowModalAddItem(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
Tambah Permissionss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
data={mockData}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={9}
|
||||||
|
table={table}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<ModalAddPermission
|
||||||
|
currentStep={currentStep}
|
||||||
|
isOpen={showModalAddItem}
|
||||||
|
onClose={() => setShowModalAddItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
<ModalUpdatePermission
|
||||||
|
isOpen={showModalUpdateItem}
|
||||||
|
onClose={() => setShowModalUpdateItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
<ModalDeletePermission
|
||||||
|
isOpen={showModalDeleteItem}
|
||||||
|
onClose={() => setShowModalDeleteItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||||
|
import {
|
||||||
|
FilterOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
AuditOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
useReactTable,
|
||||||
|
RowSelectionState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import ModalProcessDelivery from './_components/modal-process-item';
|
||||||
|
|
||||||
|
type OrderValid = 'valid' | 'invalid' | 'unchecked';
|
||||||
|
type Status = 'undelivered' | 'delivered';
|
||||||
|
|
||||||
|
interface Prize {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
orderValid: OrderValid;
|
||||||
|
items: string;
|
||||||
|
address: string;
|
||||||
|
status: Status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
'Sertifikat + Laminating',
|
||||||
|
'Lanyard + ID Card',
|
||||||
|
'Pin',
|
||||||
|
'Sticker Isi 3',
|
||||||
|
'Sticker Isi 5',
|
||||||
|
'Gelang Karet',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock data for transactions
|
||||||
|
const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: 'Nama Lengkap',
|
||||||
|
orderValid: (i % 3 === 0
|
||||||
|
? 'invalid'
|
||||||
|
: i % 5 === 0
|
||||||
|
? 'unchecked'
|
||||||
|
: 'valid') as OrderValid,
|
||||||
|
items: items[i % items.length],
|
||||||
|
address: 'Jl. Pantai Cibaduyut Indah',
|
||||||
|
status: (i % 3 === 0 ? 'undelivered' : 'delivered') as Status,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
const [showModalProcessDelivery, setShowModalProcessDelivery] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
||||||
|
const [showFilter, setShowFilter] = useState(false);
|
||||||
|
|
||||||
|
const deliveryOptions = [
|
||||||
|
{ id: 'option1', value: 'undelivered', label: 'Undelivered' },
|
||||||
|
{ id: 'option1', value: 'delivered', label: 'Delivered' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns: ColumnDef<Prize>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'No',
|
||||||
|
accessorKey: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Nama Lengkap',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Order Valid?',
|
||||||
|
accessorKey: 'orderValid',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.orderValid;
|
||||||
|
const statusColors: Record<OrderValid, string> = {
|
||||||
|
valid: 'bg-success-200 text-success-500',
|
||||||
|
invalid: 'bg-danger-200 text-danger-500',
|
||||||
|
unchecked: 'bg-warning-200 text-warning-900',
|
||||||
|
};
|
||||||
|
const statusText: Record<OrderValid, string> = {
|
||||||
|
valid: 'Valid',
|
||||||
|
invalid: 'Invalid',
|
||||||
|
unchecked: 'Unchecked',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||||
|
>
|
||||||
|
{statusText[status]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Items',
|
||||||
|
accessorKey: 'items',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Alamat Pengiriman',
|
||||||
|
accessorKey: 'address',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status;
|
||||||
|
const statusColors: Record<Status, string> = {
|
||||||
|
delivered: 'bg-success-200 text-success-500',
|
||||||
|
undelivered: 'bg-danger-200 text-danger-500',
|
||||||
|
};
|
||||||
|
const statusText: Record<Status, string> = {
|
||||||
|
delivered: 'Delivered',
|
||||||
|
undelivered: 'Undelivered',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||||
|
>
|
||||||
|
{statusText[status]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowModalProcessDelivery(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full"
|
||||||
|
>
|
||||||
|
<AuditOutlined className="text-[16px]" /> Process
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||||
|
<h1 className="text-p2 font-semibold">Data Pengiriman Hadiah</h1>
|
||||||
|
</header>
|
||||||
|
{/* Account Table Section */}
|
||||||
|
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||||
|
{/* Search and Filter */}
|
||||||
|
<div className="flex justify-between items-center gap-8 mb-2">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee"
|
||||||
|
className="pl-12 w-full max-h-full"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||||
|
<SearchOutlined />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="flex items-center gap-3"
|
||||||
|
onClick={() => setShowFilter(!showFilter)}
|
||||||
|
>
|
||||||
|
<FilterOutlined />
|
||||||
|
Filters
|
||||||
|
</Button>
|
||||||
|
{showFilter && (
|
||||||
|
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
||||||
|
<Filter
|
||||||
|
options={deliveryOptions}
|
||||||
|
onClose={() => setShowFilter(false)}
|
||||||
|
onFilterChange={(value) => {
|
||||||
|
console.log('Selected filter:', value);
|
||||||
|
// Filter logic di sini
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Modal Process Delivery */}
|
||||||
|
<ModalProcessDelivery
|
||||||
|
isOpen={showModalProcessDelivery}
|
||||||
|
onClose={() => setShowModalProcessDelivery(false)}
|
||||||
|
handleProcessDelivery={() => {
|
||||||
|
console.log('Action ketika user menekan tombol Proses Pengiriman');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { useItem, useConfirmItem } from '../_hook/use-item';
|
||||||
|
|
||||||
|
interface IModalAddRole {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleAdd?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalAddRole = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
currentStep,
|
||||||
|
nextStep,
|
||||||
|
resetStep,
|
||||||
|
handleAdd,
|
||||||
|
}: IModalAddRole) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-0 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
resetStep();
|
||||||
|
}}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<StepTwo
|
||||||
|
onClose={onClose}
|
||||||
|
handleAdd={handleAdd}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepOneProps {
|
||||||
|
nextStep: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||||
|
const { form, onSubmit } = useItem(nextStep);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header>
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Tambah Roles
|
||||||
|
</h2>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ControlledInputField
|
||||||
|
control={form.control}
|
||||||
|
label="Nama Role"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Nama Role"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 items-start overflow-auto">
|
||||||
|
<span className="text-p3 font-medium text-neutral-800 sticky left-0">
|
||||||
|
Permissions
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-x-6 overflow-x-scroll">
|
||||||
|
{[
|
||||||
|
'Gacha Items',
|
||||||
|
'Gacha Roll',
|
||||||
|
'Roll',
|
||||||
|
'Users',
|
||||||
|
'Gacha Claim',
|
||||||
|
].map((title) => (
|
||||||
|
<div
|
||||||
|
key={title}
|
||||||
|
className="flex flex-col gap-4 select-none text-label2 font-medium text-neutral-900 "
|
||||||
|
>
|
||||||
|
<span className="text-nowrap text-label1">{title}</span>
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`${title}-all`}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor={`${title}-all`} className="text-nowrap">
|
||||||
|
Check All
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<hr className="border-blue-200" />
|
||||||
|
<div className="flex flex-col items-start gap-4 mb-4">
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input type="checkbox" id={`${title}-read`} />
|
||||||
|
<label htmlFor={`${title}-read`}>Read</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input type="checkbox" id={`${title}-create`} />
|
||||||
|
<label htmlFor={`${title}-create`}>Create</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input type="checkbox" id={`${title}-update`} />
|
||||||
|
<label htmlFor={`${title}-update`}>Update</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input type="checkbox" id={`${title}-delete`} />
|
||||||
|
<label htmlFor={`${title}-delete`}>Delete</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="primary" size="lg" className="w-full" type="submit">
|
||||||
|
Tambah Role
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStepTwoProps {
|
||||||
|
onClose: () => void;
|
||||||
|
handleAdd?: () => Promise<boolean>;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepTwo = ({ onClose, handleAdd, resetStep }: IStepTwoProps) => {
|
||||||
|
const { onConfirm, onCancel } = useConfirmItem(
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleAdd,
|
||||||
|
{
|
||||||
|
success: 'Data role berhasil ditambahkan',
|
||||||
|
error: 'Data role gagal ditambahkan',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Header className="mb-10 text-center items-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Tambah Roles
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-center text-neutral-400">
|
||||||
|
Apakah kamu yakin ingin
|
||||||
|
<br /> menambahkan role ini?
|
||||||
|
</p>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex mb-0 gap-4">
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Tambahkan
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalAddRole;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { useConfirmItem } from '../_hook/use-item';
|
||||||
|
|
||||||
|
interface IModalDeletePermission {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleDelete?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalDeletePermission = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleDelete,
|
||||||
|
}: IModalDeletePermission) => {
|
||||||
|
const { onConfirm } = useConfirmItem(onClose, resetStep, handleDelete, {
|
||||||
|
success: 'Data roles berhasil dihapus',
|
||||||
|
error: 'Data roles gagal dihapus',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
closeButtonClassName="hidden"
|
||||||
|
>
|
||||||
|
<Modal.Header className="gap-8">
|
||||||
|
<img
|
||||||
|
src="/chibi-delete.webp"
|
||||||
|
alt="Delete?"
|
||||||
|
width={148}
|
||||||
|
className="self-center"
|
||||||
|
/>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
|
||||||
|
Delete Roles
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin untuk menghapus role ini? Menghapus data ini
|
||||||
|
mungkin akan mempengaruhi fungsional sistem
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Batal Hapus
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full bg-danger-500 hover:bg-danger-600"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Hapus Role
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalDeletePermission;
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { useConfirmItem } from '../_hook/use-item';
|
||||||
|
|
||||||
|
interface IModalUpdatePermission {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
handleUpdate?: () => Promise<boolean>;
|
||||||
|
currentStep?: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
resetStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalUpdatePermission = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
resetStep,
|
||||||
|
handleUpdate,
|
||||||
|
}: IModalUpdatePermission) => {
|
||||||
|
const { onConfirm } = useConfirmItem(onClose, resetStep, handleUpdate, {
|
||||||
|
success: 'Perubahan roles berhasil dilakukan',
|
||||||
|
error: 'Perubahan roles gagal dilakukan',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-0 text-center"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
disableEscapeKeyDown={true}
|
||||||
|
>
|
||||||
|
<Modal.Header>
|
||||||
|
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||||
|
Update Roles
|
||||||
|
</h2>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<InputField
|
||||||
|
label="Nama Role"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan Nama Role"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 items-start overflow-auto">
|
||||||
|
<span className="text-p3 font-medium text-neutral-800 sticky left-0">
|
||||||
|
Permissions
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-x-6 overflow-x-scroll">
|
||||||
|
{['Gacha Items', 'Gacha Roll', 'Roll', 'Users', 'Gacha Claim'].map(
|
||||||
|
(title) => (
|
||||||
|
<div
|
||||||
|
key={title}
|
||||||
|
className="flex flex-col gap-4 select-none text-label2 font-medium text-neutral-900 "
|
||||||
|
>
|
||||||
|
<span className="text-nowrap text-label1">{title}</span>
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`${title}-all`}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor={`${title}-all`} className="text-nowrap">
|
||||||
|
Check All
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<hr className="border-blue-200" />
|
||||||
|
<div className="flex flex-col items-start gap-4 mb-4">
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input type="checkbox" id={`${title}-read`} />
|
||||||
|
<label htmlFor={`${title}-read`}>Read</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input type="checkbox" id={`${title}-create`} />
|
||||||
|
<label htmlFor={`${title}-create`}>Create</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input type="checkbox" id={`${title}-update`} />
|
||||||
|
<label htmlFor={`${title}-update`}>Update</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
<input type="checkbox" id={`${title}-delete`} />
|
||||||
|
<label htmlFor={`${title}-delete`}>Delete</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Update Role
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalUpdatePermission;
|
||||||
+10
-6
@@ -1,18 +1,23 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
// import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
// import {
|
||||||
|
// gachaRollItemSchema,
|
||||||
|
// TGachaRollItem
|
||||||
|
// } from '@imphnen-frontend-service/service';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export const useItem = (
|
export const useItem = (
|
||||||
nextStep: () => void,
|
nextStep: () => void,
|
||||||
initialValues?: any,
|
initialValues?: any
|
||||||
onDataCapture?: (data: any) => void,
|
|
||||||
) => {
|
) => {
|
||||||
const form = useForm<any>({
|
const form = useForm<any>({
|
||||||
|
// resolver: zodResolver(),
|
||||||
mode: 'all',
|
mode: 'all',
|
||||||
defaultValues: initialValues,
|
defaultValues: initialValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => {
|
const onSubmit = form.handleSubmit((data) => {
|
||||||
onDataCapture?.(data);
|
console.log('Form data:', data);
|
||||||
nextStep();
|
nextStep();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,9 +38,8 @@ export const useConfirmItem = (
|
|||||||
) => {
|
) => {
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
if (actionFunction) {
|
// const result = await actionFunction?.();
|
||||||
await actionFunction();
|
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||||
}
|
|
||||||
toast.success(messages?.success);
|
toast.success(messages?.success);
|
||||||
onClose();
|
onClose();
|
||||||
resetStep();
|
resetStep();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import ModalAddRole from './_components/modal-add-role';
|
||||||
|
import ModalUpdateRole from './_components/modal-update-role';
|
||||||
|
import ModalDeleteRole from './_components/modal-delete-role';
|
||||||
|
import { useQueryState } from '@imphnen-frontend-service/utils';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface Role {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: Role[] = [
|
||||||
|
{ id: 1, name: 'Admin' },
|
||||||
|
{ id: 2, name: 'Admin Pembayaran' },
|
||||||
|
{ id: 3, name: 'Staff' },
|
||||||
|
{ id: 4, name: 'Staff Aktivasi User' },
|
||||||
|
{ id: 5, name: 'User' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
const [showModalAddItem, setShowModalAddItem] = useState(false);
|
||||||
|
const [showModalUpdateItem, setShowModalUpdateItem] = useState(false);
|
||||||
|
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
step: currentStep,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
resetStep,
|
||||||
|
} = useQueryState('step', {
|
||||||
|
defaultValue: 1,
|
||||||
|
maxValue: 2,
|
||||||
|
minValue: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
||||||
|
|
||||||
|
const columns: ColumnDef<Role>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'ID',
|
||||||
|
accessorKey: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Roles Name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: () => (
|
||||||
|
<div className="flex gap-[8px]">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowModalUpdateItem(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<EditOutlined /> Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowModalDeleteItem(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<DeleteOutlined /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||||
|
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||||
|
<h1 className="text-p2 font-semibold">Roles</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||||
|
<div className="flex justify-between items-center gap-8 mb-2">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Cari berdasarkan nama roles"
|
||||||
|
className="pl-12 w-full max-h-full"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||||
|
<SearchOutlined />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="flex gap-3 text-nowrap"
|
||||||
|
onClick={() => {
|
||||||
|
setShowModalAddItem(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
Tambah Role
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
data={mockData}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={9}
|
||||||
|
table={table}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<ModalAddRole
|
||||||
|
currentStep={currentStep}
|
||||||
|
isOpen={showModalAddItem}
|
||||||
|
onClose={() => setShowModalAddItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
<ModalUpdateRole
|
||||||
|
isOpen={showModalUpdateItem}
|
||||||
|
onClose={() => setShowModalUpdateItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
<ModalDeleteRole
|
||||||
|
isOpen={showModalDeleteItem}
|
||||||
|
onClose={() => setShowModalDeleteItem(false)}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
resetStep={resetStep}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||||
|
import {
|
||||||
|
FilterOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
AuditOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
useReactTable,
|
||||||
|
RowSelectionState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import ModalValidate from './_components/modal-validate';
|
||||||
|
|
||||||
|
type TransactionStatus = 'valid' | 'invalid' | 'unchecked';
|
||||||
|
|
||||||
|
interface Transaction {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
transactionNumber: string;
|
||||||
|
status: TransactionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock data for transactions
|
||||||
|
const mockTransactions: Transaction[] = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
||||||
|
transactionNumber: '25D2133Y9AFYBD',
|
||||||
|
status: (i % 3 === 0
|
||||||
|
? 'invalid'
|
||||||
|
: i % 5 === 0
|
||||||
|
? 'unchecked'
|
||||||
|
: 'valid') as TransactionStatus,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
const [showModalValidate, setShowModalValidate] = useState(false);
|
||||||
|
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
||||||
|
const [showFilter, setShowFilter] = useState(false);
|
||||||
|
|
||||||
|
const validationOptions = [
|
||||||
|
{ id: 'option1', value: 'unchecked', label: 'Unchecked' },
|
||||||
|
{ id: 'option2', value: 'valid', label: 'Valid' },
|
||||||
|
{ id: 'option3', value: 'invalid', label: 'Invalid' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns: ColumnDef<Transaction>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'No',
|
||||||
|
accessorKey: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Nama Lengkap',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Nomor Transaksi',
|
||||||
|
accessorKey: 'transactionNumber',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Order Valid?',
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status;
|
||||||
|
const statusColors: Record<TransactionStatus, string> = {
|
||||||
|
valid: 'bg-success-200 text-success-500',
|
||||||
|
invalid: 'bg-danger-200 text-danger-500',
|
||||||
|
unchecked: 'bg-warning-200 text-warning-900',
|
||||||
|
};
|
||||||
|
const statusText: Record<TransactionStatus, string> = {
|
||||||
|
valid: 'Valid',
|
||||||
|
invalid: 'Invalid',
|
||||||
|
unchecked: 'Unchecked',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||||
|
>
|
||||||
|
{statusText[status]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowModalValidate(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full"
|
||||||
|
>
|
||||||
|
<AuditOutlined className="text-[16px]" /> Update
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockTransactions,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockTransactions.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||||
|
<h1 className="text-p2 font-semibold">Validasi Transaksi</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Account Table Section */}
|
||||||
|
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||||
|
{/* Search and Filter */}
|
||||||
|
<div className="flex justify-between items-center gap-8 mb-2">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee"
|
||||||
|
className="pl-12 w-full max-h-full"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||||
|
<SearchOutlined />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="flex items-center gap-3"
|
||||||
|
onClick={() => setShowFilter(!showFilter)}
|
||||||
|
>
|
||||||
|
<FilterOutlined />
|
||||||
|
Filters
|
||||||
|
</Button>
|
||||||
|
{showFilter && (
|
||||||
|
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
||||||
|
<Filter
|
||||||
|
options={validationOptions}
|
||||||
|
onClose={() => setShowFilter(false)}
|
||||||
|
onFilterChange={(value) => {
|
||||||
|
console.log('Selected filter:', value);
|
||||||
|
// Filter logic di sini
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable data={mockTransactions} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<ModalValidate
|
||||||
|
isOpen={showModalValidate}
|
||||||
|
onClose={() => setShowModalValidate(false)}
|
||||||
|
handleValid={() => {
|
||||||
|
console.log('Action ketika user klik Valid');
|
||||||
|
}}
|
||||||
|
handleInvalid={() => {
|
||||||
|
console.log('Action ketika user klik Tidak Valid');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import type { Row, Table } from '@tanstack/react-table';
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
Checkbox,
|
|
||||||
} from '@imphnen-frontend-service/ui/atoms';
|
|
||||||
|
|
||||||
/** Renders a header checkbox that toggles all rows (with indeterminate support). */
|
|
||||||
export function SelectAllCheckbox<T>({ table }: { table: Table<T> }) {
|
|
||||||
return (
|
|
||||||
<Checkbox
|
|
||||||
checked={
|
|
||||||
table.getIsAllRowsSelected()
|
|
||||||
? true
|
|
||||||
: table.getIsSomeRowsSelected()
|
|
||||||
? 'indeterminate'
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
|
|
||||||
}
|
|
||||||
aria-label="Select all"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Per-row checkbox. */
|
|
||||||
export function RowSelectCheckbox<T>({ row }: { row: Row<T> }) {
|
|
||||||
return (
|
|
||||||
<Checkbox
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
|
||||||
aria-label="Select row"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeleteConfirmProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DeleteConfirmDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onConfirm,
|
|
||||||
title = 'Hapus data ini?',
|
|
||||||
description = 'Tindakan ini tidak dapat dibatalkan. Data akan dihapus permanen.',
|
|
||||||
}: DeleteConfirmProps) {
|
|
||||||
return (
|
|
||||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Batal</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Hapus
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { FC, ReactElement, useState } from 'react';
|
|
||||||
import { useLocation, useNavigate } from '@tanstack/react-router';
|
|
||||||
import {
|
|
||||||
LayoutDashboard,
|
|
||||||
Users,
|
|
||||||
UsersRound,
|
|
||||||
ClipboardCheck,
|
|
||||||
BookOpen,
|
|
||||||
MessageSquare,
|
|
||||||
MessageCircle,
|
|
||||||
Calendar,
|
|
||||||
CalendarClock,
|
|
||||||
Settings,
|
|
||||||
BarChart3,
|
|
||||||
RefreshCcw,
|
|
||||||
Inbox,
|
|
||||||
UserCog,
|
|
||||||
UserPlus,
|
|
||||||
ShieldCheck,
|
|
||||||
KeyRound,
|
|
||||||
User,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarHeader,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
SidebarMenuSub,
|
|
||||||
SidebarMenuSubButton,
|
|
||||||
SidebarMenuSubItem,
|
|
||||||
} from '@imphnen-frontend-service/ui/atoms';
|
|
||||||
import { cn } from '@imphnen-frontend-service/utils';
|
|
||||||
|
|
||||||
type MenuLink = {
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MenuGroup = {
|
|
||||||
label: string;
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
children: MenuLink[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type MenuItem = MenuLink | MenuGroup;
|
|
||||||
|
|
||||||
const MENUS: MenuItem[] = [
|
|
||||||
{
|
|
||||||
label: 'Hackathon',
|
|
||||||
icon: BarChart3,
|
|
||||||
children: [
|
|
||||||
{ label: 'Dashboard', href: '/hackathon-dashboard', icon: LayoutDashboard },
|
|
||||||
{ label: 'Users', href: '/hackathon-users', icon: Users },
|
|
||||||
{ label: 'Teams', href: '/hackathon-teams', icon: UsersRound },
|
|
||||||
{ label: 'Submissions', href: '/hackathon-submissions', icon: ClipboardCheck },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Dimentorin',
|
|
||||||
icon: BookOpen,
|
|
||||||
children: [
|
|
||||||
{ label: 'Dashboard', href: '/dashboard-dimentorin', icon: LayoutDashboard },
|
|
||||||
{ label: 'Users', href: '/users-dimentorin', icon: UserCog },
|
|
||||||
{ label: 'Session', href: '/session-dimentorin', icon: CalendarClock },
|
|
||||||
{ label: 'Content & Roadmap', href: '/roadmap-dimentorin', icon: BookOpen },
|
|
||||||
{ label: 'Feedback & Review', href: '/feedback-review-dimentorin', icon: MessageSquare },
|
|
||||||
{ label: 'Settings', href: '/settings-dimentorin', icon: Settings },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Gacha',
|
|
||||||
icon: RefreshCcw,
|
|
||||||
children: [
|
|
||||||
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
|
||||||
{ label: 'Gacha Roll', href: '/gacha-roll', icon: RefreshCcw },
|
|
||||||
{ label: 'Validasi Transaksi', href: '/transactions', icon: ClipboardCheck },
|
|
||||||
{ label: 'Data Pengiriman', href: '/prizes', icon: Inbox },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'CMS',
|
|
||||||
icon: BookOpen,
|
|
||||||
children: [
|
|
||||||
{ label: 'Events', href: '/cms-events', icon: Calendar },
|
|
||||||
{ label: 'Testimonials', href: '/cms-testimonials', icon: MessageCircle },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const FLAT_MENUS: MenuLink[] = [
|
|
||||||
{ label: 'Permissions', href: '/permissions', icon: ShieldCheck },
|
|
||||||
{ label: 'Roles', href: '/roles', icon: KeyRound },
|
|
||||||
{ label: 'Data Akun', href: '/accounts', icon: User },
|
|
||||||
];
|
|
||||||
|
|
||||||
const isMenuGroup = (item: MenuItem): item is MenuGroup =>
|
|
||||||
(item as MenuGroup).children !== undefined;
|
|
||||||
|
|
||||||
export const BackofficeSidebar: FC = (): ReactElement => {
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
|
||||||
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return location.pathname.includes(path);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() => {
|
|
||||||
const initial: Record<string, boolean> = {};
|
|
||||||
MENUS.forEach((menu) => {
|
|
||||||
if (
|
|
||||||
isMenuGroup(menu) &&
|
|
||||||
menu.children.some((child) => isActive(child.href))
|
|
||||||
) {
|
|
||||||
initial[menu.label] = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return initial;
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleGroup = (label: string) =>
|
|
||||||
setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sidebar collapsible="offcanvas" variant="inset">
|
|
||||||
<SidebarHeader>
|
|
||||||
<div className="flex items-center justify-center px-2 py-3">
|
|
||||||
<img
|
|
||||||
src="/logos/simple.svg"
|
|
||||||
alt="IMPHNEN Logo"
|
|
||||||
className="h-10 w-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SidebarHeader>
|
|
||||||
<SidebarContent>
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
{MENUS.map((menu) => {
|
|
||||||
const GroupIcon = menu.icon;
|
|
||||||
const open = !!openGroups[menu.label];
|
|
||||||
const groupHasActive =
|
|
||||||
isMenuGroup(menu) &&
|
|
||||||
menu.children.some((c) => isActive(c.href));
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem key={menu.label}>
|
|
||||||
<SidebarMenuButton
|
|
||||||
onClick={() => toggleGroup(menu.label)}
|
|
||||||
isActive={groupHasActive && !open}
|
|
||||||
className="justify-between"
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<GroupIcon className="size-4" />
|
|
||||||
<span>{menu.label}</span>
|
|
||||||
</span>
|
|
||||||
{open ? (
|
|
||||||
<ChevronDown className="size-3.5 opacity-60" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="size-3.5 opacity-60" />
|
|
||||||
)}
|
|
||||||
</SidebarMenuButton>
|
|
||||||
{isMenuGroup(menu) && open && (
|
|
||||||
<SidebarMenuSub>
|
|
||||||
{menu.children.map((child) => {
|
|
||||||
const ChildIcon = child.icon;
|
|
||||||
return (
|
|
||||||
<SidebarMenuSubItem key={child.href}>
|
|
||||||
<SidebarMenuSubButton
|
|
||||||
asChild
|
|
||||||
isActive={isActive(child.href)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
navigate({ to: child.href })
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
'w-full text-left',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ChildIcon className="size-4" />
|
|
||||||
<span>{child.label}</span>
|
|
||||||
</button>
|
|
||||||
</SidebarMenuSubButton>
|
|
||||||
</SidebarMenuSubItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SidebarMenuSub>
|
|
||||||
)}
|
|
||||||
</SidebarMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel>System</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
{FLAT_MENUS.map((menu) => {
|
|
||||||
const Icon = menu.icon;
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem key={menu.href}>
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={isActive(menu.href)}
|
|
||||||
onClick={() => navigate({ to: menu.href })}
|
|
||||||
>
|
|
||||||
<Icon className="size-4" />
|
|
||||||
<span>{menu.label}</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
</SidebarContent>
|
|
||||||
</Sidebar>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,518 +0,0 @@
|
|||||||
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,6 +1,5 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import 'tw-animate-css';
|
|
||||||
@source "../../../libs/ui/**/*.{ts,tsx}";
|
@source "../../../libs/ui/**/*.{ts,tsx}";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
@@ -97,45 +96,6 @@
|
|||||||
/* ~10px */
|
/* ~10px */
|
||||||
--text-label3: 0.677rem;
|
--text-label3: 0.677rem;
|
||||||
/* ~8px */
|
/* ~8px */
|
||||||
|
|
||||||
/* shadcn/ui semantic tokens — aliased to existing palette (keep colors consistent) */
|
|
||||||
--color-background: #ffffff;
|
|
||||||
--color-foreground: #3d3d3d;
|
|
||||||
--color-card: #ffffff;
|
|
||||||
--color-card-foreground: #3d3d3d;
|
|
||||||
--color-popover: #ffffff;
|
|
||||||
--color-popover-foreground: #3d3d3d;
|
|
||||||
--color-primary: #23a1eb;
|
|
||||||
--color-primary-foreground: #ffffff;
|
|
||||||
--color-secondary: #e7e7e7;
|
|
||||||
--color-secondary-foreground: #3d3d3d;
|
|
||||||
--color-muted: #f6f6f6;
|
|
||||||
--color-muted-foreground: #6d6d6d;
|
|
||||||
--color-accent: #e1f0fd;
|
|
||||||
--color-accent-foreground: #085f9c;
|
|
||||||
--color-destructive: #ff5242;
|
|
||||||
--color-destructive-foreground: #ffffff;
|
|
||||||
--color-border: #d1d1d1;
|
|
||||||
--color-input: #d1d1d1;
|
|
||||||
--color-ring: #3eb0f2;
|
|
||||||
|
|
||||||
--color-sidebar: #ffffff;
|
|
||||||
--color-sidebar-foreground: #4f4f4f;
|
|
||||||
--color-sidebar-primary: #23a1eb;
|
|
||||||
--color-sidebar-primary-foreground: #ffffff;
|
|
||||||
--color-sidebar-accent: #f0f8ff;
|
|
||||||
--color-sidebar-accent-foreground: #085f9c;
|
|
||||||
--color-sidebar-border: #e7e7e7;
|
|
||||||
--color-sidebar-ring: #3eb0f2;
|
|
||||||
|
|
||||||
--color-chart-1: #23a1eb;
|
|
||||||
--color-chart-2: #35ba43;
|
|
||||||
--color-chart-3: #04acf3;
|
|
||||||
--color-chart-4: #ffed27;
|
|
||||||
--color-chart-5: #ff5242;
|
|
||||||
|
|
||||||
--radius: 8px;
|
|
||||||
--font-sans: 'Bai Jamjuree', sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -170,7 +130,7 @@
|
|||||||
html {
|
html {
|
||||||
font-family: 'Bai Jamjuree', sans-serif;
|
font-family: 'Bai Jamjuree', sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,42 @@
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client';
|
||||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
import { middleware } from './middleware';
|
||||||
import { QueryProvider } from '@imphnen-frontend-service/utils'
|
import { StrictMode } from 'react';
|
||||||
import { Toaster } from 'sonner'
|
import { createBrowserRouter, RouteObject, RouterProvider } from 'react-router';
|
||||||
import { routeTree } from './routeTree.gen'
|
import {
|
||||||
import './index.css'
|
add404PageToRoutesChildren,
|
||||||
|
addErrorElementToRoutes,
|
||||||
|
convertPagesToRoute,
|
||||||
|
QueryProvider,
|
||||||
|
} from '@imphnen-frontend-service/utils';
|
||||||
|
import { Toaster } from 'sonner';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
const router = createRouter({ routeTree })
|
const files = import.meta.glob('./app/**/*(page|layout).tsx');
|
||||||
|
const errorFiles = import.meta.glob('./app/**/*error.tsx');
|
||||||
|
const notFoundFiles = import.meta.glob('./app/**/*404.tsx');
|
||||||
|
const loadingFiles = import.meta.glob('./app/**/*loading.tsx');
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
const routes = convertPagesToRoute(files, loadingFiles) as RouteObject;
|
||||||
interface Register {
|
addErrorElementToRoutes(errorFiles, routes);
|
||||||
router: typeof router
|
add404PageToRoutesChildren(notFoundFiles, routes);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootElement = document.getElementById('root')
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
...routes,
|
||||||
|
loader: middleware,
|
||||||
|
shouldRevalidate: () => true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
if (!rootElement) throw new Error('Failed to find the root element')
|
const rootElement = document.getElementById('root');
|
||||||
|
|
||||||
|
if (!rootElement) throw new Error('Failed to find the root element');
|
||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<QueryProvider>
|
<StrictMode>
|
||||||
<RouterProvider router={router} />
|
<QueryProvider>
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
</QueryProvider>
|
<RouterProvider router={router} />
|
||||||
)
|
</QueryProvider>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { LoaderFunctionArgs } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
const pathname = url.pathname;
|
||||||
|
|
||||||
|
if (pathname) return null;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -1,923 +0,0 @@
|
|||||||
/* eslint-disable */
|
|
||||||
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
// noinspection JSUnusedGlobalSymbols
|
|
||||||
|
|
||||||
// This file was automatically generated by TanStack Router.
|
|
||||||
// You should NOT make any changes in this file as it will be overwritten.
|
|
||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
|
||||||
import { Route as PublicRouteImport } from './routes/_public'
|
|
||||||
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
|
||||||
import { Route as AuthenticatedUsersDimentorinRouteImport } from './routes/_authenticated/users-dimentorin'
|
|
||||||
import { Route as AuthenticatedTransactionsRouteImport } from './routes/_authenticated/transactions'
|
|
||||||
import { Route as AuthenticatedSettingsDimentorinRouteImport } from './routes/_authenticated/settings-dimentorin'
|
|
||||||
import { Route as AuthenticatedSessionDimentorinRouteImport } from './routes/_authenticated/session-dimentorin'
|
|
||||||
import { Route as AuthenticatedRolesRouteImport } from './routes/_authenticated/roles'
|
|
||||||
import { Route as AuthenticatedRoadmapDimentorinRouteImport } from './routes/_authenticated/roadmap-dimentorin'
|
|
||||||
import { Route as AuthenticatedPrizesRouteImport } from './routes/_authenticated/prizes'
|
|
||||||
import { Route as AuthenticatedPermissionsRouteImport } from './routes/_authenticated/permissions'
|
|
||||||
import { Route as AuthenticatedHackathonUsersRouteImport } from './routes/_authenticated/hackathon-users'
|
|
||||||
import { Route as AuthenticatedHackathonTeamsRouteImport } from './routes/_authenticated/hackathon-teams'
|
|
||||||
import { Route as AuthenticatedHackathonSubmissionsRouteImport } from './routes/_authenticated/hackathon-submissions'
|
|
||||||
import { Route as AuthenticatedHackathonDashboardRouteImport } from './routes/_authenticated/hackathon-dashboard'
|
|
||||||
import { Route as AuthenticatedGachaRollRouteImport } from './routes/_authenticated/gacha-roll'
|
|
||||||
import { Route as AuthenticatedFeedbackReviewDimentorinRouteImport } from './routes/_authenticated/feedback-review-dimentorin'
|
|
||||||
import { Route as AuthenticatedDashboardDimentorinRouteImport } from './routes/_authenticated/dashboard-dimentorin'
|
|
||||||
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
|
|
||||||
import { Route as AuthenticatedCmsTestimonialsRouteImport } from './routes/_authenticated/cms-testimonials'
|
|
||||||
import { Route as AuthenticatedCmsEventsRouteImport } from './routes/_authenticated/cms-events'
|
|
||||||
import { Route as AuthenticatedAccountsRouteImport } from './routes/_authenticated/accounts'
|
|
||||||
import { Route as PublicAuthLoginRouteImport } from './routes/_public/auth/login'
|
|
||||||
import { Route as AuthenticatedUsersDimentorinIdRouteImport } from './routes/_authenticated/users-dimentorin_/$id'
|
|
||||||
import { Route as AuthenticatedSessionDimentorinIdRouteImport } from './routes/_authenticated/session-dimentorin_/$id'
|
|
||||||
import { Route as AuthenticatedRolesCreateRouteImport } from './routes/_authenticated/roles_/create'
|
|
||||||
import { Route as AuthenticatedRolesIdRouteImport } from './routes/_authenticated/roles_/$id'
|
|
||||||
import { Route as AuthenticatedRoadmapDimentorinCreateRouteImport } from './routes/_authenticated/roadmap-dimentorin_/create'
|
|
||||||
import { Route as AuthenticatedRoadmapDimentorinIdRouteImport } from './routes/_authenticated/roadmap-dimentorin_/$id'
|
|
||||||
import { Route as AuthenticatedPermissionsCreateRouteImport } from './routes/_authenticated/permissions_/create'
|
|
||||||
import { Route as AuthenticatedPermissionsIdRouteImport } from './routes/_authenticated/permissions_/$id'
|
|
||||||
import { Route as AuthenticatedGachaRollCreateRouteImport } from './routes/_authenticated/gacha-roll_/create'
|
|
||||||
import { Route as AuthenticatedGachaRollIdRouteImport } from './routes/_authenticated/gacha-roll_/$id'
|
|
||||||
import { Route as AuthenticatedDashboardCreateRouteImport } from './routes/_authenticated/dashboard_/create'
|
|
||||||
import { Route as AuthenticatedDashboardIdRouteImport } from './routes/_authenticated/dashboard_/$id'
|
|
||||||
import { Route as AuthenticatedCmsTestimonialsCreateRouteImport } from './routes/_authenticated/cms-testimonials_/create'
|
|
||||||
import { Route as AuthenticatedCmsTestimonialsIdRouteImport } from './routes/_authenticated/cms-testimonials_/$id'
|
|
||||||
import { Route as AuthenticatedCmsEventsCreateRouteImport } from './routes/_authenticated/cms-events_/create'
|
|
||||||
import { Route as AuthenticatedCmsEventsIdRouteImport } from './routes/_authenticated/cms-events_/$id'
|
|
||||||
import { Route as AuthenticatedAccountsIdRouteImport } from './routes/_authenticated/accounts_/$id'
|
|
||||||
|
|
||||||
const PublicRoute = PublicRouteImport.update({
|
|
||||||
id: '/_public',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedRoute = AuthenticatedRouteImport.update({
|
|
||||||
id: '/_authenticated',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
|
||||||
id: '/',
|
|
||||||
path: '/',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedUsersDimentorinRoute =
|
|
||||||
AuthenticatedUsersDimentorinRouteImport.update({
|
|
||||||
id: '/users-dimentorin',
|
|
||||||
path: '/users-dimentorin',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedTransactionsRoute =
|
|
||||||
AuthenticatedTransactionsRouteImport.update({
|
|
||||||
id: '/transactions',
|
|
||||||
path: '/transactions',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedSettingsDimentorinRoute =
|
|
||||||
AuthenticatedSettingsDimentorinRouteImport.update({
|
|
||||||
id: '/settings-dimentorin',
|
|
||||||
path: '/settings-dimentorin',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedSessionDimentorinRoute =
|
|
||||||
AuthenticatedSessionDimentorinRouteImport.update({
|
|
||||||
id: '/session-dimentorin',
|
|
||||||
path: '/session-dimentorin',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedRolesRoute = AuthenticatedRolesRouteImport.update({
|
|
||||||
id: '/roles',
|
|
||||||
path: '/roles',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedRoadmapDimentorinRoute =
|
|
||||||
AuthenticatedRoadmapDimentorinRouteImport.update({
|
|
||||||
id: '/roadmap-dimentorin',
|
|
||||||
path: '/roadmap-dimentorin',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedPrizesRoute = AuthenticatedPrizesRouteImport.update({
|
|
||||||
id: '/prizes',
|
|
||||||
path: '/prizes',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedPermissionsRoute =
|
|
||||||
AuthenticatedPermissionsRouteImport.update({
|
|
||||||
id: '/permissions',
|
|
||||||
path: '/permissions',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedHackathonUsersRoute =
|
|
||||||
AuthenticatedHackathonUsersRouteImport.update({
|
|
||||||
id: '/hackathon-users',
|
|
||||||
path: '/hackathon-users',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedHackathonTeamsRoute =
|
|
||||||
AuthenticatedHackathonTeamsRouteImport.update({
|
|
||||||
id: '/hackathon-teams',
|
|
||||||
path: '/hackathon-teams',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedHackathonSubmissionsRoute =
|
|
||||||
AuthenticatedHackathonSubmissionsRouteImport.update({
|
|
||||||
id: '/hackathon-submissions',
|
|
||||||
path: '/hackathon-submissions',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedHackathonDashboardRoute =
|
|
||||||
AuthenticatedHackathonDashboardRouteImport.update({
|
|
||||||
id: '/hackathon-dashboard',
|
|
||||||
path: '/hackathon-dashboard',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedGachaRollRoute = AuthenticatedGachaRollRouteImport.update({
|
|
||||||
id: '/gacha-roll',
|
|
||||||
path: '/gacha-roll',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedFeedbackReviewDimentorinRoute =
|
|
||||||
AuthenticatedFeedbackReviewDimentorinRouteImport.update({
|
|
||||||
id: '/feedback-review-dimentorin',
|
|
||||||
path: '/feedback-review-dimentorin',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedDashboardDimentorinRoute =
|
|
||||||
AuthenticatedDashboardDimentorinRouteImport.update({
|
|
||||||
id: '/dashboard-dimentorin',
|
|
||||||
path: '/dashboard-dimentorin',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedDashboardRoute = AuthenticatedDashboardRouteImport.update({
|
|
||||||
id: '/dashboard',
|
|
||||||
path: '/dashboard',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedCmsTestimonialsRoute =
|
|
||||||
AuthenticatedCmsTestimonialsRouteImport.update({
|
|
||||||
id: '/cms-testimonials',
|
|
||||||
path: '/cms-testimonials',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedCmsEventsRoute = AuthenticatedCmsEventsRouteImport.update({
|
|
||||||
id: '/cms-events',
|
|
||||||
path: '/cms-events',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedAccountsRoute = AuthenticatedAccountsRouteImport.update({
|
|
||||||
id: '/accounts',
|
|
||||||
path: '/accounts',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const PublicAuthLoginRoute = PublicAuthLoginRouteImport.update({
|
|
||||||
id: '/auth/login',
|
|
||||||
path: '/auth/login',
|
|
||||||
getParentRoute: () => PublicRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedUsersDimentorinIdRoute =
|
|
||||||
AuthenticatedUsersDimentorinIdRouteImport.update({
|
|
||||||
id: '/users-dimentorin_/$id',
|
|
||||||
path: '/users-dimentorin/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedSessionDimentorinIdRoute =
|
|
||||||
AuthenticatedSessionDimentorinIdRouteImport.update({
|
|
||||||
id: '/session-dimentorin_/$id',
|
|
||||||
path: '/session-dimentorin/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedRolesCreateRoute =
|
|
||||||
AuthenticatedRolesCreateRouteImport.update({
|
|
||||||
id: '/roles_/create',
|
|
||||||
path: '/roles/create',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedRolesIdRoute = AuthenticatedRolesIdRouteImport.update({
|
|
||||||
id: '/roles_/$id',
|
|
||||||
path: '/roles/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedRoadmapDimentorinCreateRoute =
|
|
||||||
AuthenticatedRoadmapDimentorinCreateRouteImport.update({
|
|
||||||
id: '/roadmap-dimentorin_/create',
|
|
||||||
path: '/roadmap-dimentorin/create',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedRoadmapDimentorinIdRoute =
|
|
||||||
AuthenticatedRoadmapDimentorinIdRouteImport.update({
|
|
||||||
id: '/roadmap-dimentorin_/$id',
|
|
||||||
path: '/roadmap-dimentorin/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedPermissionsCreateRoute =
|
|
||||||
AuthenticatedPermissionsCreateRouteImport.update({
|
|
||||||
id: '/permissions_/create',
|
|
||||||
path: '/permissions/create',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedPermissionsIdRoute =
|
|
||||||
AuthenticatedPermissionsIdRouteImport.update({
|
|
||||||
id: '/permissions_/$id',
|
|
||||||
path: '/permissions/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedGachaRollCreateRoute =
|
|
||||||
AuthenticatedGachaRollCreateRouteImport.update({
|
|
||||||
id: '/gacha-roll_/create',
|
|
||||||
path: '/gacha-roll/create',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedGachaRollIdRoute =
|
|
||||||
AuthenticatedGachaRollIdRouteImport.update({
|
|
||||||
id: '/gacha-roll_/$id',
|
|
||||||
path: '/gacha-roll/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedDashboardCreateRoute =
|
|
||||||
AuthenticatedDashboardCreateRouteImport.update({
|
|
||||||
id: '/dashboard_/create',
|
|
||||||
path: '/dashboard/create',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedDashboardIdRoute =
|
|
||||||
AuthenticatedDashboardIdRouteImport.update({
|
|
||||||
id: '/dashboard_/$id',
|
|
||||||
path: '/dashboard/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedCmsTestimonialsCreateRoute =
|
|
||||||
AuthenticatedCmsTestimonialsCreateRouteImport.update({
|
|
||||||
id: '/cms-testimonials_/create',
|
|
||||||
path: '/cms-testimonials/create',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedCmsTestimonialsIdRoute =
|
|
||||||
AuthenticatedCmsTestimonialsIdRouteImport.update({
|
|
||||||
id: '/cms-testimonials_/$id',
|
|
||||||
path: '/cms-testimonials/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedCmsEventsCreateRoute =
|
|
||||||
AuthenticatedCmsEventsCreateRouteImport.update({
|
|
||||||
id: '/cms-events_/create',
|
|
||||||
path: '/cms-events/create',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedCmsEventsIdRoute =
|
|
||||||
AuthenticatedCmsEventsIdRouteImport.update({
|
|
||||||
id: '/cms-events_/$id',
|
|
||||||
path: '/cms-events/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthenticatedAccountsIdRoute = AuthenticatedAccountsIdRouteImport.update({
|
|
||||||
id: '/accounts_/$id',
|
|
||||||
path: '/accounts/$id',
|
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
|
||||||
'/': typeof IndexRoute
|
|
||||||
'/accounts': typeof AuthenticatedAccountsRoute
|
|
||||||
'/cms-events': typeof AuthenticatedCmsEventsRoute
|
|
||||||
'/cms-testimonials': typeof AuthenticatedCmsTestimonialsRoute
|
|
||||||
'/dashboard': typeof AuthenticatedDashboardRoute
|
|
||||||
'/dashboard-dimentorin': typeof AuthenticatedDashboardDimentorinRoute
|
|
||||||
'/feedback-review-dimentorin': typeof AuthenticatedFeedbackReviewDimentorinRoute
|
|
||||||
'/gacha-roll': typeof AuthenticatedGachaRollRoute
|
|
||||||
'/hackathon-dashboard': typeof AuthenticatedHackathonDashboardRoute
|
|
||||||
'/hackathon-submissions': typeof AuthenticatedHackathonSubmissionsRoute
|
|
||||||
'/hackathon-teams': typeof AuthenticatedHackathonTeamsRoute
|
|
||||||
'/hackathon-users': typeof AuthenticatedHackathonUsersRoute
|
|
||||||
'/permissions': typeof AuthenticatedPermissionsRoute
|
|
||||||
'/prizes': typeof AuthenticatedPrizesRoute
|
|
||||||
'/roadmap-dimentorin': typeof AuthenticatedRoadmapDimentorinRoute
|
|
||||||
'/roles': typeof AuthenticatedRolesRoute
|
|
||||||
'/session-dimentorin': typeof AuthenticatedSessionDimentorinRoute
|
|
||||||
'/settings-dimentorin': typeof AuthenticatedSettingsDimentorinRoute
|
|
||||||
'/transactions': typeof AuthenticatedTransactionsRoute
|
|
||||||
'/users-dimentorin': typeof AuthenticatedUsersDimentorinRoute
|
|
||||||
'/accounts/$id': typeof AuthenticatedAccountsIdRoute
|
|
||||||
'/cms-events/$id': typeof AuthenticatedCmsEventsIdRoute
|
|
||||||
'/cms-events/create': typeof AuthenticatedCmsEventsCreateRoute
|
|
||||||
'/cms-testimonials/$id': typeof AuthenticatedCmsTestimonialsIdRoute
|
|
||||||
'/cms-testimonials/create': typeof AuthenticatedCmsTestimonialsCreateRoute
|
|
||||||
'/dashboard/$id': typeof AuthenticatedDashboardIdRoute
|
|
||||||
'/dashboard/create': typeof AuthenticatedDashboardCreateRoute
|
|
||||||
'/gacha-roll/$id': typeof AuthenticatedGachaRollIdRoute
|
|
||||||
'/gacha-roll/create': typeof AuthenticatedGachaRollCreateRoute
|
|
||||||
'/permissions/$id': typeof AuthenticatedPermissionsIdRoute
|
|
||||||
'/permissions/create': typeof AuthenticatedPermissionsCreateRoute
|
|
||||||
'/roadmap-dimentorin/$id': typeof AuthenticatedRoadmapDimentorinIdRoute
|
|
||||||
'/roadmap-dimentorin/create': typeof AuthenticatedRoadmapDimentorinCreateRoute
|
|
||||||
'/roles/$id': typeof AuthenticatedRolesIdRoute
|
|
||||||
'/roles/create': typeof AuthenticatedRolesCreateRoute
|
|
||||||
'/session-dimentorin/$id': typeof AuthenticatedSessionDimentorinIdRoute
|
|
||||||
'/users-dimentorin/$id': typeof AuthenticatedUsersDimentorinIdRoute
|
|
||||||
'/auth/login': typeof PublicAuthLoginRoute
|
|
||||||
}
|
|
||||||
export interface FileRoutesByTo {
|
|
||||||
'/': typeof IndexRoute
|
|
||||||
'/accounts': typeof AuthenticatedAccountsRoute
|
|
||||||
'/cms-events': typeof AuthenticatedCmsEventsRoute
|
|
||||||
'/cms-testimonials': typeof AuthenticatedCmsTestimonialsRoute
|
|
||||||
'/dashboard': typeof AuthenticatedDashboardRoute
|
|
||||||
'/dashboard-dimentorin': typeof AuthenticatedDashboardDimentorinRoute
|
|
||||||
'/feedback-review-dimentorin': typeof AuthenticatedFeedbackReviewDimentorinRoute
|
|
||||||
'/gacha-roll': typeof AuthenticatedGachaRollRoute
|
|
||||||
'/hackathon-dashboard': typeof AuthenticatedHackathonDashboardRoute
|
|
||||||
'/hackathon-submissions': typeof AuthenticatedHackathonSubmissionsRoute
|
|
||||||
'/hackathon-teams': typeof AuthenticatedHackathonTeamsRoute
|
|
||||||
'/hackathon-users': typeof AuthenticatedHackathonUsersRoute
|
|
||||||
'/permissions': typeof AuthenticatedPermissionsRoute
|
|
||||||
'/prizes': typeof AuthenticatedPrizesRoute
|
|
||||||
'/roadmap-dimentorin': typeof AuthenticatedRoadmapDimentorinRoute
|
|
||||||
'/roles': typeof AuthenticatedRolesRoute
|
|
||||||
'/session-dimentorin': typeof AuthenticatedSessionDimentorinRoute
|
|
||||||
'/settings-dimentorin': typeof AuthenticatedSettingsDimentorinRoute
|
|
||||||
'/transactions': typeof AuthenticatedTransactionsRoute
|
|
||||||
'/users-dimentorin': typeof AuthenticatedUsersDimentorinRoute
|
|
||||||
'/accounts/$id': typeof AuthenticatedAccountsIdRoute
|
|
||||||
'/cms-events/$id': typeof AuthenticatedCmsEventsIdRoute
|
|
||||||
'/cms-events/create': typeof AuthenticatedCmsEventsCreateRoute
|
|
||||||
'/cms-testimonials/$id': typeof AuthenticatedCmsTestimonialsIdRoute
|
|
||||||
'/cms-testimonials/create': typeof AuthenticatedCmsTestimonialsCreateRoute
|
|
||||||
'/dashboard/$id': typeof AuthenticatedDashboardIdRoute
|
|
||||||
'/dashboard/create': typeof AuthenticatedDashboardCreateRoute
|
|
||||||
'/gacha-roll/$id': typeof AuthenticatedGachaRollIdRoute
|
|
||||||
'/gacha-roll/create': typeof AuthenticatedGachaRollCreateRoute
|
|
||||||
'/permissions/$id': typeof AuthenticatedPermissionsIdRoute
|
|
||||||
'/permissions/create': typeof AuthenticatedPermissionsCreateRoute
|
|
||||||
'/roadmap-dimentorin/$id': typeof AuthenticatedRoadmapDimentorinIdRoute
|
|
||||||
'/roadmap-dimentorin/create': typeof AuthenticatedRoadmapDimentorinCreateRoute
|
|
||||||
'/roles/$id': typeof AuthenticatedRolesIdRoute
|
|
||||||
'/roles/create': typeof AuthenticatedRolesCreateRoute
|
|
||||||
'/session-dimentorin/$id': typeof AuthenticatedSessionDimentorinIdRoute
|
|
||||||
'/users-dimentorin/$id': typeof AuthenticatedUsersDimentorinIdRoute
|
|
||||||
'/auth/login': typeof PublicAuthLoginRoute
|
|
||||||
}
|
|
||||||
export interface FileRoutesById {
|
|
||||||
__root__: typeof rootRouteImport
|
|
||||||
'/': typeof IndexRoute
|
|
||||||
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
|
||||||
'/_public': typeof PublicRouteWithChildren
|
|
||||||
'/_authenticated/accounts': typeof AuthenticatedAccountsRoute
|
|
||||||
'/_authenticated/cms-events': typeof AuthenticatedCmsEventsRoute
|
|
||||||
'/_authenticated/cms-testimonials': typeof AuthenticatedCmsTestimonialsRoute
|
|
||||||
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
|
|
||||||
'/_authenticated/dashboard-dimentorin': typeof AuthenticatedDashboardDimentorinRoute
|
|
||||||
'/_authenticated/feedback-review-dimentorin': typeof AuthenticatedFeedbackReviewDimentorinRoute
|
|
||||||
'/_authenticated/gacha-roll': typeof AuthenticatedGachaRollRoute
|
|
||||||
'/_authenticated/hackathon-dashboard': typeof AuthenticatedHackathonDashboardRoute
|
|
||||||
'/_authenticated/hackathon-submissions': typeof AuthenticatedHackathonSubmissionsRoute
|
|
||||||
'/_authenticated/hackathon-teams': typeof AuthenticatedHackathonTeamsRoute
|
|
||||||
'/_authenticated/hackathon-users': typeof AuthenticatedHackathonUsersRoute
|
|
||||||
'/_authenticated/permissions': typeof AuthenticatedPermissionsRoute
|
|
||||||
'/_authenticated/prizes': typeof AuthenticatedPrizesRoute
|
|
||||||
'/_authenticated/roadmap-dimentorin': typeof AuthenticatedRoadmapDimentorinRoute
|
|
||||||
'/_authenticated/roles': typeof AuthenticatedRolesRoute
|
|
||||||
'/_authenticated/session-dimentorin': typeof AuthenticatedSessionDimentorinRoute
|
|
||||||
'/_authenticated/settings-dimentorin': typeof AuthenticatedSettingsDimentorinRoute
|
|
||||||
'/_authenticated/transactions': typeof AuthenticatedTransactionsRoute
|
|
||||||
'/_authenticated/users-dimentorin': typeof AuthenticatedUsersDimentorinRoute
|
|
||||||
'/_authenticated/accounts_/$id': typeof AuthenticatedAccountsIdRoute
|
|
||||||
'/_authenticated/cms-events_/$id': typeof AuthenticatedCmsEventsIdRoute
|
|
||||||
'/_authenticated/cms-events_/create': typeof AuthenticatedCmsEventsCreateRoute
|
|
||||||
'/_authenticated/cms-testimonials_/$id': typeof AuthenticatedCmsTestimonialsIdRoute
|
|
||||||
'/_authenticated/cms-testimonials_/create': typeof AuthenticatedCmsTestimonialsCreateRoute
|
|
||||||
'/_authenticated/dashboard_/$id': typeof AuthenticatedDashboardIdRoute
|
|
||||||
'/_authenticated/dashboard_/create': typeof AuthenticatedDashboardCreateRoute
|
|
||||||
'/_authenticated/gacha-roll_/$id': typeof AuthenticatedGachaRollIdRoute
|
|
||||||
'/_authenticated/gacha-roll_/create': typeof AuthenticatedGachaRollCreateRoute
|
|
||||||
'/_authenticated/permissions_/$id': typeof AuthenticatedPermissionsIdRoute
|
|
||||||
'/_authenticated/permissions_/create': typeof AuthenticatedPermissionsCreateRoute
|
|
||||||
'/_authenticated/roadmap-dimentorin_/$id': typeof AuthenticatedRoadmapDimentorinIdRoute
|
|
||||||
'/_authenticated/roadmap-dimentorin_/create': typeof AuthenticatedRoadmapDimentorinCreateRoute
|
|
||||||
'/_authenticated/roles_/$id': typeof AuthenticatedRolesIdRoute
|
|
||||||
'/_authenticated/roles_/create': typeof AuthenticatedRolesCreateRoute
|
|
||||||
'/_authenticated/session-dimentorin_/$id': typeof AuthenticatedSessionDimentorinIdRoute
|
|
||||||
'/_authenticated/users-dimentorin_/$id': typeof AuthenticatedUsersDimentorinIdRoute
|
|
||||||
'/_public/auth/login': typeof PublicAuthLoginRoute
|
|
||||||
}
|
|
||||||
export interface FileRouteTypes {
|
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
|
||||||
fullPaths:
|
|
||||||
| '/'
|
|
||||||
| '/accounts'
|
|
||||||
| '/cms-events'
|
|
||||||
| '/cms-testimonials'
|
|
||||||
| '/dashboard'
|
|
||||||
| '/dashboard-dimentorin'
|
|
||||||
| '/feedback-review-dimentorin'
|
|
||||||
| '/gacha-roll'
|
|
||||||
| '/hackathon-dashboard'
|
|
||||||
| '/hackathon-submissions'
|
|
||||||
| '/hackathon-teams'
|
|
||||||
| '/hackathon-users'
|
|
||||||
| '/permissions'
|
|
||||||
| '/prizes'
|
|
||||||
| '/roadmap-dimentorin'
|
|
||||||
| '/roles'
|
|
||||||
| '/session-dimentorin'
|
|
||||||
| '/settings-dimentorin'
|
|
||||||
| '/transactions'
|
|
||||||
| '/users-dimentorin'
|
|
||||||
| '/accounts/$id'
|
|
||||||
| '/cms-events/$id'
|
|
||||||
| '/cms-events/create'
|
|
||||||
| '/cms-testimonials/$id'
|
|
||||||
| '/cms-testimonials/create'
|
|
||||||
| '/dashboard/$id'
|
|
||||||
| '/dashboard/create'
|
|
||||||
| '/gacha-roll/$id'
|
|
||||||
| '/gacha-roll/create'
|
|
||||||
| '/permissions/$id'
|
|
||||||
| '/permissions/create'
|
|
||||||
| '/roadmap-dimentorin/$id'
|
|
||||||
| '/roadmap-dimentorin/create'
|
|
||||||
| '/roles/$id'
|
|
||||||
| '/roles/create'
|
|
||||||
| '/session-dimentorin/$id'
|
|
||||||
| '/users-dimentorin/$id'
|
|
||||||
| '/auth/login'
|
|
||||||
fileRoutesByTo: FileRoutesByTo
|
|
||||||
to:
|
|
||||||
| '/'
|
|
||||||
| '/accounts'
|
|
||||||
| '/cms-events'
|
|
||||||
| '/cms-testimonials'
|
|
||||||
| '/dashboard'
|
|
||||||
| '/dashboard-dimentorin'
|
|
||||||
| '/feedback-review-dimentorin'
|
|
||||||
| '/gacha-roll'
|
|
||||||
| '/hackathon-dashboard'
|
|
||||||
| '/hackathon-submissions'
|
|
||||||
| '/hackathon-teams'
|
|
||||||
| '/hackathon-users'
|
|
||||||
| '/permissions'
|
|
||||||
| '/prizes'
|
|
||||||
| '/roadmap-dimentorin'
|
|
||||||
| '/roles'
|
|
||||||
| '/session-dimentorin'
|
|
||||||
| '/settings-dimentorin'
|
|
||||||
| '/transactions'
|
|
||||||
| '/users-dimentorin'
|
|
||||||
| '/accounts/$id'
|
|
||||||
| '/cms-events/$id'
|
|
||||||
| '/cms-events/create'
|
|
||||||
| '/cms-testimonials/$id'
|
|
||||||
| '/cms-testimonials/create'
|
|
||||||
| '/dashboard/$id'
|
|
||||||
| '/dashboard/create'
|
|
||||||
| '/gacha-roll/$id'
|
|
||||||
| '/gacha-roll/create'
|
|
||||||
| '/permissions/$id'
|
|
||||||
| '/permissions/create'
|
|
||||||
| '/roadmap-dimentorin/$id'
|
|
||||||
| '/roadmap-dimentorin/create'
|
|
||||||
| '/roles/$id'
|
|
||||||
| '/roles/create'
|
|
||||||
| '/session-dimentorin/$id'
|
|
||||||
| '/users-dimentorin/$id'
|
|
||||||
| '/auth/login'
|
|
||||||
id:
|
|
||||||
| '__root__'
|
|
||||||
| '/'
|
|
||||||
| '/_authenticated'
|
|
||||||
| '/_public'
|
|
||||||
| '/_authenticated/accounts'
|
|
||||||
| '/_authenticated/cms-events'
|
|
||||||
| '/_authenticated/cms-testimonials'
|
|
||||||
| '/_authenticated/dashboard'
|
|
||||||
| '/_authenticated/dashboard-dimentorin'
|
|
||||||
| '/_authenticated/feedback-review-dimentorin'
|
|
||||||
| '/_authenticated/gacha-roll'
|
|
||||||
| '/_authenticated/hackathon-dashboard'
|
|
||||||
| '/_authenticated/hackathon-submissions'
|
|
||||||
| '/_authenticated/hackathon-teams'
|
|
||||||
| '/_authenticated/hackathon-users'
|
|
||||||
| '/_authenticated/permissions'
|
|
||||||
| '/_authenticated/prizes'
|
|
||||||
| '/_authenticated/roadmap-dimentorin'
|
|
||||||
| '/_authenticated/roles'
|
|
||||||
| '/_authenticated/session-dimentorin'
|
|
||||||
| '/_authenticated/settings-dimentorin'
|
|
||||||
| '/_authenticated/transactions'
|
|
||||||
| '/_authenticated/users-dimentorin'
|
|
||||||
| '/_authenticated/accounts_/$id'
|
|
||||||
| '/_authenticated/cms-events_/$id'
|
|
||||||
| '/_authenticated/cms-events_/create'
|
|
||||||
| '/_authenticated/cms-testimonials_/$id'
|
|
||||||
| '/_authenticated/cms-testimonials_/create'
|
|
||||||
| '/_authenticated/dashboard_/$id'
|
|
||||||
| '/_authenticated/dashboard_/create'
|
|
||||||
| '/_authenticated/gacha-roll_/$id'
|
|
||||||
| '/_authenticated/gacha-roll_/create'
|
|
||||||
| '/_authenticated/permissions_/$id'
|
|
||||||
| '/_authenticated/permissions_/create'
|
|
||||||
| '/_authenticated/roadmap-dimentorin_/$id'
|
|
||||||
| '/_authenticated/roadmap-dimentorin_/create'
|
|
||||||
| '/_authenticated/roles_/$id'
|
|
||||||
| '/_authenticated/roles_/create'
|
|
||||||
| '/_authenticated/session-dimentorin_/$id'
|
|
||||||
| '/_authenticated/users-dimentorin_/$id'
|
|
||||||
| '/_public/auth/login'
|
|
||||||
fileRoutesById: FileRoutesById
|
|
||||||
}
|
|
||||||
export interface RootRouteChildren {
|
|
||||||
IndexRoute: typeof IndexRoute
|
|
||||||
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
|
|
||||||
PublicRoute: typeof PublicRouteWithChildren
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
|
||||||
interface FileRoutesByPath {
|
|
||||||
'/_public': {
|
|
||||||
id: '/_public'
|
|
||||||
path: ''
|
|
||||||
fullPath: '/'
|
|
||||||
preLoaderRoute: typeof PublicRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/_authenticated': {
|
|
||||||
id: '/_authenticated'
|
|
||||||
path: ''
|
|
||||||
fullPath: '/'
|
|
||||||
preLoaderRoute: typeof AuthenticatedRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/': {
|
|
||||||
id: '/'
|
|
||||||
path: '/'
|
|
||||||
fullPath: '/'
|
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/_authenticated/users-dimentorin': {
|
|
||||||
id: '/_authenticated/users-dimentorin'
|
|
||||||
path: '/users-dimentorin'
|
|
||||||
fullPath: '/users-dimentorin'
|
|
||||||
preLoaderRoute: typeof AuthenticatedUsersDimentorinRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/transactions': {
|
|
||||||
id: '/_authenticated/transactions'
|
|
||||||
path: '/transactions'
|
|
||||||
fullPath: '/transactions'
|
|
||||||
preLoaderRoute: typeof AuthenticatedTransactionsRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/settings-dimentorin': {
|
|
||||||
id: '/_authenticated/settings-dimentorin'
|
|
||||||
path: '/settings-dimentorin'
|
|
||||||
fullPath: '/settings-dimentorin'
|
|
||||||
preLoaderRoute: typeof AuthenticatedSettingsDimentorinRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/session-dimentorin': {
|
|
||||||
id: '/_authenticated/session-dimentorin'
|
|
||||||
path: '/session-dimentorin'
|
|
||||||
fullPath: '/session-dimentorin'
|
|
||||||
preLoaderRoute: typeof AuthenticatedSessionDimentorinRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/roles': {
|
|
||||||
id: '/_authenticated/roles'
|
|
||||||
path: '/roles'
|
|
||||||
fullPath: '/roles'
|
|
||||||
preLoaderRoute: typeof AuthenticatedRolesRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/roadmap-dimentorin': {
|
|
||||||
id: '/_authenticated/roadmap-dimentorin'
|
|
||||||
path: '/roadmap-dimentorin'
|
|
||||||
fullPath: '/roadmap-dimentorin'
|
|
||||||
preLoaderRoute: typeof AuthenticatedRoadmapDimentorinRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/prizes': {
|
|
||||||
id: '/_authenticated/prizes'
|
|
||||||
path: '/prizes'
|
|
||||||
fullPath: '/prizes'
|
|
||||||
preLoaderRoute: typeof AuthenticatedPrizesRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/permissions': {
|
|
||||||
id: '/_authenticated/permissions'
|
|
||||||
path: '/permissions'
|
|
||||||
fullPath: '/permissions'
|
|
||||||
preLoaderRoute: typeof AuthenticatedPermissionsRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/hackathon-users': {
|
|
||||||
id: '/_authenticated/hackathon-users'
|
|
||||||
path: '/hackathon-users'
|
|
||||||
fullPath: '/hackathon-users'
|
|
||||||
preLoaderRoute: typeof AuthenticatedHackathonUsersRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/hackathon-teams': {
|
|
||||||
id: '/_authenticated/hackathon-teams'
|
|
||||||
path: '/hackathon-teams'
|
|
||||||
fullPath: '/hackathon-teams'
|
|
||||||
preLoaderRoute: typeof AuthenticatedHackathonTeamsRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/hackathon-submissions': {
|
|
||||||
id: '/_authenticated/hackathon-submissions'
|
|
||||||
path: '/hackathon-submissions'
|
|
||||||
fullPath: '/hackathon-submissions'
|
|
||||||
preLoaderRoute: typeof AuthenticatedHackathonSubmissionsRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/hackathon-dashboard': {
|
|
||||||
id: '/_authenticated/hackathon-dashboard'
|
|
||||||
path: '/hackathon-dashboard'
|
|
||||||
fullPath: '/hackathon-dashboard'
|
|
||||||
preLoaderRoute: typeof AuthenticatedHackathonDashboardRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/gacha-roll': {
|
|
||||||
id: '/_authenticated/gacha-roll'
|
|
||||||
path: '/gacha-roll'
|
|
||||||
fullPath: '/gacha-roll'
|
|
||||||
preLoaderRoute: typeof AuthenticatedGachaRollRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/feedback-review-dimentorin': {
|
|
||||||
id: '/_authenticated/feedback-review-dimentorin'
|
|
||||||
path: '/feedback-review-dimentorin'
|
|
||||||
fullPath: '/feedback-review-dimentorin'
|
|
||||||
preLoaderRoute: typeof AuthenticatedFeedbackReviewDimentorinRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/dashboard-dimentorin': {
|
|
||||||
id: '/_authenticated/dashboard-dimentorin'
|
|
||||||
path: '/dashboard-dimentorin'
|
|
||||||
fullPath: '/dashboard-dimentorin'
|
|
||||||
preLoaderRoute: typeof AuthenticatedDashboardDimentorinRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/dashboard': {
|
|
||||||
id: '/_authenticated/dashboard'
|
|
||||||
path: '/dashboard'
|
|
||||||
fullPath: '/dashboard'
|
|
||||||
preLoaderRoute: typeof AuthenticatedDashboardRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/cms-testimonials': {
|
|
||||||
id: '/_authenticated/cms-testimonials'
|
|
||||||
path: '/cms-testimonials'
|
|
||||||
fullPath: '/cms-testimonials'
|
|
||||||
preLoaderRoute: typeof AuthenticatedCmsTestimonialsRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/cms-events': {
|
|
||||||
id: '/_authenticated/cms-events'
|
|
||||||
path: '/cms-events'
|
|
||||||
fullPath: '/cms-events'
|
|
||||||
preLoaderRoute: typeof AuthenticatedCmsEventsRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/accounts': {
|
|
||||||
id: '/_authenticated/accounts'
|
|
||||||
path: '/accounts'
|
|
||||||
fullPath: '/accounts'
|
|
||||||
preLoaderRoute: typeof AuthenticatedAccountsRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_public/auth/login': {
|
|
||||||
id: '/_public/auth/login'
|
|
||||||
path: '/auth/login'
|
|
||||||
fullPath: '/auth/login'
|
|
||||||
preLoaderRoute: typeof PublicAuthLoginRouteImport
|
|
||||||
parentRoute: typeof PublicRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/users-dimentorin_/$id': {
|
|
||||||
id: '/_authenticated/users-dimentorin_/$id'
|
|
||||||
path: '/users-dimentorin/$id'
|
|
||||||
fullPath: '/users-dimentorin/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedUsersDimentorinIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/session-dimentorin_/$id': {
|
|
||||||
id: '/_authenticated/session-dimentorin_/$id'
|
|
||||||
path: '/session-dimentorin/$id'
|
|
||||||
fullPath: '/session-dimentorin/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedSessionDimentorinIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/roles_/create': {
|
|
||||||
id: '/_authenticated/roles_/create'
|
|
||||||
path: '/roles/create'
|
|
||||||
fullPath: '/roles/create'
|
|
||||||
preLoaderRoute: typeof AuthenticatedRolesCreateRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/roles_/$id': {
|
|
||||||
id: '/_authenticated/roles_/$id'
|
|
||||||
path: '/roles/$id'
|
|
||||||
fullPath: '/roles/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedRolesIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/roadmap-dimentorin_/create': {
|
|
||||||
id: '/_authenticated/roadmap-dimentorin_/create'
|
|
||||||
path: '/roadmap-dimentorin/create'
|
|
||||||
fullPath: '/roadmap-dimentorin/create'
|
|
||||||
preLoaderRoute: typeof AuthenticatedRoadmapDimentorinCreateRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/roadmap-dimentorin_/$id': {
|
|
||||||
id: '/_authenticated/roadmap-dimentorin_/$id'
|
|
||||||
path: '/roadmap-dimentorin/$id'
|
|
||||||
fullPath: '/roadmap-dimentorin/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedRoadmapDimentorinIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/permissions_/create': {
|
|
||||||
id: '/_authenticated/permissions_/create'
|
|
||||||
path: '/permissions/create'
|
|
||||||
fullPath: '/permissions/create'
|
|
||||||
preLoaderRoute: typeof AuthenticatedPermissionsCreateRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/permissions_/$id': {
|
|
||||||
id: '/_authenticated/permissions_/$id'
|
|
||||||
path: '/permissions/$id'
|
|
||||||
fullPath: '/permissions/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedPermissionsIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/gacha-roll_/create': {
|
|
||||||
id: '/_authenticated/gacha-roll_/create'
|
|
||||||
path: '/gacha-roll/create'
|
|
||||||
fullPath: '/gacha-roll/create'
|
|
||||||
preLoaderRoute: typeof AuthenticatedGachaRollCreateRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/gacha-roll_/$id': {
|
|
||||||
id: '/_authenticated/gacha-roll_/$id'
|
|
||||||
path: '/gacha-roll/$id'
|
|
||||||
fullPath: '/gacha-roll/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedGachaRollIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/dashboard_/create': {
|
|
||||||
id: '/_authenticated/dashboard_/create'
|
|
||||||
path: '/dashboard/create'
|
|
||||||
fullPath: '/dashboard/create'
|
|
||||||
preLoaderRoute: typeof AuthenticatedDashboardCreateRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/dashboard_/$id': {
|
|
||||||
id: '/_authenticated/dashboard_/$id'
|
|
||||||
path: '/dashboard/$id'
|
|
||||||
fullPath: '/dashboard/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedDashboardIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/cms-testimonials_/create': {
|
|
||||||
id: '/_authenticated/cms-testimonials_/create'
|
|
||||||
path: '/cms-testimonials/create'
|
|
||||||
fullPath: '/cms-testimonials/create'
|
|
||||||
preLoaderRoute: typeof AuthenticatedCmsTestimonialsCreateRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/cms-testimonials_/$id': {
|
|
||||||
id: '/_authenticated/cms-testimonials_/$id'
|
|
||||||
path: '/cms-testimonials/$id'
|
|
||||||
fullPath: '/cms-testimonials/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedCmsTestimonialsIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/cms-events_/create': {
|
|
||||||
id: '/_authenticated/cms-events_/create'
|
|
||||||
path: '/cms-events/create'
|
|
||||||
fullPath: '/cms-events/create'
|
|
||||||
preLoaderRoute: typeof AuthenticatedCmsEventsCreateRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/cms-events_/$id': {
|
|
||||||
id: '/_authenticated/cms-events_/$id'
|
|
||||||
path: '/cms-events/$id'
|
|
||||||
fullPath: '/cms-events/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedCmsEventsIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
'/_authenticated/accounts_/$id': {
|
|
||||||
id: '/_authenticated/accounts_/$id'
|
|
||||||
path: '/accounts/$id'
|
|
||||||
fullPath: '/accounts/$id'
|
|
||||||
preLoaderRoute: typeof AuthenticatedAccountsIdRouteImport
|
|
||||||
parentRoute: typeof AuthenticatedRoute
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthenticatedRouteChildren {
|
|
||||||
AuthenticatedAccountsRoute: typeof AuthenticatedAccountsRoute
|
|
||||||
AuthenticatedCmsEventsRoute: typeof AuthenticatedCmsEventsRoute
|
|
||||||
AuthenticatedCmsTestimonialsRoute: typeof AuthenticatedCmsTestimonialsRoute
|
|
||||||
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
|
|
||||||
AuthenticatedDashboardDimentorinRoute: typeof AuthenticatedDashboardDimentorinRoute
|
|
||||||
AuthenticatedFeedbackReviewDimentorinRoute: typeof AuthenticatedFeedbackReviewDimentorinRoute
|
|
||||||
AuthenticatedGachaRollRoute: typeof AuthenticatedGachaRollRoute
|
|
||||||
AuthenticatedHackathonDashboardRoute: typeof AuthenticatedHackathonDashboardRoute
|
|
||||||
AuthenticatedHackathonSubmissionsRoute: typeof AuthenticatedHackathonSubmissionsRoute
|
|
||||||
AuthenticatedHackathonTeamsRoute: typeof AuthenticatedHackathonTeamsRoute
|
|
||||||
AuthenticatedHackathonUsersRoute: typeof AuthenticatedHackathonUsersRoute
|
|
||||||
AuthenticatedPermissionsRoute: typeof AuthenticatedPermissionsRoute
|
|
||||||
AuthenticatedPrizesRoute: typeof AuthenticatedPrizesRoute
|
|
||||||
AuthenticatedRoadmapDimentorinRoute: typeof AuthenticatedRoadmapDimentorinRoute
|
|
||||||
AuthenticatedRolesRoute: typeof AuthenticatedRolesRoute
|
|
||||||
AuthenticatedSessionDimentorinRoute: typeof AuthenticatedSessionDimentorinRoute
|
|
||||||
AuthenticatedSettingsDimentorinRoute: typeof AuthenticatedSettingsDimentorinRoute
|
|
||||||
AuthenticatedTransactionsRoute: typeof AuthenticatedTransactionsRoute
|
|
||||||
AuthenticatedUsersDimentorinRoute: typeof AuthenticatedUsersDimentorinRoute
|
|
||||||
AuthenticatedAccountsIdRoute: typeof AuthenticatedAccountsIdRoute
|
|
||||||
AuthenticatedCmsEventsIdRoute: typeof AuthenticatedCmsEventsIdRoute
|
|
||||||
AuthenticatedCmsEventsCreateRoute: typeof AuthenticatedCmsEventsCreateRoute
|
|
||||||
AuthenticatedCmsTestimonialsIdRoute: typeof AuthenticatedCmsTestimonialsIdRoute
|
|
||||||
AuthenticatedCmsTestimonialsCreateRoute: typeof AuthenticatedCmsTestimonialsCreateRoute
|
|
||||||
AuthenticatedDashboardIdRoute: typeof AuthenticatedDashboardIdRoute
|
|
||||||
AuthenticatedDashboardCreateRoute: typeof AuthenticatedDashboardCreateRoute
|
|
||||||
AuthenticatedGachaRollIdRoute: typeof AuthenticatedGachaRollIdRoute
|
|
||||||
AuthenticatedGachaRollCreateRoute: typeof AuthenticatedGachaRollCreateRoute
|
|
||||||
AuthenticatedPermissionsIdRoute: typeof AuthenticatedPermissionsIdRoute
|
|
||||||
AuthenticatedPermissionsCreateRoute: typeof AuthenticatedPermissionsCreateRoute
|
|
||||||
AuthenticatedRoadmapDimentorinIdRoute: typeof AuthenticatedRoadmapDimentorinIdRoute
|
|
||||||
AuthenticatedRoadmapDimentorinCreateRoute: typeof AuthenticatedRoadmapDimentorinCreateRoute
|
|
||||||
AuthenticatedRolesIdRoute: typeof AuthenticatedRolesIdRoute
|
|
||||||
AuthenticatedRolesCreateRoute: typeof AuthenticatedRolesCreateRoute
|
|
||||||
AuthenticatedSessionDimentorinIdRoute: typeof AuthenticatedSessionDimentorinIdRoute
|
|
||||||
AuthenticatedUsersDimentorinIdRoute: typeof AuthenticatedUsersDimentorinIdRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
|
||||||
AuthenticatedAccountsRoute: AuthenticatedAccountsRoute,
|
|
||||||
AuthenticatedCmsEventsRoute: AuthenticatedCmsEventsRoute,
|
|
||||||
AuthenticatedCmsTestimonialsRoute: AuthenticatedCmsTestimonialsRoute,
|
|
||||||
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
|
|
||||||
AuthenticatedDashboardDimentorinRoute: AuthenticatedDashboardDimentorinRoute,
|
|
||||||
AuthenticatedFeedbackReviewDimentorinRoute:
|
|
||||||
AuthenticatedFeedbackReviewDimentorinRoute,
|
|
||||||
AuthenticatedGachaRollRoute: AuthenticatedGachaRollRoute,
|
|
||||||
AuthenticatedHackathonDashboardRoute: AuthenticatedHackathonDashboardRoute,
|
|
||||||
AuthenticatedHackathonSubmissionsRoute:
|
|
||||||
AuthenticatedHackathonSubmissionsRoute,
|
|
||||||
AuthenticatedHackathonTeamsRoute: AuthenticatedHackathonTeamsRoute,
|
|
||||||
AuthenticatedHackathonUsersRoute: AuthenticatedHackathonUsersRoute,
|
|
||||||
AuthenticatedPermissionsRoute: AuthenticatedPermissionsRoute,
|
|
||||||
AuthenticatedPrizesRoute: AuthenticatedPrizesRoute,
|
|
||||||
AuthenticatedRoadmapDimentorinRoute: AuthenticatedRoadmapDimentorinRoute,
|
|
||||||
AuthenticatedRolesRoute: AuthenticatedRolesRoute,
|
|
||||||
AuthenticatedSessionDimentorinRoute: AuthenticatedSessionDimentorinRoute,
|
|
||||||
AuthenticatedSettingsDimentorinRoute: AuthenticatedSettingsDimentorinRoute,
|
|
||||||
AuthenticatedTransactionsRoute: AuthenticatedTransactionsRoute,
|
|
||||||
AuthenticatedUsersDimentorinRoute: AuthenticatedUsersDimentorinRoute,
|
|
||||||
AuthenticatedAccountsIdRoute: AuthenticatedAccountsIdRoute,
|
|
||||||
AuthenticatedCmsEventsIdRoute: AuthenticatedCmsEventsIdRoute,
|
|
||||||
AuthenticatedCmsEventsCreateRoute: AuthenticatedCmsEventsCreateRoute,
|
|
||||||
AuthenticatedCmsTestimonialsIdRoute: AuthenticatedCmsTestimonialsIdRoute,
|
|
||||||
AuthenticatedCmsTestimonialsCreateRoute:
|
|
||||||
AuthenticatedCmsTestimonialsCreateRoute,
|
|
||||||
AuthenticatedDashboardIdRoute: AuthenticatedDashboardIdRoute,
|
|
||||||
AuthenticatedDashboardCreateRoute: AuthenticatedDashboardCreateRoute,
|
|
||||||
AuthenticatedGachaRollIdRoute: AuthenticatedGachaRollIdRoute,
|
|
||||||
AuthenticatedGachaRollCreateRoute: AuthenticatedGachaRollCreateRoute,
|
|
||||||
AuthenticatedPermissionsIdRoute: AuthenticatedPermissionsIdRoute,
|
|
||||||
AuthenticatedPermissionsCreateRoute: AuthenticatedPermissionsCreateRoute,
|
|
||||||
AuthenticatedRoadmapDimentorinIdRoute: AuthenticatedRoadmapDimentorinIdRoute,
|
|
||||||
AuthenticatedRoadmapDimentorinCreateRoute:
|
|
||||||
AuthenticatedRoadmapDimentorinCreateRoute,
|
|
||||||
AuthenticatedRolesIdRoute: AuthenticatedRolesIdRoute,
|
|
||||||
AuthenticatedRolesCreateRoute: AuthenticatedRolesCreateRoute,
|
|
||||||
AuthenticatedSessionDimentorinIdRoute: AuthenticatedSessionDimentorinIdRoute,
|
|
||||||
AuthenticatedUsersDimentorinIdRoute: AuthenticatedUsersDimentorinIdRoute,
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
|
||||||
AuthenticatedRouteChildren,
|
|
||||||
)
|
|
||||||
|
|
||||||
interface PublicRouteChildren {
|
|
||||||
PublicAuthLoginRoute: typeof PublicAuthLoginRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
const PublicRouteChildren: PublicRouteChildren = {
|
|
||||||
PublicAuthLoginRoute: PublicAuthLoginRoute,
|
|
||||||
}
|
|
||||||
|
|
||||||
const PublicRouteWithChildren =
|
|
||||||
PublicRoute._addFileChildren(PublicRouteChildren)
|
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
|
||||||
IndexRoute: IndexRoute,
|
|
||||||
AuthenticatedRoute: AuthenticatedRouteWithChildren,
|
|
||||||
PublicRoute: PublicRouteWithChildren,
|
|
||||||
}
|
|
||||||
export const routeTree = rootRouteImport
|
|
||||||
._addFileChildren(rootRouteChildren)
|
|
||||||
._addFileTypes<FileRouteTypes>()
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createRootRoute, Outlet } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
|
||||||
component: () => <Outlet />,
|
|
||||||
})
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
|
|
||||||
import { SessionToken } from '@imphnen-frontend-service/service';
|
|
||||||
import {
|
|
||||||
SidebarInset,
|
|
||||||
SidebarProvider,
|
|
||||||
} from '@imphnen-frontend-service/ui/atoms';
|
|
||||||
import { BackofficeSidebar } from '../components/sidebar';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated')({
|
|
||||||
beforeLoad: () => {
|
|
||||||
const session = SessionToken.get();
|
|
||||||
if (!session?.token?.access_token) {
|
|
||||||
throw redirect({ to: '/auth/login' });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
component: AuthenticatedLayout,
|
|
||||||
});
|
|
||||||
|
|
||||||
function AuthenticatedLayout() {
|
|
||||||
return (
|
|
||||||
<SidebarProvider>
|
|
||||||
<BackofficeSidebar />
|
|
||||||
<SidebarInset>
|
|
||||||
<Outlet />
|
|
||||||
</SidebarInset>
|
|
||||||
</SidebarProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-57
@@ -1,57 +0,0 @@
|
|||||||
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
@@ -1,36 +0,0 @@
|
|||||||
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
@@ -1,233 +0,0 @@
|
|||||||
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
@@ -1,501 +0,0 @@
|
|||||||
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
@@ -1,79 +0,0 @@
|
|||||||
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
@@ -1,626 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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
@@ -1,34 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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
@@ -1,35 +0,0 @@
|
|||||||
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
@@ -1,131 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export const useItem = (
|
|
||||||
nextStep: () => void,
|
|
||||||
initialValues?: any,
|
|
||||||
onDataCapture?: (data: any) => void,
|
|
||||||
) => {
|
|
||||||
const form = useForm<any>({
|
|
||||||
mode: 'all',
|
|
||||||
defaultValues: initialValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => {
|
|
||||||
onDataCapture?.(data);
|
|
||||||
nextStep();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
form,
|
|
||||||
onSubmit,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useConfirmItem = (
|
|
||||||
onClose: () => void,
|
|
||||||
resetStep: () => void,
|
|
||||||
actionFunction?: () => Promise<boolean>,
|
|
||||||
messages?: {
|
|
||||||
success?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
const onConfirm = async () => {
|
|
||||||
try {
|
|
||||||
if (actionFunction) {
|
|
||||||
await actionFunction();
|
|
||||||
}
|
|
||||||
toast.success(messages?.success);
|
|
||||||
onClose();
|
|
||||||
resetStep();
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
toast.error(messages?.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCancel = () => {
|
|
||||||
onClose();
|
|
||||||
resetStep();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export const useItem = (
|
|
||||||
nextStep: () => void,
|
|
||||||
initialValues?: any,
|
|
||||||
onDataCapture?: (data: any) => void,
|
|
||||||
) => {
|
|
||||||
const form = useForm<any>({
|
|
||||||
mode: 'all',
|
|
||||||
defaultValues: initialValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => {
|
|
||||||
onDataCapture?.(data);
|
|
||||||
nextStep();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
form,
|
|
||||||
onSubmit,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useConfirmItem = (
|
|
||||||
onClose: () => void,
|
|
||||||
resetStep: () => void,
|
|
||||||
actionFunction?: () => Promise<boolean>,
|
|
||||||
messages?: {
|
|
||||||
success?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
const onConfirm = async () => {
|
|
||||||
try {
|
|
||||||
if (actionFunction) {
|
|
||||||
await actionFunction();
|
|
||||||
}
|
|
||||||
toast.success(messages?.success);
|
|
||||||
onClose();
|
|
||||||
resetStep();
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
toast.error(messages?.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCancel = () => {
|
|
||||||
onClose();
|
|
||||||
resetStep();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
||||||
import * as React from 'react';
|
|
||||||
import { Filter as FilterIcon, Search, Pencil } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
Checkbox,
|
|
||||||
Input,
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
Badge,
|
|
||||||
} from '@imphnen-frontend-service/ui/atoms';
|
|
||||||
import {
|
|
||||||
DataTable,
|
|
||||||
Filter,
|
|
||||||
BackofficeWrapper,
|
|
||||||
} from '@imphnen-frontend-service/ui/organisms';
|
|
||||||
import {
|
|
||||||
ColumnDef,
|
|
||||||
getCoreRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
PaginationState,
|
|
||||||
useReactTable,
|
|
||||||
RowSelectionState,
|
|
||||||
} from '@tanstack/react-table';
|
|
||||||
import {
|
|
||||||
useUserList,
|
|
||||||
TUsersListItem,
|
|
||||||
} from '@imphnen-frontend-service/service';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/accounts')({
|
|
||||||
component: AccountsPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
function AccountsPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [search, setSearch] = React.useState('');
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: 10,
|
|
||||||
});
|
|
||||||
const [rowSelection, setRowSelection] =
|
|
||||||
React.useState<RowSelectionState>({});
|
|
||||||
const [showFilter, setShowFilter] = React.useState(false);
|
|
||||||
|
|
||||||
const { data: usersData, isLoading } = useUserList({
|
|
||||||
search,
|
|
||||||
page: pagination.pageIndex + 1,
|
|
||||||
per_page: pagination.pageSize,
|
|
||||||
});
|
|
||||||
|
|
||||||
const users: TUsersListItem[] = usersData?.data ?? [];
|
|
||||||
const totalItems = usersData?.meta?.total ?? users.length;
|
|
||||||
|
|
||||||
const columns: ColumnDef<TUsersListItem>[] = [
|
|
||||||
{
|
|
||||||
id: 'select',
|
|
||||||
header: ({ table }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={
|
|
||||||
table.getIsAllRowsSelected()
|
|
||||||
? true
|
|
||||||
: table.getIsSomeRowsSelected()
|
|
||||||
? 'indeterminate'
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
|
|
||||||
}
|
|
||||||
aria-label="Select all"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
|
||||||
aria-label="Select row"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ header: 'No', accessorKey: 'id' },
|
|
||||||
{ header: 'Nama Lengkap', accessorKey: 'fullname' },
|
|
||||||
{ header: 'Email', accessorKey: 'email' },
|
|
||||||
{ header: 'Role', accessorKey: 'role' },
|
|
||||||
{
|
|
||||||
header: 'Status',
|
|
||||||
accessorKey: 'is_active',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge variant={row.original.is_active ? 'success' : 'destructive'}>
|
|
||||||
{row.original.is_active ? 'Aktif' : 'Tidak Aktif'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Action',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate({ to: '/accounts/$id', params: { id: row.original.id } });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="size-3.5" /> Edit
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: users,
|
|
||||||
columns,
|
|
||||||
state: { pagination, rowSelection },
|
|
||||||
enableRowSelection: true,
|
|
||||||
onRowSelectionChange: setRowSelection,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
|
||||||
manualPagination: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BackofficeWrapper
|
|
||||||
title="Data Akun"
|
|
||||||
description="Kelola akun pengguna yang terdaftar"
|
|
||||||
>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="relative w-full sm:max-w-sm">
|
|
||||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Cari nama lengkap atau email…"
|
|
||||||
className="pl-9"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Popover open={showFilter} onOpenChange={setShowFilter}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="secondary" size="md">
|
|
||||||
<FilterIcon className="size-4" />
|
|
||||||
Filters
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="end" className="w-auto p-0">
|
|
||||||
<Filter
|
|
||||||
onClose={() => setShowFilter(false)}
|
|
||||||
options={[
|
|
||||||
{ id: 'all', value: 'all', label: 'Semua' },
|
|
||||||
{ id: 'active', value: 'active', label: 'Aktif' },
|
|
||||||
{ id: 'inactive', value: 'inactive', label: 'Tidak Aktif' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
|
||||||
Memuat data…
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DataTable
|
|
||||||
data={users}
|
|
||||||
columns={columns}
|
|
||||||
table={table}
|
|
||||||
manualPagination
|
|
||||||
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
|
||||||
currentPage={pagination.pageIndex + 1}
|
|
||||||
onPageChange={(p) =>
|
|
||||||
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</BackofficeWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
||||||
import * as React from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@imphnen-frontend-service/ui/atoms';
|
|
||||||
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
|
||||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
|
||||||
import {
|
|
||||||
useUserList,
|
|
||||||
useUpdateUserById,
|
|
||||||
} from '@imphnen-frontend-service/service';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/accounts_/$id')({
|
|
||||||
component: AccountsEditPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
function AccountsEditPage() {
|
|
||||||
const { id } = Route.useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const updateUser = useUpdateUserById();
|
|
||||||
|
|
||||||
const { data: usersData, isLoading } = useUserList({
|
|
||||||
search: '',
|
|
||||||
per_page: 100,
|
|
||||||
});
|
|
||||||
const user = usersData?.data?.find((u) => u.id === id);
|
|
||||||
|
|
||||||
const [fullName, setFullName] = React.useState('');
|
|
||||||
const [email, setEmail] = React.useState('');
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
setFullName(user.fullname);
|
|
||||||
setEmail(user.email);
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
try {
|
|
||||||
await updateUser.mutateAsync({
|
|
||||||
id,
|
|
||||||
data: { fullname: fullName, email },
|
|
||||||
});
|
|
||||||
toast.success('Data akun berhasil diperbarui');
|
|
||||||
navigate({ to: '/accounts' });
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
toast.error('Data akun gagal diperbarui');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BackofficeWrapper title="Edit Data Akun">
|
|
||||||
<div className="mx-auto w-full max-w-2xl">
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigate({ to: '/accounts' })}
|
|
||||||
className="mb-4 -ml-2"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
Kembali
|
|
||||||
</Button>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Edit Data Akun</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
|
||||||
Memuat data…
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<InputField
|
|
||||||
label="Nama Lengkap"
|
|
||||||
type="text"
|
|
||||||
placeholder="Masukkan nama lengkap"
|
|
||||||
value={fullName}
|
|
||||||
onChange={(e) => setFullName(e.target.value)}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
<InputField
|
|
||||||
label="Email"
|
|
||||||
type="text"
|
|
||||||
placeholder="Masukkan email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="md"
|
|
||||||
onClick={() => navigate({ to: '/accounts' })}
|
|
||||||
>
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={updateUser.isPending || isLoading}
|
|
||||||
>
|
|
||||||
{updateUser.isPending ? 'Menyimpan…' : 'Perbarui Data'}
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</BackofficeWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
||||||
import * as React from 'react';
|
|
||||||
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
Checkbox,
|
|
||||||
Input,
|
|
||||||
} from '@imphnen-frontend-service/ui/atoms';
|
|
||||||
import {
|
|
||||||
DataTable,
|
|
||||||
BackofficeWrapper,
|
|
||||||
} from '@imphnen-frontend-service/ui/organisms';
|
|
||||||
import {
|
|
||||||
ColumnDef,
|
|
||||||
getCoreRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
PaginationState,
|
|
||||||
RowSelectionState,
|
|
||||||
useReactTable,
|
|
||||||
} from '@tanstack/react-table';
|
|
||||||
import {
|
|
||||||
useEventList,
|
|
||||||
useDeleteEvent,
|
|
||||||
TEventsListItem,
|
|
||||||
} from '@imphnen-frontend-service/service';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/cms-events')({
|
|
||||||
component: CmsEventsPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
function CmsEventsPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [search, setSearch] = React.useState('');
|
|
||||||
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: 10,
|
|
||||||
});
|
|
||||||
const [rowSelection, setRowSelection] =
|
|
||||||
React.useState<RowSelectionState>({});
|
|
||||||
|
|
||||||
const { data: eventsData, isLoading } = useEventList({
|
|
||||||
search,
|
|
||||||
page: pagination.pageIndex + 1,
|
|
||||||
per_page: pagination.pageSize,
|
|
||||||
});
|
|
||||||
const deleteEvent = useDeleteEvent();
|
|
||||||
|
|
||||||
const events: TEventsListItem[] = eventsData?.data ?? [];
|
|
||||||
const totalItems = eventsData?.meta?.total ?? events.length;
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await deleteEvent.mutateAsync(id);
|
|
||||||
toast.success('Data event berhasil dihapus');
|
|
||||||
setDeleteId(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
toast.error('Data event gagal dihapus');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: ColumnDef<TEventsListItem>[] = [
|
|
||||||
{
|
|
||||||
id: 'select',
|
|
||||||
header: ({ table }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={
|
|
||||||
table.getIsAllRowsSelected()
|
|
||||||
? true
|
|
||||||
: table.getIsSomeRowsSelected()
|
|
||||||
? 'indeterminate'
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
|
|
||||||
}
|
|
||||||
aria-label="Select all"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
|
||||||
aria-label="Select row"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ header: 'Name', accessorKey: 'name' },
|
|
||||||
{
|
|
||||||
header: 'Location',
|
|
||||||
accessorKey: 'location',
|
|
||||||
cell: ({ row }) => row.original.location || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Price',
|
|
||||||
accessorKey: 'price',
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.price === 0
|
|
||||||
? 'Free'
|
|
||||||
: `Rp ${row.original.price.toLocaleString('id-ID')}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Start Date',
|
|
||||||
accessorKey: 'start_date',
|
|
||||||
cell: ({ row }) =>
|
|
||||||
new Date(row.original.start_date).toLocaleDateString('id-ID', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Online',
|
|
||||||
accessorKey: 'is_online',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge variant={row.original.is_online ? 'success' : 'secondary'}>
|
|
||||||
{row.original.is_online ? 'Online' : 'Offline'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Action',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate({
|
|
||||||
to: '/cms-events/$id',
|
|
||||||
params: { id: row.original.id },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="size-3.5" />
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setDeleteId(row.original.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: events,
|
|
||||||
columns,
|
|
||||||
state: { pagination, rowSelection },
|
|
||||||
enableRowSelection: true,
|
|
||||||
onRowSelectionChange: setRowSelection,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
|
||||||
manualPagination: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BackofficeWrapper title="CMS Events" description="Kelola event komunitas">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="relative w-full sm:max-w-sm">
|
|
||||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Cari nama event…"
|
|
||||||
className="pl-9"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate({ to: '/cms-events/create' })}
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
Tambah Event
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
|
||||||
Memuat data…
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DataTable
|
|
||||||
data={events}
|
|
||||||
columns={columns}
|
|
||||||
table={table}
|
|
||||||
manualPagination
|
|
||||||
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
|
||||||
currentPage={pagination.pageIndex + 1}
|
|
||||||
onPageChange={(p) =>
|
|
||||||
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<AlertDialog
|
|
||||||
open={!!deleteId}
|
|
||||||
onOpenChange={(o) => !o && setDeleteId(null)}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Hapus event ini?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Tindakan ini tidak dapat dibatalkan. Event akan dihapus permanen.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Batal</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => deleteId && handleDelete(deleteId)}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Hapus
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</BackofficeWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { useForm } from 'react-hook-form'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
|
||||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms'
|
|
||||||
import {
|
|
||||||
useEventList,
|
|
||||||
useUpdateEvent,
|
|
||||||
} from '@imphnen-frontend-service/service'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/cms-events_/$id')({
|
|
||||||
component: CmsEventsEditPage,
|
|
||||||
})
|
|
||||||
|
|
||||||
function CmsEventsEditPage() {
|
|
||||||
const { id } = Route.useParams()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const updateEvent = useUpdateEvent()
|
|
||||||
|
|
||||||
const { data: eventsData, isLoading } = useEventList({ search: '', per_page: 100 })
|
|
||||||
const event = eventsData?.data?.find((e) => e.id === id)
|
|
||||||
|
|
||||||
const form = useForm<{
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
detail_link: string
|
|
||||||
location: string
|
|
||||||
price: number
|
|
||||||
start_date: string
|
|
||||||
end_date: string
|
|
||||||
is_online: boolean
|
|
||||||
}>({
|
|
||||||
mode: 'all',
|
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
detail_link: '',
|
|
||||||
location: '',
|
|
||||||
price: 0,
|
|
||||||
start_date: '',
|
|
||||||
end_date: '',
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (event) {
|
|
||||||
form.reset({
|
|
||||||
name: event.name,
|
|
||||||
description: event.description,
|
|
||||||
detail_link: event.detail_link,
|
|
||||||
location: event.location,
|
|
||||||
price: event.price,
|
|
||||||
start_date: event.start_date,
|
|
||||||
end_date: event.end_date,
|
|
||||||
is_online: event.is_online,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [event])
|
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit(async (data) => {
|
|
||||||
try {
|
|
||||||
await updateEvent.mutateAsync({ id, data })
|
|
||||||
toast.success('Perubahan event berhasil dilakukan')
|
|
||||||
navigate({ to: '/cms-events' })
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error)
|
|
||||||
toast.error('Perubahan event gagal dilakukan')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<main className="w-full px-[48px] py-[40px]">
|
|
||||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
|
||||||
<div className="max-w-2xl mx-auto w-full">
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate({ to: '/cms-events' })}
|
|
||||||
className="text-primary-500 hover:text-primary-600"
|
|
||||||
>
|
|
||||||
<ArrowLeftOutlined className="text-[20px]" />
|
|
||||||
</button>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Edit Event</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
||||||
<form onSubmit={onSubmit} className="flex flex-col gap-6">
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
label="Nama Event"
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
placeholder="Masukkan Nama Event"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
label="Deskripsi"
|
|
||||||
name="description"
|
|
||||||
type="text"
|
|
||||||
placeholder="Masukkan Deskripsi Event"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
label="Link Detail"
|
|
||||||
name="detail_link"
|
|
||||||
type="text"
|
|
||||||
placeholder="Masukkan Link Detail"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
label="Lokasi"
|
|
||||||
name="location"
|
|
||||||
type="text"
|
|
||||||
placeholder="Masukkan Lokasi"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
label="Harga"
|
|
||||||
name="price"
|
|
||||||
type="number"
|
|
||||||
placeholder="Masukkan Harga"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
label="Tanggal Mulai"
|
|
||||||
name="start_date"
|
|
||||||
type="date"
|
|
||||||
placeholder="Pilih Tanggal Mulai"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
control={form.control}
|
|
||||||
label="Tanggal Selesai"
|
|
||||||
name="end_date"
|
|
||||||
type="date"
|
|
||||||
placeholder="Pilih Tanggal Selesai"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="is_online_update"
|
|
||||||
className="rounded"
|
|
||||||
{...form.register('is_online')}
|
|
||||||
/>
|
|
||||||
<label htmlFor="is_online_update" className="text-p3 font-medium text-neutral-800">
|
|
||||||
Event Online
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Update Event
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="bordered"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => navigate({ to: '/cms-events' })}
|
|
||||||
>
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
||||||
import * as React from 'react';
|
|
||||||
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { useForm } from 'react-hook-form'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
|
||||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms'
|
|
||||||
import {
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { useForm } from 'react-hook-form'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
|
||||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms'
|
|
||||||
import {
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { useForm } from 'react-hook-form'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
|
||||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms'
|
|
||||||
import {
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { useForm } from 'react-hook-form'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
|
||||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms'
|
|
||||||
import {
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { useForm } from 'react-hook-form'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
|
||||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms'
|
|
||||||
import { 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user