Compare commits

..
33 Commits
Author SHA1 Message Date
asepharyana ce0579de5d fix(dimentorin): unwrap paginated response + local dev API URL 2026-08-04 18:15:57 +07:00
asepharyana 7df4592a47 feat(dimentorin): integrate mentors API - service layer + public list/detail pages 2026-08-04 17:55:59 +07:00
asepharyana 77eb755783 merge: integrate dimentorin landing pages (mentoring/articles/resources) with auth branch 2026-08-04 17:09:37 +07:00
euxzy 8c6a85090d feat(dimentorin): slicing landing - detail articles page 2025-07-28 21:33:58 +07:00
euxzy a6134cb32a feat(dimentorin): slicing landing - articles page 2025-07-28 20:45:29 +07:00
euxzy d9c5ff0b18 feat(dimentorin): slicing landing - resources page 2025-07-27 20:21:41 +07:00
euxzy 924354366c feat(dimentorin): slicing modal booking mentor 2025-07-26 22:12:47 +07:00
euxzy dce74c5c7c Merge remote-tracking branch 'origin/develop' into feat/dimentorin-landing-page 2025-07-23 18:49:55 +07:00
euxzy d4c263cad3 feat(dimentorin): slicing landing page detail mentor 2025-07-22 08:20:59 +07:00
boboiazumi e2be5105e9 feat: mentor status page pending and success 2025-07-12 22:38:23 +07:00
boboiazumi eb5ff2e46c feat: mentor register pages 2025-07-12 22:15:43 +07:00
boboiazumi e1269f4daa fix: conflict 2025-07-10 10:18:44 +07:00
boboiazumi 487623e4fb Merge branch 'develop' of https://github.com/IMPHNEN/imphnen-frontend-service into develop
merge
2025-07-10 10:07:36 +07:00
boboiazumi eff44c42a1 stash 2025-07-10 10:07:26 +07:00
euxzy 3e232dcdc2 feat(dimentorin): slicing landing page menu mentoring 2025-07-10 08:01:11 +07:00
boboiazumi aa52b62ae7 fix: minor 2025-06-19 21:36:08 +07:00
boboiazumi 3dec77bd0a Merge remote-tracking branch 'origin/DimentorinAuth' into DimentorinAuth
merge
2025-05-30 14:27:00 +07:00
boboiazumi ec81922001 fix: conflict 2025-05-30 14:26:04 +07:00
boboiazumi d7b70b21d1 Merge branch 'develop' of https://github.com/IMPHNEN/imphnen-frontend-service into develop
merge
2025-05-30 14:20:02 +07:00
boboiazumi a7cf85dd66 feat: OTP Resend 2025-05-30 14:19:36 +07:00
Naufal Azmi bb22068856 Update middleware.ts 2025-05-26 20:25:07 +07:00
Naufal Azmi 209fa3fff1 Merge branch 'develop' into DimentorinAuth 2025-05-26 20:22:04 +07:00
boboiazumi 935d23887c feat: register and otp integration 2025-05-26 20:14:26 +07:00
boboiazumi 363f1582c9 fix: button disabled when loading 2025-05-26 17:05:02 +07:00
boboiazumi 1a9b47953a fix: conflict 2025-05-26 16:28:46 +07:00
boboiazumi 182ff7facc fix: conflict 2025-05-25 10:12:21 +07:00
boboiazumi af8968ac01 Merge branch 'develop' of https://github.com/IMPHNEN/imphnen-frontend-service into develop
merge develop
2025-05-25 09:30:39 +07:00
boboiazumi 4bd66af0ff fix: login page button disabled & register page 2025-05-25 09:24:24 +07:00
boboiazumi 73826fb4c3 feat: auth login responsive && auth login implementation 2025-04-08 12:28:18 +07:00
boboiazumi 2f9407e258 Merge branch 'develop' of https://github.com/IMPHNEN/imphnen-frontend-service into develop 2025-04-07 21:35:47 +07:00
boboiazumi 534bf0e10a fix: conflict 2025-04-07 21:35:35 +07:00
boboiazumi df479a0a8d fix: success register 2025-04-07 20:48:19 +07:00
boboiazumi 1948d2c67f feat: responsive 2025-04-07 20:40:58 +07:00
698 changed files with 31692 additions and 58918 deletions
-1
View File
@@ -1 +0,0 @@
use flake
+35
View File
@@ -0,0 +1,35 @@
name: Deploy Backoffice
on:
push:
branches:
- develop
paths:
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
else
cd ~/imphnen-frontend-service-backoffice && git pull
fi
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
docker image prune -af
+35
View File
@@ -0,0 +1,35 @@
name: Deploy Dimentorin
on:
push:
branches:
- develop
paths:
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
else
cd ~/imphnen-frontend-service-dimentorin && git pull
fi
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
docker image prune -af
+35
View File
@@ -0,0 +1,35 @@
name: Deploy Gacha
on:
push:
branches:
- develop
paths:
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-gacha ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
else
cd ~/imphnen-frontend-service-gacha && git pull
fi
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-gacha/apps/gacha/.env
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
docker image prune -af
+35
View File
@@ -0,0 +1,35 @@
name: Deploy Landing
on:
push:
branches:
- develop
paths:
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
set -e
if [ ! -d ~/imphnen-frontend-service ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
else
cd ~/imphnen-frontend-service && git pull
fi
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" > ~/imphnen-frontend-service/apps/landing/.env
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
docker image prune -af
-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 dist
tmp tmp
out-tsc out-tsc
docs/FigmaImage/
# dependencies # dependencies
node_modules node_modules
@@ -62,21 +62,3 @@ storybook-static
# Next.js # Next.js
.next .next
out out
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
.env.local
# Nix
.direnv
result
.claude/worktrees
.claude/settings.local.json
# Screenshots and Test Reports
**/screenshots
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache
+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)
+102 -84
View File
@@ -4,117 +4,135 @@
<img src="docs/logo.svg" alt="IMPHNEN"> <img src="docs/logo.svg" alt="IMPHNEN">
</p> </p>
Monorepo for all frontend services of [IMPHNEN](https://imphnen.dev) (Ingin Menjadi Programmer Handal Namun Enggan Ngoding) — Indonesia's largest programmer community. This repository is a **monorepo** for all frontend services of IMPHNEN. The monorepo includes three main applications:
## Apps 1. **Gacha** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Gacha Website</a>.
2. **Backoffice** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Internal Management Website</a>.
3. **Dimentorin** - Application for <a href="https://dimentorin.imphnen.dev/" target="_blank">Mentoring Service</a>.
4. **Landing Page** - Application for <a href="https://imphnen.dev/" target="_blank">Landing Page</a>.
| App | Framework | URL | ## How to install
|-----|-----------|-----|
| **Landing** | Next.js 16 | [imphnen.dev](https://imphnen.dev) |
| **Backoffice** | Vite + React | [backoffice.imphnen.dev](https://backoffice.imphnen.dev) |
| **Hackathon** | Vite + React | [hackathon.imphnen.dev](https://dimentorin.imphnen.dev) |
| **Dimentorin** | Vite + React | [dimentorin.imphnen.dev](https://dimentorin.imphnen.dev) |
| **Gacha** | Vite + React | [gacha.imphnen.dev](https://gacha.imphnen.dev) |
| **QR Campaign** | Vite + React | [qr.imphnen.dev](https://qr.imphnen.dev) |
| **Infra** | Vite + React | [infra.imphnen.dev](https://infra.imphnen.dev) |
## Shared Libraries 1. Clone this repository:
```sh
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git
cd imphnen-frontend-service
```
2. Install all dependencies:
```sh
npm install
```
| Lib | Purpose | ## How to run
|-----|---------|
| `utils` | Pure utilities — `cn()`, `For`, `Show`, `useQueryState`, `useModalLogin` |
| `service` | Business logic — API clients, auth hooks, storage, constants |
| `ui` | UI components — atoms, molecules, organisms (atomic design) |
## Getting Started ### Setup Environment Variables
### Prerequisites Before running the applications, you need to set up the environment variables. You can do this by copying the `.env.example` file to `.env` and modifying the values according to your needs.
- Node.js 22
- [Nix](https://nixos.org/download/) (optional, for reproducible builds)
### Setup
```sh ```sh
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git cd apps/{appname}
cd imphnen-frontend-service cp .env.example .env
npm install
```
Or with Nix:
```sh
nix develop # enters dev shell with node 22, bun, git, jq
```
### Environment Variables
Copy `.env.example` to `.env` in the app directory:
```sh
cp apps/<app>/.env.example apps/<app>/.env
``` ```
### Development ### Development
```sh Use the following commands to run in development mode:
nx dev <app> # e.g. nx dev landing, nx dev backoffice
``` - **Gacha**:
```sh
npm run gacha:dev
```
- **Backoffice**:
```sh
npm run backoffice:dev
```
- **Dimentorin**:
```sh
npm run dimentorin:dev
```
- **Landing Page**:
```sh
npm run landing:dev
```
### Build ### Build
```sh Use the following commands to build the applications:
nx build <app> # build single app
nx run-many -t build --all # build everything
nx affected -t build # build only what changed
```
### Nix Build - **Gacha**:
```sh
npm run gacha:build
```
- **Backoffice**:
```sh
npm run backoffice:build
```
- **Dimentorin**:
```sh
npm run dimentorin:build
```
- **Landing Page**:
```sh
npm run landing:build
```
```sh ### Production
nix build .#<app> # e.g. nix build .#landing, .#dimentorin
```
All Nix config lives in `flake.nix`. When `package-lock.json` changes, update `npmDepsHash` using `lib.fakeHash`. Use the following commands to run the applications in production mode:
### Testing - **Gacha**:
```sh
```sh npm run gacha:prod
nx test <project> # unit tests (vitest) ```
nx e2e <app>-e2e # e2e tests (playwright) - **Backoffice**:
nx lint <project> # eslint ```sh
``` npm run backoffice:prod
```
- **Dimentorin**:
```sh
npm run dimentorin:prod
```
- **Landing Page**:
```sh
npm run landing:prod
```
### Storybook ### Storybook
```sh This repository uses Storybook to develop, test, and document UI components in an isolated and interactive environment. Below are the commands to work with Storybook:
nx storybook ui # run storybook for ui lib
nx build-storybook ui # build static storybook
```
## CI/CD - **Run Storybook**
GitHub Actions pipeline (`.github/workflows/nix-build.yml`): This command starts Storybook in development mode, allowing you to view and test UI components interactively.
1. **detect** — uses `nx affected` to find changed apps ```sh
2. **build** — matrix strategy builds only affected apps with Nix, pushes to [Cachix](https://app.cachix.org/cache/msdqn) npm run ui:storybook
3. **deploy** — updates `flake.lock` in [imphnen-infrastructure](https://github.com/IMPHNEN/imphnen-infrastructure) and deploys to the server using [clan](https://clan.lol) ```
## Tech Stack - **Run Unit Test**
- **Monorepo**: Nx 22.6 This command runs unit tests for the UI components to ensure they function as expected.
- **Frontend**: React 19, TypeScript
- **Styling**: Tailwind CSS v4, CVA
- **State**: Zustand, TanStack React Query
- **Forms**: react-hook-form + zod
- **Build**: Nix flakes, Cachix
- **CI**: GitHub Actions
## Contributing ```sh
npm run ui:test
```
1. Fork and clone the repository - **Build Components**
2. Create a branch: `git checkout -b feat/feature-name`
3. Make changes, commit, and push
4. Open a pull request to the `develop` branch
Issues and feedback welcome via [GitHub Issues](https://github.com/IMPHNEN/imphnen-frontend-service/issues). This command generates a static build of Storybook, which can be deployed for sharing and documentation purposes.
```sh
npm run ui:build
```
## How to contribute
1. Fork the repository and clone it locally.
2. Create a new branch for a new feature or fix:
```sh
git checkout -b feat/feature-name
```
3. Make changes, commit, and push to your forked repository.
4. Create a pull request to this repository `develop` branch.
If you encounter any issues or problems, feel free to create a new Issue.
-21
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
-1
View File
@@ -1,2 +1 @@
VITE_API_URL= VITE_API_URL=
VITE_GITHUB_CLIENT_ID=
+3 -2
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="light"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Backoffice</title> <title>Backoffice</title>
@@ -7,9 +7,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" /> <link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
<link rel="stylesheet" href="/src/styles.css" />
</head> </head>
<body> <body>
<div id="root"><div style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#f9fafb"><div style="width:40px;height:40px;border:3px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:spin .8s linear infinite"></div></div><style>@keyframes spin{to{transform:rotate(360deg)}}</style></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,162 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
import { useState } from 'react';
interface IModalEditAccount {
isOpen: boolean;
onClose: () => void;
handleEditAccount?: () => void;
currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
}
const ModalEditAccount = ({
isOpen,
onClose,
currentStep,
nextStep,
prevStep,
resetStep,
handleEditAccount,
}: IModalEditAccount) => {
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
isOpen={isOpen}
onClose={() => {
onClose();
resetStep();
}}
disableEscapeKeyDown={true}
>
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
{currentStep === 2 && (
<StepTwo
onClose={onClose}
handleEditAccount={handleEditAccount}
resetStep={resetStep}
/>
)}
</Modal>
);
};
interface IStepOneProps {
nextStep: () => void;
onClose: () => void;
}
const StepOne = ({ nextStep }: IStepOneProps) => {
const [fullName, setFullName] = useState('Ahmad Wiyana');
const [email, setEmail] = useState('fullname23@gmail.com');
const [phoneNumber, setPhoneNumber] = useState('081904423804');
const [address, setAddress] = useState('Jl. Pantai Cibaduyut Indah');
return (
<>
<Modal.Header>
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Edit Data Akun
</h2>
</Modal.Header>
<Modal.Content className="flex flex-col gap-8">
<div className="flex flex-col gap-4">
<InputField
label="Nama Lengkap"
type="text"
placeholder="Masukkan Nama Lengkap"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
size="lg"
className="w-full"
/>
<InputField
label="Email"
type="text"
placeholder="Masukkan Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
size="lg"
className="w-full"
/>
<InputField
label="Nomor Telepon"
type="text"
placeholder="Masukkan Nomor Telepon"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
size="lg"
className="w-full"
/>
<InputField
label="Alamat"
type="text"
placeholder="Masukkan Alamat"
value={address}
onChange={(e) => setAddress(e.target.value)}
size="lg"
className="w-full"
/>
</div>
<Button
variant="primary"
size="lg"
className="w-full"
onClick={nextStep}
>
Perbarui Data
</Button>
</Modal.Content>
</>
);
};
interface IStepTwoProps {
onClose: () => void;
handleEditAccount?: () => void;
resetStep: () => void;
}
const StepTwo = ({ onClose, handleEditAccount, resetStep }: IStepTwoProps) => (
<>
<Modal.Header className="mb-0 text-center items-center">
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Update Data
</h2>
<p className="text-p3 text-neutral-400">
Apakah kamu yakin dengan
<br /> perubahan yang dilakukan?
</p>
</Modal.Header>
<Modal.Content className="flex gap-4">
<Button
variant="bordered"
size="lg"
className="w-full"
onClick={() => {
onClose();
resetStep();
}}
>
Batal
</Button>
<Button
variant="primary"
size="lg"
className="w-full"
onClick={() => {
handleEditAccount && handleEditAccount();
onClose();
resetStep();
}}
>
Update
</Button>
</Modal.Content>
</>
);
export default ModalEditAccount;
@@ -0,0 +1,195 @@
import * as React from 'react';
import { FC, Fragment, ReactElement, useState } from 'react';
import {
FilterOutlined,
SearchOutlined,
EditOutlined,
} from '@ant-design/icons';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
useReactTable,
RowSelectionState,
} from '@tanstack/react-table';
import ModalEditAccount from './_components/modal-edit-account';
import { useQueryState } from '@imphnen-frontend-service/utils';
interface Account {
id: number;
name: string;
email: string;
phone: string;
address: string;
}
// Mock data for demonstration
const mockData: Account[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
email: 'fullname23@gmail.com',
phone: '081904423804',
address: 'Jl. Pantai Cibaduyut Indah',
}));
export const Components: FC = (): ReactElement => {
const [showModalEditAccount, setShowModalEditAccount] = useState(false);
const {
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('step', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const [showFilter, setShowFilter] = useState(false);
const columns: ColumnDef<Account>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Nama Lengkap',
accessorKey: 'name',
},
{
header: 'Email',
accessorKey: 'email',
},
{
header: 'Nomor Telp',
accessorKey: 'phone',
},
{
header: 'Alamat Pengiriman',
accessorKey: 'address',
},
{
header: 'Action',
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowModalEditAccount(true);
}}
className="flex items-center gap-2"
>
<EditOutlined /> Edit
</Button>
),
},
];
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
{/* Header */}
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Data Akun</h1>
</header>
{/* Account Table Section */}
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
{/* Search and Filter */}
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama lengkap, email"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex items-center gap-3"
disabled
onClick={() => setShowFilter(!showFilter)}
>
<FilterOutlined />
Filters
</Button>
{showFilter && (
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
<Filter onClose={() => setShowFilter(false)} options={[]} />
</div>
)}
</div>
</div>
{/* Table */}
<DataTable data={mockData} columns={columns} table={table} />
</section>
</main>
{/* Modal Edit Account */}
<ModalEditAccount
currentStep={currentStep}
isOpen={showModalEditAccount}
onClose={() => setShowModalEditAccount(false)}
handleEditAccount={() => {
console.log('Account updated');
}}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
</Fragment>
);
};
export default Components;
@@ -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 = ( export const useItem = (
nextStep: () => void, nextStep: () => void,
initialValues?: Partial<TGachaItem>, initialValues?: TGachaItem
onDataCapture?: (data: any) => void,
) => { ) => {
const form = useForm<any>({ const form = useForm<TGachaItem>({
resolver: zodResolver(gachaItemSchema), resolver: zodResolver(gachaItemSchema),
mode: 'all', mode: 'all',
defaultValues: initialValues, defaultValues: initialValues,
}); });
const onSubmit = form.handleSubmit((data) => { const onSubmit = form.handleSubmit((data) => {
onDataCapture?.(data); console.log('Form data:', data);
nextStep(); nextStep();
}); });
@@ -39,9 +38,8 @@ export const useConfirmItem = (
) => { ) => {
const onConfirm = async () => { const onConfirm = async () => {
try { try {
if (actionFunction) { // const result = await actionFunction?.();
await actionFunction(); // result ? toast.success(messages?.success) : toast.error(messages?.error);
}
toast.success(messages?.success); toast.success(messages?.success);
onClose(); onClose();
resetStep(); resetStep();
@@ -0,0 +1,188 @@
import {
PlusOutlined,
ReloadOutlined,
UsergroupAddOutlined,
UsergroupDeleteOutlined,
UserSwitchOutlined,
} from '@ant-design/icons';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { FC, Fragment, ReactElement, useState } from 'react';
import ModalAddItem from './_components/modal-add-item';
import ModalEditItem from './_components/modal-edit-item';
import ModalDeleteItem from './_components/modal-delete-item';
import { useQueryState } from '@imphnen-frontend-service/utils';
export const Components: FC = (): ReactElement => {
const [showModalAddItem, setShowModalAddItem] = useState(false);
const [showModalEditItem, setShowModalEditItem] = useState(false);
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
const {
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('step', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Dashboard</h1>
</header>
<div className="flex justify-between gap-[40px] p-8 bg-white rounded-md">
<div className="w-full flex flex-col gap-[40px]">
<section>
<h2 className="text-p2 font-medium text-primary-500 mb-8">
Summary
</h2>
<div className="grid grid-cols-2 gap-4">
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<UsergroupAddOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500">Participants</p>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<ReloadOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500">
Roll and Reroll
</p>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<UserSwitchOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500">Redeem</p>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<UsergroupDeleteOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500">
Inactive Users
</p>
</div>
</div>
</div>
</section>
<section className="flex flex-col gap-8">
<div className="flex justify-between items-center">
<h2 className="text-p2 font-medium text-primary-500">
Gacha Items
</h2>
<Button
variant="primary"
size="sm"
className="items-end gap-3"
onClick={() => setShowModalAddItem(true)}
>
<span>Tambah Item</span>
<PlusOutlined className="text-[16px]" />
</Button>
</div>
<div className="flex flex-col gap-4 max-h-140 overflow-auto">
{[1, 2, 3, 4, 5, 6].map((item) => (
<div
key={item}
className="bg-white overflow-clip rounded-lg shadow-sm flex justify-between border border-neutral-100"
>
<div className="flex flex-col py-4 px-6 gap-[8px]">
<div>
<h3 className="text-p3 text-primary-500 font-medium">
Lanyard IMPHNEN
</h3>
<div className="flex items-center justify-start gap-10 text-label2 text-gray-500 mt-1">
<span>Prize {item}</span>
<span>Quantity: 10</span>
</div>
</div>
<div className="flex justify-start gap-2">
<Button
variant="text"
size="sm"
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent hover:text-primary-500"
onClick={() => setShowModalEditItem(true)}
>
Edit
</Button>
<Button
variant="text"
size="sm"
className="text-[10px] text-red-500 p-0 font-normal hover:bg-transparent hover:text-red-700"
onClick={() => setShowModalDeleteItem(true)}
>
Delete
</Button>
</div>
</div>
<img src="gacha-clip.webp" alt="Lanyard IMPHNEN" />
</div>
))}
</div>
</section>
</div>
{/* Right-side illustration */}
<img
src="gacha.webp"
alt=""
className="rounded-lg hidden xl:block xl:min-w-[436px] h-auto object-cover"
/>
</div>
</main>
{/* Modal Add Item */}
<ModalAddItem
currentStep={currentStep}
isOpen={showModalAddItem}
onClose={() => setShowModalAddItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
{/* Modal Edit Item */}
<ModalEditItem
currentStep={currentStep}
isOpen={showModalEditItem}
onClose={() => setShowModalEditItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
{/* Modal Delete Item */}
<ModalDeleteItem
isOpen={showModalDeleteItem}
onClose={() => setShowModalDeleteItem(false)}
/>
</Fragment>
);
};
export default Components;
@@ -0,0 +1,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 = ( export const useItem = (
nextStep: () => void, nextStep: () => void,
initialValues?: Partial<TGachaRollItem>, initialValues?: TGachaRollItem
onDataCapture?: (data: any) => void,
) => { ) => {
const form = useForm<any>({ const form = useForm<TGachaRollItem>({
resolver: zodResolver(gachaRollItemSchema), resolver: zodResolver(gachaRollItemSchema),
mode: 'all', mode: 'all',
defaultValues: initialValues, defaultValues: initialValues,
}); });
const onSubmit = form.handleSubmit((data) => { const onSubmit = form.handleSubmit((data) => {
onDataCapture?.(data); console.log('Form data:', data);
nextStep(); nextStep();
}); });
@@ -39,9 +38,8 @@ export const useConfirmItem = (
) => { ) => {
const onConfirm = async () => { const onConfirm = async () => {
try { try {
if (actionFunction) { // const result = await actionFunction?.();
await actionFunction(); // result ? toast.success(messages?.success) : toast.error(messages?.error);
}
toast.success(messages?.success); toast.success(messages?.success);
onClose(); onClose();
resetStep(); resetStep();
@@ -0,0 +1,210 @@
import * as React from 'react';
import { FC, Fragment, ReactElement, useState } from 'react';
import {
SearchOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
useReactTable,
RowSelectionState,
} from '@tanstack/react-table';
import ModalAddItem from './_components/modal-add-item';
import ModalUpdateItem from './_components/modal-update-item';
import ModalDeleteItem from './_components/modal-delete-item';
import { useQueryState } from '@imphnen-frontend-service/utils';
interface GachaItem {
id: number;
name: string;
chanceRate: number;
quantity: number;
}
const mockData: GachaItem[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: 'Hoodie IMPHNEN Official 2025',
chanceRate: 0.1,
quantity: 10,
}));
export const Components: FC = (): ReactElement => {
const [showModalAddItem, setShowModalAddItem] = useState(false);
const [showModalUpdateItem, setShowModalUpdateItem] = useState(false);
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
const {
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('step', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const columns: ColumnDef<GachaItem>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Nama Item',
accessorKey: 'name',
},
{
header: 'Chance Rate',
accessorKey: 'chanceRate',
},
{
header: 'Quantity',
accessorKey: 'quantity',
},
{
header: 'Action',
cell: () => (
<div className="flex gap-[8px]">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowModalUpdateItem(true);
}}
className="flex items-center gap-2"
>
<EditOutlined /> Update
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowModalDeleteItem(true);
}}
className="flex items-center gap-2"
>
<DeleteOutlined /> Delete
</Button>
</div>
),
},
];
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Gacha Roll</h1>
</header>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama item"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => {
setShowModalAddItem(true);
}}
>
<PlusOutlined />
Tambah Item
</Button>
</div>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
</main>
<ModalAddItem
currentStep={currentStep}
isOpen={showModalAddItem}
onClose={() => setShowModalAddItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
<ModalUpdateItem
currentStep={currentStep}
isOpen={showModalUpdateItem}
onClose={() => setShowModalUpdateItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
<ModalDeleteItem
isOpen={showModalDeleteItem}
onClose={() => setShowModalDeleteItem(false)}
handleDeleteItem={() => {
console.log('Item deleted');
}}
/>
</Fragment>
);
};
export default Components;
@@ -0,0 +1,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,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 { useForm } from 'react-hook-form';
// import { zodResolver } from '@hookform/resolvers/zod';
// import {
// gachaRollItemSchema,
// TGachaRollItem
// } from '@imphnen-frontend-service/service';
import { toast } from 'sonner'; import { toast } from 'sonner';
export const useItem = ( export const useItem = (
nextStep: () => void, nextStep: () => void,
initialValues?: any, initialValues?: any
onDataCapture?: (data: any) => void,
) => { ) => {
const form = useForm<any>({ const form = useForm<any>({
// resolver: zodResolver(),
mode: 'all', mode: 'all',
defaultValues: initialValues, defaultValues: initialValues,
}); });
const onSubmit = form.handleSubmit((data) => { const onSubmit = form.handleSubmit((data) => {
onDataCapture?.(data); console.log('Form data:', data);
nextStep(); nextStep();
}); });
@@ -33,9 +38,8 @@ export const useConfirmItem = (
) => { ) => {
const onConfirm = async () => { const onConfirm = async () => {
try { try {
if (actionFunction) { // const result = await actionFunction?.();
await actionFunction(); // result ? toast.success(messages?.success) : toast.error(messages?.error);
}
toast.success(messages?.success); toast.success(messages?.success);
onClose(); onClose();
resetStep(); resetStep();
@@ -0,0 +1,202 @@
import { FC, Fragment, ReactElement, useState } from 'react';
import {
SearchOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import ModalAddPermission from './_components/modal-add-permission';
import ModalUpdatePermission from './_components/modal-update-permission';
import ModalDeletePermission from './_components/modal-delete-permission';
import { useQueryState } from '@imphnen-frontend-service/utils';
import React from 'react';
interface Permission {
id: number;
name: string;
}
const mockData: Permission[] = [
{ id: 1, name: 'Read' },
{ id: 2, name: 'Create' },
{ id: 3, name: 'Update' },
{ id: 4, name: 'Delete' },
];
export const Components: FC = (): ReactElement => {
const [showModalAddItem, setShowModalAddItem] = useState(false);
const [showModalUpdateItem, setShowModalUpdateItem] = useState(false);
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
const {
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('step', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const columns: ColumnDef<Permission>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'Action',
cell: () => (
<div className="flex gap-[8px]">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowModalUpdateItem(true);
}}
className="flex items-center gap-2"
>
<EditOutlined /> Update
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowModalDeleteItem(true);
}}
className="flex items-center gap-2"
>
<DeleteOutlined /> Delete
</Button>
</div>
),
},
];
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Permissions</h1>
</header>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama permissions"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => {
setShowModalAddItem(true);
}}
>
<PlusOutlined />
Tambah Permissionss
</Button>
</div>
</div>
<DataTable
data={mockData}
columns={columns}
pageSize={9}
table={table}
/>
</section>
</main>
<ModalAddPermission
currentStep={currentStep}
isOpen={showModalAddItem}
onClose={() => setShowModalAddItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
<ModalUpdatePermission
isOpen={showModalUpdateItem}
onClose={() => setShowModalUpdateItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
<ModalDeletePermission
isOpen={showModalDeleteItem}
onClose={() => setShowModalDeleteItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
</Fragment>
);
};
export default Components;
@@ -0,0 +1,251 @@
import * as React from 'react';
import { FC, Fragment, ReactElement, useState } from 'react';
import {
FilterOutlined,
SearchOutlined,
AuditOutlined,
} from '@ant-design/icons';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
useReactTable,
RowSelectionState,
} from '@tanstack/react-table';
import ModalProcessDelivery from './_components/modal-process-item';
type OrderValid = 'valid' | 'invalid' | 'unchecked';
type Status = 'undelivered' | 'delivered';
interface Prize {
id: number;
name: string;
orderValid: OrderValid;
items: string;
address: string;
status: Status;
}
const items = [
'Sertifikat + Laminating',
'Lanyard + ID Card',
'Pin',
'Sticker Isi 3',
'Sticker Isi 5',
'Gelang Karet',
];
// Mock data for transactions
const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: 'Nama Lengkap',
orderValid: (i % 3 === 0
? 'invalid'
: i % 5 === 0
? 'unchecked'
: 'valid') as OrderValid,
items: items[i % items.length],
address: 'Jl. Pantai Cibaduyut Indah',
status: (i % 3 === 0 ? 'undelivered' : 'delivered') as Status,
}));
export const Components: FC = (): ReactElement => {
const [showModalProcessDelivery, setShowModalProcessDelivery] =
useState(false);
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const [showFilter, setShowFilter] = useState(false);
const deliveryOptions = [
{ id: 'option1', value: 'undelivered', label: 'Undelivered' },
{ id: 'option1', value: 'delivered', label: 'Delivered' },
];
const columns: ColumnDef<Prize>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Nama Lengkap',
accessorKey: 'name',
},
{
header: 'Order Valid?',
accessorKey: 'orderValid',
cell: ({ row }) => {
const status = row.original.orderValid;
const statusColors: Record<OrderValid, string> = {
valid: 'bg-success-200 text-success-500',
invalid: 'bg-danger-200 text-danger-500',
unchecked: 'bg-warning-200 text-warning-900',
};
const statusText: Record<OrderValid, string> = {
valid: 'Valid',
invalid: 'Invalid',
unchecked: 'Unchecked',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Items',
accessorKey: 'items',
},
{
header: 'Alamat Pengiriman',
accessorKey: 'address',
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<Status, string> = {
delivered: 'bg-success-200 text-success-500',
undelivered: 'bg-danger-200 text-danger-500',
};
const statusText: Record<Status, string> = {
delivered: 'Delivered',
undelivered: 'Undelivered',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowModalProcessDelivery(true);
}}
className="flex items-center gap-2 w-full"
>
<AuditOutlined className="text-[16px]" /> Process
</Button>
),
},
];
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
{/* Header */}
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Data Pengiriman Hadiah</h1>
</header>
{/* Account Table Section */}
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
{/* Search and Filter */}
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex items-center gap-3"
onClick={() => setShowFilter(!showFilter)}
>
<FilterOutlined />
Filters
</Button>
{showFilter && (
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
<Filter
options={deliveryOptions}
onClose={() => setShowFilter(false)}
onFilterChange={(value) => {
console.log('Selected filter:', value);
// Filter logic di sini
}}
/>
</div>
)}
</div>
</div>
{/* Table */}
<DataTable data={mockData} columns={columns} table={table} />
</section>
</main>
{/* Modal Process Delivery */}
<ModalProcessDelivery
isOpen={showModalProcessDelivery}
onClose={() => setShowModalProcessDelivery(false)}
handleProcessDelivery={() => {
console.log('Action ketika user menekan tombol Proses Pengiriman');
}}
/>
</Fragment>
);
};
export default Components;
@@ -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 { useForm } from 'react-hook-form';
// import { zodResolver } from '@hookform/resolvers/zod';
// import {
// gachaRollItemSchema,
// TGachaRollItem
// } from '@imphnen-frontend-service/service';
import { toast } from 'sonner'; import { toast } from 'sonner';
export const useItem = ( export const useItem = (
nextStep: () => void, nextStep: () => void,
initialValues?: any, initialValues?: any
onDataCapture?: (data: any) => void,
) => { ) => {
const form = useForm<any>({ const form = useForm<any>({
// resolver: zodResolver(),
mode: 'all', mode: 'all',
defaultValues: initialValues, defaultValues: initialValues,
}); });
const onSubmit = form.handleSubmit((data) => { const onSubmit = form.handleSubmit((data) => {
onDataCapture?.(data); console.log('Form data:', data);
nextStep(); nextStep();
}); });
@@ -33,9 +38,8 @@ export const useConfirmItem = (
) => { ) => {
const onConfirm = async () => { const onConfirm = async () => {
try { try {
if (actionFunction) { // const result = await actionFunction?.();
await actionFunction(); // result ? toast.success(messages?.success) : toast.error(messages?.error);
}
toast.success(messages?.success); toast.success(messages?.success);
onClose(); onClose();
resetStep(); resetStep();
@@ -0,0 +1,203 @@
import { FC, Fragment, ReactElement, useState } from 'react';
import {
SearchOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import ModalAddRole from './_components/modal-add-role';
import ModalUpdateRole from './_components/modal-update-role';
import ModalDeleteRole from './_components/modal-delete-role';
import { useQueryState } from '@imphnen-frontend-service/utils';
import React from 'react';
interface Role {
id: number;
name: string;
}
const mockData: Role[] = [
{ id: 1, name: 'Admin' },
{ id: 2, name: 'Admin Pembayaran' },
{ id: 3, name: 'Staff' },
{ id: 4, name: 'Staff Aktivasi User' },
{ id: 5, name: 'User' },
];
export const Components: FC = (): ReactElement => {
const [showModalAddItem, setShowModalAddItem] = useState(false);
const [showModalUpdateItem, setShowModalUpdateItem] = useState(false);
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
const {
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('step', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const columns: ColumnDef<Role>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'ID',
accessorKey: 'id',
},
{
header: 'Roles Name',
accessorKey: 'name',
},
{
header: 'Action',
cell: () => (
<div className="flex gap-[8px]">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowModalUpdateItem(true);
}}
className="flex items-center gap-2"
>
<EditOutlined /> Update
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowModalDeleteItem(true);
}}
className="flex items-center gap-2"
>
<DeleteOutlined /> Delete
</Button>
</div>
),
},
];
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Roles</h1>
</header>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama roles"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => {
setShowModalAddItem(true);
}}
>
<PlusOutlined />
Tambah Role
</Button>
</div>
</div>
<DataTable
data={mockData}
columns={columns}
pageSize={9}
table={table}
/>
</section>
</main>
<ModalAddRole
currentStep={currentStep}
isOpen={showModalAddItem}
onClose={() => setShowModalAddItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
<ModalUpdateRole
isOpen={showModalUpdateItem}
onClose={() => setShowModalUpdateItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
<ModalDeleteRole
isOpen={showModalDeleteItem}
onClose={() => setShowModalDeleteItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
</Fragment>
);
};
export default Components;
@@ -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;
@@ -2,9 +2,9 @@ import { useForm } from 'react-hook-form';
import { import {
authLoginSchema, authLoginSchema,
TLoginRequest, TLoginRequest,
usePostLogin,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useSession } from '@imphnen-frontend-service/utils';
export const useLogin = () => { export const useLogin = () => {
const form = useForm<TLoginRequest>({ const form = useForm<TLoginRequest>({
@@ -12,13 +12,12 @@ export const useLogin = () => {
mode: 'all', mode: 'all',
}); });
const loginMutation = usePostLogin(); const { signIn } = useSession();
const onSubmit = form.handleSubmit((data) => loginMutation.mutate(data)); const onSubmit = form.handleSubmit((data) => signIn(data));
return { return {
form, form,
onSubmit, onSubmit,
isLoading: loginMutation.isPending,
}; };
}; };
@@ -0,0 +1,53 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useLogin } from './_hooks/use-login';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
export const Components: FC = (): ReactElement => {
const { form, onSubmit } = useLogin();
return (
<div className="flex justify-center items-center min-h-screen">
<div className="bg-white border border-primary-200 shadow-lg p-[60px] text-center flex flex-col justify-items-stretch gap-8 rounded-2xl">
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
<h1 className="text-primary-500 text-p1 font-semibold">
Welcome to IMPHNEN Backoffice
</h1>
<form onSubmit={onSubmit} className="flex flex-col gap-8">
<ControlledInputField
control={form.control}
label="Email"
placeholder="Masukkan Email"
type="email"
name="email"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Password"
placeholder="Masukkan Password"
type="password"
name="password"
size="lg"
className="w-full"
/>
<Button
disabled={
form.formState.isSubmitting ||
form.formState.isValidating ||
!form.formState.isValid
}
type="submit"
size="md"
className="w-full"
>
Login
</Button>
</form>
</div>
</div>
);
};
export default Components;
@@ -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;
@@ -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>
);
};

Some files were not shown because too many files have changed in this diff Show More