Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f9a537c20 | ||
|
|
040f414e6d | ||
|
|
3eb86c6ef4 | ||
|
|
48d97af044 | ||
|
|
36f7d10e7a |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx nx run-many:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npm view:*)",
|
||||
"Bash(npx nx build landing)",
|
||||
"Bash(npm uninstall:*)",
|
||||
"Bash(dir:*)",
|
||||
"Bash(ren page.tsx page-original.tsx)",
|
||||
"Bash(ren:*)",
|
||||
"Bash(npx supabase:*)",
|
||||
"Bash(libs/service/src/types/supabase.ts)",
|
||||
"Bash(npx nx build:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(ls:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
.next
|
||||
out
|
||||
build
|
||||
coverage
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Nx
|
||||
.nx
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# Misc
|
||||
*.md
|
||||
!README.md
|
||||
.editorconfig
|
||||
.prettierrc
|
||||
.eslintrc
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
VITE_SUPABASE_URL=https://your-project-id.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=your-anon-key-here
|
||||
@@ -1,109 +0,0 @@
|
||||
name: Nix Build & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['develop']
|
||||
pull_request:
|
||||
branches: ['develop']
|
||||
|
||||
jobs:
|
||||
detect:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
affected: ${{ steps.affected.outputs.apps }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Detect affected apps
|
||||
id: affected
|
||||
run: |
|
||||
AFFECTED=$(npx nx show projects --affected --base=HEAD~1 --type=app 2>/dev/null | tr '\n' ' ')
|
||||
echo "Affected apps: $AFFECTED"
|
||||
APPS="[]"
|
||||
for app in landing backoffice gacha dimentorin hackathon infra qrcampaign; do
|
||||
if echo "$AFFECTED" | grep -qw "$app"; then
|
||||
APPS=$(echo "$APPS" | jq -c ". + [\"$app\"]")
|
||||
fi
|
||||
done
|
||||
echo "apps=$APPS" >> "$GITHUB_OUTPUT"
|
||||
echo "Matrix: $APPS"
|
||||
|
||||
build:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.affected != '[]'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
app: ${{ fromJson(needs.detect.outputs.affected) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
|
||||
- name: Setup Cachix
|
||||
uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: msdqn
|
||||
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
||||
|
||||
- name: Build ${{ matrix.app }}
|
||||
run: nix build .#${{ matrix.app }} -o result-${{ matrix.app }}
|
||||
|
||||
- name: Push to Cachix
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
|
||||
run: cachix push msdqn result-${{ matrix.app }}
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
|
||||
- name: Setup SSH
|
||||
env:
|
||||
INFRA_DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${INFRA_DEPLOY_KEY}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||
ssh-keyscan 167.235.70.37 >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Update infra flake.lock
|
||||
run: |
|
||||
export GIT_SSH_COMMAND="ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes"
|
||||
git clone git@github.com:IMPHNEN/imphnen-infrastructure.git /tmp/infra
|
||||
cd /tmp/infra
|
||||
nix flake update imphnen-frontend
|
||||
if git diff --quiet flake.lock; then
|
||||
echo "flake.lock unchanged, skipping"
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add flake.lock
|
||||
git commit -m "chore: update imphnen-frontend-service to ${GITHUB_SHA::7}"
|
||||
git push
|
||||
fi
|
||||
|
||||
- name: Deploy to server
|
||||
continue-on-error: true
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key -o ConnectTimeout=15 -o StrictHostKeyChecking=accept-new root@167.235.70.37 \
|
||||
'nixos-rebuild switch --flake github:IMPHNEN/imphnen-infrastructure#hetzner --refresh 2>&1 | tail -30'
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/deploy_key
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Test and Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['develop']
|
||||
pull_request:
|
||||
branches: ['develop']
|
||||
|
||||
env:
|
||||
NODE_VERSION: 22.14.0
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js ${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Set env vars
|
||||
run: |
|
||||
echo "CMS_SECRET=${{ secrets.CMS_SECRET }}" >> $GITHUB_ENV
|
||||
echo "CMS_STORAGE_REGION=${{ secrets.CMS_STORAGE_REGION }}" >> $GITHUB_ENV
|
||||
echo "CMS_STORAGE_ENDPOINT=${{ secrets.CMS_STORAGE_ENDPOINT }}" >> $GITHUB_ENV
|
||||
echo "CMS_STORAGE_ACCESS_KEY_ID=${{ secrets.CMS_STORAGE_ACCESS_KEY_ID }}" >> $GITHUB_ENV
|
||||
echo "CMS_STORAGE_SECRET_ACCESS_KEY=${{ secrets.CMS_STORAGE_SECRET_ACCESS_KEY }}" >> $GITHUB_ENV
|
||||
echo "CMS_POSTGRES_URL=${{ secrets.CMS_POSTGRES_URL }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Nx Build
|
||||
run: npx nx run-many --target=build --all
|
||||
+1
-15
@@ -4,7 +4,7 @@
|
||||
dist
|
||||
tmp
|
||||
out-tsc
|
||||
docs/FigmaImage/
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
@@ -66,17 +66,3 @@ out
|
||||
.github/instructions/nx.instructions.md
|
||||
|
||||
.env.local
|
||||
|
||||
# Nix
|
||||
.direnv
|
||||
result
|
||||
|
||||
.claude/worktrees
|
||||
.claude/settings.local.json
|
||||
|
||||
# Screenshots and Test Reports
|
||||
**/screenshots
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"nx-mcp": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["nx", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# path to a directory with all packages
|
||||
storage: ../tmp/local-registry/storage
|
||||
|
||||
# a list of other known repositories we can talk to
|
||||
uplinks:
|
||||
npmjs:
|
||||
url: https://registry.npmjs.org/
|
||||
maxage: 60m
|
||||
|
||||
packages:
|
||||
'**':
|
||||
# give all users (including non-authenticated users) full access
|
||||
# because it is a local registry
|
||||
access: $all
|
||||
publish: $all
|
||||
unpublish: $all
|
||||
|
||||
# if package is not available locally, proxy requests to npm registry
|
||||
proxy: npmjs
|
||||
|
||||
# log settings
|
||||
log:
|
||||
type: stdout
|
||||
format: pretty
|
||||
level: warn
|
||||
|
||||
publish:
|
||||
allow_offline: true # set offline to true to allow publish offline
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"nrwl.angular-console",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"ms-playwright.playwright"
|
||||
]
|
||||
}
|
||||
@@ -1,93 +1,13 @@
|
||||
# IMPHNEN Frontend Service
|
||||
<!-- nx configuration start-->
|
||||
<!-- Leave the start & end comments to automatically receive updates. -->
|
||||
|
||||
Nx monorepo for IMPHNEN (Ingin Menjadi Programmer Handal Namun Enggan Ngoding) — Indonesia's largest programmer community.
|
||||
# General Guidelines for working with Nx
|
||||
|
||||
## Tech Stack
|
||||
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
|
||||
- You have access to the Nx MCP server and its tools, use them to help the user
|
||||
- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable.
|
||||
- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies
|
||||
- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
|
||||
- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors
|
||||
|
||||
- **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)
|
||||
<!-- nx configuration end-->
|
||||
|
||||
@@ -4,117 +4,135 @@
|
||||
<img src="docs/logo.svg" alt="IMPHNEN">
|
||||
</p>
|
||||
|
||||
Monorepo for all frontend services of [IMPHNEN](https://imphnen.dev) (Ingin Menjadi Programmer Handal Namun Enggan Ngoding) — Indonesia's largest programmer community.
|
||||
This repository is a **monorepo** for all frontend services of IMPHNEN. The monorepo includes three main applications:
|
||||
|
||||
## Apps
|
||||
1. **Gacha** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Gacha Website</a>.
|
||||
2. **Backoffice** - Application for <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 |
|
||||
|-----|-----------|-----|
|
||||
| **Landing** | Next.js 16 | [imphnen.dev](https://imphnen.dev) |
|
||||
| **Backoffice** | Vite + React | [backoffice.imphnen.dev](https://backoffice.imphnen.dev) |
|
||||
| **Hackathon** | Vite + React | [hackathon.imphnen.dev](https://dimentorin.imphnen.dev) |
|
||||
| **Dimentorin** | Vite + React | [dimentorin.imphnen.dev](https://dimentorin.imphnen.dev) |
|
||||
| **Gacha** | Vite + React | [gacha.imphnen.dev](https://gacha.imphnen.dev) |
|
||||
| **QR Campaign** | Vite + React | [qr.imphnen.dev](https://qr.imphnen.dev) |
|
||||
| **Infra** | Vite + React | [infra.imphnen.dev](https://infra.imphnen.dev) |
|
||||
## How to install
|
||||
|
||||
## Shared Libraries
|
||||
1. Clone this repository:
|
||||
```sh
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git
|
||||
cd imphnen-frontend-service
|
||||
```
|
||||
2. Install all dependencies:
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
| Lib | Purpose |
|
||||
|-----|---------|
|
||||
| `utils` | Pure utilities — `cn()`, `For`, `Show`, `useQueryState`, `useModalLogin` |
|
||||
| `service` | Business logic — API clients, auth hooks, storage, constants |
|
||||
| `ui` | UI components — atoms, molecules, organisms (atomic design) |
|
||||
## How to run
|
||||
|
||||
## Getting Started
|
||||
### Setup Environment Variables
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 22
|
||||
- [Nix](https://nixos.org/download/) (optional, for reproducible builds)
|
||||
|
||||
### Setup
|
||||
Before running the applications, you need to set up the environment variables. You can do this by copying the `.env.example` file to `.env` and modifying the values according to your needs.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git
|
||||
cd imphnen-frontend-service
|
||||
npm install
|
||||
```
|
||||
|
||||
Or with Nix:
|
||||
|
||||
```sh
|
||||
nix develop # enters dev shell with node 22, bun, git, jq
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env` in the app directory:
|
||||
|
||||
```sh
|
||||
cp apps/<app>/.env.example apps/<app>/.env
|
||||
cd apps/{appname}
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```sh
|
||||
nx dev <app> # e.g. nx dev landing, nx dev backoffice
|
||||
```
|
||||
Use the following commands to run in development mode:
|
||||
|
||||
- **Gacha**:
|
||||
```sh
|
||||
npm run gacha:dev
|
||||
```
|
||||
- **Backoffice**:
|
||||
```sh
|
||||
npm run backoffice:dev
|
||||
```
|
||||
- **Dimentorin**:
|
||||
```sh
|
||||
npm run dimentorin:dev
|
||||
```
|
||||
- **Landing Page**:
|
||||
```sh
|
||||
npm run landing:dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```sh
|
||||
nx build <app> # build single app
|
||||
nx run-many -t build --all # build everything
|
||||
nx affected -t build # build only what changed
|
||||
```
|
||||
Use the following commands to build the applications:
|
||||
|
||||
### Nix Build
|
||||
- **Gacha**:
|
||||
```sh
|
||||
npm run gacha:build
|
||||
```
|
||||
- **Backoffice**:
|
||||
```sh
|
||||
npm run backoffice:build
|
||||
```
|
||||
- **Dimentorin**:
|
||||
```sh
|
||||
npm run dimentorin:build
|
||||
```
|
||||
- **Landing Page**:
|
||||
```sh
|
||||
npm run landing:build
|
||||
```
|
||||
|
||||
```sh
|
||||
nix build .#<app> # e.g. nix build .#landing, .#dimentorin
|
||||
```
|
||||
### Production
|
||||
|
||||
All Nix config lives in `flake.nix`. When `package-lock.json` changes, update `npmDepsHash` using `lib.fakeHash`.
|
||||
Use the following commands to run the applications in production mode:
|
||||
|
||||
### Testing
|
||||
|
||||
```sh
|
||||
nx test <project> # unit tests (vitest)
|
||||
nx e2e <app>-e2e # e2e tests (playwright)
|
||||
nx lint <project> # eslint
|
||||
```
|
||||
- **Gacha**:
|
||||
```sh
|
||||
npm run gacha:prod
|
||||
```
|
||||
- **Backoffice**:
|
||||
```sh
|
||||
npm run backoffice:prod
|
||||
```
|
||||
- **Dimentorin**:
|
||||
```sh
|
||||
npm run dimentorin:prod
|
||||
```
|
||||
- **Landing Page**:
|
||||
```sh
|
||||
npm run landing:prod
|
||||
```
|
||||
|
||||
### Storybook
|
||||
|
||||
```sh
|
||||
nx storybook ui # run storybook for ui lib
|
||||
nx build-storybook ui # build static storybook
|
||||
```
|
||||
This repository uses Storybook to develop, test, and document UI components in an isolated and interactive environment. Below are the commands to work with Storybook:
|
||||
|
||||
## CI/CD
|
||||
- **Run Storybook**
|
||||
|
||||
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
|
||||
2. **build** — matrix strategy builds only affected apps with Nix, pushes to [Cachix](https://app.cachix.org/cache/msdqn)
|
||||
3. **deploy** — updates `flake.lock` in [imphnen-infrastructure](https://github.com/IMPHNEN/imphnen-infrastructure) and deploys to the server using [clan](https://clan.lol)
|
||||
```sh
|
||||
npm run ui:storybook
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
- **Run Unit Test**
|
||||
|
||||
- **Monorepo**: Nx 22.6
|
||||
- **Frontend**: React 19, TypeScript
|
||||
- **Styling**: Tailwind CSS v4, CVA
|
||||
- **State**: Zustand, TanStack React Query
|
||||
- **Forms**: react-hook-form + zod
|
||||
- **Build**: Nix flakes, Cachix
|
||||
- **CI**: GitHub Actions
|
||||
This command runs unit tests for the UI components to ensure they function as expected.
|
||||
|
||||
## Contributing
|
||||
```sh
|
||||
npm run ui:test
|
||||
```
|
||||
|
||||
1. Fork and clone the repository
|
||||
2. Create a branch: `git checkout -b feat/feature-name`
|
||||
3. Make changes, commit, and push
|
||||
4. Open a pull request to the `develop` branch
|
||||
- **Build Components**
|
||||
|
||||
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
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
Tue, Nov 25, 2025 4:21:27 PM
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Deployment trigger
|
||||
# Updated to deploy circular dependency fixes and auth hooks
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
VITE_API_URL=
|
||||
VITE_GITHUB_CLIENT_ID=
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
|
||||
<link rel="stylesheet" href="/src/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"><div style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#f9fafb"><div style="width:40px;height:40px;border:3px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:spin .8s linear infinite"></div></div><style>@keyframes spin{to{transform:rotate(360deg)}}</style></div>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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;
|
||||
+5
-10
@@ -1,4 +1,5 @@
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
|
||||
import { PieLabelProps } from "recharts/types/polar/Pie"
|
||||
|
||||
type ChartProps = {
|
||||
name: string
|
||||
@@ -13,20 +14,14 @@ const chartData: ChartProps[] = [
|
||||
]
|
||||
|
||||
const RADIAN = Math.PI / 180;
|
||||
const renderCustomizedLabel = (props: any) => {
|
||||
const cx = props.cx ?? 0
|
||||
const cy = props.cy ?? 0
|
||||
const midAngle = props.midAngle ?? 0
|
||||
const innerRadius = props.innerRadius ?? 0
|
||||
const outerRadius = props.outerRadius ?? 0
|
||||
const percent = props.percent ?? 0
|
||||
const renderCustomizedLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }: PieLabelProps) => {
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
const x = cx + radius * Math.cos(-(midAngle ?? 0) * RADIAN);
|
||||
const y = cy + radius * Math.sin(-(midAngle ?? 0) * RADIAN);
|
||||
|
||||
return (
|
||||
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
{`${((percent ?? 1) * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||
import { BackofficeWrapper } from "@imphnen-frontend-service/ui/organisms";
|
||||
import { For } from "@imphnen-frontend-service/utils";
|
||||
import { ReactElement } from "react";
|
||||
import { UserGrowthChart } from "./_components/chart/user-growth";
|
||||
import { SessionStatusChart } from "./_components/chart/session-status";
|
||||
|
||||
const Overview = () => {
|
||||
return (
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">0</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Users</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Components(): ReactElement {
|
||||
return (
|
||||
<BackofficeWrapper title="Dimentorin.dev">
|
||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-5">Overview</h1>
|
||||
|
||||
<div className="space-y-14">
|
||||
<div>
|
||||
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
|
||||
Overview
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-5 gap-5">
|
||||
<For data={Array.from({ length: 5 })}>
|
||||
{(_, index) => <Overview key={index} />}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
|
||||
Trends & Analytics
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-7 gap-x-5">
|
||||
<div className="bg-white px-6 py-4 rounded-lg col-span-5">
|
||||
<div className="flex items-center justify-between mb-7">
|
||||
<h2 className="font-semibold text-p3 text-neutral-700">User Growth</h2>
|
||||
<div></div>
|
||||
</div>
|
||||
<UserGrowthChart />
|
||||
</div>
|
||||
<div className="bg-white px-6 py-4 rounded-lg col-span-2">
|
||||
<h2 className="font-semibold text-p3 text-neutral-700 mb-7">Session Status</h2>
|
||||
<SessionStatusChart />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-5">
|
||||
<div className="bg-white px-7 py-4 rounded-lg">
|
||||
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top 5 Mentors</h2>
|
||||
|
||||
<div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-label1 bg-primary-50 text-left rounded-full">
|
||||
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
|
||||
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
|
||||
<th className="font-medium py-4 px-5 rounded-r-lg">Avg Rating</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<For data={Array.from({ length: 5 })}>
|
||||
{(_, index) => (
|
||||
<tr key={index} className="shadow rounded-lg">
|
||||
<td className="py-4 px-5">{index + 1}</td>
|
||||
<td className="py-4 px-5">Mursid Al-Catraz</td>
|
||||
<td className="py-4 px-5">4.9</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white px-6 py-4 rounded-lg">
|
||||
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top Booked Mentoring Topics</h2>
|
||||
|
||||
<div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-label1 bg-primary-50 text-left font-medium">
|
||||
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
|
||||
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
|
||||
<th className="font-medium py-4 px-5 rounded-r-lg">Total Sesi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<For data={Array.from({ length: 5 })}>
|
||||
{(_, index) => (
|
||||
<tr key={index} className="shadow rounded-lg">
|
||||
<td className="py-4 px-5">{index + 1}</td>
|
||||
<td className="py-4 px-5">Mursid Al-Catraz</td>
|
||||
<td className="py-4 px-5">1000</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BackofficeWrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { useConfirmItem, useItem } from '../_hook/use-item';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
interface IModalAddItem {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleAddItem?: () => Promise<boolean>;
|
||||
currentStep?: number;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const ModalAddItem = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentStep,
|
||||
nextStep,
|
||||
resetStep,
|
||||
handleAddItem,
|
||||
}: IModalAddItem) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
disableEscapeKeyDown={true}
|
||||
>
|
||||
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||
{currentStep === 2 && (
|
||||
<StepTwo
|
||||
onClose={onClose}
|
||||
handleAddItem={handleAddItem}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepOneProps {
|
||||
nextStep: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||
const { form, onSubmit } = useItem(nextStep);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header>
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Tambah Item Gacha
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Lengkapi detail di bawah ini untuk menambahkan item gacha
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Nama Hadiah"
|
||||
name="itemName"
|
||||
type="text"
|
||||
placeholder="Masukkan Nama Hadiah"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Quantity"
|
||||
name="quantity"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Masukkan Kuantitas Item"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Foto Barang"
|
||||
type="file"
|
||||
name="foto"
|
||||
placeholder=".jpg, .jpeg, atau .png"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={nextStep}
|
||||
>
|
||||
Tambahkan Item
|
||||
</Button>
|
||||
</form>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepTwoProps {
|
||||
onClose: () => void;
|
||||
handleAddItem?: () => Promise<boolean>;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => {
|
||||
const { onConfirm, onCancel } = useConfirmItem(
|
||||
onClose,
|
||||
resetStep,
|
||||
handleAddItem,
|
||||
{
|
||||
success: 'Item ditambahkan ke gacha item',
|
||||
error: 'Item gagal ditambahkan ke gacha item',
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header className="mb-0 text-center items-center">
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Tambah Item
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin ingin
|
||||
<br /> menambahkan item ini?
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Tambahkan
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalAddItem;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
|
||||
interface IModalDeleteItem {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleDeleteItem?: () => void;
|
||||
}
|
||||
|
||||
const ModalDeleteItem = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
handleDeleteItem,
|
||||
}: IModalDeleteItem) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
closeButtonClassName="hidden"
|
||||
>
|
||||
<Modal.Header className="gap-8">
|
||||
<img
|
||||
src="/chibi-delete.webp"
|
||||
alt="Delete item?"
|
||||
width={148}
|
||||
className="self-center"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
|
||||
Delete Item
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin untuk menghapus item ini?
|
||||
</p>
|
||||
</div>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onClose}
|
||||
>
|
||||
Batal Hapus
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
handleDeleteItem && handleDeleteItem();
|
||||
}}
|
||||
>
|
||||
Hapus Item
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalDeleteItem;
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { useConfirmItem, useItem } from '../_hook/use-item';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
interface IModalEditItem {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleEditItem?: () => Promise<boolean>;
|
||||
currentStep?: number;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const ModalEditItem = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentStep,
|
||||
nextStep,
|
||||
resetStep,
|
||||
handleEditItem,
|
||||
}: IModalEditItem) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
disableEscapeKeyDown={true}
|
||||
>
|
||||
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||
{currentStep === 2 && (
|
||||
<StepTwo
|
||||
onClose={onClose}
|
||||
handleEditItem={handleEditItem}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepOneProps {
|
||||
nextStep: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||
const initialValues = {
|
||||
itemName: 'Hoodie IMPHNEN Official 2025',
|
||||
quantity: 10,
|
||||
};
|
||||
|
||||
const { form, onSubmit } = useItem(nextStep, initialValues);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header>
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Edit Item Gacha
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Silakan mengubah detail dari item yang diperlukan
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex flex-col gap-8">
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Nama Hadiah"
|
||||
type="text"
|
||||
name="itemName"
|
||||
placeholder="Masukkan Nama Hadiah"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Quantity"
|
||||
type="number"
|
||||
name="quantity"
|
||||
placeholder="Masukkan Kuantitas Item"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Foto Barang"
|
||||
type="file"
|
||||
name="foto"
|
||||
placeholder=".jpg, .jpeg, atau .png"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={nextStep}
|
||||
>
|
||||
Perbarui Item
|
||||
</Button>
|
||||
</form>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepTwoProps {
|
||||
onClose: () => void;
|
||||
handleEditItem?: () => Promise<boolean>;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const StepTwo = ({ onClose, handleEditItem, resetStep }: IStepTwoProps) => {
|
||||
const { onConfirm, onCancel } = useConfirmItem(
|
||||
onClose,
|
||||
resetStep,
|
||||
handleEditItem,
|
||||
{
|
||||
success: 'Perubahan item berhasil dilakukan',
|
||||
error: 'Perubahan item gagal dilakukan',
|
||||
}
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Modal.Header className="mb-0 text-center items-center">
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Update Item
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin dengan
|
||||
<br /> perubahan yang dilakukan?
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalEditItem;
|
||||
+5
-7
@@ -8,17 +8,16 @@ import { toast } from 'sonner';
|
||||
|
||||
export const useItem = (
|
||||
nextStep: () => void,
|
||||
initialValues?: Partial<TGachaItem>,
|
||||
onDataCapture?: (data: any) => void,
|
||||
initialValues?: TGachaItem
|
||||
) => {
|
||||
const form = useForm<any>({
|
||||
const form = useForm<TGachaItem>({
|
||||
resolver: zodResolver(gachaItemSchema),
|
||||
mode: 'all',
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
onDataCapture?.(data);
|
||||
console.log('Form data:', data);
|
||||
nextStep();
|
||||
});
|
||||
|
||||
@@ -39,9 +38,8 @@ export const useConfirmItem = (
|
||||
) => {
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
if (actionFunction) {
|
||||
await actionFunction();
|
||||
}
|
||||
// const result = await actionFunction?.();
|
||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||
toast.success(messages?.success);
|
||||
onClose();
|
||||
resetStep();
|
||||
@@ -0,0 +1,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,181 @@
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
|
||||
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
|
||||
import { cn, For } from "@imphnen-frontend-service/utils";
|
||||
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
|
||||
import { ReactElement, useState } from "react"
|
||||
|
||||
const TABS = {
|
||||
MENTORING: 'Mentoring',
|
||||
PLATFORM: 'Platform'
|
||||
} as const
|
||||
type Tabs = typeof TABS[keyof typeof TABS]
|
||||
|
||||
type FeedbackStatus = 'done' | 'todo';
|
||||
|
||||
interface FeedbackType {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
rating: number
|
||||
status: FeedbackStatus
|
||||
}
|
||||
|
||||
const mockData: FeedbackType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana',
|
||||
email: 'fullname23@gmail.com',
|
||||
rating: 4.5,
|
||||
status: i % 2 === 0 ? 'done' : 'todo',
|
||||
}))
|
||||
|
||||
export default function Components(): ReactElement {
|
||||
const [activeTab, setActiveTab] = useState<Tabs>(TABS.MENTORING)
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
});
|
||||
|
||||
const columns: ColumnDef<FeedbackType>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
meta: { cellClassName: cn("w-20") },
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
header: 'Email',
|
||||
accessorKey: 'email',
|
||||
},
|
||||
{
|
||||
id: 'rating',
|
||||
header: 'Rating',
|
||||
accessorKey: 'rating',
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status;
|
||||
const statusColors: Record<FeedbackStatus, string> = {
|
||||
done: 'bg-success-200 text-success-500',
|
||||
todo: 'bg-primary-200 text-primary-500',
|
||||
};
|
||||
const statusText: Record<FeedbackStatus, string> = {
|
||||
done: 'Done',
|
||||
todo: 'To Do',
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||
>
|
||||
{statusText[status]}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
meta: { cellClassName: cn("w-72") },
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
className="flex items-center gap-2 w-max"
|
||||
>
|
||||
<SearchOutlined className="text-[16px]" /> Lihat Feedback
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||
manualPagination: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="Dimentorin.dev">
|
||||
<div className="mb-8 flex justify-between items-center">
|
||||
<h1 className="text-p1 font-semibold text-neutral-700">Feedback</h1>
|
||||
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
||||
<For data={Object.values(TABS)}>
|
||||
{(tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
variant="text"
|
||||
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{tab}
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
<div className="flex justify-between items-center gap-5 mb-2">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama mentor/mentee"
|
||||
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>
|
||||
<Select>
|
||||
<option selected disabled>Rating</option>
|
||||
<option value="4.5">4.5</option>
|
||||
<option value="5">5</option>
|
||||
</Select>
|
||||
<Select>
|
||||
<option selected disabled>Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DataTable data={mockData} columns={columns} table={table} />
|
||||
</section>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { useItem, useConfirmItem } from '../_hook/use-item';
|
||||
|
||||
interface IModalAddItem {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleAddItem?: () => Promise<boolean>;
|
||||
currentStep?: number;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const ModalAddItem = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentStep,
|
||||
nextStep,
|
||||
resetStep,
|
||||
handleAddItem,
|
||||
}: IModalAddItem) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
disableEscapeKeyDown={true}
|
||||
>
|
||||
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||
{currentStep === 2 && (
|
||||
<StepTwo
|
||||
onClose={onClose}
|
||||
handleAddItem={handleAddItem}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepOneProps {
|
||||
nextStep: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||
const { form, onSubmit } = useItem(nextStep);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header>
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Tambah Item Roll Gacha
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Lengkapi detal di bawah ini, untuk menambahkan item gacha
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Pilih Item"
|
||||
name="itemName"
|
||||
type="text"
|
||||
placeholder="Pilih item yang dimasukkan ke roll"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Quantity"
|
||||
name="quantity"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Masukkan Kuantitas Item"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Chance Rate"
|
||||
name="chanceRate"
|
||||
type="number"
|
||||
value={0.1}
|
||||
min={0.1}
|
||||
step={0.1}
|
||||
max={1}
|
||||
placeholder="Masukkan Chance Rate (0,1 - 1)"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" size="lg" className="w-full" type="submit">
|
||||
Tambahkan Item
|
||||
</Button>
|
||||
</form>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepTwoProps {
|
||||
onClose: () => void;
|
||||
handleAddItem?: () => Promise<boolean>;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => {
|
||||
const { onConfirm, onCancel } = useConfirmItem(
|
||||
onClose,
|
||||
resetStep,
|
||||
handleAddItem,
|
||||
{
|
||||
success: 'Item ditambahkan ke roll gacha',
|
||||
error: 'Item gagal ditambahkan ke roll gacha',
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header className="mb-0 text-center items-center">
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Tambah ke Roll Gacha
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin ingin
|
||||
<br /> menambahkan item ini ke roll gacha?
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Tambahkan
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalAddItem;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
|
||||
interface IModalDeleteItem {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleDeleteItem?: () => void;
|
||||
}
|
||||
|
||||
const ModalDeleteItem = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
handleDeleteItem,
|
||||
}: IModalDeleteItem) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
closeButtonClassName="hidden"
|
||||
>
|
||||
<Modal.Header className="gap-8">
|
||||
<img
|
||||
src="/chibi-delete.webp"
|
||||
alt="Delete item?"
|
||||
width={148}
|
||||
className="self-center"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
|
||||
Delete Item
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin untuk menghapus item ini?
|
||||
</p>
|
||||
</div>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onClose}
|
||||
>
|
||||
Batal Hapus
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
handleDeleteItem && handleDeleteItem();
|
||||
}}
|
||||
>
|
||||
Hapus Item
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalDeleteItem;
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { useConfirmItem, useItem } from '../_hook/use-item';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
interface IModalUpdateItem {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleUpdateItem?: () => Promise<boolean>;
|
||||
currentStep?: number;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const ModalUpdateItem = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentStep,
|
||||
nextStep,
|
||||
resetStep,
|
||||
handleUpdateItem,
|
||||
}: IModalUpdateItem) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
disableEscapeKeyDown={true}
|
||||
>
|
||||
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||
{currentStep === 2 && (
|
||||
<StepTwo
|
||||
onClose={onClose}
|
||||
handleUpdateItem={handleUpdateItem}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepOneProps {
|
||||
nextStep: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||
const initialValues = {
|
||||
itemName: 'Hoodie IMPHNEN Official 2025',
|
||||
quantity: 10,
|
||||
chanceRate: 0.1,
|
||||
};
|
||||
|
||||
const { form, onSubmit } = useItem(nextStep, initialValues);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header>
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Update Item Roll Gacha
|
||||
</h2>
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Pilih Item"
|
||||
name="itemName"
|
||||
type="text"
|
||||
placeholder="Pilih item yang dimasukkan ke roll"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Quantity"
|
||||
name="quantity"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Masukkan Kuantitas Item"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Chance Rate"
|
||||
name="chanceRate"
|
||||
type="number"
|
||||
value={0.1}
|
||||
min={0.1}
|
||||
step={0.1}
|
||||
max={1}
|
||||
placeholder="Masukkan Chance Rate (0,1 - 1)"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={nextStep}
|
||||
>
|
||||
Perbarui Item
|
||||
</Button>
|
||||
</form>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepTwoProps {
|
||||
onClose: () => void;
|
||||
handleUpdateItem?: () => Promise<boolean>;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const StepTwo = ({ onClose, handleUpdateItem, resetStep }: IStepTwoProps) => {
|
||||
const { onConfirm, onCancel } = useConfirmItem(
|
||||
onClose,
|
||||
resetStep,
|
||||
handleUpdateItem,
|
||||
{
|
||||
success: 'Perubahan item roll berhasil dilakukan',
|
||||
error: 'Perubahan item roll gagal dilakukan',
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header className="mb-0 text-center items-center">
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Update Item
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin dengan
|
||||
<br /> perubahan yang dilakukan?
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalUpdateItem;
|
||||
+5
-7
@@ -8,17 +8,16 @@ import { toast } from 'sonner';
|
||||
|
||||
export const useItem = (
|
||||
nextStep: () => void,
|
||||
initialValues?: Partial<TGachaRollItem>,
|
||||
onDataCapture?: (data: any) => void,
|
||||
initialValues?: TGachaRollItem
|
||||
) => {
|
||||
const form = useForm<any>({
|
||||
const form = useForm<TGachaRollItem>({
|
||||
resolver: zodResolver(gachaRollItemSchema),
|
||||
mode: 'all',
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
onDataCapture?.(data);
|
||||
console.log('Form data:', data);
|
||||
nextStep();
|
||||
});
|
||||
|
||||
@@ -39,9 +38,8 @@ export const useConfirmItem = (
|
||||
) => {
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
if (actionFunction) {
|
||||
await actionFunction();
|
||||
}
|
||||
// const result = await actionFunction?.();
|
||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||
toast.success(messages?.success);
|
||||
onClose();
|
||||
resetStep();
|
||||
@@ -0,0 +1,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,64 @@
|
||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
getAdminUsers,
|
||||
getAdminTeams,
|
||||
getAdminSubmissions,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
|
||||
export const HackathonDashboardPage: FC = (): ReactElement => {
|
||||
// Fetch total participants
|
||||
const { data: usersData } = useQuery({
|
||||
queryKey: ['admin-users-count'],
|
||||
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
|
||||
});
|
||||
|
||||
// Fetch total teams
|
||||
const { data: teamsData } = useQuery({
|
||||
queryKey: ['admin-teams-count'],
|
||||
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
|
||||
});
|
||||
|
||||
// Fetch total submissions
|
||||
const { data: submissionsData } = useQuery({
|
||||
queryKey: ['admin-submissions-count'],
|
||||
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
|
||||
});
|
||||
|
||||
const totalParticipants = usersData?.meta?.total_data ?? '??';
|
||||
const totalTeams = teamsData?.meta?.total_data ?? '??';
|
||||
const totalSubmissions = submissionsData?.meta?.total_data ?? '??';
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
|
||||
|
||||
<section className="grid grid-cols-5 gap-5">
|
||||
{/* Participant */}
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||
{totalParticipants}
|
||||
</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Participants</p>
|
||||
</div>
|
||||
{/* Team */}
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||
{totalTeams}
|
||||
</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Teams</p>
|
||||
</div>
|
||||
{/* Project Submitted */}
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||
{totalSubmissions}
|
||||
</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
|
||||
</div>
|
||||
</section>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HackathonDashboardPage;
|
||||
+19
-3
@@ -40,7 +40,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center">
|
||||
@@ -68,8 +68,9 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
|
||||
{/* Submission Status */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-4 border rounded-lg',
|
||||
@@ -95,6 +96,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Description */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-neutral-700 mb-2">
|
||||
Project Description
|
||||
@@ -104,11 +106,13 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Project Links */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-neutral-700">
|
||||
Project Links
|
||||
</h3>
|
||||
|
||||
{/* Repository URL */}
|
||||
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||
<LinkOutlined className="text-primary-500 mt-1" />
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -126,6 +130,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo URL */}
|
||||
{submission.demo_url && (
|
||||
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||
<LinkOutlined className="text-primary-500 mt-1" />
|
||||
@@ -145,6 +150,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Presentation URL */}
|
||||
{submission.presentation_url && (
|
||||
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||
<LinkOutlined className="text-primary-500 mt-1" />
|
||||
@@ -165,6 +171,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Screenshots */}
|
||||
{submission.screenshots && submission.screenshots.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-neutral-700 flex items-center gap-2">
|
||||
@@ -191,6 +198,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-neutral-200">
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-1">Created</p>
|
||||
@@ -219,11 +227,19 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-neutral-200 bg-neutral-50">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
|
||||
{/* <Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
console.log('Edit submission:', submission.id);
|
||||
}}
|
||||
>
|
||||
Edit Status
|
||||
</Button> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,375 @@
|
||||
import {
|
||||
FC,
|
||||
ReactElement,
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import SubmissionModal from './_components/submission-modal';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
LoadingOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
getAdminSubmissions,
|
||||
TAdminSubmissionItem,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
type SubmissionType = TAdminSubmissionItem;
|
||||
|
||||
export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const currentPage = Math.max(
|
||||
1,
|
||||
parseInt(searchParams.get('page') || '1', 10)
|
||||
);
|
||||
const searchQuery = searchParams.get('search') || '';
|
||||
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
|
||||
const statusFilter = searchParams.get('status') || 'all';
|
||||
|
||||
const [showSubmissionModal, setShowSubmissionModal] = useState(false);
|
||||
const [selectedSubmission, setSelectedSubmission] =
|
||||
useState<SubmissionType | null>(null);
|
||||
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
||||
|
||||
// Fetch submissions from API
|
||||
const {
|
||||
data: submissionsResponse,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
'admin-submissions',
|
||||
currentPage,
|
||||
perPage,
|
||||
statusFilter,
|
||||
searchQuery,
|
||||
],
|
||||
queryFn: () =>
|
||||
getAdminSubmissions({
|
||||
page: currentPage,
|
||||
per_page: perPage,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
search: searchQuery || undefined,
|
||||
}),
|
||||
staleTime: 30000, // 30 seconds cache
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
const totalData = submissionsResponse?.meta?.total_data || 0;
|
||||
const totalPages = submissionsResponse?.meta?.total_page || 1;
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = useCallback(
|
||||
(newPage: number) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', newPage.toString());
|
||||
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||
if (searchQuery) params.set('search', searchQuery);
|
||||
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||
setSearchParams(params);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
[setSearchParams, perPage, searchQuery, statusFilter]
|
||||
);
|
||||
|
||||
// Validate page number
|
||||
useEffect(() => {
|
||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||
setSearchParams({ page: totalPages.toString() });
|
||||
}
|
||||
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
||||
|
||||
// Sync globalFilter with URL
|
||||
useEffect(() => {
|
||||
setGlobalFilter(searchQuery);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Handle search
|
||||
const handleSearch = useCallback(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', '1');
|
||||
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||
if (globalFilter.trim()) {
|
||||
params.set('search', globalFilter.trim());
|
||||
}
|
||||
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||
setSearchParams(params);
|
||||
}, [globalFilter, setSearchParams, perPage, statusFilter]);
|
||||
|
||||
const handleSearchKeyPress = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
},
|
||||
[handleSearch]
|
||||
);
|
||||
|
||||
// Handle per page change
|
||||
const handlePerPageChange = useCallback(
|
||||
(newPerPage: number) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', '1');
|
||||
params.set('per_page', newPerPage.toString());
|
||||
if (searchQuery) params.set('search', searchQuery);
|
||||
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||
setSearchParams(params);
|
||||
},
|
||||
[setSearchParams, searchQuery, statusFilter]
|
||||
);
|
||||
|
||||
// Handle status filter change
|
||||
// const handleStatusFilterChange = useCallback(
|
||||
// (newStatus: string) => {
|
||||
// const params = new URLSearchParams();
|
||||
// params.set('page', '1');
|
||||
// if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||
// if (searchQuery) params.set('search', searchQuery);
|
||||
// if (newStatus !== 'all') params.set('status', newStatus);
|
||||
// setSearchParams(params);
|
||||
// },
|
||||
// [setSearchParams, perPage, searchQuery]
|
||||
// );
|
||||
|
||||
// Handle modal
|
||||
const handleShowSubmissionModal = useCallback(
|
||||
(submission: SubmissionType) => {
|
||||
setSelectedSubmission(submission);
|
||||
setShowSubmissionModal(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCloseSubmissionModal = useCallback(() => {
|
||||
setShowSubmissionModal(false);
|
||||
setSelectedSubmission(null);
|
||||
}, []);
|
||||
|
||||
// Get submissions data
|
||||
const filteredData = useMemo(() => {
|
||||
return submissionsResponse?.data || [];
|
||||
}, [submissionsResponse]);
|
||||
|
||||
// Memoize columns
|
||||
const columns: ColumnDef<SubmissionType>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'project_name',
|
||||
header: 'Project Name',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium text-neutral-900">
|
||||
{row.original.project_name}
|
||||
</span>
|
||||
),
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'team_id',
|
||||
header: 'Team ID',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-neutral-700 font-mono">
|
||||
{row.original.team_id}
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
|
||||
status === 'submitted'
|
||||
? 'bg-success-100 text-success-800'
|
||||
: status === 'pending'
|
||||
? 'bg-orange-100 text-orange-800'
|
||||
: 'bg-neutral-100 text-neutral-700'
|
||||
)}
|
||||
>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'submitted_at',
|
||||
header: 'Submitted',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-neutral-900 text-sm">
|
||||
{new Date(row.original.submitted_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
enableSorting: true,
|
||||
sortingFn: 'datetime',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
meta: { cellClassName: cn('w-48') },
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||
onClick={() => handleShowSubmissionModal(row.original)}
|
||||
>
|
||||
<EyeOutlined className="text-sm" />
|
||||
View
|
||||
</Button>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[handleShowSubmissionModal]
|
||||
);
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||
Project Submissions
|
||||
</h1>
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||
<input
|
||||
type="text"
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="Search by project name..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
onKeyPress={handleSearchKeyPress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Per Page Dropdown */}
|
||||
<div className="relative">
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value={perPage}
|
||||
onChange={(e) =>
|
||||
handlePerPageChange(parseInt(e.target.value, 10))
|
||||
}
|
||||
>
|
||||
<option value={10}>10 / page</option>
|
||||
<option value={20}>20 / page</option>
|
||||
<option value={50}>50 / page</option>
|
||||
<option value={100}>100 / page</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
{/* <div className="relative">
|
||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value={statusFilter}
|
||||
onChange={(e) => handleStatusFilterChange(e.target.value)}
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="submitted">Submitted</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filters */}
|
||||
{/* {statusFilter !== 'all' && (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||
Status: {statusFilter}
|
||||
<button
|
||||
onClick={() => handleStatusFilterChange('all')}
|
||||
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleStatusFilterChange('all');
|
||||
setGlobalFilter('');
|
||||
}}
|
||||
className="text-sm text-neutral-600"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{/* Loading & results */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||
<span className="ml-3 text-neutral-600">
|
||||
Loading submissions...
|
||||
</span>
|
||||
</div>
|
||||
) : filteredData.length > 0 ? (
|
||||
<>
|
||||
<div className="text-sm text-neutral-600">
|
||||
Showing {filteredData.length} of {totalData} submissions (Page{' '}
|
||||
{currentPage} of {totalPages})
|
||||
{isFetching && (
|
||||
<span className="ml-2 text-primary-500">(Updating...)</span>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={filteredData}
|
||||
columns={columns}
|
||||
pageSize={perPage}
|
||||
manualPagination={true}
|
||||
pageCount={totalPages}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-neutral-500">
|
||||
No submissions found. Try adjusting your filters.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Submission Modal */}
|
||||
{selectedSubmission && (
|
||||
<SubmissionModal
|
||||
isOpen={showSubmissionModal}
|
||||
onClose={handleCloseSubmissionModal}
|
||||
submission={selectedSubmission}
|
||||
/>
|
||||
)}
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HackathonSubmissionsPage;
|
||||
+13
@@ -33,6 +33,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
const bannerInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Initialize form data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (team) {
|
||||
@@ -54,6 +55,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
}
|
||||
}, [isOpen, team]);
|
||||
|
||||
// Check if form has changes
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!formData || !team) return !!formData;
|
||||
return (
|
||||
@@ -66,6 +68,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
);
|
||||
}, [formData, team]);
|
||||
|
||||
// Check if required fields are filled
|
||||
const isFormValid = useMemo(() => {
|
||||
if (!formData) return false;
|
||||
return (
|
||||
@@ -141,6 +144,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
@@ -168,6 +172,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}>
|
||||
<input
|
||||
type="file"
|
||||
@@ -184,6 +189,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Banner Section */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-neutral-700">
|
||||
Team Banner{' '}
|
||||
@@ -228,6 +234,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logo & Name */}
|
||||
<div className="grid grid-cols-12 gap-4 items-start">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
@@ -298,6 +305,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-neutral-700">
|
||||
Description <span className="text-danger-500">*</span>
|
||||
@@ -311,6 +319,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* City */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-neutral-700">
|
||||
City <span className="text-danger-500">*</span>
|
||||
@@ -327,6 +336,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-neutral-700">
|
||||
Team Visibility
|
||||
@@ -363,6 +373,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Details */}
|
||||
{team && (
|
||||
<div className="space-y-4 border-t border-neutral-200 pt-4">
|
||||
<div className="space-y-2">
|
||||
@@ -418,6 +429,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-6 border-t border-neutral-200">
|
||||
<div>
|
||||
{team && (
|
||||
@@ -451,6 +463,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
+5
-2
@@ -15,7 +15,7 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
||||
className = '',
|
||||
showPlaceholder = true,
|
||||
}) => {
|
||||
const aspectRatioClass = 'aspect-[3/1]';
|
||||
const aspectRatioClass = 'aspect-[3/1]'; // 3:1 aspect ratio
|
||||
|
||||
if (!banner && !showPlaceholder) {
|
||||
return null;
|
||||
@@ -35,6 +35,7 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
||||
alt={`${teamName} banner`}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
// Fallback to placeholder if image fails to load
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
const placeholder = target.nextElementSibling as HTMLElement;
|
||||
@@ -43,10 +44,11 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Fallback placeholder (hidden by default, shown on image error) */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
|
||||
'hidden'
|
||||
'hidden' // Hidden by default
|
||||
)}
|
||||
>
|
||||
<div className="text-center">
|
||||
@@ -59,6 +61,7 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// No banner - show placeholder
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -0,0 +1,447 @@
|
||||
import {
|
||||
FC,
|
||||
ReactElement,
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import ModalTeamDetail from './_components/modal-team-detail-new';
|
||||
import { CityFilterSelect } from '../../../components/city-filter-select';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
EditOutlined,
|
||||
TeamOutlined,
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
PlusOutlined,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
getAdminTeams,
|
||||
TAdminTeamItem,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
type TeamType = TAdminTeamItem;
|
||||
|
||||
export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const currentPage = Math.max(
|
||||
1,
|
||||
parseInt(searchParams.get('page') || '1', 10)
|
||||
);
|
||||
const searchQuery = searchParams.get('search') || '';
|
||||
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [showNewTeamModal, setShowNewTeamModal] = useState(false);
|
||||
const [selectedTeam, setSelectedTeam] = useState<TeamType | null>(null);
|
||||
useState<TeamType | null>(null);
|
||||
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
||||
|
||||
// Advanced filtering states
|
||||
const [visibilityFilter, setVisibilityFilter] = useState('all');
|
||||
const [cityFilter, setCityFilter] = useState('all');
|
||||
|
||||
// Fetch teams from API
|
||||
const {
|
||||
data: teamsResponse,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
'admin-teams',
|
||||
currentPage,
|
||||
perPage,
|
||||
cityFilter,
|
||||
visibilityFilter,
|
||||
searchQuery,
|
||||
],
|
||||
queryFn: () =>
|
||||
getAdminTeams({
|
||||
page: currentPage,
|
||||
per_page: perPage,
|
||||
search: searchQuery || undefined,
|
||||
}),
|
||||
staleTime: 30000, // 30 seconds cache
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
const totalData = teamsResponse?.meta?.total_data || 0;
|
||||
const totalPages = teamsResponse?.meta?.total_page || 1;
|
||||
|
||||
// Handle page change - update URL query params
|
||||
const handlePageChange = useCallback(
|
||||
(newPage: number) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', newPage.toString());
|
||||
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||
if (searchQuery) params.set('search', searchQuery);
|
||||
setSearchParams(params);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
[setSearchParams, perPage, searchQuery]
|
||||
);
|
||||
|
||||
// Validate page number doesn't exceed total pages
|
||||
useEffect(() => {
|
||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||
setSearchParams({ page: totalPages.toString() });
|
||||
}
|
||||
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
||||
|
||||
// Sync globalFilter with URL search param on mount
|
||||
useEffect(() => {
|
||||
setGlobalFilter(searchQuery);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Handle search teams
|
||||
const handleSearch = useCallback(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', '1');
|
||||
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||
if (globalFilter.trim()) {
|
||||
params.set('search', globalFilter.trim());
|
||||
}
|
||||
setSearchParams(params);
|
||||
}, [globalFilter, setSearchParams, perPage]);
|
||||
|
||||
// Handle Enter key press in search input
|
||||
const handleSearchKeyPress = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
},
|
||||
[handleSearch]
|
||||
);
|
||||
|
||||
// Handle per page change
|
||||
const handlePerPageChange = useCallback(
|
||||
(newPerPage: number) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', '1');
|
||||
params.set('per_page', newPerPage.toString());
|
||||
if (searchQuery) params.set('search', searchQuery);
|
||||
setSearchParams(params);
|
||||
},
|
||||
[setSearchParams, searchQuery]
|
||||
);
|
||||
|
||||
// Memoize the callback to prevent recreation
|
||||
const handleShowDetailModal = useCallback((team: TeamType) => {
|
||||
setSelectedTeam(team);
|
||||
setShowDetailModal(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseDetailModal = useCallback(() => {
|
||||
setShowDetailModal(false);
|
||||
setSelectedTeam(null);
|
||||
}, []);
|
||||
|
||||
const handleShowNewTeamModal = useCallback(() => {
|
||||
setShowNewTeamModal(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseNewTeamModal = useCallback(() => {
|
||||
setShowNewTeamModal(false);
|
||||
}, []);
|
||||
|
||||
// Get teams data from API response
|
||||
const filteredData = useMemo(() => {
|
||||
return teamsResponse?.data || [];
|
||||
}, [teamsResponse]);
|
||||
|
||||
// Memoize columns to prevent recreation on every render
|
||||
const columns: ColumnDef<TeamType>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Team',
|
||||
cell: ({ row }) => {
|
||||
const team = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Team Logo */}
|
||||
<div className="w-10 h-10 rounded-full bg-neutral-100 flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<TeamOutlined className="text-neutral-400 text-lg" />
|
||||
)}
|
||||
</div>
|
||||
{/* Team Name */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="font-medium text-neutral-900 truncate max-w-sm"
|
||||
title={team.name}
|
||||
>
|
||||
{team.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'city',
|
||||
header: 'City',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-neutral-700">{row.original.city}</span>
|
||||
),
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'visibility',
|
||||
header: 'Visibility',
|
||||
cell: ({ row }) => {
|
||||
const isPublic = row.original.visibility === 'public';
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
|
||||
isPublic
|
||||
? 'bg-success-100 text-success-800'
|
||||
: 'bg-neutral-100 text-neutral-700'
|
||||
)}
|
||||
>
|
||||
{isPublic ? 'Public' : 'Private'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: 'leader',
|
||||
header: 'Leader ID',
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm text-neutral-700 font-mono">
|
||||
{row.original.leader_id}
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Created',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-neutral-900 text-sm">
|
||||
{new Date(row.original.created_at).toLocaleDateString('en-UK', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
enableSorting: true,
|
||||
sortingFn: 'datetime',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
meta: { cellClassName: cn('w-48') },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||
onClick={() => handleShowDetailModal(row.original)}
|
||||
>
|
||||
<EditOutlined className="text-sm" />
|
||||
Manage
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[handleShowDetailModal]
|
||||
);
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||
Team Management
|
||||
</h1>
|
||||
{/* Filters and actions */}
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||
{/* Left side - Search & filters */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||
<input
|
||||
type="text"
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="Search teams by name or city..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
onKeyPress={handleSearchKeyPress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Per Page Dropdown */}
|
||||
<div className="relative">
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value={perPage}
|
||||
onChange={(e) =>
|
||||
handlePerPageChange(parseInt(e.target.value, 10))
|
||||
}
|
||||
>
|
||||
<option value={10}>10 / page</option>
|
||||
<option value={20}>20 / page</option>
|
||||
<option value={50}>50 / page</option>
|
||||
<option value={100}>100 / page</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Visibility Filter */}
|
||||
{/* <div className="relative">
|
||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value={visibilityFilter}
|
||||
onChange={(e) => setVisibilityFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Visibility</option>
|
||||
<option value="public">Public</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
</div> */}
|
||||
|
||||
{/* City Filter */}
|
||||
{/* <CityFilterSelect
|
||||
value={cityFilter}
|
||||
onChange={setCityFilter}
|
||||
className="w-full sm:w-44"
|
||||
placeholder="Search cities..."
|
||||
allOptionLabel="All Cities"
|
||||
/> */}
|
||||
</div>
|
||||
|
||||
{/* Right side - Add Team Button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
onClick={handleShowNewTeamModal}
|
||||
>
|
||||
<PlusOutlined className="text-sm" />
|
||||
Add Team
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filters display */}
|
||||
{(visibilityFilter !== 'all' || cityFilter !== 'all') && (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||
|
||||
{/* Visibility filter badge */}
|
||||
{visibilityFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||
Visibility: {visibilityFilter}
|
||||
<button
|
||||
onClick={() => setVisibilityFilter('all')}
|
||||
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* City filter badge */}
|
||||
{cityFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||
City: {cityFilter}
|
||||
<button
|
||||
onClick={() => setCityFilter('all')}
|
||||
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Clear all filters */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setVisibilityFilter('all');
|
||||
setCityFilter('all');
|
||||
setGlobalFilter('');
|
||||
}}
|
||||
className="text-sm text-neutral-600"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading & results display */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||
<span className="ml-3 text-neutral-600">Loading teams...</span>
|
||||
</div>
|
||||
) : filteredData.length > 0 ? (
|
||||
<>
|
||||
<div className="text-sm text-neutral-600">
|
||||
Showing {filteredData.length} of {totalData} teams (Page{' '}
|
||||
{currentPage} of {totalPages})
|
||||
{isFetching && (
|
||||
<span className="ml-2 text-primary-500">(Updating...)</span>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={filteredData}
|
||||
columns={columns}
|
||||
pageSize={perPage}
|
||||
manualPagination={true}
|
||||
pageCount={totalPages}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-neutral-500">
|
||||
No teams found. Try adjusting your filters.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Modals component */}
|
||||
<ModalTeamDetail
|
||||
isOpen={showDetailModal}
|
||||
onClose={handleCloseDetailModal}
|
||||
team={selectedTeam}
|
||||
/>
|
||||
|
||||
{/* New Team Modal */}
|
||||
<ModalTeamDetail
|
||||
isOpen={showNewTeamModal}
|
||||
onClose={handleCloseNewTeamModal}
|
||||
team={null} // null indicates creating new team
|
||||
/>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HackathonTeamsPage;
|
||||
+33
-4
@@ -37,13 +37,16 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Initialize form data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (user) {
|
||||
// Edit existing user
|
||||
setFormData({ ...user });
|
||||
} else {
|
||||
// Create new user
|
||||
setFormData({
|
||||
id: '',
|
||||
id: '', // Will be generated by backend
|
||||
fullname: '',
|
||||
bio: '',
|
||||
location: '',
|
||||
@@ -57,9 +60,10 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
}
|
||||
}, [isOpen, user]);
|
||||
|
||||
// Check if form has changes
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!formData) return false;
|
||||
if (!user) return true;
|
||||
if (!user) return true; // New user always has changes
|
||||
return (
|
||||
formData.fullname !== user.fullname ||
|
||||
formData.location !== user.location ||
|
||||
@@ -70,6 +74,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
);
|
||||
}, [formData, user]);
|
||||
|
||||
// Check if required fields are filled
|
||||
const isFormValid = useMemo(() => {
|
||||
if (!formData) return false;
|
||||
return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
|
||||
@@ -94,22 +99,25 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
if (!formData) return;
|
||||
|
||||
if (user) {
|
||||
// Update existing user
|
||||
console.log('Update user data:', formData);
|
||||
} else {
|
||||
// Create new user
|
||||
console.log('Create new user:', formData);
|
||||
}
|
||||
// Here you would typically make an API call to save the data
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (user) {
|
||||
setFormData({ ...user });
|
||||
setFormData({ ...user }); // Reset to original for edit mode
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
if (!user) return;
|
||||
if (!user) return; // Can't delete new user
|
||||
console.log('Delete user:', user.id);
|
||||
setShowDeleteConfirm(false);
|
||||
onClose();
|
||||
@@ -118,16 +126,19 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('Image size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const avatarUrl = e.target?.result as string;
|
||||
@@ -172,6 +183,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Hidden File Input */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
@@ -179,8 +191,10 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Interactive User Avatar */}
|
||||
<div className="relative group ">
|
||||
<div className="w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-primary-300 transition-colors">
|
||||
{formData.avatar ? (
|
||||
@@ -194,6 +208,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Avatar Hover Overlay */}
|
||||
<button
|
||||
onClick={() => setShowAvatarMenu(!showAvatarMenu)}
|
||||
className="absolute inset-0 bg-neutral-400 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
@@ -201,6 +216,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
<CameraOutlined className="text-white text-lg" />
|
||||
</button>
|
||||
|
||||
{/* Avatar Menu Dropdown */}
|
||||
{showAvatarMenu && (
|
||||
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
|
||||
<button
|
||||
@@ -253,14 +269,17 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Left Column - Basic Info */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
Basic Information
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* Full Name - Required */}
|
||||
<div className="flex items-center gap-3">
|
||||
<UserOutlined className="text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
@@ -291,6 +310,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location - Required */}
|
||||
<div className="flex items-center gap-3">
|
||||
<EnvironmentOutlined className="text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
@@ -326,6 +346,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Joined Date - Read Only - Only show for existing users */}
|
||||
{user && (
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarOutlined className="text-neutral-400" />
|
||||
@@ -349,6 +370,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bio Section - Optional */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
|
||||
Bio{' '}
|
||||
@@ -368,7 +390,9 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Skills & Status */}
|
||||
<div className="space-y-6">
|
||||
{/* Account Status - Enhanced Tab Design */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
Account Status
|
||||
@@ -424,6 +448,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Skills Section - Optional */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
Skills & Expertise{' '}
|
||||
@@ -485,6 +510,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Details - Read Only - Only show for existing users */}
|
||||
{user && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
@@ -521,6 +547,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="border-t border-neutral-200 px-8 py-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -531,6 +558,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
? 'Please fill required fields'
|
||||
: 'No changes made'}
|
||||
</div>
|
||||
{/* Delete Account Button - Only show for existing users */}
|
||||
{user && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
@@ -567,6 +595,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 z-60">
|
||||
<div
|
||||
@@ -0,0 +1,573 @@
|
||||
import {
|
||||
FC,
|
||||
ReactElement,
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import ModalUserDetail from './_components/modal-user-detail';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
EditOutlined,
|
||||
UserOutlined,
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
PlusOutlined,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { CityFilterSelect } from '../../../components/city-filter-select';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
getAdminUsers,
|
||||
TAdminUserItem,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
type UserType = TAdminUserItem;
|
||||
|
||||
// Skills options for filter
|
||||
const skillsOptions = [
|
||||
'Frontend Developer',
|
||||
'Backend Developer',
|
||||
'Full Stack Developer',
|
||||
'DevOps Engineer',
|
||||
'UI/UX Designer',
|
||||
'Product Manager',
|
||||
'Data Scientist',
|
||||
'Mobile Developer',
|
||||
];
|
||||
|
||||
export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const currentPage = Math.max(
|
||||
1,
|
||||
parseInt(searchParams.get('page') || '1', 10)
|
||||
);
|
||||
const searchQuery = searchParams.get('search') || '';
|
||||
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [showNewUserModal, setShowNewUserModal] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
|
||||
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
||||
|
||||
// Advanced filtering states
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [cityFilter, setCityFilter] = useState('all');
|
||||
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
|
||||
|
||||
// Fetch users from API
|
||||
const {
|
||||
data: usersResponse,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
'admin-users',
|
||||
currentPage,
|
||||
perPage,
|
||||
cityFilter,
|
||||
statusFilter,
|
||||
searchQuery,
|
||||
],
|
||||
queryFn: () =>
|
||||
getAdminUsers({
|
||||
page: currentPage,
|
||||
per_page: perPage,
|
||||
search: searchQuery || undefined,
|
||||
}),
|
||||
staleTime: 30000, // 30 seconds cache
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
const totalData = usersResponse?.meta?.total_data || 0;
|
||||
const totalPages = usersResponse?.meta?.total_page || 1;
|
||||
|
||||
// Handle page change - update URL query params
|
||||
const handlePageChange = useCallback(
|
||||
(newPage: number) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', newPage.toString());
|
||||
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||
if (searchQuery) params.set('search', searchQuery);
|
||||
setSearchParams(params);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
[setSearchParams, perPage, searchQuery]
|
||||
);
|
||||
|
||||
// Validate page number doesn't exceed total pages
|
||||
useEffect(() => {
|
||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||
setSearchParams({ page: totalPages.toString() });
|
||||
}
|
||||
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
||||
|
||||
// Sync globalFilter with URL search param on mount
|
||||
useEffect(() => {
|
||||
setGlobalFilter(searchQuery);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Handle search users
|
||||
const handleSearch = useCallback(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', '1');
|
||||
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||
if (globalFilter.trim()) {
|
||||
params.set('search', globalFilter.trim());
|
||||
}
|
||||
setSearchParams(params);
|
||||
}, [globalFilter, setSearchParams, perPage]);
|
||||
|
||||
// Handle Enter key press in search input
|
||||
const handleSearchKeyPress = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
},
|
||||
[handleSearch]
|
||||
);
|
||||
|
||||
// Handle per page change
|
||||
const handlePerPageChange = useCallback(
|
||||
(newPerPage: number) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', '1');
|
||||
params.set('per_page', newPerPage.toString());
|
||||
if (searchQuery) params.set('search', searchQuery);
|
||||
setSearchParams(params);
|
||||
},
|
||||
[setSearchParams, searchQuery]
|
||||
);
|
||||
|
||||
// Memoize the callback to prevent recreation
|
||||
const handleShowDetailModal = useCallback((user: UserType) => {
|
||||
setSelectedUser(user);
|
||||
setShowDetailModal(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseDetailModal = useCallback(() => {
|
||||
setShowDetailModal(false);
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
const handleShowNewUserModal = useCallback(() => {
|
||||
setShowNewUserModal(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseNewUserModal = useCallback(() => {
|
||||
setShowNewUserModal(false);
|
||||
}, []);
|
||||
|
||||
// Filter data based on current filter states
|
||||
const filteredData = useMemo(() => {
|
||||
const usersData = usersResponse?.data || [];
|
||||
return usersData.filter((user: UserType) => {
|
||||
// Status filter
|
||||
if (statusFilter !== 'all') {
|
||||
const isActive = statusFilter === 'active';
|
||||
if (user.is_active !== isActive) return false;
|
||||
}
|
||||
|
||||
// Skills filter
|
||||
if (skillsFilter.length > 0) {
|
||||
const userSkills = user.skills || [];
|
||||
const hasMatchingSkill = skillsFilter.some((skill) =>
|
||||
userSkills.includes(skill)
|
||||
);
|
||||
if (!hasMatchingSkill) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [usersResponse, statusFilter, skillsFilter]);
|
||||
|
||||
// Memoize columns to prevent recreation on every render
|
||||
const columns: ColumnDef<UserType>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'fullname',
|
||||
header: 'User',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Avatar */}
|
||||
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
|
||||
{row.original.avatar ? (
|
||||
<img
|
||||
src={row.original.avatar}
|
||||
alt={row.original.fullname}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<UserOutlined className="text-neutral-500 text-lg" />
|
||||
)}
|
||||
</div>
|
||||
{/* Name only */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-neutral-900 truncate">
|
||||
{row.original.fullname}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'skills',
|
||||
header: 'Skills',
|
||||
cell: ({ row }) => {
|
||||
const skills = row.original.skills || [];
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||
{skills.length > 0 ? (
|
||||
<>
|
||||
{skills.slice(0, 2).map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
|
||||
>
|
||||
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||
</span>
|
||||
))}
|
||||
{skills.length > 2 && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
|
||||
+{skills.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-neutral-400">-</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'location',
|
||||
header: 'Location',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-neutral-700">{row.original.location}</span>
|
||||
),
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'is_active',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
|
||||
)}
|
||||
>
|
||||
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
enableSorting: true,
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const aActive = rowA.original.is_active;
|
||||
const bActive = rowB.original.is_active;
|
||||
if (aActive && !bActive) return -1;
|
||||
if (!aActive && bActive) return 1;
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Joined',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-neutral-900 text-sm">
|
||||
{new Date(row.original.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
enableSorting: true,
|
||||
sortingFn: 'datetime',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
meta: { cellClassName: cn('w-48') },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||
onClick={() => handleShowDetailModal(row.original)}
|
||||
>
|
||||
<EditOutlined className="text-sm" />
|
||||
Manage
|
||||
</Button>
|
||||
{/* <Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-sm px-4 py-2"
|
||||
onClick={() => {
|
||||
// Toggle user status - implement later
|
||||
console.log(`Toggle status for ${row.original.fullname}`);
|
||||
}}
|
||||
>
|
||||
{row.original.is_active ? 'Deactivate' : 'Activate'}
|
||||
</Button> */}
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[handleShowDetailModal]
|
||||
);
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||
User Management
|
||||
</h1>
|
||||
{/* Filters and actions */}
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||
{/* Left side - Search & filters */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||
<input
|
||||
type="text"
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="Search users by name or location..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
onKeyPress={handleSearchKeyPress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Per Page Dropdown */}
|
||||
<div className="relative">
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value={perPage}
|
||||
onChange={(e) =>
|
||||
handlePerPageChange(parseInt(e.target.value, 10))
|
||||
}
|
||||
>
|
||||
<option value={10}>10 / page</option>
|
||||
<option value={20}>20 / page</option>
|
||||
<option value={50}>50 / page</option>
|
||||
<option value={100}>100 / page</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
{/* <div className="relative">
|
||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div> */}
|
||||
|
||||
{/* City Filter */}
|
||||
{/* <CityFilterSelect
|
||||
value={cityFilter}
|
||||
onChange={setCityFilter}
|
||||
className="w-full sm:w-44"
|
||||
placeholder="Search cities..."
|
||||
allOptionLabel="All Cities"
|
||||
/>
|
||||
{cityFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||
Location: {cityFilter}
|
||||
<button
|
||||
onClick={() => setCityFilter('all')}
|
||||
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)} */}
|
||||
|
||||
{/* Skills Filter with Icon */}
|
||||
{/* <div className="relative">
|
||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (
|
||||
e.target.value &&
|
||||
!skillsFilter.includes(e.target.value)
|
||||
) {
|
||||
setSkillsFilter((prev) => [...prev, e.target.value]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Add Skill Filter</option>
|
||||
{skillsOptions.map((skill) => (
|
||||
<option
|
||||
key={skill}
|
||||
value={skill}
|
||||
disabled={skillsFilter.includes(skill)}
|
||||
>
|
||||
{skill}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* Right side - Add User Button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
onClick={handleShowNewUserModal}
|
||||
>
|
||||
<PlusOutlined className="text-sm" />
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filters display */}
|
||||
{(skillsFilter.length > 0 ||
|
||||
statusFilter !== 'all' ||
|
||||
cityFilter !== 'all') && (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||
|
||||
{/* Status filter badge */}
|
||||
{statusFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||
Status: {statusFilter}
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Location filter badge */}
|
||||
{cityFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||
City: {cityFilter}
|
||||
<button
|
||||
onClick={() => setCityFilter('all')}
|
||||
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Skills filter badges */}
|
||||
{skillsFilter.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
|
||||
>
|
||||
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||
<button
|
||||
onClick={() =>
|
||||
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
||||
}
|
||||
className="text-purple-600 hover:text-purple-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Clear all filters */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStatusFilter('all');
|
||||
setCityFilter('all');
|
||||
setSkillsFilter([]);
|
||||
setGlobalFilter('');
|
||||
}}
|
||||
className="text-sm text-neutral-600"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading & results display */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||
<span className="ml-3 text-neutral-600">Loading users...</span>
|
||||
</div>
|
||||
) : filteredData.length > 0 ? (
|
||||
<>
|
||||
<div className="text-sm text-neutral-600">
|
||||
Showing {filteredData.length} of {totalData} users (Page{' '}
|
||||
{currentPage} of {totalPages})
|
||||
{isFetching && (
|
||||
<span className="ml-2 text-primary-500">(Updating...)</span>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={filteredData}
|
||||
columns={columns}
|
||||
pageSize={perPage}
|
||||
manualPagination={true}
|
||||
pageCount={totalPages}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-neutral-500">
|
||||
No users found. Try adjusting your filters.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Modals component */}
|
||||
<ModalUserDetail
|
||||
isOpen={showDetailModal}
|
||||
onClose={handleCloseDetailModal}
|
||||
user={selectedUser}
|
||||
/>
|
||||
|
||||
{/* New User Modal */}
|
||||
<ModalUserDetail
|
||||
isOpen={showNewUserModal}
|
||||
onClose={handleCloseNewUserModal}
|
||||
user={null} // null indicates creating new user
|
||||
/>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HackathonUsersPage;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
export const AppLayout: FC = (): ReactElement => {
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||
<BackofficeSidebar
|
||||
isOpen={mobileSidebarOpen}
|
||||
onClose={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Sticky top header */}
|
||||
<header
|
||||
className={
|
||||
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
|
||||
(mobileSidebarOpen ? 'z-0' : 'z-30')
|
||||
}
|
||||
>
|
||||
{/* Mobile menu button (shown on small screens) */}
|
||||
<button
|
||||
type="button"
|
||||
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700 cursor-pointer"
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-p3 font-semibold text-primary-700">
|
||||
IMPHNEN Backoffice
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<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;
|
||||
+72
@@ -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;
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { useConfirmItem } from '../_hook/use-item';
|
||||
|
||||
interface IModalUpdatePermission {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleUpdate?: () => Promise<boolean>;
|
||||
currentStep?: number;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const ModalUpdatePermission = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
resetStep,
|
||||
handleUpdate,
|
||||
}: IModalUpdatePermission) => {
|
||||
const { onConfirm } = useConfirmItem(onClose, resetStep, handleUpdate, {
|
||||
success: 'Perubahan permissions berhasil dilakukan',
|
||||
error: 'Perubahan permissions gagal dilakukan',
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-3 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
disableEscapeKeyDown={true}
|
||||
>
|
||||
<Modal.Header>
|
||||
<h2 className="text-p1 font-semibold text-primary-500">
|
||||
Update Permissions
|
||||
</h2>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex flex-col gap-8">
|
||||
<InputField
|
||||
label="Name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Nama Permission"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
type="submit"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Update Permission
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalUpdatePermission;
|
||||
+10
-6
@@ -1,18 +1,23 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
// import { zodResolver } from '@hookform/resolvers/zod';
|
||||
// import {
|
||||
// gachaRollItemSchema,
|
||||
// TGachaRollItem
|
||||
// } from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const useItem = (
|
||||
nextStep: () => void,
|
||||
initialValues?: any,
|
||||
onDataCapture?: (data: any) => void,
|
||||
initialValues?: any
|
||||
) => {
|
||||
const form = useForm<any>({
|
||||
// resolver: zodResolver(),
|
||||
mode: 'all',
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
onDataCapture?.(data);
|
||||
console.log('Form data:', data);
|
||||
nextStep();
|
||||
});
|
||||
|
||||
@@ -33,9 +38,8 @@ export const useConfirmItem = (
|
||||
) => {
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
if (actionFunction) {
|
||||
await actionFunction();
|
||||
}
|
||||
// const result = await actionFunction?.();
|
||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||
toast.success(messages?.success);
|
||||
onClose();
|
||||
resetStep();
|
||||
@@ -0,0 +1,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;
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { ArrowRightOutlined } from "@ant-design/icons";
|
||||
import { Button, Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
|
||||
import { Accordion, Modal, ModalProps } from "@imphnen-frontend-service/ui/molecules";
|
||||
import { cn, For } from "@imphnen-frontend-service/utils";
|
||||
import { FC } from "react";
|
||||
|
||||
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
|
||||
|
||||
export const ModalCreateRoadmap: FC<Omit<ModalProps, 'children'>> = ({ isOpen, onClose }) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
className="xl:max-w-[64rem] bg-white px-10 py-9"
|
||||
closeButtonClassName="hidden"
|
||||
>
|
||||
<div className="overflow-y-auto max-h-[80vh]">
|
||||
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
|
||||
Create Roadmaps
|
||||
</h1>
|
||||
|
||||
<div className="grid grid-cols-5 gap-6 mb-12">
|
||||
<div className="col-span-3">
|
||||
<label className={labelClass}>Prompt</label>
|
||||
<Textarea
|
||||
className="min-w-full w-full h-[calc(100%-2rem)]"
|
||||
placeholder="Create a learning roadmap for (subject) at the (level) level. Include topics, estimated duration, and logical progression."
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-8">
|
||||
<div>
|
||||
<label className={labelClass}>Roadmap Name</label>
|
||||
<Input type="text" className="min-w-full w-full" placeholder="Nama Roadmap" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Roadmap Name</label>
|
||||
<Select className="w-full">
|
||||
<option value="pemula">Pemula</option>
|
||||
<option value="menengah">Menengah</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Model</label>
|
||||
<Select className="w-full">
|
||||
<option value="gpt-3.5-turbo">GPT 3.5 Turbo</option>
|
||||
<option value="gpt-4">GPT 4</option>
|
||||
<option value="gpt-4-32k">GPT 4 32k</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-full">
|
||||
<Button>
|
||||
Generate Roadmap
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-neutral-600 text-p2 font-semibold mb-8">
|
||||
Roadmap Preview
|
||||
</h2>
|
||||
<div className="space-y-2.5">
|
||||
<For data={Array.from({ length: 3 })}>
|
||||
{(_, index) => (
|
||||
<Accordion
|
||||
key={index}
|
||||
title={`Day ${index + 1}`}
|
||||
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit. In convallis tincidunt nisl, id consequat mi malesuada vel. Nulla facilisi. Nam in turpis ligula."
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-12">
|
||||
<Button type="button" className="flex items-center gap-5">
|
||||
Submit Roadmap
|
||||
<ArrowRightOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user