Compare commits

..
1 Commits
Author SHA1 Message Date
Maulana Sodiqin 28849e30b0 feat: setup 2025-03-13 22:29:56 +07:00
597 changed files with 5464 additions and 83316 deletions
-24
View File
@@ -1,24 +0,0 @@
{
"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": []
}
}
-55
View File
@@ -1,55 +0,0 @@
# 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
-3
View File
@@ -1,3 +0,0 @@
VITE_SUPABASE_URL=https://your-project-id.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key-here
-30
View File
@@ -1,30 +0,0 @@
## Issue
<!-- #GitHubIssueNumber / Issue Link / Describe the problem here (if the issue hasn't been defined) -->
## What did you do?
<!--
- Describe what did you do in this code
- Example:
- [CORRECT] Add auth feature / Modify auth feature.
- [WRONG] Create file /controllers/auth.ts, /models/user.ts, modify /routes/web.ts
-->
## Screenshot / Video
<!--
Attach pictures of this work / video explanation about this work
-->
## Note for Deployment
<!--
- Tell to DevOps what should he do to deploy this change
- example:
- Please run migration when deploy this
- Please upload these files into folder xxx
- Please create folder `xxx` and give permission `777`
- Please run this SQL after deploy ```sql INSERT INTO xxx (...) ```
- etc...
-->
-44
View File
@@ -1,44 +0,0 @@
name: Deploy Backoffice
on:
push:
branches:
- develop
paths:
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
else
cd ~/imphnen-frontend-service-backoffice && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build --progress=plain
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
docker image prune -af
-44
View File
@@ -1,44 +0,0 @@
name: Deploy Dimentorin
on:
push:
branches:
- develop
paths:
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
else
cd ~/imphnen-frontend-service-dimentorin && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build --progress=plain
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
docker image prune -af
-44
View File
@@ -1,44 +0,0 @@
name: Deploy Gacha
on:
push:
branches:
- develop
paths:
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-gacha ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
else
cd ~/imphnen-frontend-service-gacha && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service-gacha/apps/gacha/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build --progress=plain
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
docker image prune -af
-45
View File
@@ -1,45 +0,0 @@
name: Deploy Hackathon
on:
push:
branches:
- develop
paths:
- 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-hackathon ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-hackathon
else
cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service-hackathon/.env.local << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true
docker builder prune -af || true
DOCKER_BUILDKIT=1 docker compose --env-file ~/imphnen-frontend-service-hackathon/.env.local -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml build --progress=plain
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml up -d
docker image prune -af
-44
View File
@@ -1,44 +0,0 @@
name: Deploy Landing
on:
push:
branches:
- develop
paths:
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: |
set -e
if [ ! -d ~/imphnen-frontend-service ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
else
cd ~/imphnen-frontend-service && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service/apps/landing/.env << EOF
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build --progress=plain
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
docker image prune -af
-37
View File
@@ -1,37 +0,0 @@
name: Test and Build
on:
push:
branches: ['develop']
pull_request:
branches: ['develop']
env:
NODE_VERSION: 22.14.0
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install dependencies
run: npm install
- name: Set env vars
run: |
echo "CMS_SECRET=${{ secrets.CMS_SECRET }}" >> $GITHUB_ENV
echo "CMS_STORAGE_REGION=${{ secrets.CMS_STORAGE_REGION }}" >> $GITHUB_ENV
echo "CMS_STORAGE_ENDPOINT=${{ secrets.CMS_STORAGE_ENDPOINT }}" >> $GITHUB_ENV
echo "CMS_STORAGE_ACCESS_KEY_ID=${{ secrets.CMS_STORAGE_ACCESS_KEY_ID }}" >> $GITHUB_ENV
echo "CMS_STORAGE_SECRET_ACCESS_KEY=${{ secrets.CMS_STORAGE_SECRET_ACCESS_KEY }}" >> $GITHUB_ENV
echo "CMS_POSTGRES_URL=${{ secrets.CMS_POSTGRES_URL }}" >> $GITHUB_ENV
- name: Nx Build
run: npx nx run-many --target=build --all
+1 -24
View File
@@ -42,27 +42,4 @@ Thumbs.db
.nx/workspace-data .nx/workspace-data
vite.config.*.timestamp* vite.config.*.timestamp*
vitest.config.*.timestamp* vitest.config.*.timestamp*
storybook-static
.env
.env.prod
.env.develop
.env.staging
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
# Next.js
.next
out
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
.env.local
-9
View File
@@ -1,9 +0,0 @@
{
"mcpServers": {
"nx-mcp": {
"type": "stdio",
"command": "npx",
"args": ["nx", "mcp"]
}
}
}
+1 -2
View File
@@ -2,7 +2,6 @@
"recommendations": [ "recommendations": [
"nrwl.angular-console", "nrwl.angular-console",
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint"
"ms-playwright.playwright"
] ]
} }
-13
View File
@@ -1,13 +0,0 @@
<!-- nx configuration start-->
<!-- Leave the start & end comments to automatically receive updates. -->
# General Guidelines for working with Nx
- 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
<!-- nx configuration end-->
+70 -107
View File
@@ -1,138 +1,101 @@
# IMPHNEN Frontend Service # ImphnenFrontendService
<p align="center"> <a alt="Nx logo" href="https://nx.dev" target="_blank" rel="noreferrer"><img src="https://raw.githubusercontent.com/nrwl/nx/master/images/nx-logo.png" width="45"></a>
<img src="docs/logo.svg" alt="IMPHNEN">
</p>
This repository is a **monorepo** for all frontend services of IMPHNEN. The monorepo includes three main applications: ✨ Your new, shiny [Nx workspace](https://nx.dev) is ready ✨.
1. **Gacha** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Gacha Website</a>. [Learn more about this workspace setup and its capabilities](https://nx.dev/getting-started/tutorials/react-monorepo-tutorial?utm_source=nx_project&amp;utm_medium=readme&amp;utm_campaign=nx_projects) or run `npx nx graph` to visually explore what was created. Now, let's get you up to speed!
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>.
## How to install ## Run tasks
1. Clone this repository: To run the dev server for your app, use:
```sh
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git
cd imphnen-frontend-service
```
2. Install all dependencies:
```sh
npm install
```
## How to run
### Setup Environment Variables
Before running the applications, you need to set up the environment variables. You can do this by copying the `.env.example` file to `.env` and modifying the values according to your needs.
```sh ```sh
cd apps/{appname} npx nx serve dimentorin
cp .env.example .env
``` ```
### Development To create a production bundle:
Use the following commands to run in development mode: ```sh
npx nx build dimentorin
```
- **Gacha**: To see all available targets to run for a project, run:
```sh
npm run gacha:dev
```
- **Backoffice**:
```sh
npm run backoffice:dev
```
- **Dimentorin**:
```sh
npm run dimentorin:dev
```
- **Landing Page**:
```sh
npm run landing:dev
```
### Build ```sh
npx nx show project dimentorin
```
Use the following commands to build the applications: These targets are either [inferred automatically](https://nx.dev/concepts/inferred-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or defined in the `project.json` or `package.json` files.
- **Gacha**: [More about running tasks in the docs &raquo;](https://nx.dev/features/run-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
```sh
npm run gacha:build
```
- **Backoffice**:
```sh
npm run backoffice:build
```
- **Dimentorin**:
```sh
npm run dimentorin:build
```
- **Landing Page**:
```sh
npm run landing:build
```
### Production ## Add new projects
Use the following commands to run the applications in production mode: While you could add new projects to your workspace manually, you might want to leverage [Nx plugins](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) and their [code generation](https://nx.dev/features/generate-code?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) feature.
- **Gacha**: Use the plugin's generator to create new projects.
```sh
npm run gacha:prod
```
- **Backoffice**:
```sh
npm run backoffice:prod
```
- **Dimentorin**:
```sh
npm run dimentorin:prod
```
- **Landing Page**:
```sh
npm run landing:prod
```
### Storybook To generate a new application, use:
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: ```sh
npx nx g @nx/react:app demo
```
- **Run Storybook** To generate a new library, use:
This command starts Storybook in development mode, allowing you to view and test UI components interactively. ```sh
npx nx g @nx/react:lib mylib
```
```sh You can use `npx nx list` to get a list of installed plugins. Then, run `npx nx list <plugin-name>` to learn about more specific capabilities of a particular plugin. Alternatively, [install Nx Console](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) to browse plugins and generators in your IDE.
npm run ui:storybook
```
- **Run Unit Test** [Learn more about Nx plugins &raquo;](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) | [Browse the plugin registry &raquo;](https://nx.dev/plugin-registry?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
This command runs unit tests for the UI components to ensure they function as expected. ## Set up CI!
```sh ### Step 1
npm run ui:test
```
- **Build Components** To connect to Nx Cloud, run the following command:
This command generates a static build of Storybook, which can be deployed for sharing and documentation purposes. ```sh
npx nx connect
```
```sh Connecting to Nx Cloud ensures a [fast and scalable CI](https://nx.dev/ci/intro/why-nx-cloud?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) pipeline. It includes features such as:
npm run ui:build
```
## How to contribute - [Remote caching](https://nx.dev/ci/features/remote-cache?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [Task distribution across multiple machines](https://nx.dev/ci/features/distribute-task-execution?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [Automated e2e test splitting](https://nx.dev/ci/features/split-e2e-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [Task flakiness detection and rerunning](https://nx.dev/ci/features/flaky-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
1. Fork the repository and clone it locally. ### Step 2
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. Use the following command to configure a CI workflow for your workspace:
```sh
npx nx g ci-workflow
```
[Learn more about Nx on CI](https://nx.dev/ci/intro/ci-with-nx#ready-get-started-with-your-provider?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
## Install Nx Console
Nx Console is an editor extension that enriches your developer experience. It lets you run tasks, generate code, and improves code autocompletion in your IDE. It is available for VSCode and IntelliJ.
[Install Nx Console &raquo;](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
## Useful links
Learn more:
- [Learn more about this workspace setup](https://nx.dev/getting-started/tutorials/react-monorepo-tutorial?utm_source=nx_project&amp;utm_medium=readme&amp;utm_campaign=nx_projects)
- [Learn about Nx on CI](https://nx.dev/ci/intro/ci-with-nx?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [Releasing Packages with Nx release](https://nx.dev/features/manage-releases?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [What are Nx plugins?](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
And join the Nx community:
- [Discord](https://go.nx.dev/community)
- [Follow us on X](https://twitter.com/nxdevtools) or [LinkedIn](https://www.linkedin.com/company/nrwl)
- [Our Youtube channel](https://www.youtube.com/@nxdevtools)
- [Our blog](https://nx.dev/blog?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
-1
View File
@@ -1 +0,0 @@
Tue, Nov 25, 2025 4:21:27 PM
-2
View File
@@ -1,2 +0,0 @@
# Deployment trigger
# Updated to deploy circular dependency fixes and auth hooks
-1
View File
@@ -1 +0,0 @@
VITE_API_URL=
-12
View File
@@ -1,12 +0,0 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/react'],
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
// Override or add rules here
rules: {},
},
];
-16
View File
@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Backoffice</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-9
View File
@@ -1,9 +0,0 @@
import { join } from 'path';
export default {
plugins: {
'@tailwindcss/postcss': {
base: join(import.meta.dirname, '../../'),
},
},
};
-9
View File
@@ -1,9 +0,0 @@
{
"name": "backoffice",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/backoffice/src",
"projectType": "application",
"tags": [],
"// targets": "to see all targets run: nx show project backoffice --web",
"targets": {}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 351 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 511 KiB

@@ -1,162 +0,0 @@
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,195 +0,0 @@
import * as React from 'react';
import { FC, Fragment, ReactElement, useState } from 'react';
import {
FilterOutlined,
SearchOutlined,
EditOutlined,
} from '@ant-design/icons';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
useReactTable,
RowSelectionState,
} from '@tanstack/react-table';
import ModalEditAccount from './_components/modal-edit-account';
import { useQueryState } from '@imphnen-frontend-service/utils';
interface Account {
id: number;
name: string;
email: string;
phone: string;
address: string;
}
// Mock data for demonstration
const mockData: Account[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
email: 'fullname23@gmail.com',
phone: '081904423804',
address: 'Jl. Pantai Cibaduyut Indah',
}));
export const Components: FC = (): ReactElement => {
const [showModalEditAccount, setShowModalEditAccount] = useState(false);
const {
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('step', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const [showFilter, setShowFilter] = useState(false);
const columns: ColumnDef<Account>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Nama Lengkap',
accessorKey: 'name',
},
{
header: 'Email',
accessorKey: 'email',
},
{
header: 'Nomor Telp',
accessorKey: 'phone',
},
{
header: 'Alamat Pengiriman',
accessorKey: 'address',
},
{
header: 'Action',
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowModalEditAccount(true);
}}
className="flex items-center gap-2"
>
<EditOutlined /> Edit
</Button>
),
},
];
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
{/* Header */}
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Data Akun</h1>
</header>
{/* Account Table Section */}
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
{/* Search and Filter */}
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama lengkap, email"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex items-center gap-3"
disabled
onClick={() => setShowFilter(!showFilter)}
>
<FilterOutlined />
Filters
</Button>
{showFilter && (
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
<Filter onClose={() => setShowFilter(false)} options={[]} />
</div>
)}
</div>
</div>
{/* Table */}
<DataTable data={mockData} columns={columns} table={table} />
</section>
</main>
{/* Modal Edit Account */}
<ModalEditAccount
currentStep={currentStep}
isOpen={showModalEditAccount}
onClose={() => setShowModalEditAccount(false)}
handleEditAccount={() => {
console.log('Account updated');
}}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
</Fragment>
);
};
export default Components;
@@ -1,52 +0,0 @@
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
import { PieLabelProps } from "recharts/types/polar/Pie"
type ChartProps = {
name: string
value: number
color: string
}
const chartData: ChartProps[] = [
{ name: "Active", value: 49, color: "#23A1EB" },
{ name: "Done", value: 24, color: "#81CBF8" },
{ name: "Canceled", value: 27, color: "#BCE1FB" },
]
const RADIAN = Math.PI / 180;
const renderCustomizedLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }: PieLabelProps) => {
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-(midAngle ?? 0) * RADIAN);
const y = cy + radius * Math.sin(-(midAngle ?? 0) * RADIAN);
return (
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
{`${((percent ?? 1) * 100).toFixed(0)}%`}
</text>
);
};
export const SessionStatusChart = () => {
return (
<ResponsiveContainer width="100%" height={320}>
<PieChart width={500} height={320}>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={100}
labelLine={false}
label={renderCustomizedLabel}
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
)
}
@@ -1,36 +0,0 @@
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
type ChartProps = {
name: string
activeUser: number
activeSession: number
}
const chartData: ChartProps[] = [
{ name: "2014", activeUser: 0, activeSession: 0 },
{ name: "2015", activeUser: 15, activeSession: 25 },
{ name: "2016", activeUser: 30, activeSession: 40 },
{ name: "2017", activeUser: 45, activeSession: 55 },
{ name: "2018", activeUser: 60, activeSession: 70 },
{ name: "2019", activeUser: 75, activeSession: 85 },
{ name: "2020", activeUser: 90, activeSession: 95 },
{ name: "2021", activeUser: 85, activeSession: 80 },
{ name: "2022", activeUser: 95, activeSession: 90 },
{ name: "2023", activeUser: 100, activeSession: 100 },
]
export const UserGrowthChart = () => {
return (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={chartData} width={500} height={320} margin={{ left: -32 }}>
<CartesianGrid />
<XAxis dataKey="name" />
<YAxis tickCount={10} />
<Tooltip />
<Legend />
<Line dataKey="activeUser" stroke="#23A1EB" />
<Line dataKey="activeSession" stroke="#0877C1" />
</LineChart>
</ResponsiveContainer>
)
}
@@ -1,115 +0,0 @@
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>
)
}
@@ -1,161 +0,0 @@
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;
@@ -1,62 +0,0 @@
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;
@@ -1,164 +0,0 @@
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;
@@ -1,61 +0,0 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
gachaItemSchema,
TGachaItem
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
export const useItem = (
nextStep: () => void,
initialValues?: TGachaItem
) => {
const form = useForm<TGachaItem>({
resolver: zodResolver(gachaItemSchema),
mode: 'all',
defaultValues: initialValues,
});
const onSubmit = form.handleSubmit((data) => {
console.log('Form data:', data);
nextStep();
});
return {
form,
onSubmit,
};
};
export const useConfirmItem = (
onClose: () => void,
resetStep: () => void,
actionFunction?: () => Promise<boolean>,
messages?: {
success?: string;
error?: string;
}
) => {
const onConfirm = async () => {
try {
// const result = await actionFunction?.();
// result ? toast.success(messages?.success) : toast.error(messages?.error);
toast.success(messages?.success);
onClose();
resetStep();
} catch (error) {
console.log(error);
toast.error(messages?.error);
}
};
const onCancel = () => {
onClose();
resetStep();
};
return {
onConfirm,
onCancel,
};
};
@@ -1,188 +0,0 @@
import {
PlusOutlined,
ReloadOutlined,
UsergroupAddOutlined,
UsergroupDeleteOutlined,
UserSwitchOutlined,
} from '@ant-design/icons';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { FC, Fragment, ReactElement, useState } from 'react';
import ModalAddItem from './_components/modal-add-item';
import ModalEditItem from './_components/modal-edit-item';
import ModalDeleteItem from './_components/modal-delete-item';
import { useQueryState } from '@imphnen-frontend-service/utils';
export const Components: FC = (): ReactElement => {
const [showModalAddItem, setShowModalAddItem] = useState(false);
const [showModalEditItem, setShowModalEditItem] = useState(false);
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
const {
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('step', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Dashboard</h1>
</header>
<div className="flex justify-between gap-[40px] p-8 bg-white rounded-md">
<div className="w-full flex flex-col gap-[40px]">
<section>
<h2 className="text-p2 font-medium text-primary-500 mb-8">
Summary
</h2>
<div className="grid grid-cols-2 gap-4">
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<UsergroupAddOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500">Participants</p>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<ReloadOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500">
Roll and Reroll
</p>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<UserSwitchOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500">Redeem</p>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<UsergroupDeleteOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500">
Inactive Users
</p>
</div>
</div>
</div>
</section>
<section className="flex flex-col gap-8">
<div className="flex justify-between items-center">
<h2 className="text-p2 font-medium text-primary-500">
Gacha Items
</h2>
<Button
variant="primary"
size="sm"
className="items-end gap-3"
onClick={() => setShowModalAddItem(true)}
>
<span>Tambah Item</span>
<PlusOutlined className="text-[16px]" />
</Button>
</div>
<div className="flex flex-col gap-4 max-h-140 overflow-auto">
{[1, 2, 3, 4, 5, 6].map((item) => (
<div
key={item}
className="bg-white overflow-clip rounded-lg shadow-sm flex justify-between border border-neutral-100"
>
<div className="flex flex-col py-4 px-6 gap-[8px]">
<div>
<h3 className="text-p3 text-primary-500 font-medium">
Lanyard IMPHNEN
</h3>
<div className="flex items-center justify-start gap-10 text-label2 text-gray-500 mt-1">
<span>Prize {item}</span>
<span>Quantity: 10</span>
</div>
</div>
<div className="flex justify-start gap-2">
<Button
variant="text"
size="sm"
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent hover:text-primary-500"
onClick={() => setShowModalEditItem(true)}
>
Edit
</Button>
<Button
variant="text"
size="sm"
className="text-[10px] text-red-500 p-0 font-normal hover:bg-transparent hover:text-red-700"
onClick={() => setShowModalDeleteItem(true)}
>
Delete
</Button>
</div>
</div>
<img src="gacha-clip.webp" alt="Lanyard IMPHNEN" />
</div>
))}
</div>
</section>
</div>
{/* Right-side illustration */}
<img
src="gacha.webp"
alt=""
className="rounded-lg hidden xl:block xl:min-w-[436px] h-auto object-cover"
/>
</div>
</main>
{/* Modal Add Item */}
<ModalAddItem
currentStep={currentStep}
isOpen={showModalAddItem}
onClose={() => setShowModalAddItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
{/* Modal Edit Item */}
<ModalEditItem
currentStep={currentStep}
isOpen={showModalEditItem}
onClose={() => setShowModalEditItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
{/* Modal Delete Item */}
<ModalDeleteItem
isOpen={showModalDeleteItem}
onClose={() => setShowModalDeleteItem(false)}
/>
</Fragment>
);
};
export default Components;
@@ -1,181 +0,0 @@
import { SearchOutlined } from "@ant-design/icons";
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
import { cn, For } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { ReactElement, useState } from "react"
const TABS = {
MENTORING: 'Mentoring',
PLATFORM: 'Platform'
} as const
type Tabs = typeof TABS[keyof typeof TABS]
type FeedbackStatus = 'done' | 'todo';
interface FeedbackType {
id: number
name: string
email: string
rating: number
status: FeedbackStatus
}
const mockData: FeedbackType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana',
email: 'fullname23@gmail.com',
rating: 4.5,
status: i % 2 === 0 ? 'done' : 'todo',
}))
export default function Components(): ReactElement {
const [activeTab, setActiveTab] = useState<Tabs>(TABS.MENTORING)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<FeedbackType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-20") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
},
{
id: 'email',
header: 'Email',
accessorKey: 'email',
},
{
id: 'rating',
header: 'Rating',
accessorKey: 'rating',
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<FeedbackStatus, string> = {
done: 'bg-success-200 text-success-500',
todo: 'bg-primary-200 text-primary-500',
};
const statusText: Record<FeedbackStatus, string> = {
done: 'Done',
todo: 'To Do',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
meta: { cellClassName: cn("w-72") },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation()
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Lihat Feedback
</Button>
),
},
]
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<BackofficeWrapper title="Dimentorin.dev">
<div className="mb-8 flex justify-between items-center">
<h1 className="text-p1 font-semibold text-neutral-700">Feedback</h1>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
<For data={Object.values(TABS)}>
{(tab) => (
<Button
key={tab}
variant="text"
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
onClick={() => setActiveTab(tab)}
>
{tab}
</Button>
)}
</For>
</div>
</div>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama mentor/mentee"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<Select>
<option selected disabled>Rating</option>
<option value="4.5">4.5</option>
<option value="5">5</option>
</Select>
<Select>
<option selected disabled>Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
</BackofficeWrapper>
);
}
@@ -1,160 +0,0 @@
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;
@@ -1,62 +0,0 @@
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;
@@ -1,168 +0,0 @@
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;
@@ -1,61 +0,0 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
gachaRollItemSchema,
TGachaRollItem
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
export const useItem = (
nextStep: () => void,
initialValues?: TGachaRollItem
) => {
const form = useForm<TGachaRollItem>({
resolver: zodResolver(gachaRollItemSchema),
mode: 'all',
defaultValues: initialValues,
});
const onSubmit = form.handleSubmit((data) => {
console.log('Form data:', data);
nextStep();
});
return {
form,
onSubmit,
};
};
export const useConfirmItem = (
onClose: () => void,
resetStep: () => void,
actionFunction?: () => Promise<boolean>,
messages?: {
success?: string;
error?: string;
}
) => {
const onConfirm = async () => {
try {
// const result = await actionFunction?.();
// result ? toast.success(messages?.success) : toast.error(messages?.error);
toast.success(messages?.success);
onClose();
resetStep();
} catch (error) {
console.log(error);
toast.error(messages?.error);
}
};
const onCancel = () => {
onClose();
resetStep();
};
return {
onConfirm,
onCancel,
};
};
@@ -1,210 +0,0 @@
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,32 +0,0 @@
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import { FC, ReactElement } from 'react';
export const HackathonDashboardPage: FC = (): ReactElement => {
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
<section className="grid grid-cols-5 gap-5">
{/* Participant */}
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
1261
</h3>
<p className="text-neutral-400 text-p3">Total Participants</p>
</div>
{/* Team */}
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">206</h3>
<p className="text-neutral-400 text-p3">Total Teams</p>
</div>
{/* Project Submitted */}
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">0</h3>
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
</div>
</section>
</BackofficeWrapper>
);
};
export default HackathonDashboardPage;
@@ -1,128 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import { EditOutlined } from '@ant-design/icons';
export const HackathonUsersPage: FC = (): ReactElement => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const mockData: any[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
project_name: `Project ${i + 1}`,
repository_url: `https://github.com/user/repo${i + 1}`,
demo_url: `https://demo.example.com/project${i + 1}`,
presentation_url: `https://slides.example.com/project${i + 1}`,
}));
type UserStatus = 'active' | 'inactive';
interface SubmissionType {
id: number;
project_name: string;
repository_url: string;
demo_url: string;
presentation_url: string;
}
const columns: ColumnDef<SubmissionType>[] = [
{
header: 'Project Name',
accessorKey: 'project_name',
},
{
header: 'Repository URL',
accessorKey: 'repository_url',
},
{
header: 'Demo URL',
accessorKey: 'demo_url',
},
{
header: 'Presentation URL',
accessorKey: 'presentation_url',
},
{
header: 'Action',
meta: { cellClassName: cn('w-72') },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 w-max"
onClick={() => {
// View detail logic
}}
>
<EditOutlined className="text-base" /> View & Manage
</Button>
),
},
];
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
Project Submission
</h1>
{/* Filters and actions */}
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center">
<input
type="text"
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64"
placeholder="Search name or email"
/>
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
</select>
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
<option value="all">All City</option>
<option value="jakarta">Jakarta</option>
<option value="bandung">Bandung</option>
</select>
</div>
{/* Table */}
<DataTable data={mockData} columns={columns} table={table} />
</section>
{/* Modals extracted into shared backoffice components */}
</BackofficeWrapper>
);
};
export default HackathonUsersPage;
@@ -1,210 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import { useTeams } from '@imphnen-frontend-service/service';
import { EditOutlined } from '@ant-design/icons';
export const HackathonTeamsPage: FC = (): ReactElement => {
const { data: teamsData } = useTeams();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const mockData: TeamType[] = Array.from({ length: 90 }, (_, i) => ({
id: `team-${i + 1}`,
name: `Team ${i + 1} - ${i % 3 === 0 ? 'Innovators' : 'Hackers'}`,
city: i % 2 === 0 ? 'Jakarta' : 'Bandung',
visibility: i % 4 === 0 ? 'private' : 'public',
member_count: Math.floor(Math.random() * 4) + 1,
has_submission: i % 3 !== 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
leader: {
user: {
fullname: `Leader User ${i}`,
email: `leader${i}@example.com`,
},
},
}));
interface TeamType {
id: string;
name: string;
city: string;
visibility: 'public' | 'private';
member_count: number;
has_submission: boolean;
created_at: string;
updated_at: string;
leader?: {
user: {
fullname: string;
email: string;
};
};
}
const columns: ColumnDef<TeamType>[] = [
{
accessorKey: 'id',
header: 'ID',
},
{
accessorKey: 'name',
header: 'Team Name',
},
{
accessorKey: 'city',
header: 'City',
},
{
accessorKey: 'visibility',
header: 'Visibility',
cell: ({ row }) => {
const isPublic = row.original.visibility === 'public';
return (
<span
className={cn(
'py-2 px-4 text-sm rounded-2xl text-center',
isPublic
? 'bg-info-200 text-info-700'
: 'bg-gray-200 text-gray-700'
)}
>
{isPublic ? 'Public' : 'Private'}
</span>
);
},
},
{
accessorKey: 'member_count',
header: 'Members',
},
{
id: 'leader',
header: 'Leader',
cell: ({ row }) => {
const leader = row.original.leader?.user;
return leader ? (
<div>
<div className="text-sm font-medium text-gray-900">
{leader.fullname}
</div>
<div className="text-xs text-gray-500">{leader.email}</div>
</div>
) : (
<span className="text-gray-400 italic">-</span>
);
},
},
{
accessorKey: 'has_submission',
header: 'Submitted',
cell: ({ row }) => {
const hasSubmission = row.original.has_submission;
return (
<span
className={cn(
'py-2 px-4 text-sm rounded-2xl text-center',
hasSubmission
? 'bg-success-200 text-success-700'
: 'bg-danger-200 text-danger-700'
)}
>
{hasSubmission ? 'Yes' : 'No'}
</span>
);
},
},
{
accessorKey: 'updated_at',
header: 'Last Updated',
cell: ({ row }) => {
return new Date(row.original.updated_at).toLocaleDateString();
},
},
{
id: 'actions',
header: 'Action',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 w-max"
onClick={() => {
// View detail logic
}}
>
<EditOutlined className="text-base" /> View & Manage
</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 (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
Team Management
</h1>
{/* Filters and actions */}
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center">
<input
type="text"
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64"
placeholder="Search name or email"
/>
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
</select>
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
<option value="all">All City</option>
<option value="jakarta">Jakarta</option>
<option value="bandung">Bandung</option>
</select>
</div>
{/* Table */}
<DataTable data={mockData} columns={columns} table={table} />
</section>
{/* Modals extracted into shared backoffice components */}
</BackofficeWrapper>
);
};
export default HackathonTeamsPage;
@@ -1,651 +0,0 @@
import { FC, useState, useEffect, useMemo, useRef } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import {
UserOutlined,
EnvironmentOutlined,
CalendarOutlined,
SaveOutlined,
CloseOutlined,
ExclamationOutlined,
CameraOutlined,
DeleteOutlined,
UploadOutlined,
} from '@ant-design/icons';
interface UserType {
id: string;
avatar?: string;
fullname: string;
bio?: string;
location: string;
is_active: boolean;
skills: string[];
created_at: string;
updated_at: string;
}
interface ModalProps {
isOpen: boolean;
onClose: () => void;
user: UserType | null;
}
const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
const [formData, setFormData] = useState<UserType | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Initialize form data when modal opens
useEffect(() => {
if (isOpen) {
if (user) {
// Edit existing user
setFormData({ ...user });
} else {
// Create new user
setFormData({
id: '', // Will be generated by backend
fullname: '',
bio: '',
location: '',
is_active: true,
skills: [],
avatar: undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
}
}
}, [isOpen, user]);
// Check if form has changes
const hasChanges = useMemo(() => {
if (!formData) return false;
if (!user) return true; // New user always has changes
return (
formData.fullname !== user.fullname ||
formData.location !== user.location ||
formData.is_active !== user.is_active ||
formData.avatar !== user.avatar ||
JSON.stringify(formData.skills) !== JSON.stringify(user.skills) ||
formData.bio !== user.bio
);
}, [formData, user]);
// Check if required fields are filled
const isFormValid = useMemo(() => {
if (!formData) return false;
return formData.fullname.trim() !== '' && formData.location.trim() !== '';
}, [formData]);
const canSave = hasChanges && isFormValid;
if (!isOpen || !formData) return null;
const handleInputChange = (
field: keyof UserType,
value: string | boolean | string[] | undefined
) => {
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
};
const handleSkillsChange = (skills: string[]) => {
setFormData((prev) => (prev ? { ...prev, skills } : null));
};
const handleSave = () => {
if (!formData) return;
if (user) {
// Update existing user
console.log('Update user data:', formData);
} else {
// Create new user
console.log('Create new user:', formData);
}
// Here you would typically make an API call to save the data
onClose();
};
const handleCancel = () => {
if (user) {
setFormData({ ...user }); // Reset to original for edit mode
}
onClose();
};
const handleDeleteAccount = () => {
if (!user) return; // Can't delete new user
console.log('Delete user:', user.id);
setShowDeleteConfirm(false);
onClose();
};
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
// Validate file type
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
alert('Image size must be less than 5MB');
return;
}
// Create preview URL
const reader = new FileReader();
reader.onload = (e) => {
const avatarUrl = e.target?.result as string;
handleInputChange('avatar', avatarUrl);
setShowAvatarMenu(false);
};
reader.readAsDataURL(file);
}
};
const handleRemoveAvatar = () => {
handleInputChange('avatar', undefined);
setShowAvatarMenu(false);
};
const triggerFileUpload = () => {
fileInputRef.current?.click();
};
const availableSkills = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
return (
<div className="fixed inset-0 z-50">
<div
className="fixed inset-0 bg-black/50"
onClick={(e) => {
setShowAvatarMenu(false);
onClose();
}}
/>
<div className="fixed inset-0 flex items-center justify-center p-4">
<div
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Hidden File Input */}
<input
type="file"
ref={fileInputRef}
onChange={handleAvatarUpload}
accept="image/*"
className="hidden"
/>
{/* Header */}
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
<div className="flex items-center gap-4">
{/* Interactive User Avatar */}
<div className="relative group ">
<div className="w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-primary-300 transition-colors">
{formData.avatar ? (
<img
src={formData.avatar}
alt={formData.fullname}
className="w-full h-full object-cover"
/>
) : (
<UserOutlined className="text-neutral-500 text-2xl" />
)}
</div>
{/* Avatar Hover Overlay */}
<button
onClick={() => setShowAvatarMenu(!showAvatarMenu)}
className="absolute inset-0 bg-neutral-400 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<CameraOutlined className="text-white text-lg" />
</button>
{/* Avatar Menu Dropdown */}
{showAvatarMenu && (
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
<button
onClick={triggerFileUpload}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
>
<UploadOutlined className="text-sm" />
{formData.avatar ? 'Change Photo' : 'Upload Photo'}
</button>
{formData.avatar && (
<button
onClick={handleRemoveAvatar}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
>
<DeleteOutlined className="text-sm" />
Remove Photo
</button>
)}
</div>
)}
</div>
<div>
<div className="flex items-center gap-3 mb-2">
<h2 className="text-2xl font-bold text-neutral-900">
{user ? 'Edit User Profile' : 'Create New User'}
</h2>
{user && (
<span className="px-3 py-1 bg-info-100 text-info-700 text-xs font-medium rounded-2xl">
Hover avatar to change
</span>
)}
</div>
<div className="text-sm text-neutral-500">
{user
? `Make changes to ${
formData.fullname || 'this user'
}'s profile information`
: 'Fill in the information below to create a new user account'}
</div>
</div>
</div>
<button
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
onClick={() => {
setShowAvatarMenu(false);
handleCancel();
}}
>
<CloseOutlined className="text-neutral-400 text-lg cursor-pointer" />
</button>
</div>
{/* Content */}
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left Column - Basic Info */}
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
Basic Information
</h3>
<div className="space-y-4">
{/* Full Name - Required */}
<div className="flex items-center gap-3">
<UserOutlined className="text-neutral-400" />
<div className="flex-1">
<label className="text-sm text-neutral-500 block mb-1">
Full Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.fullname}
onChange={(e) =>
handleInputChange('fullname', e.target.value)
}
className={cn(
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none',
formData.fullname.trim() === ''
? 'border-red-300 bg-red-50'
: 'border-neutral-300'
)}
placeholder="Enter full name"
/>
{formData.fullname.trim() === '' && (
<p className="text-red-500 text-xs mt-1">
Full name is required
</p>
)}
</div>
</div>
{/* Location - Required */}
<div className="flex items-center gap-3">
<EnvironmentOutlined className="text-neutral-400" />
<div className="flex-1">
<label className="text-sm text-neutral-500 block mb-1">
Location <span className="text-red-500">*</span>
</label>
<select
value={formData.location}
onChange={(e) =>
handleInputChange('location', e.target.value)
}
className={cn(
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white',
formData.location.trim() === ''
? 'border-red-300 bg-red-50'
: 'border-neutral-300'
)}
>
<option value="">Select location</option>
<option value="Jakarta">Jakarta</option>
<option value="Bandung">Bandung</option>
<option value="Surabaya">Surabaya</option>
<option value="Medan">Medan</option>
<option value="Yogyakarta">Yogyakarta</option>
</select>
{formData.location.trim() === '' && (
<p className="text-red-500 text-xs mt-1">
Location is required
</p>
)}
</div>
</div>
{/* Joined Date - Read Only - Only show for existing users */}
{user && (
<div className="flex items-center gap-3">
<CalendarOutlined className="text-neutral-400" />
<div>
<p className="text-sm text-neutral-500">
Joined Date
</p>
<p className="font-medium">
{new Date(formData.created_at).toLocaleDateString(
'en-US',
{
year: 'numeric',
month: 'long',
day: 'numeric',
}
)}
</p>
</div>
</div>
)}
</div>
</div>
{/* Bio Section - Optional */}
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
Bio{' '}
<span className="text-neutral-400 text-sm font-normal">
(Optional)
</span>
</h3>
<textarea
value={formData.bio || ''}
onChange={(e) =>
handleInputChange('bio', e.target.value || undefined)
}
placeholder="Tell us about yourself..."
rows={4}
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
/>
</div>
</div>
{/* Right Column - Skills & Status */}
<div className="space-y-6">
{/* Account Status - Enhanced Tab Design */}
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
Account Status
</h3>
<div className="flex bg-neutral-100 p-1 rounded-lg">
<button
onClick={() => handleInputChange('is_active', true)}
className={cn(
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
formData.is_active
? 'bg-white text-success-700 shadow-sm ring-1 ring-success-200'
: 'text-neutral-600 hover:text-neutral-800'
)}
>
<div className="flex items-center justify-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
formData.is_active
? 'bg-success-500'
: 'bg-neutral-400'
)}
/>
Active
</div>
</button>
<button
onClick={() => handleInputChange('is_active', false)}
className={cn(
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
!formData.is_active
? 'bg-white text-neutral-700 shadow-sm ring-1 ring-neutral-200'
: 'text-neutral-600 hover:text-neutral-800'
)}
>
<div className="flex items-center justify-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
!formData.is_active
? 'bg-neutral-500'
: 'bg-neutral-400'
)}
/>
Inactive
</div>
</button>
</div>
<p className="text-xs text-neutral-500 mt-2">
{formData.is_active
? 'User can access their account and participate in activities'
: 'User account is suspended and cannot access services'}
</p>
</div>
{/* Skills Section - Optional */}
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
Skills & Expertise{' '}
<span className="text-neutral-400 text-sm font-normal">
(Optional)
</span>
</h3>
<div className="space-y-3">
<div className="flex flex-wrap gap-2 min-h-10 p-3 border border-neutral-300 rounded-lg bg-neutral-50">
{formData.skills.length > 0 ? (
formData.skills.map((skill, index) => (
<span
key={index}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-2xl text-sm font-medium bg-blue-100 text-blue-800"
>
{skill}
<button
onClick={() =>
handleSkillsChange(
formData.skills.filter((_, i) => i !== index)
)
}
className="text-blue-600 hover:text-blue-800 ml-1 cursor-pointer"
>
</button>
</span>
))
) : (
<span className="text-neutral-400 text-sm">
No skills added yet
</span>
)}
</div>
<select
value=""
onChange={(e) => {
if (
e.target.value &&
!formData.skills.includes(e.target.value)
) {
handleSkillsChange([
...formData.skills,
e.target.value,
]);
}
}}
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white"
>
<option value="">Add a skill...</option>
{availableSkills
.filter((skill) => !formData.skills.includes(skill))
.map((skill) => (
<option key={skill} value={skill}>
{skill}
</option>
))}
</select>
</div>
</div>
{/* Account Details - Read Only - Only show for existing users */}
{user && (
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
Account Details
</h3>
<div className="space-y-3 bg-neutral-50 p-4 rounded-lg">
<div className="flex justify-between items-center py-1">
<span className="text-neutral-600 text-sm">
User ID
</span>
<span className="font-mono text-sm text-neutral-800">
{formData.id}
</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-neutral-600 text-sm">
Last Updated
</span>
<span className="text-sm text-neutral-800">
{new Date(formData.updated_at).toLocaleDateString(
'en-US',
{
month: 'short',
day: 'numeric',
year: 'numeric',
}
)}
</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Footer Actions */}
<div className="border-t border-neutral-200 px-8 py-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="text-sm text-neutral-500">
{canSave
? 'Ready to save changes'
: hasChanges
? 'Please fill required fields'
: 'No changes made'}
</div>
{/* Delete Account Button - Only show for existing users */}
{user && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="text-red-600 hover:text-red-700 text-sm font-medium transition-colors cursor-pointer"
>
Delete Account
</button>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="secondary"
size="sm"
onClick={handleCancel}
className="px-6"
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSave}
disabled={!canSave}
className={cn(
'flex items-center gap-2 px-6',
!canSave && 'opacity-50 cursor-not-allowed'
)}
>
<SaveOutlined className="text-sm" />
{user ? 'Save Changes' : 'Create User'}
</Button>
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 z-60">
<div
className="fixed inset-0 bg-black/50"
onClick={() => setShowDeleteConfirm(false)}
/>
<div className="fixed inset-0 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
<div className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<ExclamationOutlined className="text-red-600 text-lg" />
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900">
Delete Account
</h3>
<p className="text-sm text-neutral-500">
This action cannot be undone
</p>
</div>
</div>
<p className="text-neutral-700 mb-6">
Are you sure you want to permanently delete{' '}
<strong>{formData.fullname}</strong>'s account? This will
remove all their data and cannot be reversed.
</p>
<div className="flex gap-3 justify-end">
<Button
variant="secondary"
size="sm"
onClick={() => setShowDeleteConfirm(false)}
className="px-4"
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleDeleteAccount}
className="px-4 bg-red-600 hover:bg-red-700 border-red-600"
>
Delete Account
</Button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default ModalUserDetail;
@@ -1,476 +0,0 @@
import { FC, ReactElement, useState, useMemo, useCallback } from 'react';
import ModalUserDetail from './_components/modal-user-detail';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import {
EditOutlined,
UserOutlined,
SearchOutlined,
FilterOutlined,
PlusOutlined,
} from '@ant-design/icons';
// Removed unused SearchOutlined icon after schema revision
// Define interface outside component
interface UserType {
id: string; // UUID
avatar?: string;
fullname: string;
bio?: string;
location: string;
is_active: boolean; // admin can deactivate
skills: string[]; // Frontend Developer, Backend Developer, etc.
created_at: string;
updated_at: string;
}
// Move mock data outside component to prevent recreation
const skillsOptions = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
const locations = ['Jakarta', 'Bandung', 'Surabaya', 'Medan', 'Yogyakarta'];
const bios = [
'Passionate developer with 5+ years experience',
'Tech enthusiast and problem solver',
'Building scalable solutions for modern problems',
'Creative designer with technical background',
'Data-driven decision maker',
];
const mockData: UserType[] = Array.from({ length: 50 }, (_, i) => {
const randomSkillsCount = Math.floor(Math.random() * 3) + 1; // 1-3 skills
const randomSkills = skillsOptions
.sort(() => 0.5 - Math.random())
.slice(0, randomSkillsCount);
return {
id: `24db9e4d-ca4c-46aa-ac36-8ef04bbe01${String(i).padStart(2, '0')}`,
avatar:
i % 4 === 0
? `https://ui-avatars.com/api/?name=${encodeURIComponent(
i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana'
)}&background=random`
: undefined,
fullname:
i % 3 === 0
? 'Ahmad Wijuana'
: i % 3 === 1
? 'Sofia Wijuana'
: 'Budi Santoso',
bio: i % 4 === 0 ? bios[i % bios.length] : undefined,
location: locations[i % locations.length],
is_active: i % 7 !== 0, // More realistic distribution
skills: randomSkills,
created_at: new Date(
Date.now() - i * 86400000 * (Math.random() * 30 + 1)
).toISOString(), // Random within last 30-60 days
updated_at: new Date().toISOString(),
};
});
export const HackathonUsersPage: FC = (): ReactElement => {
const [showDetailModal, setShowDetailModal] = useState(false);
const [showNewUserModal, setShowNewUserModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
const [globalFilter, setGlobalFilter] = useState('');
// Advanced filtering states
const [statusFilter, setStatusFilter] = useState('all');
const [locationFilter, setLocationFilter] = useState('all');
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
// Constants
const pageSize = 10;
// Memoize the callback to prevent recreation
const handleShowDetailModal = useCallback((user: UserType) => {
setSelectedUser(user);
setShowDetailModal(true);
}, []);
const handleCloseDetailModal = useCallback(() => {
setShowDetailModal(false);
setSelectedUser(null);
}, []);
const handleShowNewUserModal = useCallback(() => {
setShowNewUserModal(true);
}, []);
const handleCloseNewUserModal = useCallback(() => {
setShowNewUserModal(false);
}, []);
// Filter data based on current filter states
const filteredData = useMemo(() => {
return mockData.filter((user) => {
// Status filter
if (statusFilter !== 'all') {
const isActive = statusFilter === 'active';
if (user.is_active !== isActive) return false;
}
// Location filter
if (locationFilter !== 'all' && user.location !== locationFilter) {
return false;
}
// Skills filter
if (skillsFilter.length > 0) {
const hasMatchingSkill = skillsFilter.some((skill) =>
user.skills.includes(skill)
);
if (!hasMatchingSkill) return false;
}
return true;
});
}, [statusFilter, locationFilter, skillsFilter]);
// Memoize columns to prevent recreation on every render
const columns: ColumnDef<UserType>[] = useMemo(
() => [
{
accessorKey: 'fullname',
header: 'User',
cell: ({ row }) => (
<div className="flex items-center gap-3">
{/* Avatar */}
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
{row.original.avatar ? (
<img
src={row.original.avatar}
alt={row.original.fullname}
className="w-full h-full object-cover"
/>
) : (
<UserOutlined className="text-neutral-500 text-lg" />
)}
</div>
{/* Name only */}
<div className="min-w-0 flex-1">
<p className="font-medium text-neutral-900 truncate">
{row.original.fullname}
</p>
</div>
</div>
),
enableSorting: true,
},
{
accessorKey: 'skills',
header: 'Skills',
cell: ({ row }) => (
<div className="flex flex-wrap gap-1 max-w-xs">
{row.original.skills.slice(0, 2).map((skill, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
>
{skill.replace(' Developer', '').replace(' Engineer', '')}
</span>
))}
{row.original.skills.length > 2 && (
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
+{row.original.skills.length - 2}
</span>
)}
</div>
),
enableSorting: false,
},
{
accessorKey: 'location',
header: 'Location',
cell: ({ row }) => (
<span className="text-neutral-700">{row.original.location}</span>
),
enableSorting: true,
},
{
accessorKey: 'is_active',
header: 'Status',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
)}
/>
<span
className={cn(
'text-sm font-medium',
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
)}
>
{row.original.is_active ? 'Active' : 'Inactive'}
</span>
</div>
),
enableSorting: true,
sortingFn: (rowA, rowB) => {
const aActive = rowA.original.is_active;
const bActive = rowB.original.is_active;
if (aActive && !bActive) return -1;
if (!aActive && bActive) return 1;
return 0;
},
},
{
accessorKey: 'created_at',
header: 'Joined',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
{new Date(row.original.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
),
enableSorting: true,
sortingFn: 'datetime',
},
{
id: 'actions',
header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowDetailModal(row.original)}
>
<EditOutlined className="text-sm" />
Manage
</Button>
<Button
variant="secondary"
size="sm"
className="text-sm px-4 py-2"
onClick={() => {
// Toggle user status - implement later
console.log(`Toggle status for ${row.original.fullname}`);
}}
>
{row.original.is_active ? 'Deactivate' : 'Activate'}
</Button>
</div>
),
enableSorting: false,
},
],
[handleShowDetailModal]
);
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
User Management
</h1>
{/* Filters and actions */}
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center justify-between">
{/* Left side - Search & filters */}
<div className="flex flex-wrap gap-3 items-center">
{/* Search bar */}
<div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input
type="text"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
placeholder="Search users by name or location..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
/>
</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>
{/* Location 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-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={locationFilter}
onChange={(e) => setLocationFilter(e.target.value)}
>
<option value="all">All Locations</option>
{locations.map((location) => (
<option key={location} value={location}>
{location}
</option>
))}
</select>
</div>
{/* Skills Filter with Icon */}
<div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value=""
onChange={(e) => {
if (
e.target.value &&
!skillsFilter.includes(e.target.value)
) {
setSkillsFilter((prev) => [...prev, e.target.value]);
}
}}
>
<option value="">Add Skill Filter</option>
{skillsOptions.map((skill) => (
<option
key={skill}
value={skill}
disabled={skillsFilter.includes(skill)}
>
{skill}
</option>
))}
</select>
</div>
</div>
{/* Right side - Add User Button */}
<div className="flex items-center gap-3">
<Button
variant="primary"
size="md"
className="flex items-center gap-2 px-4 py-2"
onClick={handleShowNewUserModal}
>
<PlusOutlined className="text-sm" />
Add User
</Button>
</div>
</div>
{/* Active filters display */}
{(skillsFilter.length > 0 ||
statusFilter !== 'all' ||
locationFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
{/* Status filter badge */}
{statusFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Status: {statusFilter}
<button
onClick={() => setStatusFilter('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
</button>
</span>
)}
{/* Location filter badge */}
{locationFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
Location: {locationFilter}
<button
onClick={() => setLocationFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
</button>
</span>
)}
{/* Skills filter badges */}
{skillsFilter.map((skill) => (
<span
key={skill}
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
>
{skill.replace(' Developer', '').replace(' Engineer', '')}
<button
onClick={() =>
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
}
className="text-purple-600 hover:text-purple-800 cursor-pointer"
>
</button>
</span>
))}
{/* Clear all filters */}
<Button
variant="secondary"
size="sm"
onClick={() => {
setStatusFilter('all');
setLocationFilter('all');
setSkillsFilter([]);
setGlobalFilter('');
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)}
{/* Pagination-aware results display */}
{filteredData.length > 0 && (
<div className="text-sm text-neutral-600">
Showing {Math.min(pageSize, filteredData.length)} of{' '}
{filteredData.length} users
{filteredData.length > pageSize}
</div>
)}
{/* Table */}
<DataTable data={filteredData} columns={columns} pageSize={10} />
</section>
{/* Modals component */}
<ModalUserDetail
isOpen={showDetailModal}
onClose={handleCloseDetailModal}
user={selectedUser}
/>
{/* New User Modal */}
<ModalUserDetail
isOpen={showNewUserModal}
onClose={handleCloseNewUserModal}
user={null} // null indicates creating new user
/>
</BackofficeWrapper>
);
};
export default HackathonUsersPage;

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