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
|
||||
tmp
|
||||
out-tsc
|
||||
docs/FigmaImage/
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
@@ -62,21 +62,3 @@ storybook-static
|
||||
# Next.js
|
||||
.next
|
||||
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">
|
||||
</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 |
|
||||
|-----|-----------|-----|
|
||||
| **Landing** | Next.js 16 | [imphnen.dev](https://imphnen.dev) |
|
||||
| **Backoffice** | Vite + React | [backoffice.imphnen.dev](https://backoffice.imphnen.dev) |
|
||||
| **Hackathon** | Vite + React | [hackathon.imphnen.dev](https://dimentorin.imphnen.dev) |
|
||||
| **Dimentorin** | Vite + React | [dimentorin.imphnen.dev](https://dimentorin.imphnen.dev) |
|
||||
| **Gacha** | Vite + React | [gacha.imphnen.dev](https://gacha.imphnen.dev) |
|
||||
| **QR Campaign** | Vite + React | [qr.imphnen.dev](https://qr.imphnen.dev) |
|
||||
| **Infra** | Vite + React | [infra.imphnen.dev](https://infra.imphnen.dev) |
|
||||
## How to install
|
||||
|
||||
## Shared Libraries
|
||||
1. Clone this repository:
|
||||
```sh
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git
|
||||
cd imphnen-frontend-service
|
||||
```
|
||||
2. Install all dependencies:
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
| Lib | Purpose |
|
||||
|-----|---------|
|
||||
| `utils` | Pure utilities — `cn()`, `For`, `Show`, `useQueryState`, `useModalLogin` |
|
||||
| `service` | Business logic — API clients, auth hooks, storage, constants |
|
||||
| `ui` | UI components — atoms, molecules, organisms (atomic design) |
|
||||
|
||||
## 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
|
||||
```
|
||||
## How to run
|
||||
|
||||
### Development
|
||||
|
||||
```sh
|
||||
nx dev <app> # e.g. nx dev landing, nx dev backoffice
|
||||
```
|
||||
Use the following commands to run in development mode:
|
||||
|
||||
- **Gacha**:
|
||||
```sh
|
||||
npm run gacha:dev
|
||||
```
|
||||
- **Backoffice**:
|
||||
```sh
|
||||
npm run backoffice:dev
|
||||
```
|
||||
- **Dimentorin**:
|
||||
```sh
|
||||
npm run dimentorin:dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```sh
|
||||
nx build <app> # build single app
|
||||
nx run-many -t build --all # build everything
|
||||
nx affected -t build # build only what changed
|
||||
```
|
||||
Use the following commands to build the applications:
|
||||
|
||||
### Nix Build
|
||||
- **Gacha**:
|
||||
```sh
|
||||
npm run gacha:build
|
||||
```
|
||||
- **Backoffice**:
|
||||
```sh
|
||||
npm run backoffice:build
|
||||
```
|
||||
- **Dimentorin**:
|
||||
```sh
|
||||
npm run dimentorin:build
|
||||
```
|
||||
|
||||
```sh
|
||||
nix build .#<app> # e.g. nix build .#landing, .#dimentorin
|
||||
```
|
||||
### Production
|
||||
|
||||
All Nix config lives in `flake.nix`. When `package-lock.json` changes, update `npmDepsHash` using `lib.fakeHash`.
|
||||
Use the following commands to run the applications in production mode:
|
||||
|
||||
### Testing
|
||||
|
||||
```sh
|
||||
nx test <project> # unit tests (vitest)
|
||||
nx e2e <app>-e2e # e2e tests (playwright)
|
||||
nx lint <project> # eslint
|
||||
```
|
||||
- **Gacha**:
|
||||
```sh
|
||||
npm run gacha:prod
|
||||
```
|
||||
- **Backoffice**:
|
||||
```sh
|
||||
npm run backoffice:prod
|
||||
```
|
||||
- **Dimentorin**:
|
||||
```sh
|
||||
npm run dimentorin:prod
|
||||
```
|
||||
|
||||
### Storybook
|
||||
|
||||
```sh
|
||||
nx storybook ui # run storybook for ui lib
|
||||
nx build-storybook ui # build static storybook
|
||||
```
|
||||
This repository uses Storybook to develop, test, and document UI components in an isolated and interactive environment. Below are the commands to work with Storybook:
|
||||
|
||||
## CI/CD
|
||||
- **Run Storybook**
|
||||
|
||||
This command starts Storybook in development mode, allowing you to view and test UI components interactively.
|
||||
|
||||
GitHub Actions pipeline (`.github/workflows/nix-build.yml`):
|
||||
```sh
|
||||
npm run ui:storybook
|
||||
```
|
||||
|
||||
|
||||
1. **detect** — uses `nx affected` to find changed apps
|
||||
2. **build** — matrix strategy builds only affected apps with Nix, pushes to [Cachix](https://app.cachix.org/cache/msdqn)
|
||||
3. **deploy** — updates `flake.lock` in [imphnen-infrastructure](https://github.com/IMPHNEN/imphnen-infrastructure) and deploys to the server using [clan](https://clan.lol)
|
||||
- **Run Unit Test**
|
||||
|
||||
## Tech Stack
|
||||
This command runs unit tests for the UI components to ensure they function as expected.
|
||||
|
||||
- **Monorepo**: Nx 22.6
|
||||
- **Frontend**: React 19, TypeScript
|
||||
- **Styling**: Tailwind CSS v4, CVA
|
||||
- **State**: Zustand, TanStack React Query
|
||||
- **Forms**: react-hook-form + zod
|
||||
- **Build**: Nix flakes, Cachix
|
||||
- **CI**: GitHub Actions
|
||||
```sh
|
||||
npm run ui:test
|
||||
```
|
||||
|
||||
## Contributing
|
||||
- **Build Components**
|
||||
|
||||
1. Fork and clone the repository
|
||||
2. Create a branch: `git checkout -b feat/feature-name`
|
||||
3. Make changes, commit, and push
|
||||
4. Open a pull request to the `develop` branch
|
||||
This command generates a static build of Storybook, which can be deployed for sharing and documentation purposes.
|
||||
|
||||
Issues and feedback welcome via [GitHub Issues](https://github.com/IMPHNEN/imphnen-frontend-service/issues).
|
||||
```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>
|
||||
<html lang="en" data-theme="light">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Backoffice</title>
|
||||
@@ -7,9 +7,10 @@
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
|
||||
<link rel="stylesheet" href="/src/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"><div style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#f9fafb"><div style="width:40px;height:40px;border:3px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:spin .8s linear infinite"></div></div><style>@keyframes spin{to{transform:rotate(360deg)}}</style></div>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
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 = (
|
||||
nextStep: () => void,
|
||||
initialValues?: Partial<TGachaItem>,
|
||||
onDataCapture?: (data: any) => void,
|
||||
initialValues?: TGachaItem
|
||||
) => {
|
||||
const form = useForm<any>({
|
||||
const form = useForm<TGachaItem>({
|
||||
resolver: zodResolver(gachaItemSchema),
|
||||
mode: 'all',
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
onDataCapture?.(data);
|
||||
console.log('Form data:', data);
|
||||
nextStep();
|
||||
});
|
||||
|
||||
@@ -39,9 +38,8 @@ export const useConfirmItem = (
|
||||
) => {
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
if (actionFunction) {
|
||||
await actionFunction();
|
||||
}
|
||||
// const result = await actionFunction?.();
|
||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||
toast.success(messages?.success);
|
||||
onClose();
|
||||
resetStep();
|
||||
@@ -0,0 +1,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 = (
|
||||
nextStep: () => void,
|
||||
initialValues?: Partial<TGachaRollItem>,
|
||||
onDataCapture?: (data: any) => void,
|
||||
initialValues?: TGachaRollItem
|
||||
) => {
|
||||
const form = useForm<any>({
|
||||
const form = useForm<TGachaRollItem>({
|
||||
resolver: zodResolver(gachaRollItemSchema),
|
||||
mode: 'all',
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
onDataCapture?.(data);
|
||||
console.log('Form data:', data);
|
||||
nextStep();
|
||||
});
|
||||
|
||||
@@ -39,9 +38,8 @@ export const useConfirmItem = (
|
||||
) => {
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
if (actionFunction) {
|
||||
await actionFunction();
|
||||
}
|
||||
// const result = await actionFunction?.();
|
||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||
toast.success(messages?.success);
|
||||
onClose();
|
||||
resetStep();
|
||||
@@ -0,0 +1,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 { zodResolver } from '@hookform/resolvers/zod';
|
||||
// import {
|
||||
// gachaRollItemSchema,
|
||||
// TGachaRollItem
|
||||
// } from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const useItem = (
|
||||
nextStep: () => void,
|
||||
initialValues?: any,
|
||||
onDataCapture?: (data: any) => void,
|
||||
initialValues?: any
|
||||
) => {
|
||||
const form = useForm<any>({
|
||||
// resolver: zodResolver(),
|
||||
mode: 'all',
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
onDataCapture?.(data);
|
||||
console.log('Form data:', data);
|
||||
nextStep();
|
||||
});
|
||||
|
||||
@@ -33,9 +38,8 @@ export const useConfirmItem = (
|
||||
) => {
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
if (actionFunction) {
|
||||
await actionFunction();
|
||||
}
|
||||
// const result = await actionFunction?.();
|
||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||
toast.success(messages?.success);
|
||||
onClose();
|
||||
resetStep();
|
||||
@@ -0,0 +1,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 { zodResolver } from '@hookform/resolvers/zod';
|
||||
// import {
|
||||
// gachaRollItemSchema,
|
||||
// TGachaRollItem
|
||||
// } from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const useItem = (
|
||||
nextStep: () => void,
|
||||
initialValues?: any,
|
||||
onDataCapture?: (data: any) => void,
|
||||
initialValues?: any
|
||||
) => {
|
||||
const form = useForm<any>({
|
||||
// resolver: zodResolver(),
|
||||
mode: 'all',
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
onDataCapture?.(data);
|
||||
console.log('Form data:', data);
|
||||
nextStep();
|
||||
});
|
||||
|
||||
@@ -33,9 +38,8 @@ export const useConfirmItem = (
|
||||
) => {
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
if (actionFunction) {
|
||||
await actionFunction();
|
||||
}
|
||||
// const result = await actionFunction?.();
|
||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||
toast.success(messages?.success);
|
||||
onClose();
|
||||
resetStep();
|
||||
@@ -0,0 +1,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 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@source "../../../libs/ui/**/*.{ts,tsx}";
|
||||
|
||||
@theme {
|
||||
@@ -97,45 +96,6 @@
|
||||
/* ~10px */
|
||||
--text-label3: 0.677rem;
|
||||
/* ~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 {
|
||||
@@ -170,7 +130,7 @@
|
||||
html {
|
||||
font-family: 'Bai Jamjuree', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,42 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
import { QueryProvider } from '@imphnen-frontend-service/utils'
|
||||
import { Toaster } from 'sonner'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import './index.css'
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { middleware } from './middleware';
|
||||
import { StrictMode } from 'react';
|
||||
import { createBrowserRouter, RouteObject, RouterProvider } from 'react-router';
|
||||
import {
|
||||
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' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
const routes = convertPagesToRoute(files, loadingFiles) as RouteObject;
|
||||
addErrorElementToRoutes(errorFiles, routes);
|
||||
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(
|
||||
<QueryProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-right" />
|
||||
</QueryProvider>
|
||||
)
|
||||
<StrictMode>
|
||||
<QueryProvider>
|
||||
<Toaster position="top-right" />
|
||||
<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