Compare commits

..
630 changed files with 59560 additions and 21861 deletions

No files matched your search

+24
View File
@@ -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": []
}
}
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -e
if [[ ! -d "/Users/ms/Development/personal/imphnen-frontend-service" ]]; then
echo "Cannot find source directory; Did you move it?"
echo "(Looking for "/Users/ms/Development/personal/imphnen-frontend-service")"
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
exit 1
fi
# rebuild the cache forcefully
_nix_direnv_force_reload=1 direnv exec "/Users/ms/Development/personal/imphnen-frontend-service" true
# Update the mtime for .envrc.
# This will cause direnv to reload again - but without re-building.
touch "/Users/ms/Development/personal/imphnen-frontend-service/.envrc"
# Also update the timestamp of whatever profile_rc we have.
# This makes sure that we know we are up to date.
touch -r "/Users/ms/Development/personal/imphnen-frontend-service/.envrc" "/Users/ms/Development/personal/imphnen-frontend-service/.direnv"/*.rc
@@ -0,0 +1 @@
/nix/store/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
@@ -0,0 +1 @@
/nix/store/8hx8jwa24q6rkzhca9iqy5ckk9rbphi1-source
@@ -0,0 +1 @@
/nix/store/jizz7ld5r2w11y03wrmnvwsskc45krj9-source
@@ -0,0 +1 @@
/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
@@ -0,0 +1 @@
/nix/store/y4p9szcab9xd7v1c7k9ghr86hb6myyg4-nix-shell-env
File diff suppressed because it is too large. Load diff
+55
View File
@@ -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
+15 -84
View File
@@ -1,4 +1,4 @@
name: Nix Build & Deploy name: Nix Build & Cache
on: on:
push: push:
@@ -7,44 +7,8 @@ on:
branches: ['develop'] branches: ['develop']
jobs: 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: build:
needs: detect
if: needs.detect.outputs.affected != '[]'
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
app: ${{ fromJson(needs.detect.outputs.affected) }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -57,53 +21,20 @@ jobs:
name: msdqn name: msdqn
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
- name: Build ${{ matrix.app }} - name: Build all packages
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: | run: |
mkdir -p ~/.ssh nix build .#landing -o result-landing
echo "${INFRA_DEPLOY_KEY}" > ~/.ssh/deploy_key nix build .#backoffice -o result-backoffice
chmod 600 ~/.ssh/deploy_key nix build .#gacha -o result-gacha
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null nix build .#dimentorin -o result-dimentorin
ssh-keyscan 167.235.70.37 >> ~/.ssh/known_hosts 2>/dev/null nix build .#hackathon -o result-hackathon
nix build .#infra -o result-infra
- name: Update infra flake.lock - name: Show build outputs
run: | run: |
export GIT_SSH_COMMAND="ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes" echo "Landing: $(readlink result-landing)"
git clone git@github.com:IMPHNEN/imphnen-infrastructure.git /tmp/infra echo "Backoffice: $(readlink result-backoffice)"
cd /tmp/infra echo "Gacha: $(readlink result-gacha)"
nix flake update imphnen-frontend echo "Dimentorin: $(readlink result-dimentorin)"
if git diff --quiet flake.lock; then echo "Hackathon: $(readlink result-hackathon)"
echo "flake.lock unchanged, skipping" echo "Infra: $(readlink result-infra)"
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add flake.lock
git commit -m "chore: update imphnen-frontend-service to ${GITHUB_SHA::7}"
git push
fi
- name: Deploy to server
continue-on-error: true
run: |
ssh -i ~/.ssh/deploy_key -o ConnectTimeout=15 -o StrictHostKeyChecking=accept-new root@167.235.70.37 \
'nixos-rebuild switch --flake github:IMPHNEN/imphnen-infrastructure#hetzner --refresh 2>&1 | tail -30'
- name: Cleanup
if: always()
run: rm -f ~/.ssh/deploy_key
+37
View File
@@ -0,0 +1,37 @@
name: Test and Build
on:
push:
branches: ['develop']
pull_request:
branches: ['develop']
env:
NODE_VERSION: 22.14.0
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install dependencies
run: npm install
- name: Set env vars
run: |
echo "CMS_SECRET=${{ secrets.CMS_SECRET }}" >> $GITHUB_ENV
echo "CMS_STORAGE_REGION=${{ secrets.CMS_STORAGE_REGION }}" >> $GITHUB_ENV
echo "CMS_STORAGE_ENDPOINT=${{ secrets.CMS_STORAGE_ENDPOINT }}" >> $GITHUB_ENV
echo "CMS_STORAGE_ACCESS_KEY_ID=${{ secrets.CMS_STORAGE_ACCESS_KEY_ID }}" >> $GITHUB_ENV
echo "CMS_STORAGE_SECRET_ACCESS_KEY=${{ secrets.CMS_STORAGE_SECRET_ACCESS_KEY }}" >> $GITHUB_ENV
echo "CMS_POSTGRES_URL=${{ secrets.CMS_POSTGRES_URL }}" >> $GITHUB_ENV
- name: Nx Build
run: npx nx run-many --target=build --all
-7
View File
@@ -66,10 +66,3 @@ out
.github/instructions/nx.instructions.md .github/instructions/nx.instructions.md
.env.local .env.local
# Nix
.direnv
result
.claude/worktrees
.claude/settings.local.json
+9
View File
@@ -0,0 +1,9 @@
{
"mcpServers": {
"nx-mcp": {
"type": "stdio",
"command": "npx",
"args": ["nx", "mcp"]
}
}
}
+28
View File
@@ -0,0 +1,28 @@
# path to a directory with all packages
storage: ../tmp/local-registry/storage
# a list of other known repositories we can talk to
uplinks:
npmjs:
url: https://registry.npmjs.org/
maxage: 60m
packages:
'**':
# give all users (including non-authenticated users) full access
# because it is a local registry
access: $all
publish: $all
unpublish: $all
# if package is not available locally, proxy requests to npm registry
proxy: npmjs
# log settings
log:
type: stdout
format: pretty
level: warn
publish:
allow_offline: true # set offline to true to allow publish offline
+8
View File
@@ -0,0 +1,8 @@
{
"recommendations": [
"nrwl.angular-console",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"ms-playwright.playwright"
]
}
+10 -90
View File
@@ -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 <!-- nx configuration end-->
- **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)
+115 -84
View File
@@ -4,117 +4,148 @@
<img src="docs/logo.svg" alt="IMPHNEN"> <img src="docs/logo.svg" alt="IMPHNEN">
</p> </p>
Monorepo for all frontend services of [IMPHNEN](https://imphnen.dev) (Ingin Menjadi Programmer Handal Namun Enggan Ngoding) — Indonesia's largest programmer community. This repository is a **monorepo** for all frontend services of IMPHNEN. The monorepo includes three main applications:
## Apps 1. **Gacha** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Gacha Website</a>.
2. **Backoffice** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Internal Management Website</a>.
3. **Dimentorin** - Application for <a href="https://dimentorin.imphnen.dev/" target="_blank">Mentoring Service</a>.
4. **Landing Page** - Application for <a href="https://imphnen.dev/" target="_blank">Landing Page</a>.
5. **QR Campaign** - Application for QR Campaign Management.
| App | Framework | URL | ## How to install
|-----|-----------|-----|
| **Landing** | Next.js 16 | [imphnen.dev](https://imphnen.dev) |
| **Backoffice** | Vite + React | [backoffice.imphnen.dev](https://backoffice.imphnen.dev) |
| **Hackathon** | Vite + React | [hackathon.imphnen.dev](https://dimentorin.imphnen.dev) |
| **Dimentorin** | Vite + React | [dimentorin.imphnen.dev](https://dimentorin.imphnen.dev) |
| **Gacha** | Vite + React | [gacha.imphnen.dev](https://gacha.imphnen.dev) |
| **QR Campaign** | Vite + React | [qr.imphnen.dev](https://qr.imphnen.dev) |
| **Infra** | Vite + React | [infra.imphnen.dev](https://infra.imphnen.dev) |
## Shared Libraries 1. Clone this repository:
```sh
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git
cd imphnen-frontend-service
```
2. Install all dependencies:
```sh
npm install
```
| Lib | Purpose | ## How to run
|-----|---------|
| `utils` | Pure utilities — `cn()`, `For`, `Show`, `useQueryState`, `useModalLogin` |
| `service` | Business logic — API clients, auth hooks, storage, constants |
| `ui` | UI components — atoms, molecules, organisms (atomic design) |
## Getting Started ### Setup Environment Variables
### Prerequisites Before running the applications, you need to set up the environment variables. You can do this by copying the `.env.example` file to `.env` and modifying the values according to your needs.
- Node.js 22
- [Nix](https://nixos.org/download/) (optional, for reproducible builds)
### Setup
```sh ```sh
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git cd apps/{appname}
cd imphnen-frontend-service cp .env.example .env
npm install
```
Or with Nix:
```sh
nix develop # enters dev shell with node 22, bun, git, jq
```
### Environment Variables
Copy `.env.example` to `.env` in the app directory:
```sh
cp apps/<app>/.env.example apps/<app>/.env
``` ```
### Development ### Development
```sh Use the following commands to run in development mode:
nx dev <app> # e.g. nx dev landing, nx dev backoffice
``` - **Gacha**:
```sh
npm run gacha:dev
```
- **Backoffice**:
```sh
npm run backoffice:dev
```
- **Dimentorin**:
```sh
npm run dimentorin:dev
```
- **Landing Page**:
```sh
npm run landing:dev
```
- **QR Campaign**:
```sh
npm run qrcampaign:dev
```
### Build ### Build
```sh Use the following commands to build the applications:
nx build <app> # build single app
nx run-many -t build --all # build everything
nx affected -t build # build only what changed
```
### Nix Build - **Gacha**:
```sh
npm run gacha:build
```
- **Backoffice**:
```sh
npm run backoffice:build
```
- **Dimentorin**:
```sh
npm run dimentorin:build
```
- **Landing Page**:
```sh
npm run landing:build
```
- **QR Campaign**:
```sh
npm run qrcampaign:build
```
```sh ### Production
nix build .#<app> # e.g. nix build .#landing, .#dimentorin
```
All Nix config lives in `flake.nix`. When `package-lock.json` changes, update `npmDepsHash` using `lib.fakeHash`. Use the following commands to run the applications in production mode:
### Testing - **Gacha**:
```sh
```sh npm run gacha:prod
nx test <project> # unit tests (vitest) ```
nx e2e <app>-e2e # e2e tests (playwright) - **Backoffice**:
nx lint <project> # eslint ```sh
``` npm run backoffice:prod
```
- **Dimentorin**:
```sh
npm run dimentorin:prod
```
- **Landing Page**:
```sh
npm run landing:prod
```
- **QR Campaign**:
```sh
npm run qrcampaign:prod
```
### Storybook ### Storybook
```sh This repository uses Storybook to develop, test, and document UI components in an isolated and interactive environment. Below are the commands to work with Storybook:
nx storybook ui # run storybook for ui lib
nx build-storybook ui # build static storybook
```
## CI/CD - **Run Storybook**
GitHub Actions pipeline (`.github/workflows/nix-build.yml`): This command starts Storybook in development mode, allowing you to view and test UI components interactively.
1. **detect** — uses `nx affected` to find changed apps ```sh
2. **build** — matrix strategy builds only affected apps with Nix, pushes to [Cachix](https://app.cachix.org/cache/msdqn) npm run ui:storybook
3. **deploy** — updates `flake.lock` in [imphnen-infrastructure](https://github.com/IMPHNEN/imphnen-infrastructure) and deploys to the server using [clan](https://clan.lol) ```
## Tech Stack - **Run Unit Test**
- **Monorepo**: Nx 22.6 This command runs unit tests for the UI components to ensure they function as expected.
- **Frontend**: React 19, TypeScript
- **Styling**: Tailwind CSS v4, CVA
- **State**: Zustand, TanStack React Query
- **Forms**: react-hook-form + zod
- **Build**: Nix flakes, Cachix
- **CI**: GitHub Actions
## Contributing ```sh
npm run ui:test
```
1. Fork and clone the repository - **Build Components**
2. Create a branch: `git checkout -b feat/feature-name`
3. Make changes, commit, and push
4. Open a pull request to the `develop` branch
Issues and feedback welcome via [GitHub Issues](https://github.com/IMPHNEN/imphnen-frontend-service/issues). This command generates a static build of Storybook, which can be deployed for sharing and documentation purposes.
```sh
npm run ui:build
```
## How to contribute
1. Fork the repository and clone it locally.
2. Create a new branch for a new feature or fix:
```sh
git checkout -b feat/feature-name
```
3. Make changes, commit, and push to your forked repository.
4. Create a pull request to this repository `develop` branch.
If you encounter any issues or problems, feel free to create a new Issue.
-21
View File
@@ -1,21 +0,0 @@
# Security Policy
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
| Version | Supported |
| ------- | ------------------ |
| 5.1.x | :white_check_mark: |
| 5.0.x | :x: |
| 4.0.x | :white_check_mark: |
| < 4.0 | :x: |
## Reporting a Vulnerability
Use this section to tell people how to report a vulnerability.
Tell them where to go, how often they can expect to get an update on a
reported vulnerability, what to expect if the vulnerability is accepted or
declined, etc.
+1
View File
@@ -0,0 +1 @@
Tue, Nov 25, 2025 4:21:27 PM
+2
View File
@@ -0,0 +1,2 @@
# Deployment trigger
# Updated to deploy circular dependency fixes and auth hooks
+2 -1
View File
@@ -7,9 +7,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" /> <link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
<link rel="stylesheet" href="/src/styles.css" />
</head> </head>
<body> <body>
<div id="root"><div style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#f9fafb"><div style="width:40px;height:40px;border:3px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:spin .8s linear infinite"></div></div><style>@keyframes spin{to{transform:rotate(360deg)}}</style></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
@@ -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;
@@ -1,13 +1,14 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import * as React from 'react';
import * as React from 'react'
import { Fragment, useState } from 'react' import { FC, Fragment, ReactElement, useState } from 'react';
import { import {
FilterOutlined, FilterOutlined,
SearchOutlined, SearchOutlined,
EditOutlined, EditOutlined,
} from '@ant-design/icons' } from '@ant-design/icons';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms' import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms' import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms';
import { import {
ColumnDef, ColumnDef,
getCoreRowModel, getCoreRowModel,
@@ -15,38 +16,50 @@ import {
PaginationState, PaginationState,
useReactTable, useReactTable,
RowSelectionState, RowSelectionState,
} from '@tanstack/react-table' } from '@tanstack/react-table';
import { import ModalEditAccount from './_components/modal-edit-account';
useUserList, import { useQueryState } from '@imphnen-frontend-service/utils';
TUsersListItem,
} from '@imphnen-frontend-service/service'
export const Route = createFileRoute('/_authenticated/accounts')({ interface Account {
component: AccountsPage, id: number;
}) name: string;
email: string;
phone: string;
address: string;
}
function AccountsPage() { // Mock data for demonstration
const navigate = useNavigate() const mockData: Account[] = Array.from({ length: 90 }, (_, i) => ({
const [search, setSearch] = useState('') 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>({ const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 9,
}) });
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({}) const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const [showFilter, setShowFilter] = useState(false) const [showFilter, setShowFilter] = useState(false);
const { data: usersData, isLoading } = useUserList({ const columns: ColumnDef<Account>[] = [
search,
page: pagination.pageIndex + 1,
per_page: pagination.pageSize,
})
const users: TUsersListItem[] = usersData?.data ?? []
const totalItems = usersData?.meta?.total ?? users.length
const columns: ColumnDef<TUsersListItem>[] = [
{ {
id: 'select', id: 'select',
header: ({ table }) => ( header: ({ table }) => (
@@ -72,24 +85,19 @@ function AccountsPage() {
}, },
{ {
header: 'Nama Lengkap', header: 'Nama Lengkap',
accessorKey: 'fullname', accessorKey: 'name',
}, },
{ {
header: 'Email', header: 'Email',
accessorKey: 'email', accessorKey: 'email',
}, },
{ {
header: 'Role', header: 'Nomor Telp',
accessorKey: 'role', accessorKey: 'phone',
}, },
{ {
header: 'Status', header: 'Alamat Pengiriman',
accessorKey: 'is_active', accessorKey: 'address',
cell: ({ row }) => (
<span className={row.original.is_active ? 'text-success-500' : 'text-danger-500'}>
{row.original.is_active ? 'Aktif' : 'Tidak Aktif'}
</span>
),
}, },
{ {
header: 'Action', header: 'Action',
@@ -98,8 +106,8 @@ function AccountsPage() {
variant="primary" variant="primary"
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
navigate({ to: '/accounts/$id', params: { id: row.original.id } }) setShowModalEditAccount(true);
}} }}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
@@ -107,10 +115,10 @@ function AccountsPage() {
</Button> </Button>
), ),
}, },
] ];
const table = useReactTable({ const table = useReactTable({
data: users, data: mockData,
columns, columns,
state: { state: {
pagination, pagination,
@@ -121,24 +129,25 @@ function AccountsPage() {
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination, onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize), pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: true, manualPagination: false,
}) });
return ( return (
<Fragment> <Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8"> <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"> <header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Data Akun</h1> <h1 className="text-p2 font-semibold">Data Akun</h1>
</header> </header>
{/* Account Table Section */}
<section className="flex flex-col gap-6 p-8 bg-white rounded-md"> <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="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full"> <div className="relative w-full">
<Input <Input
placeholder="Cari berdasarkan nama lengkap, email" placeholder="Cari berdasarkan nama lengkap, email"
className="pl-12 w-full max-h-full" className="pl-12 w-full max-h-full"
value={search}
onChange={(e) => setSearch(e.target.value)}
/> />
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]"> <div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined /> <SearchOutlined />
@@ -162,13 +171,25 @@ function AccountsPage() {
)} )}
</div> </div>
</div> </div>
{isLoading ? ( {/* Table */}
<div className="text-center py-8 text-neutral-400">Loading...</div> <DataTable data={mockData} columns={columns} table={table} />
) : (
<DataTable data={users} columns={columns} table={table} />
)}
</section> </section>
</main> </main>
{/* Modal Edit Account */}
<ModalEditAccount
currentStep={currentStep}
isOpen={showModalEditAccount}
onClose={() => setShowModalEditAccount(false)}
handleEditAccount={() => {
console.log('Account updated');
}}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
</Fragment> </Fragment>
) );
} };
export default Components;
@@ -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;
@@ -8,17 +8,16 @@ import { toast } from 'sonner';
export const useItem = ( export const useItem = (
nextStep: () => void, nextStep: () => void,
initialValues?: Partial<TGachaItem>, initialValues?: TGachaItem
onDataCapture?: (data: any) => void,
) => { ) => {
const form = useForm<any>({ const form = useForm<TGachaItem>({
resolver: zodResolver(gachaItemSchema), resolver: zodResolver(gachaItemSchema),
mode: 'all', mode: 'all',
defaultValues: initialValues, defaultValues: initialValues,
}); });
const onSubmit = form.handleSubmit((data) => { const onSubmit = form.handleSubmit((data) => {
onDataCapture?.(data); console.log('Form data:', data);
nextStep(); nextStep();
}); });
@@ -39,9 +38,8 @@ export const useConfirmItem = (
) => { ) => {
const onConfirm = async () => { const onConfirm = async () => {
try { try {
if (actionFunction) { // const result = await actionFunction?.();
await actionFunction(); // result ? toast.success(messages?.success) : toast.error(messages?.error);
}
toast.success(messages?.success); toast.success(messages?.success);
onClose(); onClose();
resetStep(); resetStep();
@@ -1,47 +1,32 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { import {
PlusOutlined, PlusOutlined,
ReloadOutlined, ReloadOutlined,
UsergroupAddOutlined, UsergroupAddOutlined,
UsergroupDeleteOutlined, UsergroupDeleteOutlined,
UserSwitchOutlined, UserSwitchOutlined,
DeleteOutlined, } from '@ant-design/icons';
} from '@ant-design/icons' import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Button } from '@imphnen-frontend-service/ui/atoms' import { FC, Fragment, ReactElement, useState } from 'react';
import { Fragment, useState } from 'react' import ModalAddItem from './_components/modal-add-item';
import { import ModalEditItem from './_components/modal-edit-item';
useUserList, import ModalDeleteItem from './_components/modal-delete-item';
useGachaItemList, import { useQueryState } from '@imphnen-frontend-service/utils';
useDeleteGachaItem,
TGachaItemDto,
} from '@imphnen-frontend-service/service'
import { toast } from 'sonner'
export const Route = createFileRoute('/_authenticated/dashboard')({ export const Components: FC = (): ReactElement => {
component: DashboardPage, const [showModalAddItem, setShowModalAddItem] = useState(false);
}) const [showModalEditItem, setShowModalEditItem] = useState(false);
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
function DashboardPage() { const {
const navigate = useNavigate() step: currentStep,
const [deleteId, setDeleteId] = useState<string | null>(null) nextStep,
prevStep,
const { data: usersData } = useUserList({ per_page: 1 }) resetStep,
const { data: gachaItemsData } = useGachaItemList({ per_page: 9 }) } = useQueryState('step', {
const deleteItem = useDeleteGachaItem() defaultValue: 1,
maxValue: 2,
const totalUsers = usersData?.meta?.total ?? 0 minValue: 1,
const gachaItems: TGachaItemDto[] = gachaItemsData?.data ?? [] });
const handleDelete = async (id: string) => {
try {
await deleteItem.mutateAsync(id)
toast.success('Item berhasil dihapus')
setDeleteId(null)
} catch (error) {
console.log(error)
toast.error('Item gagal dihapus')
}
}
return ( return (
<Fragment> <Fragment>
@@ -62,7 +47,7 @@ function DashboardPage() {
<UsergroupAddOutlined className="text-[20px]" /> <UsergroupAddOutlined className="text-[20px]" />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">{totalUsers}</h3> <h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500">Participants</p> <p className="text-label1 text-neutral-500">Participants</p>
</div> </div>
</div> </div>
@@ -72,9 +57,9 @@ function DashboardPage() {
<ReloadOutlined className="text-[20px]" /> <ReloadOutlined className="text-[20px]" />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">{gachaItemsData?.meta?.total ?? 0}</h3> <h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500"> <p className="text-label1 text-neutral-500">
Gacha Items Roll and Reroll
</p> </p>
</div> </div>
</div> </div>
@@ -84,7 +69,7 @@ function DashboardPage() {
<UserSwitchOutlined className="text-[20px]" /> <UserSwitchOutlined className="text-[20px]" />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">-</h3> <h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500">Redeem</p> <p className="text-label1 text-neutral-500">Redeem</p>
</div> </div>
</div> </div>
@@ -94,7 +79,7 @@ function DashboardPage() {
<UsergroupDeleteOutlined className="text-[20px]" /> <UsergroupDeleteOutlined className="text-[20px]" />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">-</h3> <h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500"> <p className="text-label1 text-neutral-500">
Inactive Users Inactive Users
</p> </p>
@@ -112,7 +97,7 @@ function DashboardPage() {
variant="primary" variant="primary"
size="sm" size="sm"
className="items-end gap-3" className="items-end gap-3"
onClick={() => navigate({ to: '/dashboard/create' })} onClick={() => setShowModalAddItem(true)}
> >
<span>Tambah Item</span> <span>Tambah Item</span>
<PlusOutlined className="text-[16px]" /> <PlusOutlined className="text-[16px]" />
@@ -120,18 +105,19 @@ function DashboardPage() {
</div> </div>
<div className="flex flex-col gap-4 max-h-140 overflow-auto"> <div className="flex flex-col gap-4 max-h-140 overflow-auto">
{gachaItems.map((item) => ( {[1, 2, 3, 4, 5, 6].map((item) => (
<div <div
key={item.id} key={item}
className="bg-white overflow-clip rounded-lg shadow-sm flex justify-between border border-neutral-100" 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 className="flex flex-col py-4 px-6 gap-[8px]">
<div> <div>
<h3 className="text-p3 text-primary-500 font-medium"> <h3 className="text-p3 text-primary-500 font-medium">
{item.name} Lanyard IMPHNEN
</h3> </h3>
<div className="flex items-center justify-start gap-10 text-label2 text-gray-500 mt-1"> <div className="flex items-center justify-start gap-10 text-label2 text-gray-500 mt-1">
<span>{item.id}</span> <span>Prize {item}</span>
<span>Quantity: 10</span>
</div> </div>
</div> </div>
<div className="flex justify-start gap-2"> <div className="flex justify-start gap-2">
@@ -139,50 +125,29 @@ function DashboardPage() {
variant="text" variant="text"
size="sm" size="sm"
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent hover:text-primary-500" className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent hover:text-primary-500"
onClick={() => navigate({ to: '/dashboard/$id', params: { id: item.id } })} onClick={() => setShowModalEditItem(true)}
> >
Edit Edit
</Button> </Button>
{deleteId === item.id ? (
<div className="flex items-center gap-1">
<span className="text-[10px] text-neutral-400">Yakin?</span>
<Button <Button
variant="text" variant="text"
size="sm" size="sm"
className="text-[10px] text-red-500 p-0 font-normal hover:bg-transparent hover:text-red-700" className="text-[10px] text-red-500 p-0 font-normal hover:bg-transparent hover:text-red-700"
onClick={() => handleDelete(item.id)} onClick={() => setShowModalDeleteItem(true)}
>
Ya
</Button>
<Button
variant="text"
size="sm"
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent"
onClick={() => setDeleteId(null)}
>
Batal
</Button>
</div>
) : (
<Button
variant="text"
size="sm"
className="text-[10px] text-red-500 p-0 font-normal hover:bg-transparent hover:text-red-700"
onClick={() => setDeleteId(item.id)}
> >
Delete Delete
</Button> </Button>
)}
</div> </div>
</div> </div>
<img src="gacha-clip.webp" alt={item.name} /> <img src="gacha-clip.webp" alt="Lanyard IMPHNEN" />
</div> </div>
))} ))}
</div> </div>
</section> </section>
</div> </div>
{/* Right-side illustration */}
<img <img
src="gacha.webp" src="gacha.webp"
alt="" alt=""
@@ -190,6 +155,34 @@ function DashboardPage() {
/> />
</div> </div>
</main> </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> </Fragment>
) );
} };
export default Components;
@@ -1,11 +1,9 @@
import { createFileRoute } from '@tanstack/react-router' import { SearchOutlined } from "@ant-design/icons";
import { SearchOutlined } from '@ant-design/icons' import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms' import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
import { BackofficeWrapper, DataTable } from '@imphnen-frontend-service/ui/organisms' import { cn, For } from "@imphnen-frontend-service/utils";
import { cn, For } from '@imphnen-frontend-service/utils' import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from '@tanstack/react-table' import { ReactElement, useState } from "react"
import { ReactElement, useState } from 'react'
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service'
const TABS = { const TABS = {
MENTORING: 'Mentoring', MENTORING: 'Mentoring',
@@ -13,34 +11,37 @@ const TABS = {
} as const } as const
type Tabs = typeof TABS[keyof typeof TABS] type Tabs = typeof TABS[keyof typeof TABS]
export const Route = createFileRoute('/_authenticated/feedback-review-dimentorin')({ type FeedbackStatus = 'done' | 'todo';
component: FeedbackReviewDimentorinPage,
})
function FeedbackReviewDimentorinPage(): ReactElement { 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 [activeTab, setActiveTab] = useState<Tabs>(TABS.MENTORING)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({ const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 9,
}) });
const { data: sessionsData, isLoading } = useMySessions( const columns: ColumnDef<FeedbackType>[] = [
activeTab === TABS.MENTORING ? { status: 'completed' } : undefined
)
const sessions: TSessionListItem[] = activeTab === TABS.MENTORING
? (sessionsData?.sessions ?? [])
: []
const totalItems = activeTab === TABS.MENTORING
? (sessionsData?.total ?? sessions.length)
: 0
const columns: ColumnDef<TSessionListItem>[] = [
{ {
id: 'select', id: 'select',
meta: { cellClassName: cn('w-20') }, meta: { cellClassName: cn("w-20") },
header: ({ table }) => ( header: ({ table }) => (
<input <input
type="checkbox" type="checkbox"
@@ -61,38 +62,45 @@ function FeedbackReviewDimentorinPage(): ReactElement {
{ {
id: 'name', id: 'name',
header: 'Name', header: 'Name',
accessorKey: 'mentee_fullname', accessorKey: 'name',
cell: ({ row }) => <span>{row.original.mentee_fullname ?? '-'}</span>,
}, },
{ {
id: 'email', id: 'email',
header: 'Email', header: 'Email',
accessorKey: 'mentee_email', accessorKey: 'email',
cell: ({ row }) => <span>{row.original.mentee_email ?? '-'}</span>,
}, },
{ {
id: 'rating', id: 'rating',
header: 'Rating', header: 'Rating',
accessorKey: 'rating', accessorKey: 'rating',
cell: ({ row }) => <span>{row.original.rating ?? '-'}</span>,
}, },
{ {
id: 'status', id: 'status',
header: 'Status', header: 'Status',
accessorKey: 'status', accessorKey: 'status',
cell: ({ row }) => { cell: ({ row }) => {
const hasRating = !!row.original.rating 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 ( return (
<div className={`py-2 px-4 rounded-md text-center ${hasRating ? 'bg-success-200 text-success-500' : 'bg-primary-200 text-primary-500'}`}> <div
{hasRating ? 'Done' : 'To Do'} className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div> </div>
) );
}, },
}, },
{ {
header: 'Action', header: 'Action',
meta: { cellClassName: cn('w-72') }, meta: { cellClassName: cn("w-72") },
cell: () => ( cell: ({ row }) => (
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
@@ -108,7 +116,7 @@ function FeedbackReviewDimentorinPage(): ReactElement {
] ]
const table = useReactTable({ const table = useReactTable({
data: sessions, data: mockData,
columns, columns,
state: { state: {
pagination, pagination,
@@ -119,9 +127,9 @@ function FeedbackReviewDimentorinPage(): ReactElement {
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination, onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize), pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: true, manualPagination: false,
}) });
return ( return (
<BackofficeWrapper title="Dimentorin.dev"> <BackofficeWrapper title="Dimentorin.dev">
@@ -133,11 +141,8 @@ function FeedbackReviewDimentorinPage(): ReactElement {
<Button <Button
key={tab} key={tab}
variant="text" variant="text"
className={cn('px-3 py-2 capitalize', activeTab === tab && 'bg-white')} className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
onClick={() => { onClick={() => setActiveTab(tab)}
setActiveTab(tab)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
> >
{tab} {tab}
</Button> </Button>
@@ -158,27 +163,19 @@ function FeedbackReviewDimentorinPage(): ReactElement {
</div> </div>
</div> </div>
<Select> <Select>
<option disabled>Rating</option> <option selected disabled>Rating</option>
<option value="4.5">4.5</option> <option value="4.5">4.5</option>
<option value="5">5</option> <option value="5">5</option>
</Select> </Select>
<Select> <Select>
<option disabled>Status</option> <option selected disabled>Status</option>
<option value="done">Done</option> <option value="active">Active</option>
<option value="todo">To Do</option> <option value="inactive">Inactive</option>
</Select> </Select>
</div> </div>
{isLoading ? ( <DataTable data={mockData} columns={columns} table={table} />
<div className="text-center py-8 text-neutral-400">Loading...</div>
) : activeTab === TABS.PLATFORM ? (
<div className="text-center py-8 text-neutral-400">
Platform feedback tidak tersedia
</div>
) : (
<DataTable data={sessions} columns={columns} table={table} />
)}
</section> </section>
</BackofficeWrapper> </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;
@@ -8,17 +8,16 @@ import { toast } from 'sonner';
export const useItem = ( export const useItem = (
nextStep: () => void, nextStep: () => void,
initialValues?: Partial<TGachaRollItem>, initialValues?: TGachaRollItem
onDataCapture?: (data: any) => void,
) => { ) => {
const form = useForm<any>({ const form = useForm<TGachaRollItem>({
resolver: zodResolver(gachaRollItemSchema), resolver: zodResolver(gachaRollItemSchema),
mode: 'all', mode: 'all',
defaultValues: initialValues, defaultValues: initialValues,
}); });
const onSubmit = form.handleSubmit((data) => { const onSubmit = form.handleSubmit((data) => {
onDataCapture?.(data); console.log('Form data:', data);
nextStep(); nextStep();
}); });
@@ -39,9 +38,8 @@ export const useConfirmItem = (
) => { ) => {
const onConfirm = async () => { const onConfirm = async () => {
try { try {
if (actionFunction) { // const result = await actionFunction?.();
await actionFunction(); // result ? toast.success(messages?.success) : toast.error(messages?.error);
}
toast.success(messages?.success); toast.success(messages?.success);
onClose(); onClose();
resetStep(); resetStep();
@@ -0,0 +1,210 @@
import * as React from 'react';
import { FC, Fragment, ReactElement, useState } from 'react';
import {
SearchOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
useReactTable,
RowSelectionState,
} from '@tanstack/react-table';
import ModalAddItem from './_components/modal-add-item';
import ModalUpdateItem from './_components/modal-update-item';
import ModalDeleteItem from './_components/modal-delete-item';
import { useQueryState } from '@imphnen-frontend-service/utils';
interface GachaItem {
id: number;
name: string;
chanceRate: number;
quantity: number;
}
const mockData: GachaItem[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: 'Hoodie IMPHNEN Official 2025',
chanceRate: 0.1,
quantity: 10,
}));
export const Components: FC = (): ReactElement => {
const [showModalAddItem, setShowModalAddItem] = useState(false);
const [showModalUpdateItem, setShowModalUpdateItem] = useState(false);
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
const {
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('step', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const columns: ColumnDef<GachaItem>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Nama Item',
accessorKey: 'name',
},
{
header: 'Chance Rate',
accessorKey: 'chanceRate',
},
{
header: 'Quantity',
accessorKey: 'quantity',
},
{
header: 'Action',
cell: () => (
<div className="flex gap-[8px]">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowModalUpdateItem(true);
}}
className="flex items-center gap-2"
>
<EditOutlined /> Update
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowModalDeleteItem(true);
}}
className="flex items-center gap-2"
>
<DeleteOutlined /> Delete
</Button>
</div>
),
},
];
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Gacha Roll</h1>
</header>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama item"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => {
setShowModalAddItem(true);
}}
>
<PlusOutlined />
Tambah Item
</Button>
</div>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
</main>
<ModalAddItem
currentStep={currentStep}
isOpen={showModalAddItem}
onClose={() => setShowModalAddItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
<ModalUpdateItem
currentStep={currentStep}
isOpen={showModalUpdateItem}
onClose={() => setShowModalUpdateItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
<ModalDeleteItem
isOpen={showModalDeleteItem}
onClose={() => setShowModalDeleteItem(false)}
handleDeleteItem={() => {
console.log('Item deleted');
}}
/>
</Fragment>
);
};
export default Components;
@@ -1,54 +1,55 @@
import { createFileRoute } from '@tanstack/react-router' import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms' import { FC, ReactElement } from 'react';
import { FC, ReactElement } from 'react' import { useQuery } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query'
import { import {
getAdminUsers, getAdminUsers,
getAdminTeams, getAdminTeams,
getAdminSubmissions, getAdminSubmissions,
} from '@imphnen-frontend-service/service' } from '@imphnen-frontend-service/service';
export const Route = createFileRoute('/_authenticated/hackathon-dashboard')({ export const HackathonDashboardPage: FC = (): ReactElement => {
component: HackathonDashboardPage, // Fetch total participants
})
function HackathonDashboardPage() {
const { data: usersData } = useQuery({ const { data: usersData } = useQuery({
queryKey: ['admin-users-count'], queryKey: ['admin-users-count'],
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }), queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
}) });
// Fetch total teams
const { data: teamsData } = useQuery({ const { data: teamsData } = useQuery({
queryKey: ['admin-teams-count'], queryKey: ['admin-teams-count'],
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }), queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
}) });
// Fetch total submissions
const { data: submissionsData } = useQuery({ const { data: submissionsData } = useQuery({
queryKey: ['admin-submissions-count'], queryKey: ['admin-submissions-count'],
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }), queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
}) });
const totalParticipants = usersData?.meta?.total_data ?? '??' const totalParticipants = usersData?.meta?.total_data ?? '??';
const totalTeams = teamsData?.meta?.total_data ?? '??' const totalTeams = teamsData?.meta?.total_data ?? '??';
const totalSubmissions = submissionsData?.meta?.total_data ?? '??' const totalSubmissions = submissionsData?.meta?.total_data ?? '??';
return ( return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025"> <BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1> <h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
<section className="grid grid-cols-5 gap-5"> <section className="grid grid-cols-5 gap-5">
{/* Participant */}
<div className="bg-white px-6 py-4 rounded-md shadow"> <div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5"> <h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
{totalParticipants} {totalParticipants}
</h3> </h3>
<p className="text-neutral-400 text-p3">Total Participants</p> <p className="text-neutral-400 text-p3">Total Participants</p>
</div> </div>
{/* Team */}
<div className="bg-white px-6 py-4 rounded-md shadow"> <div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5"> <h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
{totalTeams} {totalTeams}
</h3> </h3>
<p className="text-neutral-400 text-p3">Total Teams</p> <p className="text-neutral-400 text-p3">Total Teams</p>
</div> </div>
{/* Project Submitted */}
<div className="bg-white px-6 py-4 rounded-md shadow"> <div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5"> <h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
{totalSubmissions} {totalSubmissions}
@@ -57,5 +58,7 @@ function HackathonDashboardPage() {
</div> </div>
</section> </section>
</BackofficeWrapper> </BackofficeWrapper>
) );
} };
export default HackathonDashboardPage;
@@ -40,7 +40,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
return ( return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto"> <div className="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 justify-between p-6 border-b border-neutral-200">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center"> <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> </button>
</div> </div>
{/* Content */}
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Submission Status */}
<div <div
className={cn( className={cn(
'flex items-center gap-3 p-4 border rounded-lg', 'flex items-center gap-3 p-4 border rounded-lg',
@@ -95,6 +96,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
</div> </div>
</div> </div>
{/* Project Description */}
<div> <div>
<h3 className="text-sm font-medium text-neutral-700 mb-2"> <h3 className="text-sm font-medium text-neutral-700 mb-2">
Project Description Project Description
@@ -104,11 +106,13 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
</p> </p>
</div> </div>
{/* Project Links */}
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700"> <h3 className="text-sm font-medium text-neutral-700">
Project Links Project Links
</h3> </h3>
{/* Repository URL */}
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg"> <div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" /> <LinkOutlined className="text-primary-500 mt-1" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -126,6 +130,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
</div> </div>
</div> </div>
{/* Demo URL */}
{submission.demo_url && ( {submission.demo_url && (
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg"> <div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" /> <LinkOutlined className="text-primary-500 mt-1" />
@@ -145,6 +150,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
</div> </div>
)} )}
{/* Presentation URL */}
{submission.presentation_url && ( {submission.presentation_url && (
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg"> <div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" /> <LinkOutlined className="text-primary-500 mt-1" />
@@ -165,6 +171,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
)} )}
</div> </div>
{/* Screenshots */}
{submission.screenshots && submission.screenshots.length > 0 && ( {submission.screenshots && submission.screenshots.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700 flex items-center gap-2"> <h3 className="text-sm font-medium text-neutral-700 flex items-center gap-2">
@@ -191,6 +198,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
</div> </div>
)} )}
{/* Metadata */}
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-neutral-200"> <div className="grid grid-cols-2 gap-4 pt-4 border-t border-neutral-200">
<div> <div>
<p className="text-xs text-neutral-500 mb-1">Created</p> <p className="text-xs text-neutral-500 mb-1">Created</p>
@@ -219,11 +227,19 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
</div> </div>
</div> </div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-neutral-200 bg-neutral-50"> <div className="flex items-center justify-end gap-3 p-6 border-t border-neutral-200 bg-neutral-50">
<Button variant="secondary" onClick={onClose}> <Button variant="secondary" onClick={onClose}>
Close Close
</Button> </Button>
{/* <Button
variant="primary"
onClick={() => {
console.log('Edit submission:', submission.id);
}}
>
Edit Status
</Button> */}
</div> </div>
</div> </div>
</div> </div>
@@ -1,4 +1,3 @@
import { createFileRoute } from '@tanstack/react-router'
import { import {
FC, FC,
ReactElement, ReactElement,
@@ -6,53 +5,46 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useCallback, useCallback,
} from 'react' } from 'react';
import SubmissionModal from './_components/hackathon-submissions/submission-modal' import SubmissionModal from './_components/submission-modal';
import { import {
BackofficeWrapper, BackofficeWrapper,
DataTable, DataTable,
} from '@imphnen-frontend-service/ui/organisms' } from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms' import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils' import { cn } from '@imphnen-frontend-service/utils';
import { import {
SearchOutlined, SearchOutlined,
FilterOutlined, FilterOutlined,
LoadingOutlined, LoadingOutlined,
EyeOutlined, EyeOutlined,
} from '@ant-design/icons' } from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import { import {
getAdminSubmissions, getAdminSubmissions,
TAdminSubmissionItem, TAdminSubmissionItem,
} from '@imphnen-frontend-service/service' } from '@imphnen-frontend-service/service';
import { useNavigate } from '@tanstack/react-router' import { useSearchParams } from 'react-router-dom';
type SubmissionType = TAdminSubmissionItem type SubmissionType = TAdminSubmissionItem;
export const Route = createFileRoute('/_authenticated/hackathon-submissions')({ export const HackathonSubmissionsPage: FC = (): ReactElement => {
component: HackathonSubmissionsPage, const [searchParams, setSearchParams] = useSearchParams();
validateSearch: (search: Record<string, unknown>) => ({ const currentPage = Math.max(
page: Number(search.page) || 1, 1,
search: (search.search as string) || '', parseInt(searchParams.get('page') || '1', 10)
per_page: Number(search.per_page) || 10, );
status: (search.status as string) || 'all', const searchQuery = searchParams.get('search') || '';
}), const perPage = parseInt(searchParams.get('per_page') || '10', 10);
}) const statusFilter = searchParams.get('status') || 'all';
function HackathonSubmissionsPage() { const [showSubmissionModal, setShowSubmissionModal] = useState(false);
const searchParams = Route.useSearch()
const navigate = useNavigate()
const currentPage = Math.max(1, searchParams.page)
const searchQuery = searchParams.search || ''
const perPage = searchParams.per_page || 10
const statusFilter = searchParams.status || 'all'
const [showSubmissionModal, setShowSubmissionModal] = useState(false)
const [selectedSubmission, setSelectedSubmission] = const [selectedSubmission, setSelectedSubmission] =
useState<SubmissionType | null>(null) useState<SubmissionType | null>(null);
const [globalFilter, setGlobalFilter] = useState(searchQuery) const [globalFilter, setGlobalFilter] = useState(searchQuery);
// Fetch submissions from API
const { const {
data: submissionsResponse, data: submissionsResponse,
isLoading, isLoading,
@@ -72,89 +64,106 @@ function HackathonSubmissionsPage() {
status: statusFilter !== 'all' ? statusFilter : undefined, status: statusFilter !== 'all' ? statusFilter : undefined,
search: searchQuery || undefined, search: searchQuery || undefined,
}), }),
staleTime: 30000, staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, gcTime: 5 * 60 * 1000, // 5 minutes
}) });
const totalData = submissionsResponse?.meta?.total_data || 0 const totalData = submissionsResponse?.meta?.total_data || 0;
const totalPages = submissionsResponse?.meta?.total_page || 1 const totalPages = submissionsResponse?.meta?.total_page || 1;
// Handle page change
const handlePageChange = useCallback( const handlePageChange = useCallback(
(newPage: number) => { (newPage: number) => {
navigate({ const params = new URLSearchParams();
search: { params.set('page', newPage.toString());
page: newPage, if (perPage !== 10) params.set('per_page', perPage.toString());
per_page: perPage !== 10 ? perPage : undefined, if (searchQuery) params.set('search', searchQuery);
search: searchQuery || undefined, if (statusFilter !== 'all') params.set('status', statusFilter);
status: statusFilter !== 'all' ? statusFilter : undefined, setSearchParams(params);
} as any, window.scrollTo({ top: 0, behavior: 'smooth' });
})
window.scrollTo({ top: 0, behavior: 'smooth' })
}, },
[navigate, perPage, searchQuery, statusFilter] [setSearchParams, perPage, searchQuery, statusFilter]
) );
// Validate page number
useEffect(() => { useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) { if (!isLoading && totalPages > 0 && currentPage > totalPages) {
navigate({ search: { page: totalPages } as any }) setSearchParams({ page: totalPages.toString() });
} }
}, [currentPage, totalPages, navigate, isLoading]) }, [currentPage, totalPages, setSearchParams, isLoading]);
// Sync globalFilter with URL
useEffect(() => { useEffect(() => {
setGlobalFilter(searchQuery) setGlobalFilter(searchQuery);
}, [searchQuery]) }, [searchQuery]);
// Handle search
const handleSearch = useCallback(() => { const handleSearch = useCallback(() => {
navigate({ const params = new URLSearchParams();
search: { params.set('page', '1');
page: 1, if (perPage !== 10) params.set('per_page', perPage.toString());
per_page: perPage !== 10 ? perPage : undefined, if (globalFilter.trim()) {
search: globalFilter.trim() || undefined, params.set('search', globalFilter.trim());
status: statusFilter !== 'all' ? statusFilter : undefined, }
} as any, if (statusFilter !== 'all') params.set('status', statusFilter);
}) setSearchParams(params);
}, [globalFilter, navigate, perPage, statusFilter]) }, [globalFilter, setSearchParams, perPage, statusFilter]);
const handleSearchKeyPress = useCallback( const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => { (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleSearch() handleSearch();
} }
}, },
[handleSearch] [handleSearch]
) );
// Handle per page change
const handlePerPageChange = useCallback( const handlePerPageChange = useCallback(
(newPerPage: number) => { (newPerPage: number) => {
navigate({ const params = new URLSearchParams();
search: { params.set('page', '1');
page: 1, params.set('per_page', newPerPage.toString());
per_page: newPerPage, if (searchQuery) params.set('search', searchQuery);
search: searchQuery || undefined, if (statusFilter !== 'all') params.set('status', statusFilter);
status: statusFilter !== 'all' ? statusFilter : undefined, setSearchParams(params);
} as any,
})
}, },
[navigate, searchQuery, statusFilter] [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( const handleShowSubmissionModal = useCallback(
(submission: SubmissionType) => { (submission: SubmissionType) => {
setSelectedSubmission(submission) setSelectedSubmission(submission);
setShowSubmissionModal(true) setShowSubmissionModal(true);
}, },
[] []
) );
const handleCloseSubmissionModal = useCallback(() => { const handleCloseSubmissionModal = useCallback(() => {
setShowSubmissionModal(false) setShowSubmissionModal(false);
setSelectedSubmission(null) setSelectedSubmission(null);
}, []) }, []);
// Get submissions data
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
return submissionsResponse?.data?.data || submissionsResponse?.data || [] return submissionsResponse?.data || [];
}, [submissionsResponse]) }, [submissionsResponse]);
// Memoize columns
const columns: ColumnDef<SubmissionType>[] = useMemo( const columns: ColumnDef<SubmissionType>[] = useMemo(
() => [ () => [
{ {
@@ -181,7 +190,7 @@ function HackathonSubmissionsPage() {
accessorKey: 'status', accessorKey: 'status',
header: 'Status', header: 'Status',
cell: ({ row }) => { cell: ({ row }) => {
const status = row.original.status const status = row.original.status;
return ( return (
<span <span
className={cn( className={cn(
@@ -195,7 +204,7 @@ function HackathonSubmissionsPage() {
> >
{status.charAt(0).toUpperCase() + status.slice(1)} {status.charAt(0).toUpperCase() + status.slice(1)}
</span> </span>
) );
}, },
enableSorting: true, enableSorting: true,
}, },
@@ -233,7 +242,7 @@ function HackathonSubmissionsPage() {
}, },
], ],
[handleShowSubmissionModal] [handleShowSubmissionModal]
) );
return ( return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025"> <BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
@@ -243,6 +252,7 @@ function HackathonSubmissionsPage() {
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6"> <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 justify-between">
<div className="flex flex-wrap gap-3 items-center"> <div className="flex flex-wrap gap-3 items-center">
{/* Search bar */}
<div className="relative"> <div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" /> <SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input <input
@@ -255,6 +265,7 @@ function HackathonSubmissionsPage() {
/> />
</div> </div>
{/* Per Page Dropdown */}
<div className="relative"> <div className="relative">
<select <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" 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"
@@ -270,16 +281,52 @@ function HackathonSubmissionsPage() {
</select> </select>
</div> </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>
</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 ? ( {isLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" /> <LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
@@ -313,6 +360,7 @@ function HackathonSubmissionsPage() {
)} )}
</section> </section>
{/* Submission Modal */}
{selectedSubmission && ( {selectedSubmission && (
<SubmissionModal <SubmissionModal
isOpen={showSubmissionModal} isOpen={showSubmissionModal}
@@ -321,5 +369,7 @@ function HackathonSubmissionsPage() {
/> />
)} )}
</BackofficeWrapper> </BackofficeWrapper>
) );
} };
export default HackathonSubmissionsPage;
@@ -33,6 +33,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
const logoInputRef = useRef<HTMLInputElement>(null); const logoInputRef = useRef<HTMLInputElement>(null);
const bannerInputRef = useRef<HTMLInputElement>(null); const bannerInputRef = useRef<HTMLInputElement>(null);
// Initialize form data when modal opens
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
if (team) { if (team) {
@@ -54,6 +55,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
} }
}, [isOpen, team]); }, [isOpen, team]);
// Check if form has changes
const hasChanges = useMemo(() => { const hasChanges = useMemo(() => {
if (!formData || !team) return !!formData; if (!formData || !team) return !!formData;
return ( return (
@@ -66,6 +68,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
); );
}, [formData, team]); }, [formData, team]);
// Check if required fields are filled
const isFormValid = useMemo(() => { const isFormValid = useMemo(() => {
if (!formData) return false; if (!formData) return false;
return ( return (
@@ -141,6 +144,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
return ( return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"> <div className="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 justify-between p-6 border-b border-neutral-200">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center"> <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> </button>
</div> </div>
{/* Content */}
<div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}> <div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}>
<input <input
type="file" type="file"
@@ -184,6 +189,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
className="hidden" className="hidden"
/> />
{/* Banner Section */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">
Team Banner{' '} Team Banner{' '}
@@ -228,6 +234,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
</div> </div>
</div> </div>
{/* Logo & Name */}
<div className="grid grid-cols-12 gap-4 items-start"> <div className="grid grid-cols-12 gap-4 items-start">
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-neutral-700 mb-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>
</div> </div>
{/* Description */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">
Description <span className="text-danger-500">*</span> Description <span className="text-danger-500">*</span>
@@ -311,6 +319,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
/> />
</div> </div>
{/* City */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">
City <span className="text-danger-500">*</span> City <span className="text-danger-500">*</span>
@@ -327,6 +336,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
/> />
</div> </div>
{/* Visibility */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">
Team Visibility Team Visibility
@@ -363,6 +373,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
</div> </div>
</div> </div>
{/* Team Details */}
{team && ( {team && (
<div className="space-y-4 border-t border-neutral-200 pt-4"> <div className="space-y-4 border-t border-neutral-200 pt-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -418,6 +429,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
)} )}
</div> </div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-neutral-200"> <div className="flex items-center justify-between p-6 border-t border-neutral-200">
<div> <div>
{team && ( {team && (
@@ -451,6 +463,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
</div> </div>
</div> </div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && ( {showDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6"> <div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
@@ -15,7 +15,7 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
className = '', className = '',
showPlaceholder = true, showPlaceholder = true,
}) => { }) => {
const aspectRatioClass = 'aspect-[3/1]'; const aspectRatioClass = 'aspect-[3/1]'; // 3:1 aspect ratio
if (!banner && !showPlaceholder) { if (!banner && !showPlaceholder) {
return null; return null;
@@ -35,6 +35,7 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
alt={`${teamName} banner`} alt={`${teamName} banner`}
className="w-full h-full object-cover" className="w-full h-full object-cover"
onError={(e) => { onError={(e) => {
// Fallback to placeholder if image fails to load
const target = e.target as HTMLImageElement; const target = e.target as HTMLImageElement;
target.style.display = 'none'; target.style.display = 'none';
const placeholder = target.nextElementSibling as HTMLElement; const placeholder = target.nextElementSibling as HTMLElement;
@@ -43,10 +44,11 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
} }
}} }}
/> />
{/* Fallback placeholder (hidden by default, shown on image error) */}
<div <div
className={cn( className={cn(
'absolute inset-0 bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center', '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"> <div className="text-center">
@@ -59,6 +61,7 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
); );
} }
// No banner - show placeholder
return ( return (
<div <div
className={cn( className={cn(
@@ -1,4 +1,3 @@
import { createFileRoute } from '@tanstack/react-router'
import { import {
FC, FC,
ReactElement, ReactElement,
@@ -6,16 +5,16 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useCallback, useCallback,
} from 'react' } from 'react';
import ModalTeamDetail from './_components/hackathon-teams/modal-team-detail-new' import ModalTeamDetail from './_components/modal-team-detail-new';
import { CityFilterSelect } from '../../components/city-filter-select' import { CityFilterSelect } from '../../../components/city-filter-select';
import { import {
BackofficeWrapper, BackofficeWrapper,
DataTable, DataTable,
} from '@imphnen-frontend-service/ui/organisms' } from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms' import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils' import { cn } from '@imphnen-frontend-service/utils';
import { import {
EditOutlined, EditOutlined,
TeamOutlined, TeamOutlined,
@@ -23,40 +22,35 @@ import {
FilterOutlined, FilterOutlined,
PlusOutlined, PlusOutlined,
LoadingOutlined, LoadingOutlined,
} from '@ant-design/icons' } from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import { import {
getAdminTeams, getAdminTeams,
TAdminTeamItem, TAdminTeamItem,
} from '@imphnen-frontend-service/service' } from '@imphnen-frontend-service/service';
import { useNavigate } from '@tanstack/react-router' import { useSearchParams } from 'react-router-dom';
type TeamType = TAdminTeamItem type TeamType = TAdminTeamItem;
export const Route = createFileRoute('/_authenticated/hackathon-teams')({ export const HackathonTeamsPage: FC = (): ReactElement => {
component: HackathonTeamsPage, const [searchParams, setSearchParams] = useSearchParams();
validateSearch: (search: Record<string, unknown>) => ({ const currentPage = Math.max(
page: Number(search.page) || 1, 1,
search: (search.search as string) || '', parseInt(searchParams.get('page') || '1', 10)
per_page: Number(search.per_page) || 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);
function HackathonTeamsPage() { // Advanced filtering states
const searchParams = Route.useSearch() const [visibilityFilter, setVisibilityFilter] = useState('all');
const navigate = useNavigate() const [cityFilter, setCityFilter] = useState('all');
const currentPage = Math.max(1, searchParams.page)
const searchQuery = searchParams.search || ''
const perPage = searchParams.per_page || 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)
const [visibilityFilter, setVisibilityFilter] = useState('all')
const [cityFilter, setCityFilter] = useState('all')
// Fetch teams from API
const { const {
data: teamsResponse, data: teamsResponse,
isLoading, isLoading,
@@ -76,100 +70,106 @@ function HackathonTeamsPage() {
per_page: perPage, per_page: perPage,
search: searchQuery || undefined, search: searchQuery || undefined,
}), }),
staleTime: 30000, staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, gcTime: 5 * 60 * 1000, // 5 minutes
}) });
const totalData = teamsResponse?.meta?.total_data || 0 const totalData = teamsResponse?.meta?.total_data || 0;
const totalPages = teamsResponse?.meta?.total_page || 1 const totalPages = teamsResponse?.meta?.total_page || 1;
// Handle page change - update URL query params
const handlePageChange = useCallback( const handlePageChange = useCallback(
(newPage: number) => { (newPage: number) => {
navigate({ const params = new URLSearchParams();
search: { params.set('page', newPage.toString());
page: newPage, if (perPage !== 10) params.set('per_page', perPage.toString());
per_page: perPage !== 10 ? perPage : undefined, if (searchQuery) params.set('search', searchQuery);
search: searchQuery || undefined, setSearchParams(params);
} as any, window.scrollTo({ top: 0, behavior: 'smooth' });
})
window.scrollTo({ top: 0, behavior: 'smooth' })
}, },
[navigate, perPage, searchQuery] [setSearchParams, perPage, searchQuery]
) );
// Validate page number doesn't exceed total pages
useEffect(() => { useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) { if (!isLoading && totalPages > 0 && currentPage > totalPages) {
navigate({ search: { page: totalPages } as any }) setSearchParams({ page: totalPages.toString() });
} }
}, [currentPage, totalPages, navigate, isLoading]) }, [currentPage, totalPages, setSearchParams, isLoading]);
// Sync globalFilter with URL search param on mount
useEffect(() => { useEffect(() => {
setGlobalFilter(searchQuery) setGlobalFilter(searchQuery);
}, [searchQuery]) }, [searchQuery]);
// Handle search teams
const handleSearch = useCallback(() => { const handleSearch = useCallback(() => {
navigate({ const params = new URLSearchParams();
search: { params.set('page', '1');
page: 1, if (perPage !== 10) params.set('per_page', perPage.toString());
per_page: perPage !== 10 ? perPage : undefined, if (globalFilter.trim()) {
search: globalFilter.trim() || undefined, params.set('search', globalFilter.trim());
} as any, }
}) setSearchParams(params);
}, [globalFilter, navigate, perPage]) }, [globalFilter, setSearchParams, perPage]);
// Handle Enter key press in search input
const handleSearchKeyPress = useCallback( const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => { (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleSearch() handleSearch();
} }
}, },
[handleSearch] [handleSearch]
) );
// Handle per page change
const handlePerPageChange = useCallback( const handlePerPageChange = useCallback(
(newPerPage: number) => { (newPerPage: number) => {
navigate({ const params = new URLSearchParams();
search: { params.set('page', '1');
page: 1, params.set('per_page', newPerPage.toString());
per_page: newPerPage, if (searchQuery) params.set('search', searchQuery);
search: searchQuery || undefined, setSearchParams(params);
} as any,
})
}, },
[navigate, searchQuery] [setSearchParams, searchQuery]
) );
// Memoize the callback to prevent recreation
const handleShowDetailModal = useCallback((team: TeamType) => { const handleShowDetailModal = useCallback((team: TeamType) => {
setSelectedTeam(team) setSelectedTeam(team);
setShowDetailModal(true) setShowDetailModal(true);
}, []) }, []);
const handleCloseDetailModal = useCallback(() => { const handleCloseDetailModal = useCallback(() => {
setShowDetailModal(false) setShowDetailModal(false);
setSelectedTeam(null) setSelectedTeam(null);
}, []) }, []);
const handleShowNewTeamModal = useCallback(() => { const handleShowNewTeamModal = useCallback(() => {
setShowNewTeamModal(true) setShowNewTeamModal(true);
}, []) }, []);
const handleCloseNewTeamModal = useCallback(() => { const handleCloseNewTeamModal = useCallback(() => {
setShowNewTeamModal(false) setShowNewTeamModal(false);
}, []) }, []);
// Get teams data from API response
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
return teamsResponse?.data?.data || teamsResponse?.data || [] return teamsResponse?.data || [];
}, [teamsResponse]) }, [teamsResponse]);
// Memoize columns to prevent recreation on every render
const columns: ColumnDef<TeamType>[] = useMemo( const columns: ColumnDef<TeamType>[] = useMemo(
() => [ () => [
{ {
accessorKey: 'name', accessorKey: 'name',
header: 'Team', header: 'Team',
cell: ({ row }) => { cell: ({ row }) => {
const team = row.original const team = row.original;
return ( return (
<div className="flex items-center gap-3"> <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"> <div className="w-10 h-10 rounded-full bg-neutral-100 flex items-center justify-center shrink-0 overflow-hidden">
{team.logo ? ( {team.logo ? (
<img <img
@@ -181,6 +181,7 @@ function HackathonTeamsPage() {
<TeamOutlined className="text-neutral-400 text-lg" /> <TeamOutlined className="text-neutral-400 text-lg" />
)} )}
</div> </div>
{/* Team Name */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p <p
className="font-medium text-neutral-900 truncate max-w-sm" className="font-medium text-neutral-900 truncate max-w-sm"
@@ -190,7 +191,7 @@ function HackathonTeamsPage() {
</p> </p>
</div> </div>
</div> </div>
) );
}, },
enableSorting: true, enableSorting: true,
}, },
@@ -206,7 +207,7 @@ function HackathonTeamsPage() {
accessorKey: 'visibility', accessorKey: 'visibility',
header: 'Visibility', header: 'Visibility',
cell: ({ row }) => { cell: ({ row }) => {
const isPublic = row.original.visibility === 'public' const isPublic = row.original.visibility === 'public';
return ( return (
<span <span
className={cn( className={cn(
@@ -218,7 +219,7 @@ function HackathonTeamsPage() {
> >
{isPublic ? 'Public' : 'Private'} {isPublic ? 'Public' : 'Private'}
</span> </span>
) );
}, },
enableSorting: true, enableSorting: true,
}, },
@@ -268,16 +269,19 @@ function HackathonTeamsPage() {
}, },
], ],
[handleShowDetailModal] [handleShowDetailModal]
) );
return ( return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025"> <BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700"> <h1 className="mb-8 text-p1 font-semibold text-neutral-700">
Team Management Team Management
</h1> </h1>
{/* Filters and actions */}
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6"> <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 justify-between">
{/* Left side - Search & filters */}
<div className="flex flex-wrap gap-3 items-center"> <div className="flex flex-wrap gap-3 items-center">
{/* Search bar */}
<div className="relative"> <div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" /> <SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input <input
@@ -290,6 +294,7 @@ function HackathonTeamsPage() {
/> />
</div> </div>
{/* Per Page Dropdown */}
<div className="relative"> <div className="relative">
<select <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" 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"
@@ -305,15 +310,31 @@ function HackathonTeamsPage() {
</select> </select>
</div> </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> </div>
{/* Right side - Add Team Button */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button <Button
variant="primary" variant="primary"
@@ -327,10 +348,12 @@ function HackathonTeamsPage() {
</div> </div>
</div> </div>
{/* Active filters display */}
{(visibilityFilter !== 'all' || cityFilter !== 'all') && ( {(visibilityFilter !== 'all' || cityFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center"> <div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span> <span className="text-sm text-neutral-600">Active filters:</span>
{/* Visibility filter badge */}
{visibilityFilter !== 'all' && ( {visibilityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm"> <span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Visibility: {visibilityFilter} Visibility: {visibilityFilter}
@@ -343,6 +366,7 @@ function HackathonTeamsPage() {
</span> </span>
)} )}
{/* City filter badge */}
{cityFilter !== 'all' && ( {cityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm"> <span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
City: {cityFilter} City: {cityFilter}
@@ -355,13 +379,14 @@ function HackathonTeamsPage() {
</span> </span>
)} )}
{/* Clear all filters */}
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => { onClick={() => {
setVisibilityFilter('all') setVisibilityFilter('all');
setCityFilter('all') setCityFilter('all');
setGlobalFilter('') setGlobalFilter('');
}} }}
className="text-sm text-neutral-600" className="text-sm text-neutral-600"
> >
@@ -370,6 +395,7 @@ function HackathonTeamsPage() {
</div> </div>
)} )}
{/* Loading & results display */}
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" /> <LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
@@ -401,17 +427,21 @@ function HackathonTeamsPage() {
)} )}
</section> </section>
{/* Modals component */}
<ModalTeamDetail <ModalTeamDetail
isOpen={showDetailModal} isOpen={showDetailModal}
onClose={handleCloseDetailModal} onClose={handleCloseDetailModal}
team={selectedTeam} team={selectedTeam}
/> />
{/* New Team Modal */}
<ModalTeamDetail <ModalTeamDetail
isOpen={showNewTeamModal} isOpen={showNewTeamModal}
onClose={handleCloseNewTeamModal} onClose={handleCloseNewTeamModal}
team={null} team={null} // null indicates creating new team
/> />
</BackofficeWrapper> </BackofficeWrapper>
) );
} };
export default HackathonTeamsPage;
@@ -37,13 +37,16 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
const [showAvatarMenu, setShowAvatarMenu] = useState(false); const [showAvatarMenu, setShowAvatarMenu] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Initialize form data when modal opens
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
if (user) { if (user) {
// Edit existing user
setFormData({ ...user }); setFormData({ ...user });
} else { } else {
// Create new user
setFormData({ setFormData({
id: '', id: '', // Will be generated by backend
fullname: '', fullname: '',
bio: '', bio: '',
location: '', location: '',
@@ -57,9 +60,10 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
} }
}, [isOpen, user]); }, [isOpen, user]);
// Check if form has changes
const hasChanges = useMemo(() => { const hasChanges = useMemo(() => {
if (!formData) return false; if (!formData) return false;
if (!user) return true; if (!user) return true; // New user always has changes
return ( return (
formData.fullname !== user.fullname || formData.fullname !== user.fullname ||
formData.location !== user.location || formData.location !== user.location ||
@@ -70,6 +74,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
); );
}, [formData, user]); }, [formData, user]);
// Check if required fields are filled
const isFormValid = useMemo(() => { const isFormValid = useMemo(() => {
if (!formData) return false; if (!formData) return false;
return formData.fullname?.trim() !== '' && formData.location?.trim() !== ''; return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
@@ -94,22 +99,25 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
if (!formData) return; if (!formData) return;
if (user) { if (user) {
// Update existing user
console.log('Update user data:', formData); console.log('Update user data:', formData);
} else { } else {
// Create new user
console.log('Create new user:', formData); console.log('Create new user:', formData);
} }
// Here you would typically make an API call to save the data
onClose(); onClose();
}; };
const handleCancel = () => { const handleCancel = () => {
if (user) { if (user) {
setFormData({ ...user }); setFormData({ ...user }); // Reset to original for edit mode
} }
onClose(); onClose();
}; };
const handleDeleteAccount = () => { const handleDeleteAccount = () => {
if (!user) return; if (!user) return; // Can't delete new user
console.log('Delete user:', user.id); console.log('Delete user:', user.id);
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
onClose(); onClose();
@@ -118,16 +126,19 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (file) {
// Validate file type
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
alert('Please select an image file'); alert('Please select an image file');
return; return;
} }
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) { if (file.size > 5 * 1024 * 1024) {
alert('Image size must be less than 5MB'); alert('Image size must be less than 5MB');
return; return;
} }
// Create preview URL
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
const avatarUrl = e.target?.result as string; 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" className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Hidden File Input */}
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
@@ -179,8 +191,10 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
accept="image/*" accept="image/*"
className="hidden" className="hidden"
/> />
{/* Header */}
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start"> <div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Interactive User Avatar */}
<div className="relative group "> <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"> <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 ? ( {formData.avatar ? (
@@ -194,6 +208,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
)} )}
</div> </div>
{/* Avatar Hover Overlay */}
<button <button
onClick={() => setShowAvatarMenu(!showAvatarMenu)} 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" 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" /> <CameraOutlined className="text-white text-lg" />
</button> </button>
{/* Avatar Menu Dropdown */}
{showAvatarMenu && ( {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"> <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 <button
@@ -253,14 +269,17 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</button> </button>
</div> </div>
{/* Content */}
<div className="p-8" onClick={() => setShowAvatarMenu(false)}> <div className="p-8" onClick={() => setShowAvatarMenu(false)}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left Column - Basic Info */}
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4"> <h3 className="text-lg font-semibold text-neutral-900 mb-4">
Basic Information Basic Information
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
{/* Full Name - Required */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<UserOutlined className="text-neutral-400" /> <UserOutlined className="text-neutral-400" />
<div className="flex-1"> <div className="flex-1">
@@ -291,6 +310,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</div> </div>
</div> </div>
{/* Location - Required */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<EnvironmentOutlined className="text-neutral-400" /> <EnvironmentOutlined className="text-neutral-400" />
<div className="flex-1"> <div className="flex-1">
@@ -326,6 +346,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</div> </div>
</div> </div>
{/* Joined Date - Read Only - Only show for existing users */}
{user && ( {user && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<CalendarOutlined className="text-neutral-400" /> <CalendarOutlined className="text-neutral-400" />
@@ -349,6 +370,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</div> </div>
</div> </div>
{/* Bio Section - Optional */}
<div> <div>
<h3 className="text-lg font-semibold text-neutral-900 mb-3"> <h3 className="text-lg font-semibold text-neutral-900 mb-3">
Bio{' '} Bio{' '}
@@ -368,7 +390,9 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</div> </div>
</div> </div>
{/* Right Column - Skills & Status */}
<div className="space-y-6"> <div className="space-y-6">
{/* Account Status - Enhanced Tab Design */}
<div> <div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4"> <h3 className="text-lg font-semibold text-neutral-900 mb-4">
Account Status Account Status
@@ -424,6 +448,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</p> </p>
</div> </div>
{/* Skills Section - Optional */}
<div> <div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4"> <h3 className="text-lg font-semibold text-neutral-900 mb-4">
Skills & Expertise{' '} Skills & Expertise{' '}
@@ -485,6 +510,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</div> </div>
</div> </div>
{/* Account Details - Read Only - Only show for existing users */}
{user && ( {user && (
<div> <div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4"> <h3 className="text-lg font-semibold text-neutral-900 mb-4">
@@ -521,6 +547,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</div> </div>
</div> </div>
{/* Footer Actions */}
<div className="border-t border-neutral-200 px-8 py-6"> <div className="border-t border-neutral-200 px-8 py-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -531,6 +558,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
? 'Please fill required fields' ? 'Please fill required fields'
: 'No changes made'} : 'No changes made'}
</div> </div>
{/* Delete Account Button - Only show for existing users */}
{user && ( {user && (
<button <button
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
@@ -567,6 +595,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</div> </div>
</div> </div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && ( {showDeleteConfirm && (
<div className="fixed inset-0 z-60"> <div className="fixed inset-0 z-60">
<div <div
@@ -1,4 +1,3 @@
import { createFileRoute } from '@tanstack/react-router'
import { import {
FC, FC,
ReactElement, ReactElement,
@@ -6,15 +5,15 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useCallback, useCallback,
} from 'react' } from 'react';
import ModalUserDetail from './_components/hackathon-users/modal-user-detail' import ModalUserDetail from './_components/modal-user-detail';
import { import {
BackofficeWrapper, BackofficeWrapper,
DataTable, DataTable,
} from '@imphnen-frontend-service/ui/organisms' } from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms' import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils' import { cn } from '@imphnen-frontend-service/utils';
import { import {
EditOutlined, EditOutlined,
UserOutlined, UserOutlined,
@@ -22,17 +21,18 @@ import {
FilterOutlined, FilterOutlined,
PlusOutlined, PlusOutlined,
LoadingOutlined, LoadingOutlined,
} from '@ant-design/icons' } from '@ant-design/icons';
import { CityFilterSelect } from '../../components/city-filter-select' import { CityFilterSelect } from '../../../components/city-filter-select';
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import { import {
getAdminUsers, getAdminUsers,
TAdminUserItem, TAdminUserItem,
} from '@imphnen-frontend-service/service' } from '@imphnen-frontend-service/service';
import { useNavigate } from '@tanstack/react-router' import { useSearchParams } from 'react-router-dom';
type UserType = TAdminUserItem type UserType = TAdminUserItem;
// Skills options for filter
const skillsOptions = [ const skillsOptions = [
'Frontend Developer', 'Frontend Developer',
'Backend Developer', 'Backend Developer',
@@ -42,32 +42,27 @@ const skillsOptions = [
'Product Manager', 'Product Manager',
'Data Scientist', 'Data Scientist',
'Mobile Developer', 'Mobile Developer',
] ];
export const Route = createFileRoute('/_authenticated/hackathon-users')({ export const HackathonUsersPage: FC = (): ReactElement => {
component: HackathonUsersPage, const [searchParams, setSearchParams] = useSearchParams();
validateSearch: (search: Record<string, unknown>) => ({ const currentPage = Math.max(
page: Number(search.page) || 1, 1,
search: (search.search as string) || '', parseInt(searchParams.get('page') || '1', 10)
per_page: Number(search.per_page) || 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);
function HackathonUsersPage() { // Advanced filtering states
const searchParams = Route.useSearch() const [statusFilter, setStatusFilter] = useState('all');
const navigate = useNavigate() const [cityFilter, setCityFilter] = useState('all');
const currentPage = Math.max(1, searchParams.page) const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
const searchQuery = searchParams.search || ''
const perPage = searchParams.per_page || 10
const [showDetailModal, setShowDetailModal] = useState(false)
const [showNewUserModal, setShowNewUserModal] = useState(false)
const [selectedUser, setSelectedUser] = useState<UserType | null>(null)
const [globalFilter, setGlobalFilter] = useState(searchQuery)
const [statusFilter, setStatusFilter] = useState('all')
const [cityFilter, setCityFilter] = useState('all')
const [skillsFilter, setSkillsFilter] = useState<string[]>([])
// Fetch users from API
const { const {
data: usersResponse, data: usersResponse,
isLoading, isLoading,
@@ -87,107 +82,114 @@ function HackathonUsersPage() {
per_page: perPage, per_page: perPage,
search: searchQuery || undefined, search: searchQuery || undefined,
}), }),
staleTime: 30000, staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, gcTime: 5 * 60 * 1000, // 5 minutes
}) });
const totalData = usersResponse?.meta?.total_data || 0 const totalData = usersResponse?.meta?.total_data || 0;
const totalPages = usersResponse?.meta?.total_page || 1 const totalPages = usersResponse?.meta?.total_page || 1;
// Handle page change - update URL query params
const handlePageChange = useCallback( const handlePageChange = useCallback(
(newPage: number) => { (newPage: number) => {
navigate({ const params = new URLSearchParams();
search: { params.set('page', newPage.toString());
page: newPage, if (perPage !== 10) params.set('per_page', perPage.toString());
per_page: perPage !== 10 ? perPage : undefined, if (searchQuery) params.set('search', searchQuery);
search: searchQuery || undefined, setSearchParams(params);
} as any, window.scrollTo({ top: 0, behavior: 'smooth' });
})
window.scrollTo({ top: 0, behavior: 'smooth' })
}, },
[navigate, perPage, searchQuery] [setSearchParams, perPage, searchQuery]
) );
// Validate page number doesn't exceed total pages
useEffect(() => { useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) { if (!isLoading && totalPages > 0 && currentPage > totalPages) {
navigate({ search: { page: totalPages } as any }) setSearchParams({ page: totalPages.toString() });
} }
}, [currentPage, totalPages, navigate, isLoading]) }, [currentPage, totalPages, setSearchParams, isLoading]);
// Sync globalFilter with URL search param on mount
useEffect(() => { useEffect(() => {
setGlobalFilter(searchQuery) setGlobalFilter(searchQuery);
}, [searchQuery]) }, [searchQuery]);
// Handle search users
const handleSearch = useCallback(() => { const handleSearch = useCallback(() => {
navigate({ const params = new URLSearchParams();
search: { params.set('page', '1');
page: 1, if (perPage !== 10) params.set('per_page', perPage.toString());
per_page: perPage !== 10 ? perPage : undefined, if (globalFilter.trim()) {
search: globalFilter.trim() || undefined, params.set('search', globalFilter.trim());
} as any, }
}) setSearchParams(params);
}, [globalFilter, navigate, perPage]) }, [globalFilter, setSearchParams, perPage]);
// Handle Enter key press in search input
const handleSearchKeyPress = useCallback( const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => { (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleSearch() handleSearch();
} }
}, },
[handleSearch] [handleSearch]
) );
// Handle per page change
const handlePerPageChange = useCallback( const handlePerPageChange = useCallback(
(newPerPage: number) => { (newPerPage: number) => {
navigate({ const params = new URLSearchParams();
search: { params.set('page', '1');
page: 1, params.set('per_page', newPerPage.toString());
per_page: newPerPage, if (searchQuery) params.set('search', searchQuery);
search: searchQuery || undefined, setSearchParams(params);
} as any,
})
}, },
[navigate, searchQuery] [setSearchParams, searchQuery]
) );
// Memoize the callback to prevent recreation
const handleShowDetailModal = useCallback((user: UserType) => { const handleShowDetailModal = useCallback((user: UserType) => {
setSelectedUser(user) setSelectedUser(user);
setShowDetailModal(true) setShowDetailModal(true);
}, []) }, []);
const handleCloseDetailModal = useCallback(() => { const handleCloseDetailModal = useCallback(() => {
setShowDetailModal(false) setShowDetailModal(false);
setSelectedUser(null) setSelectedUser(null);
}, []) }, []);
const handleShowNewUserModal = useCallback(() => { const handleShowNewUserModal = useCallback(() => {
setShowNewUserModal(true) setShowNewUserModal(true);
}, []) }, []);
const handleCloseNewUserModal = useCallback(() => { const handleCloseNewUserModal = useCallback(() => {
setShowNewUserModal(false) setShowNewUserModal(false);
}, []) }, []);
// Filter data based on current filter states
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
const usersData = usersResponse?.data?.data || usersResponse?.data || [] const usersData = usersResponse?.data || [];
return usersData.filter((user: UserType) => { return usersData.filter((user: UserType) => {
// Status filter
if (statusFilter !== 'all') { if (statusFilter !== 'all') {
const isActive = statusFilter === 'active' const isActive = statusFilter === 'active';
if (user.is_active !== isActive) return false if (user.is_active !== isActive) return false;
} }
// Skills filter
if (skillsFilter.length > 0) { if (skillsFilter.length > 0) {
const userSkills = user.skills || [] const userSkills = user.skills || [];
const hasMatchingSkill = skillsFilter.some((skill) => const hasMatchingSkill = skillsFilter.some((skill) =>
userSkills.includes(skill) userSkills.includes(skill)
) );
if (!hasMatchingSkill) return false if (!hasMatchingSkill) return false;
} }
return true return true;
}) });
}, [usersResponse, statusFilter, skillsFilter]) }, [usersResponse, statusFilter, skillsFilter]);
// Memoize columns to prevent recreation on every render
const columns: ColumnDef<UserType>[] = useMemo( const columns: ColumnDef<UserType>[] = useMemo(
() => [ () => [
{ {
@@ -195,6 +197,7 @@ function HackathonUsersPage() {
header: 'User', header: 'User',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-3"> <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"> <div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
{row.original.avatar ? ( {row.original.avatar ? (
<img <img
@@ -206,6 +209,7 @@ function HackathonUsersPage() {
<UserOutlined className="text-neutral-500 text-lg" /> <UserOutlined className="text-neutral-500 text-lg" />
)} )}
</div> </div>
{/* Name only */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-medium text-neutral-900 truncate"> <p className="font-medium text-neutral-900 truncate">
{row.original.fullname} {row.original.fullname}
@@ -219,7 +223,7 @@ function HackathonUsersPage() {
accessorKey: 'skills', accessorKey: 'skills',
header: 'Skills', header: 'Skills',
cell: ({ row }) => { cell: ({ row }) => {
const skills = row.original.skills || [] const skills = row.original.skills || [];
return ( return (
<div className="flex flex-wrap gap-1 max-w-xs"> <div className="flex flex-wrap gap-1 max-w-xs">
{skills.length > 0 ? ( {skills.length > 0 ? (
@@ -242,7 +246,7 @@ function HackathonUsersPage() {
<span className="text-neutral-400">-</span> <span className="text-neutral-400">-</span>
)} )}
</div> </div>
) );
}, },
enableSorting: false, enableSorting: false,
}, },
@@ -277,11 +281,11 @@ function HackathonUsersPage() {
), ),
enableSorting: true, enableSorting: true,
sortingFn: (rowA, rowB) => { sortingFn: (rowA, rowB) => {
const aActive = rowA.original.is_active const aActive = rowA.original.is_active;
const bActive = rowB.original.is_active const bActive = rowB.original.is_active;
if (aActive && !bActive) return -1 if (aActive && !bActive) return -1;
if (!aActive && bActive) return 1 if (!aActive && bActive) return 1;
return 0 return 0;
}, },
}, },
{ {
@@ -314,24 +318,36 @@ function HackathonUsersPage() {
<EditOutlined className="text-sm" /> <EditOutlined className="text-sm" />
Manage Manage
</Button> </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> </div>
), ),
enableSorting: false, enableSorting: false,
}, },
], ],
[handleShowDetailModal] [handleShowDetailModal]
) );
return ( return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025"> <BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700"> <h1 className="mb-8 text-p1 font-semibold text-neutral-700">
User Management User Management
</h1> </h1>
{/* Filters and actions */}
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6"> <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 justify-between">
{/* Left side - Search & filters */}
<div className="flex flex-wrap gap-3 items-center"> <div className="flex flex-wrap gap-3 items-center">
{/* Search bar */}
<div className="relative"> <div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" /> <SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input <input
@@ -344,6 +360,7 @@ function HackathonUsersPage() {
/> />
</div> </div>
{/* Per Page Dropdown */}
<div className="relative"> <div className="relative">
<select <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" 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"
@@ -359,19 +376,70 @@ function HackathonUsersPage() {
</select> </select>
</div> </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> </div>
{/* Right side - Add User Button */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button <Button
variant="primary" variant="primary"
@@ -385,12 +453,14 @@ function HackathonUsersPage() {
</div> </div>
</div> </div>
{/* Active filters display */}
{(skillsFilter.length > 0 || {(skillsFilter.length > 0 ||
statusFilter !== 'all' || statusFilter !== 'all' ||
cityFilter !== 'all') && ( cityFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center"> <div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span> <span className="text-sm text-neutral-600">Active filters:</span>
{/* Status filter badge */}
{statusFilter !== 'all' && ( {statusFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm"> <span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Status: {statusFilter} Status: {statusFilter}
@@ -403,6 +473,7 @@ function HackathonUsersPage() {
</span> </span>
)} )}
{/* Location filter badge */}
{cityFilter !== 'all' && ( {cityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm"> <span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
City: {cityFilter} City: {cityFilter}
@@ -415,6 +486,7 @@ function HackathonUsersPage() {
</span> </span>
)} )}
{/* Skills filter badges */}
{skillsFilter.map((skill) => ( {skillsFilter.map((skill) => (
<span <span
key={skill} key={skill}
@@ -432,14 +504,15 @@ function HackathonUsersPage() {
</span> </span>
))} ))}
{/* Clear all filters */}
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => { onClick={() => {
setStatusFilter('all') setStatusFilter('all');
setCityFilter('all') setCityFilter('all');
setSkillsFilter([]) setSkillsFilter([]);
setGlobalFilter('') setGlobalFilter('');
}} }}
className="text-sm text-neutral-600" className="text-sm text-neutral-600"
> >
@@ -448,6 +521,7 @@ function HackathonUsersPage() {
</div> </div>
)} )}
{/* Loading & results display */}
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" /> <LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
@@ -479,17 +553,21 @@ function HackathonUsersPage() {
)} )}
</section> </section>
{/* Modals component */}
<ModalUserDetail <ModalUserDetail
isOpen={showDetailModal} isOpen={showDetailModal}
onClose={handleCloseDetailModal} onClose={handleCloseDetailModal}
user={selectedUser} user={selectedUser}
/> />
{/* New User Modal */}
<ModalUserDetail <ModalUserDetail
isOpen={showNewUserModal} isOpen={showNewUserModal}
onClose={handleCloseNewUserModal} onClose={handleCloseNewUserModal}
user={null} user={null} // null indicates creating new user
/> />
</BackofficeWrapper> </BackofficeWrapper>
) );
} };
export default HackathonUsersPage;
@@ -1,20 +1,9 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router' import { FC, ReactElement, useState } from 'react';
import { SessionToken } from '@imphnen-frontend-service/service' import { Outlet } from 'react-router-dom';
import { useState } from 'react' import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms'
export const Route = createFileRoute('/_authenticated')({ export const AppLayout: FC = (): ReactElement => {
beforeLoad: () => { const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const session = SessionToken.get()
if (!session?.token?.access_token) {
throw redirect({ to: '/auth/login' })
}
},
component: AuthenticatedLayout,
})
function AuthenticatedLayout() {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
return ( return (
<div className="bg-primary-50 min-h-screen flex justify-center"> <div className="bg-primary-50 min-h-screen flex justify-center">
@@ -24,12 +13,14 @@ function AuthenticatedLayout() {
onClose={() => setMobileSidebarOpen(false)} onClose={() => setMobileSidebarOpen(false)}
/> />
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{/* Sticky top header */}
<header <header
className={ className={
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' + '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') (mobileSidebarOpen ? 'z-0' : 'z-30')
} }
> >
{/* Mobile menu button (shown on small screens) */}
<button <button
type="button" type="button"
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700 cursor-pointer" className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700 cursor-pointer"
@@ -59,5 +50,7 @@ function AuthenticatedLayout() {
</div> </div>
</div> </div>
</div> </div>
) );
} };
export default AppLayout;
@@ -0,0 +1,132 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { useItem, useConfirmItem } from '../_hook/use-item';
interface IModalAddPermission {
isOpen: boolean;
onClose: () => void;
handleAddItem?: () => Promise<boolean>;
currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
}
const ModalAddPermission = ({
isOpen,
onClose,
currentStep,
nextStep,
resetStep,
handleAddItem,
}: IModalAddPermission) => {
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-3 text-center"
isOpen={isOpen}
onClose={() => {
onClose();
resetStep();
}}
disableEscapeKeyDown={true}
>
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
{currentStep === 2 && (
<StepTwo
onClose={onClose}
handleAddItem={handleAddItem}
resetStep={resetStep}
/>
)}
</Modal>
);
};
interface IStepOneProps {
nextStep: () => void;
onClose: () => void;
}
const StepOne = ({ nextStep }: IStepOneProps) => {
const { form, onSubmit } = useItem(nextStep);
return (
<>
<Modal.Header>
<h2 className="text-p1 font-semibold text-primary-500">
Tambah Permissions
</h2>
</Modal.Header>
<Modal.Content>
<form onSubmit={onSubmit} className="flex flex-col gap-8">
<ControlledInputField
control={form.control}
label="Name"
name="name"
type="text"
placeholder="Nama Permission"
size="lg"
className="w-full"
/>
<Button variant="primary" size="lg" className="w-full" type="submit">
Tambah Permission
</Button>
</form>
</Modal.Content>
</>
);
};
interface IStepTwoProps {
onClose: () => void;
handleAddItem?: () => Promise<boolean>;
resetStep: () => void;
}
const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => {
const { onConfirm, onCancel } = useConfirmItem(
onClose,
resetStep,
handleAddItem,
{
success: 'Data permissions berhasil ditambahkan',
error: 'Data permissions gagal ditambahkan',
}
);
return (
<>
<Modal.Header className="mb-7 text-center items-center">
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Tambah Permissions
</h2>
<p className="text-p3 text-center text-neutral-400">
Apakah kamu yakin ingin
<br /> menambahkan permission ini?
</p>
</Modal.Header>
<Modal.Content className="flex mb-0 gap-4">
<Button
variant="bordered"
size="lg"
className="w-full"
onClick={onCancel}
>
Batal
</Button>
<Button
variant="primary"
size="lg"
className="w-full"
onClick={onConfirm}
>
Tambahkan
</Button>
</Modal.Content>
</>
);
};
export default ModalAddPermission;
@@ -0,0 +1,72 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { useConfirmItem } from '../_hook/use-item';
interface IModalDeletePermission {
isOpen: boolean;
onClose: () => void;
handleDelete?: () => Promise<boolean>;
currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
}
const ModalDeletePermission = ({
isOpen,
onClose,
resetStep,
handleDelete,
}: IModalDeletePermission) => {
const { onConfirm } = useConfirmItem(onClose, resetStep, handleDelete, {
success: 'Data permissions berhasil dihapus',
error: 'Data permissions gagal dihapus',
});
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
isOpen={isOpen}
onClose={onClose}
closeButtonClassName="hidden"
>
<Modal.Header className="gap-8">
<img
src="/chibi-delete.webp"
alt="Delete?"
width={148}
className="self-center"
/>
<div className="text-center">
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
Delete Permissions
</h2>
<p className="text-p3 text-neutral-400">
Apakah kamu yakin untuk menghapus permission ini? Menghapus data ini
mungkin akan mempengaruhi fungsional sistem
</p>
</div>
</Modal.Header>
<Modal.Content className="flex gap-4">
<Button
variant="secondary"
size="lg"
className="w-full"
onClick={onClose}
>
Batal Hapus
</Button>
<Button
variant="primary"
size="lg"
className="w-full bg-danger-500 hover:bg-danger-600"
onClick={onConfirm}
>
Hapus Item
</Button>
</Modal.Content>
</Modal>
);
};
export default ModalDeletePermission;
@@ -0,0 +1,62 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
import { useConfirmItem } from '../_hook/use-item';
interface IModalUpdatePermission {
isOpen: boolean;
onClose: () => void;
handleUpdate?: () => Promise<boolean>;
currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
}
const ModalUpdatePermission = ({
isOpen,
onClose,
resetStep,
handleUpdate,
}: IModalUpdatePermission) => {
const { onConfirm } = useConfirmItem(onClose, resetStep, handleUpdate, {
success: 'Perubahan permissions berhasil dilakukan',
error: 'Perubahan permissions gagal dilakukan',
});
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-3 text-center"
isOpen={isOpen}
onClose={onClose}
disableEscapeKeyDown={true}
>
<Modal.Header>
<h2 className="text-p1 font-semibold text-primary-500">
Update Permissions
</h2>
</Modal.Header>
<Modal.Content className="flex flex-col gap-8">
<InputField
label="Name"
name="name"
type="text"
placeholder="Nama Permission"
size="lg"
className="w-full"
/>
<Button
variant="primary"
size="lg"
className="w-full"
type="submit"
onClick={onConfirm}
>
Update Permission
</Button>
</Modal.Content>
</Modal>
);
};
export default ModalUpdatePermission;
@@ -1,18 +1,23 @@
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
// import { zodResolver } from '@hookform/resolvers/zod';
// import {
// gachaRollItemSchema,
// TGachaRollItem
// } from '@imphnen-frontend-service/service';
import { toast } from 'sonner'; import { toast } from 'sonner';
export const useItem = ( export const useItem = (
nextStep: () => void, nextStep: () => void,
initialValues?: any, initialValues?: any
onDataCapture?: (data: any) => void,
) => { ) => {
const form = useForm<any>({ const form = useForm<any>({
// resolver: zodResolver(),
mode: 'all', mode: 'all',
defaultValues: initialValues, defaultValues: initialValues,
}); });
const onSubmit = form.handleSubmit((data) => { const onSubmit = form.handleSubmit((data) => {
onDataCapture?.(data); console.log('Form data:', data);
nextStep(); nextStep();
}); });
@@ -33,9 +38,8 @@ export const useConfirmItem = (
) => { ) => {
const onConfirm = async () => { const onConfirm = async () => {
try { try {
if (actionFunction) { // const result = await actionFunction?.();
await actionFunction(); // result ? toast.success(messages?.success) : toast.error(messages?.error);
}
toast.success(messages?.success); toast.success(messages?.success);
onClose(); onClose();
resetStep(); resetStep();
Loaded 50 of 630 files, more files were not shown because too many files have changed in this diff. Show more