Compare commits

..
Author SHA1 Message Date
ArraysID a84e2fc31a fix: remove mistakenly added env from test-build workflow 2025-05-11 05:53:31 +07:00
ArraysID 6e1c561387 Merge branch 'feat/landing-cms' of https://github.com/IMPHNEN/imphnen-frontend-service into feat/landing-cms 2025-05-11 05:36:24 +07:00
ArraysID b39e918b3c fix: remove unused environment variables from test build workflow 2025-05-11 05:32:55 +07:00
ArraysID 0a217341a5 chore: add environment placeholder variables for CMS configuration in test build workflow 2025-05-11 05:32:55 +07:00
ArraysID f1f72402b2 fix: add placeholder to .env.example 2025-05-11 05:32:55 +07:00
ArraysID 3f1b2a2bd7 fix: supress scss deps warning 2025-05-11 05:32:55 +07:00
ArraysID 57b27c6784 feat: wrap logo image in a link to navigate to the homepage 2025-05-11 05:32:55 +07:00
ArraysID f191ffc4b1 feat: update button components to use onClick for external links and improve styling 2025-05-11 05:32:25 +07:00
ArraysID 31e4ffae92 feat: update CallToActionSection label and wrap Discord button in link 2025-05-11 05:29:34 +07:00
ArraysID c9f7ce24ed feat: implement CallToAction section with dynamic data fetching and UI integration 2025-05-11 05:29:01 +07:00
ArraysID d21adebf73 feat: simplify joinTitle rendering in Testimonials section 2025-05-11 05:28:20 +07:00
ArraysID 744d39ea75 feat: implement Testimonials section with dynamic data fetching and UI integration 2025-05-11 05:28:20 +07:00
ArraysID da11ba5894 refactor: reorganize layout structure by moving Header and Footer components to RootLayout 2025-05-11 05:27:19 +07:00
ArraysID 6d29c81dad feat: enhance LearningResourcesSection with primary and secondary button links 2025-05-11 05:26:07 +07:00
ArraysID 4a7135dd17 feat: add Learning Resources section with dynamic data fetching and UI integration 2025-05-11 05:24:31 +07:00
ArraysID 33d750b221 feat: add heroImage field to HeroSection and implement image formatting utility 2025-05-11 05:24:31 +07:00
ArraysID 1f9aa2e6b3 feat: update FeaturesSection to use Iconify icons and adjust icon properties 2025-05-11 05:24:31 +07:00
ArraysID 7722c98c84 feat: replace Link component with Button for improved button styling in Community component 2025-05-11 05:24:31 +07:00
ArraysID 0914361635 feat: update Community component to use Iconify for icons and adjust CommunitiesSection configuration 2025-05-11 05:24:31 +07:00
ArraysID 74aabd1eba feat: add support for s3 bucket for media 2025-05-11 05:24:31 +07:00
ArraysID d1fc7a8fd5 refactor: move global section functions to services directory 2025-05-11 05:24:31 +07:00
ArraysID 009cabfc1c feat: add CommunitiesSection configuration and integrate with Community component 2025-05-11 05:24:31 +07:00
ArraysID a7f7521761 fix: update revalidation interval to 10 seconds 2025-05-11 05:24:31 +07:00
ArraysID 54b7a2f0eb refactor: optimize data fetching by using Promise.all 2025-05-11 05:24:31 +07:00
ArraysID ef042f2f40 feat: add revalidate constant to control page revalidation interval 2025-05-11 05:24:31 +07:00
ArraysID fd35cb9525 feat: add FeaturesSection global configuration and integrate with Features component 2025-05-11 05:24:31 +07:00
ArraysID 18d11fd1c3 feat: implement HeroSection global configuration and integrate with Hero component 2025-05-11 05:24:31 +07:00
ArraysID c82d9bcc70 fix: include withPayload in Next.js plugins for proper configuration 2025-05-11 05:24:31 +07:00
ArraysID a4dd793bb7 refactor: update config import paths to use local payload.config 2025-05-11 05:24:31 +07:00
ArraysID bba3c89b23 fix: update TypeScript paths for payload configuration 2025-05-11 05:24:31 +07:00
ArraysID 8b6aa21f82 feat: add payload configuration and update TypeScript target 2025-05-11 05:24:31 +07:00
ArraysID 0fffe2d403 feat: integrate payload cms to landing page 2025-05-11 05:24:09 +07:00
ArraysID 919fac811d fix: remove unused environment variables from test build workflow 2025-05-10 17:07:51 +07:00
ArraysID 4316dcf8b3 chore: add environment placeholder variables for CMS configuration in test build workflow 2025-05-10 17:04:33 +07:00
ArraysID b9b71c4c25 fix: add placeholder to .env.example 2025-05-10 16:50:26 +07:00
ArraysID 121faf89c3 fix: supress scss deps warning 2025-05-10 16:26:32 +07:00
ArraysID 07913efb16 Merge branch 'develop' into feat/landing-cms 2025-05-10 16:01:05 +07:00
ArraysID 4b2733511c feat: wrap logo image in a link to navigate to the homepage 2025-05-10 15:50:42 +07:00
ArraysID 9f3d00a0db feat: update button components to use onClick for external links and improve styling 2025-05-10 15:48:35 +07:00
ArraysID 7ac571c019 feat: update CallToActionSection label and wrap Discord button in link 2025-05-10 15:34:18 +07:00
ArraysID a8c958400d feat: implement CallToAction section with dynamic data fetching and UI integration 2025-05-10 15:19:54 +07:00
ArraysID 760ab67ae0 feat: simplify joinTitle rendering in Testimonials section 2025-05-10 14:56:07 +07:00
ArraysID c4cf9e4e6c feat: implement Testimonials section with dynamic data fetching and UI integration 2025-05-10 14:52:35 +07:00
ArraysID a800b1e293 refactor: reorganize layout structure by moving Header and Footer components to RootLayout 2025-05-10 13:20:43 +07:00
ArraysID da0e599b9a feat: enhance LearningResourcesSection with primary and secondary button links 2025-05-10 12:58:34 +07:00
ArraysID a02a259f4e feat: add Learning Resources section with dynamic data fetching and UI integration 2025-05-10 12:48:07 +07:00
ArraysID ff99564b05 feat: add heroImage field to HeroSection and implement image formatting utility 2025-05-10 08:54:45 +07:00
ArraysID 13ab089afc feat: update FeaturesSection to use Iconify icons and adjust icon properties 2025-05-10 07:45:29 +07:00
ArraysID 378e258520 feat: replace Link component with Button for improved button styling in Community component 2025-05-10 07:30:06 +07:00
ArraysID 88468081b6 feat: update Community component to use Iconify for icons and adjust CommunitiesSection configuration 2025-05-10 07:26:38 +07:00
ArraysID aba462e462 feat: add support for s3 bucket for media 2025-05-10 06:55:11 +07:00
ArraysID 55fd1fa443 refactor: move global section functions to services directory 2025-05-10 05:38:01 +07:00
ArraysID 07a1eb8dbc feat: add CommunitiesSection configuration and integrate with Community component 2025-05-10 05:35:24 +07:00
ArraysID aad04b6f94 fix: update revalidation interval to 10 seconds 2025-05-10 04:33:06 +07:00
ArraysID d4a91abb63 refactor: optimize data fetching by using Promise.all 2025-05-10 04:29:46 +07:00
ArraysID 7fd5528c0d feat: add revalidate constant to control page revalidation interval 2025-05-10 04:29:28 +07:00
ArraysID 39a57b4fed feat: add FeaturesSection global configuration and integrate with Features component 2025-05-10 04:08:37 +07:00
ArraysID 7b490702b9 feat: implement HeroSection global configuration and integrate with Hero component 2025-05-10 03:32:19 +07:00
ArraysID f31aad8570 fix: include withPayload in Next.js plugins for proper configuration 2025-05-08 16:07:51 +07:00
ArraysID acb7f4aef9 fix: update quotation marks for testimonials to use HTML entities 2025-05-08 15:42:24 +07:00
ArraysID c69614531b refactor: update config import paths to use local payload.config 2025-05-08 15:39:21 +07:00
ArraysID c82426282c fix: update TypeScript paths for payload configuration 2025-05-08 15:24:44 +07:00
ArraysID 45295953aa feat: add payload configuration and update TypeScript target 2025-05-08 15:16:59 +07:00
ArraysID 0561555276 feat: integrate payload cms to landing page 2025-05-08 15:12:32 +07:00
675 changed files with 34174 additions and 64970 deletions
+14
View File
@@ -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
View File
@@ -1 +0,0 @@
use flake
-109
View File
@@ -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
+37
View File
@@ -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
View File
@@ -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
+28
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"recommendations": [
"nrwl.angular-console",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"ms-playwright.playwright"
]
}
-93
View File
@@ -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)
+86 -89
View File
@@ -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
View File
@@ -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.
View File
-2
View File
@@ -1,2 +0,0 @@
VITE_API_URL=
VITE_GITHUB_CLIENT_ID=
+3 -2
View File
@@ -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;
+195
View File
@@ -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;
@@ -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;
+188
View File
@@ -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;
@@ -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;
+210
View File
@@ -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;
+12
View File
@@ -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;
+44
View File
@@ -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;
@@ -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;
+18
View File
@@ -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;
+251
View File
@@ -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;
@@ -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();
+18
View File
@@ -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;
+203
View File
@@ -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>
);
}
-233
View File
@@ -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>
);
};
-518
View File
@@ -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;
+2 -42
View File
@@ -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;
}
}
}
+36 -19
View File
@@ -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>
);
+11
View File
@@ -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;
};
-923
View File
@@ -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>()
-5
View File
@@ -1,5 +0,0 @@
import { createRootRoute, Outlet } from '@tanstack/react-router'
export const Route = createRootRoute({
component: () => <Outlet />,
})
@@ -1,28 +0,0 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
import { SessionToken } from '@imphnen-frontend-service/service';
import {
SidebarInset,
SidebarProvider,
} from '@imphnen-frontend-service/ui/atoms';
import { BackofficeSidebar } from '../components/sidebar';
export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => {
const session = SessionToken.get();
if (!session?.token?.access_token) {
throw redirect({ to: '/auth/login' });
}
},
component: AuthenticatedLayout,
});
function AuthenticatedLayout() {
return (
<SidebarProvider>
<BackofficeSidebar />
<SidebarInset>
<Outlet />
</SidebarInset>
</SidebarProvider>
);
}
@@ -1,57 +0,0 @@
import { 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>
)
}
@@ -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>
)
}
@@ -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;
@@ -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;
@@ -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;
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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